1 Commits

Author SHA1 Message Date
kkfluous
0dc45504f2 chore(debug): 本地开发跳过权限验证
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
前端 AuthProvider 注入测试用户(BI-SCHEDULE-OPT),后端 middleware BYPASS_AUTH=true。
仅用于本地调试,禁止合并回 main。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:06:56 +08:00
74 changed files with 1512 additions and 7736 deletions

View File

@@ -1,9 +0,0 @@
node_modules
dist
.env
.git
.codex
outputs
reports
data/geo
data/vehicle-heatmap/source.csv

1
.gitignore vendored
View File

@@ -1,4 +1,3 @@
node_modules
dist
.env
.worktrees

View File

@@ -5,37 +5,14 @@ services:
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
network_mode: host
environment:
DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com"
DB_HOST: "47.101.148.99"
DB_PORT: "3306"
DB_USER: "oneos_db_prod"
DB_PASSWORD: "adASHJcviqwjkbn23ngt1"
DB_NAME: "ln_asset_management"
HYDROGEN_DB_HOST: "47.99.185.173"
HYDROGEN_DB_PORT: "3306"
HYDROGEN_DB_USER: "root"
HYDROGEN_DB_PASSWORD: "lnMysql."
HYDROGEN_DB_NAME: "ln_asset_management"
MILEAGE_DB_HOST: "101.133.130.65"
MILEAGE_DB_PORT: "3306"
MILEAGE_DB_USER: "bi_reader_02"
MILEAGE_DB_PASSWORD: "bi_reader_02_Pass"
MILEAGE_DB_NAME: "hydrogen_energy"
# ECS production accesses OneOS over the private network. Configure the key
# as a Portainer stack environment variable; never commit it to this file.
ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}"
ONEOS_MILEAGE_API_KEY: "${ONEOS_MILEAGE_API_KEY}"
ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}"
DB_USER: "root"
DB_PASSWORD: "LN#Passw0rd@2026"
DB_NAME: "lingniu_prod"
SERVER_PORT: "8111"
EXTERNAL_API_BASE: "https://lnh2e.com"
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
AMAP_WEB_KEY: "${AMAP_WEB_KEY}"
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}"
HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}"
HEATMAP_DB_PORT: "${HEATMAP_DB_PORT:-36453}"
HEATMAP_DB_USER: "${HEATMAP_DB_USER}"
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}"
HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}"
HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}"
deploy:
replicas: 1
restart_policy:

View File

@@ -1,155 +0,0 @@
# OneOS 车辆里程接口需求
当前协议选源联调阻塞项和 OneOS 修改清单见:
[OneOS 车辆里程数据源协议改造清单](./oneos-mileage-source-protocol-gaps.md)。
## 开发原则
1. 新增区间查询接口;车辆里程汇总能力在现有
`POST /api/v1/vehicles/mileage/query` 上兼容扩展。
2. 现有接口的路径、鉴权、已有请求参数、已有响应字段、字段语义和默认行为必须完全兼容。
3. 公网 `https://open.d.lnoneos.com` 与 ECS 内网 `http://172.17.111.55:20310` 使用相同路径、鉴权和数据口径。
4. 鉴权继续使用 `Authorization: Bearer APP_KEY`
5. 日期采用 `YYYY-MM-DD`,时区统一为 `Asia/Shanghai`,里程单位统一为 km。
## 1. 车辆区间日里程批量查询
```http
POST /api/v1/vehicles/mileage/range/query
```
请求:
```json
{
"startDate": "2026-07-01",
"endDate": "2026-07-23",
"plateNumbers": ["沪A00001", "沪A00002"],
"protocolPriority": ["GB32960", "MQTT", "JT808"],
"cursor": null,
"pageSize": 5000
}
```
规则:
- `startDate``endDate` 必填,最长支持 366 天;
- `plateNumbers` 可省略,省略时返回授权范围内全部车辆;
- `protocolPriority` 可省略;提供时只允许 `GB32960``MQTT``JT808`
数组顺序表示逐车逐日的数据源优先级,未列出的协议视为禁用;
- BI 只会使用以下四种组合:
- 仪表数据优先:`["GB32960", "MQTT", "JT808"]`
- GPS 数据优先:`["JT808", "GB32960", "MQTT"]`
- 仅仪表数据:`["GB32960", "MQTT"]`
- 仅 GPS 数据:`["JT808"]`
- BI 页面默认只启用“仪表数据”,即默认发送
`["GB32960", "MQTT"]`;用户主动启用 GPS 后才发送包含 `JT808` 的组合;
- 仪表数据内部优先级固定为 `GB32960 > MQTT`,不提供反向切换;
- 数据量过大时使用 `cursor` 分页,同一次查询使用相同 `snapshotId`
- 每辆车每天返回一条记录;
- 无数据返回 `status: "NO_DATA"``dailyMileageKm: null`,真实零里程返回 `status: "NORMAL"``dailyMileageKm: 0`
响应:
```json
{
"code": "SUCCESS",
"message": "success",
"data": [
{
"vin": "LMRK...",
"plateNumber": "沪A00001",
"date": "2026-07-01",
"dailyMileageKm": 182.437,
"sourceProtocol": "GB32960",
"dataTime": "2026-07-01T23:58:45+08:00",
"updatedAt": "2026-07-02T05:10:00+08:00",
"status": "NORMAL"
}
],
"snapshotId": "mileage-20260723-001",
"nextCursor": null,
"traceId": "..."
}
```
## 2. 兼容扩展现有单日车辆里程接口
用于一次获取指定日期每辆车的日里程、累计里程和数据时间。
```http
POST /api/v1/vehicles/mileage/query
```
请求:
```json
{
"date": "2026-07-23",
"plateNumbers": ["沪A00001", "沪A00002"],
"protocolPriority": ["GB32960", "MQTT", "JT808"]
}
```
规则:
- `date``plateNumbers` 沿用现有接口规则;
- `protocolPriority` 的枚举、顺序和禁用规则与区间接口完全一致;
- `plateNumbers` 省略时返回授权范围内全部车辆;
- 接口默认在现有车辆结果中返回 `totalMileageKm``dataTime``updatedAt`
- 只增加响应字段,不删除或修改任何现有字段及其语义;
- 历史日期返回该自然日最终有效结果;
- 查询当天返回当前最新结果;
- `dataTime` 必须是本次统计实际采用的最后一条车辆源数据时间,不能使用接口请求时间或响应时间代替;
- 无数据时相关里程字段返回 `null`,不能使用 0 代替;
- 接口应支持当前应用授权范围内的全部车辆。
响应:
```json
{
"code": "SUCCESS",
"message": "success",
"data": [
{
"vin": "LMRK...",
"plateNumber": "沪A00001",
"date": "2026-07-23",
"dailyMileageKm": 182.437,
"totalMileageKm": 12345.678,
"sourceProtocol": "GB32960",
"dataTime": "2026-07-23T10:35:42+08:00",
"updatedAt": "2026-07-23T10:35:46+08:00",
"status": "NORMAL"
}
],
"traceId": "..."
}
```
现有响应外层结构及已有字段保持不变,调用方无需增加任何请求参数。
## 通用要求
- `dataTime``updatedAt` 使用带 `+08:00` 的 ISO 8601 格式;
- `sourceProtocol` 必须返回本条结果实际采用的协议:
`GB32960``MQTT``JT808`;无数据时返回 `null`
- OneOS 必须按 `protocolPriority` 选择第一份有效数据,不得返回已禁用协议的数据;
- `sourceProtocol=GB32960/MQTT` 在 BI 显示为“仪表数据”,
`sourceProtocol=JT808` 显示为“GPS数据”
- 每个响应返回 `traceId`
- 接口返回值保留原始精度;
- 负里程不得作为正常数据返回;
- 公网和 ECS 内网使用同一 AppKey 时,授权范围和查询结果保持一致;
- 区间接口及现有接口的汇总扩展不得影响现有调用方和默认行为。
## 验收
1. 现有 `/api/v1/vehicles/mileage/query` 请求方式保持不变,回归测试全部通过。
2. 区间接口不传车牌可完整查询全部授权车辆,分页无重复、无漏行。
3. 现有接口不传车牌时,默认返回全部授权车辆的日里程、累计里程和数据时间。
4. 正确区分无数据与真实零里程。
5. `dataTime` 能反映每辆车实际数据的新鲜度。
6. 四种 `protocolPriority` 组合均能按顺序选源,且响应中的
`sourceProtocol` 与实际采用协议一致。
7. 禁用某类协议后,响应不得回退到该协议。

View File

@@ -1,183 +0,0 @@
# OneOS 车辆里程数据源协议改造清单
## 0. 发布后复测结论
复测日期2026-07-23
开放平台发布后BI 已重新连接 `https://open.d.lnoneos.com` 并完成复测:
- 单日接口和区间接口均接受 `protocolPriority`,不再返回
`INVALID_REQUEST`
- 成功响应已返回实际使用的 `sourceProtocol`
- 仪表优先、GPS 优先、仅仪表、仅 GPS 四种模式均返回 HTTP 200
- “仅仪表数据”结果未出现 `JT808`
- “仅 GPS 数据”结果未出现 `GB32960``MQTT`
- 同一车辆四天区间查询能够随优先级在 `GB32960``JT808`
之间正确切换;
- BI 页面已显示真实“仪表数据”或“GPS数据”不再显示“来源待接口”。
复测时的单日协议分布如下,数据量会随当天车辆上报变化:
| BI 模式 | GB32960 | MQTT | JT808 | 无数据 | 禁用协议混入 |
| --- | ---: | ---: | ---: | ---: | --- |
| 仪表数据优先 | 387 | 49 | 134 | 454 | 无 |
| GPS 数据优先 | 69 | 30 | 471 | 454 | 无 |
| 仅仪表数据 | 387 | 49 | 0 | 588 | 无 |
| 仅 GPS 数据 | 0 | 0 | 471 | 553 | 无 |
结论:本清单中的协议选源阻塞项已关闭,当前接口满足 BI 对接要求。
以下内容保留为接口契约和后续回归验收依据。
## 1. 历史联调结论(发布前)
联调地址:`https://open.d.lnoneos.com`
涉及接口:
- `POST /api/v1/vehicles/mileage/query`
- `POST /api/v1/vehicles/mileage/range/query`
发布前接口不满足 BI 对车辆里程数据源切换和来源标注的要求,阻塞项如下:
| 序号 | 当前表现 | 期望表现 | 影响 |
| --- | --- | --- | --- |
| 1 | 请求增加 `protocolPriority` 后返回 HTTP 400业务码为 `INVALID_REQUEST` | 两个里程接口均接受可选字段 `protocolPriority` | BI 无法切换仪表数据和 GPS 数据的优先级,也无法单独禁用某类数据 |
| 2 | 成功响应中没有 `sourceProtocol` | 每辆车、每天的结果返回实际采用的协议 | BI 无法准确标注“仪表数据”或“GPS数据” |
| 3 | 无法验证接口是否排除了已禁用协议 | 未列入 `protocolPriority` 的协议不得参与选源 | “仅仪表数据”和“仅 GPS 数据”无法生效 |
| 4 | 无法验证仪表数据内部的降级顺序 | 仪表数据固定按 `GB32960 > MQTT` 选源 | 32960 缺失时无法确认是否正确降级到 MQTT |
BI 保留旧请求格式的兼容回退,用于开放平台异常或版本回滚时保证现有里程查询可用。回退结果不会伪造数据来源,页面会显示“来源待接口”。正常情况下新接口已不触发该回退。
## 2. OneOS 必须修改的请求字段
两个接口都需要增加以下可选字段:
```json
{
"protocolPriority": ["GB32960", "MQTT", "JT808"]
}
```
字段规则:
1. 类型为非空字符串数组。
2. 仅允许 `GB32960``MQTT``JT808`,协议名区分以此处定义为准。
3. 数组顺序表示逐车、逐自然日的数据源优先级。
4. 未出现在数组中的协议视为禁用,不得参与选源或作为兜底数据返回。
5. `protocolPriority` 省略时保持现有接口的默认行为,确保旧调用方兼容。
6. 非法枚举、空数组或重复协议应返回 HTTP 400并在响应中给出明确的错误信息和 `traceId`
BI 只会发送以下四种组合:
| BI 模式 | `protocolPriority` | 选源规则 |
| --- | --- | --- |
| 仪表数据优先 | `["GB32960", "MQTT", "JT808"]` | 先查 32960再查 MQTT最后查 JT808 |
| GPS 数据优先 | `["JT808", "GB32960", "MQTT"]` | 先查 JT808再查 32960最后查 MQTT |
| 仅仪表数据 | `["GB32960", "MQTT"]` | 只允许 32960 和 MQTT禁止使用 JT808 |
| 仅 GPS 数据 | `["JT808"]` | 只允许 JT808禁止使用 32960 和 MQTT |
BI 页面默认只启用“仪表数据”,即默认使用
`["GB32960", "MQTT"]`GPS 数据需要用户主动启用。
仪表数据内部优先级固定为 `GB32960 > MQTT`BI 不会发送 `MQTT > GB32960`
## 3. OneOS 必须增加的响应字段
两个接口的每条车辆日里程结果都需要增加:
```json
{
"sourceProtocol": "GB32960"
}
```
字段规则:
1. 有有效里程数据时,只能返回 `GB32960``MQTT``JT808`
2. 返回值必须是本条结果实际采用的协议,不能返回车辆支持的协议列表,也不能返回请求中的第一项作为固定值。
3. 无有效数据、`status="NO_DATA"` 时返回 `null`
4. `sourceProtocol` 对应关系:
- `GB32960``MQTT`BI 显示为“仪表数据”;
- `JT808`BI 显示为“GPS数据”。
5. 如果 `sourceProtocol` 不在本次请求的 `protocolPriority` 中,应视为接口错误。
响应示例:
```json
{
"code": "SUCCESS",
"message": "success",
"data": [
{
"vin": "LMRK...",
"plateNumber": "粤A00001F",
"date": "2026-07-23",
"dailyMileageKm": 182.437,
"totalMileageKm": 12345.678,
"sourceProtocol": "GB32960",
"dataTime": "2026-07-23T10:35:42+08:00",
"updatedAt": "2026-07-23T10:35:46+08:00",
"status": "NORMAL"
}
],
"traceId": "..."
}
```
## 4. 逐车逐日选源规则
OneOS 需要针对每辆车、每个自然日独立执行以下逻辑:
1.`protocolPriority` 从前到后检查数据源。
2. 找到第一份有效数据后立即使用,不再混用低优先级协议的数据。
3. 有效数据必须满足现有里程质量规则,包括里程非空、非负且数据状态有效。
4. 高优先级协议无有效数据时,才能降级到下一个已启用协议。
5. 所有已启用协议均无有效数据时,返回:
```json
{
"dailyMileageKm": null,
"sourceProtocol": null,
"status": "NO_DATA"
}
```
6. 真实零里程必须返回:
```json
{
"dailyMileageKm": 0,
"sourceProtocol": "实际采用的协议",
"status": "NORMAL"
}
```
区间查询必须逐日选源,不能先按一个协议汇总整个区间后再切换协议。
## 5. 验收用例
OneOS 修改完成后,至少提供一辆在同一天同时存在 32960、MQTT、JT808 数据的测试车辆,以及一辆只有 MQTT 数据的测试车辆。
| 用例 | 请求优先级 | 必须满足的结果 |
| --- | --- | --- |
| 仪表优先 | `GB32960, MQTT, JT808` | 同时有三种数据时采用 GB32960`sourceProtocol="GB32960"` |
| GPS 优先 | `JT808, GB32960, MQTT` | 同时有三种数据时采用 JT808`sourceProtocol="JT808"` |
| 仅仪表 | `GB32960, MQTT` | 不得返回 JT80832960 缺失时允许降级到 MQTT |
| 仅 GPS | `JT808` | 不得返回 GB32960 或 MQTT |
| 32960 降级 | `GB32960, MQTT` | 只有 MQTT 有效时返回 MQTT`sourceProtocol="MQTT"` |
| 禁用验证 | `JT808` | 即使仪表数据存在,也不能回退到仪表数据 |
| 无数据 | 任一合法组合 | 返回 `dailyMileageKm=null``sourceProtocol=null``status="NO_DATA"` |
| 真实零里程 | 任一合法组合 | 返回 `dailyMileageKm=0`、实际 `sourceProtocol``status="NORMAL"` |
| 单日接口 | 四种组合分别测试 | 请求成功且选源、来源字段符合规则 |
| 区间接口 | 四种组合分别测试 | 每辆车每天独立选源,分页无重复、无漏行 |
| 兼容性 | 不传 `protocolPriority` | 原有调用方式和响应语义保持不变 |
## 6. 交付和联调要求
1. 公网 `https://open.d.lnoneos.com` 和 ECS 内网地址同时发布相同能力。
2. 发布后提供接口版本或发布日期、测试车辆、测试日期及对应协议数据情况。
3. 提供四种合法组合的请求和响应样例。
4. 提供一组禁用协议的验证结果,证明未启用协议不会被回退使用。
5. 每个成功和失败响应均返回 `traceId`,便于双方定位问题。
完成标准:两个接口均支持 `protocolPriority`,每条结果准确返回 `sourceProtocol`,四种 BI 模式全部通过上述验收用例。

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>羚牛氢能</title>
<title>羚牛氢能车辆资产</title>
</head>
<body>
<div id="root"></div>

162
package-lock.json generated
View File

@@ -1,14 +1,13 @@
{
"name": "ln-bi",
"version": "1.1.13",
"version": "1.1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ln-bi",
"version": "1.1.13",
"version": "1.1.5",
"dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0",
"@types/jsonwebtoken": "^9.0.10",
"ali-oss": "^6.23.0",
@@ -18,7 +17,6 @@
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"mysql2": "^3.11.0",
"pg": "^8.22.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.8.1",
@@ -29,7 +27,6 @@
"@tailwindcss/vite": "^4.1.14",
"@types/ali-oss": "^6.23.3",
"@types/node": "^22.14.0",
"@types/pg": "^8.20.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.4",
@@ -39,12 +36,6 @@
"vite": "^6.2.0"
}
},
"node_modules/@amap/amap-jsapi-loader": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@amap/amap-jsapi-loader/-/amap-jsapi-loader-1.0.1.tgz",
"integrity": "sha512-nPyLKt7Ow/ThHLkSvn2etQlUzqxmTVgK7bIgwdBRTg2HK5668oN7xVxkaiRe3YZEzGzfV2XgH5Jmu2T73ljejw==",
"license": "MIT"
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -1629,18 +1620,6 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -3542,95 +3521,6 @@
"through": "~2.3"
}
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3686,45 +3576,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -4118,15 +3969,6 @@
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/sql-escaper": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "ln-bi",
"private": true,
"version": "1.1.13",
"version": "1.1.5",
"type": "module",
"scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
@@ -12,7 +12,6 @@
"lint": "tsc --noEmit"
},
"dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0",
"@types/jsonwebtoken": "^9.0.10",
"ali-oss": "^6.23.0",
@@ -22,7 +21,6 @@
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"mysql2": "^3.11.0",
"pg": "^8.22.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.8.1",
@@ -33,7 +31,6 @@
"@tailwindcss/vite": "^4.1.14",
"@types/ali-oss": "^6.23.3",
"@types/node": "^22.14.0",
"@types/pg": "^8.20.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.4",

View File

@@ -1,190 +1,67 @@
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { Truck, Route, Activity, Fuel, BatteryCharging, Receipt, MapPinned } from "lucide-react";
import { Shell, type ModuleConfig } from "./components/Shell";
import AuthProvider from "./auth/AuthProvider";
import { useAuth } from "./auth/useAuth";
import UnauthorizedPage from "./auth/UnauthorizedPage";
import { canAccessScheduling, canAccessEnergy } from "./shared/auth/roles";
import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface";
import { useEffect, useMemo, useState } from 'react';
import { Truck, Route, Activity, Zap } from 'lucide-react';
import { Shell, type ModuleConfig } from './components/Shell';
import AssetsModule from './modules/assets/AssetsModule';
import MileageModule from './modules/mileage/MileageModule';
import SchedulingModule from './modules/scheduling/SchedulingModule';
import EnergyModule from './modules/energy/EnergyModule';
import EleImportPage from './modules/ele/EleImportPage';
import FeedbackAdminPage from './modules/admin/FeedbackAdminPage';
import AuthProvider from './auth/AuthProvider';
import { useAuth } from './auth/useAuth';
import UnauthorizedPage from './auth/UnauthorizedPage';
import { canAccessScheduling, canAccessEnergy } from './shared/auth/roles';
const AssetsModule = lazy(() => import("./modules/assets/AssetsModule"));
const MileageModule = lazy(() => import("./modules/mileage/MileageModule"));
const SchedulingModule = lazy(() => import("./modules/scheduling/SchedulingModule"));
const HydrogenModule = lazy(() => import("./modules/energy/HydrogenModule"));
const ElectricModule = lazy(() => import("./modules/energy/ElectricModule"));
const EtcModule = lazy(() => import("./modules/energy/EtcModule"));
const EleImportPage = lazy(() => import("./modules/ele/EleImportPage"));
const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage"));
const VehicleHeatmapModule = lazy(() => import("./modules/vehicle-heatmap/VehicleHeatmapModule"));
const HydrogenHeatmapModule = lazy(() => import("./modules/hydrogen-heatmap/HydrogenHeatmapModule"));
const BASE_MODULES: ModuleConfig[] = [
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
];
const ASSETS_MODULE: ModuleConfig = {
id: "assets",
label: "资产管理",
icon: Truck,
component: AssetsModule,
};
const MILEAGE_MODULE: ModuleConfig = {
id: "mileage",
label: "里程管理",
icon: Route,
component: MileageModule,
};
const VEHICLE_HEATMAP_MODULE: ModuleConfig = {
id: "vehicle-heatmap",
label: "车辆",
icon: MapPinned,
component: VehicleHeatmapModule,
const ENERGY_MODULE: ModuleConfig = {
id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule,
};
const SCHEDULING_MODULE: ModuleConfig = {
id: "scheduling",
label: "智能调度",
icon: Activity,
component: SchedulingModule,
id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule,
};
const HYDROGEN_MODULE: ModuleConfig = {
id: "hydrogen",
label: "氢能",
icon: Fuel,
component: HydrogenModule,
};
const HYDROGEN_HEATMAP_MODULE: ModuleConfig = {
id: "hydrogen-heatmap",
label: "加氢",
icon: MapPinned,
component: HydrogenHeatmapModule,
};
const ELECTRIC_MODULE: ModuleConfig = {
id: "electric",
label: "电能",
icon: BatteryCharging,
component: ElectricModule,
};
const ETC_MODULE: ModuleConfig = {
id: "etc",
label: "ETC",
icon: Receipt,
component: EtcModule,
};
/**
* 把旧路径 / 根路径归一化到 `/asset` 或 `/energy` 主路径,
* 必要时携带 hash 一段定位到具体模块。已是主路径或后台管理页则不动。
*/
function normalizePath() {
if (typeof window === "undefined") return;
const { pathname, hash, search } = window.location;
// 主路径 & 隐藏后台页保持不变
if (pathname === "/asset" || pathname === "/energy") return;
if (pathname === "/ele/import" || pathname === "/admin/feedback") return;
const legacyMap: Record<string, { path: string; hash?: string }> = {
"/": { path: "/asset" },
"/vehicle": { path: "/asset", hash: "assets" },
"/assets": { path: "/asset", hash: "assets" },
"/mileage": { path: "/asset", hash: "mileage" },
"/scheduling": { path: "/asset", hash: "scheduling" },
};
// 未知路径兜底到 /asset保留原 hash 让 Shell 内部继续解析)
const target = legacyMap[pathname] ?? { path: "/asset" };
const finalHash = target.hash ? `#${target.hash}` : hash || "";
window.history.replaceState(null, "", `${target.path}${search}${finalHash}`);
}
normalizePath();
type PathSet = "asset" | "energy";
function getPathSet(): PathSet {
return window.location.pathname === "/energy" ? "energy" : "asset";
}
function getRouteKey(): string {
if (typeof window === "undefined") return "";
if (typeof window === 'undefined') return '';
const path = window.location.pathname;
const hash = window.location.hash;
if (
path === "/ele/import" ||
hash === "#/ele/import" ||
hash === "#ele/import"
)
return "ele/import";
if (
path === "/admin/feedback" ||
hash === "#/admin/feedback" ||
hash === "#admin/feedback"
)
return "admin/feedback";
return "";
if (path === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') return 'ele/import';
if (path === '/admin/feedback' || hash === '#/admin/feedback' || hash === '#admin/feedback') return 'admin/feedback';
return '';
}
function AuthGate() {
const { isLoading, isAuthenticated, error, user } = useAuth();
const [routeKey, setRouteKey] = useState(getRouteKey);
const [pathSet, setPathSet] = useState<PathSet>(getPathSet);
// 监听 hashchange / popstate让 a href="#/..." 跳转与浏览器前进后退能即时生效
// 监听 hashchange / popstate让 a href="#/..." 跳转能即时生效
useEffect(() => {
const update = () => {
setRouteKey(getRouteKey());
setPathSet(getPathSet());
};
window.addEventListener("hashchange", update);
window.addEventListener("popstate", update);
const update = () => setRouteKey(getRouteKey());
window.addEventListener('hashchange', update);
window.addEventListener('popstate', update);
return () => {
window.removeEventListener("hashchange", update);
window.removeEventListener("popstate", update);
window.removeEventListener('hashchange', update);
window.removeEventListener('popstate', update);
};
}, []);
useEffect(() => {
document.title = pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI";
}, [pathSet]);
const modules = useMemo<ModuleConfig[]>(() => {
if (pathSet === "energy") {
return [HYDROGEN_MODULE, ELECTRIC_MODULE, ETC_MODULE];
}
const heatmapChildren = canAccessEnergy(user?.roles)
? [HYDROGEN_HEATMAP_MODULE, VEHICLE_HEATMAP_MODULE]
: [VEHICLE_HEATMAP_MODULE];
const heatmapModule: ModuleConfig = {
id: "heatmap",
label: "热力图",
icon: MapPinned,
children: heatmapChildren,
};
const result: ModuleConfig[] = [ASSETS_MODULE, MILEAGE_MODULE, heatmapModule];
const modules = useMemo(() => {
const result = [...BASE_MODULES];
if (canAccessEnergy(user?.roles)) result.push(ENERGY_MODULE);
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
return result;
}, [pathSet, user?.roles]);
}, [user?.roles]);
if (isLoading) {
return (
<div className="min-h-screen bg-[var(--app-bg)] p-4 text-slate-800 md:p-8">
<div className="mx-auto flex max-w-5xl flex-col gap-4">
<div className="rounded-2xl border border-white/70 bg-white/86 p-5 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
<div className="mb-3 text-[11px] font-black text-blue-600">LN BI ACCESS</div>
<div className="text-xl font-black text-slate-950"> BI</div>
<div className="mt-2 text-xs font-bold text-slate-400"></div>
</div>
<SurfaceCard className="p-4">
<div className="grid gap-3 md:grid-cols-4">
{[0, 1, 2, 3].map(item => <SkeletonBlock key={item} className="h-24" />)}
</div>
<div className="mt-4">
<LoadingState label="正在验证身份" />
</div>
</SurfaceCard>
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
<div className="text-center">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
<p className="text-xs text-slate-400 font-bold">...</p>
</div>
</div>
);
@@ -195,28 +72,10 @@ function AuthGate() {
}
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
if (routeKey === "ele/import") {
return (
<Suspense fallback={<LoadingState label="正在加载导入工作台" />}>
<EleImportPage />
</Suspense>
);
}
if (routeKey === "admin/feedback") {
return (
<Suspense fallback={<LoadingState label="正在加载反馈后台" />}>
<FeedbackAdminPage />
</Suspense>
);
}
if (routeKey === 'ele/import') return <EleImportPage />;
if (routeKey === 'admin/feedback') return <FeedbackAdminPage />;
// /energy 整组按能源权限控制
if (pathSet === "energy" && !canAccessEnergy(user?.roles)) {
return <UnauthorizedPage message="无能源管理模块访问权限" />;
}
// key={pathSet} 让两套底栏切换时 Shell 内部 state 重置,避免残留旧 activeModule
return <Shell key={pathSet} modules={modules} />;
return <Shell modules={modules} />;
}
export default function App() {

View File

@@ -82,7 +82,19 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
const jumpToken = params.get('jumpToken');
if (!jumpToken) {
setState({ isLoading: false, isAuthenticated: false, user: null, error: '请从业务系统跳转访问' });
// 临时:本地开发免登录,含智能调度权限
setState({
isLoading: false,
isAuthenticated: true,
user: {
userId: '1105261382487539712',
userName: '本地调试',
permissionLevel: 'full',
depName: '',
roles: ['BI-SCHEDULE-OPT'],
},
error: null,
});
return;
}

View File

@@ -1,24 +1,19 @@
import { ShieldX, Monitor, Smartphone } from 'lucide-react';
import { PageFrame, SurfaceCard } from '../components/ui/surface';
export default function UnauthorizedPage({ message }: { message?: string }) {
return (
<PageFrame
title="未授权访问"
subtitle={message || '获取用户认证信息失败,可能是跳转令牌已过期或无效。'}
icon={ShieldX}
eyebrow="ACCESS CONTROL"
meta="请从授权入口重新进入"
maxWidth="max-w-xl"
>
<SurfaceCard className="p-4">
<div className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<ShieldX size={30} />
</div>
<p className="mb-4 text-[10px] font-bold uppercase tracking-wider text-slate-400"></p>
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center p-6">
<div className="text-center max-w-sm">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-slate-100 flex items-center justify-center">
<ShieldX size={36} className="text-slate-400" />
</div>
<div className="space-y-3">
<h1 className="text-lg font-black text-slate-800 mb-2">访</h1>
<p className="text-xs text-slate-400 mb-4">
{message || '获取用户认证信息失败,可能是跳转令牌已过期或无效'}
</p>
<div className="bg-white rounded-2xl border border-slate-100 p-4 text-left space-y-3">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider text-center"></p>
<div className="flex items-start gap-3 p-2.5 rounded-xl bg-slate-50">
<Monitor size={16} className="text-blue-500 flex-shrink-0 mt-0.5" />
@@ -36,7 +31,7 @@ export default function UnauthorizedPage({ message }: { message?: string }) {
</div>
</div>
</div>
</SurfaceCard>
</PageFrame>
</div>
</div>
);
}

View File

@@ -1,88 +1,71 @@
import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react';
import { motion } from 'motion/react';
import { Building2, ChevronRight, ShieldCheck } from 'lucide-react';
import { useState, useEffect, useMemo, type ComponentType } from 'react';
import { useAuth } from '../auth/useAuth';
import { DemoModeProvider } from './Blur';
import FeedbackFab from './FeedbackFab';
import { cn } from '../lib/cn';
import { LoadingState } from './ui/surface';
export interface ModuleConfig {
id: string;
label: string;
icon: ComponentType<{ size?: number; className?: string }>;
component?: ElementType;
children?: ModuleConfig[];
component: ComponentType;
}
function flattenModules(modules: ModuleConfig[]): ModuleConfig[] {
return modules.flatMap((module) => module.children ?? [module]);
}
/** hash 一级段(`#<id>` 或 `#<id>/<sub>` 都只取 id */
function getHashHead(): string {
return window.location.hash.slice(1).split('/')[0];
}
/** path 到模块 id 的映射 */
const PATH_MAP: Record<string, string> = {
'/vehicle': 'assets',
'/assets': 'assets',
'/mileage': 'mileage',
'/scheduling': 'scheduling',
'/energy': 'energy',
};
function getInitialModule(modules: ModuleConfig[]): string {
const head = getHashHead();
if (modules.some((m) => m.id === head)) return head;
// 优先看 hash
const hash = window.location.hash.slice(1);
if (modules.some((m) => m.id === hash)) return hash;
// 再看 pathname
const pathModule = PATH_MAP[window.location.pathname];
if (pathModule && modules.some((m) => m.id === pathModule)) return pathModule;
// 默认第一个
return modules[0]?.id ?? '';
}
function getHashModule(modules: ModuleConfig[]): string {
const head = getHashHead();
return modules.some((m) => m.id === head) ? head : '';
const hash = window.location.hash.slice(1);
return modules.some((m) => m.id === hash) ? hash : '';
}
export function Shell({ modules }: { modules: ModuleConfig[] }) {
const flatModules = useMemo(() => flattenModules(modules), [modules]);
const [activeModule, setActiveModule] = useState(() => getInitialModule(flattenModules(modules)));
const [openGroupId, setOpenGroupId] = useState<string | null>(null);
const [activeModule, setActiveModule] = useState(() => getInitialModule(modules));
useEffect(() => {
const onHashChange = () => {
const h = getHashModule(flatModules);
const h = getHashModule(modules);
if (h) setActiveModule(h);
};
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, [flatModules]);
}, [modules]);
useEffect(() => {
if (!openGroupId) return undefined;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpenGroupId(null);
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [openGroupId]);
useEffect(() => {
// 同步 hash 一段到当前模块:使用 replaceState 避免产生多余的 history 记录,
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出。
// 注意只比对一级段,避免把子模块写入的 `#<id>/<sub>` 二级段抹掉。
if (getHashHead() !== activeModule) {
// 同步 hash 到当前模块:使用 replaceState 避免产生多余的 history 记录,
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出
if (window.location.hash.slice(1) !== activeModule) {
const { pathname, search } = window.location;
window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`);
}
}, [activeModule]);
const switchModule = (id: string) => {
setOpenGroupId(null);
if (getHashHead() === id) {
setActiveModule(id);
return;
}
if (window.location.hash.slice(1) === id) return;
const { pathname, search } = window.location;
window.history.replaceState(null, '', `${pathname}${search}#${id}`);
setActiveModule(id);
};
const ActiveComponent = flatModules.find((module) => module.id === activeModule)?.component ?? flatModules[0]?.component;
const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component;
const { user } = useAuth();
const activeLabel = flatModules.find((module) => module.id === activeModule)?.label ?? '业务看板';
const watermarkText = useMemo(() => {
const name = user?.userName || '未登录';
const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-');
@@ -91,7 +74,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
return (
<DemoModeProvider enabled={false}>
<div className="enterprise-grid-bg flex min-h-screen">
<div className="flex min-h-screen">
{/* 全局水印 */}
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
<div className="absolute inset-0" style={{
@@ -100,114 +83,49 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
}} />
</div>
{/* Web 侧边栏 (md 及以上) */}
<nav className="fixed left-0 top-0 z-50 hidden h-full w-20 flex-col items-center border-r border-white/10 bg-slate-950 px-2 py-4 text-white shadow-[12px_0_40px_rgba(15,23,42,0.16)] md:flex">
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-white text-blue-600 shadow-lg shadow-blue-950/20">
<Building2 size={22} />
</div>
<div className="flex w-full flex-1 flex-col items-center gap-2">
{modules.map((m) => {
const Icon = m.icon;
const isGroup = Boolean(m.children?.length);
const isActive = m.id === activeModule || Boolean(m.children?.some((child) => child.id === activeModule));
const isOpen = openGroupId === m.id;
return (
<div key={m.id} className="relative">
<button
type="button"
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)}
aria-expanded={isGroup ? isOpen : undefined}
aria-haspopup={isGroup ? 'menu' : undefined}
className={cn(
'group relative flex h-16 w-16 flex-col items-center justify-center rounded-2xl text-[10px] font-black transition-all',
isActive ? 'text-white' : 'text-slate-400 hover:bg-white/8 hover:text-white',
)}
title={m.label}
>
{isActive ? (
<motion.span layoutId="desktop-shell-active" className="absolute inset-0 rounded-2xl bg-blue-600 shadow-lg shadow-blue-950/30" transition={{ type: 'spring', stiffness: 430, damping: 34 }} />
) : null}
<Icon size={21} className="relative mb-1" />
<span className="relative leading-tight">{m.label}</span>
{isGroup ? <ChevronRight size={11} className={cn('absolute right-1 top-1/2 -translate-y-1/2 transition-transform', isOpen && 'rotate-180')} /> : null}
</button>
{isGroup && isOpen ? (
<div role="menu" aria-label={`${m.label}二级菜单`} className="absolute left-[calc(100%+12px)] top-1/2 w-36 -translate-y-1/2 rounded-2xl border border-slate-700 bg-slate-900 p-2 shadow-2xl shadow-slate-950/30">
{m.children!.map((child) => {
const ChildIcon = child.icon;
const childActive = child.id === activeModule;
return (
<button key={child.id} type="button" role="menuitem" onClick={() => switchModule(child.id)} className={cn('flex h-11 w-full items-center gap-2 rounded-xl px-3 text-xs font-black transition-colors', childActive ? 'bg-blue-600 text-white' : 'text-slate-300 hover:bg-white/8 hover:text-white')}>
<ChildIcon size={16} />{child.label}
</button>
);
})}
</div>
) : null}
</div>
);
})}
</div>
<div className="flex w-full flex-col items-center gap-2 border-t border-white/10 pt-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-400/10 text-emerald-300 ring-1 ring-emerald-300/20" title={user?.userName || '当前用户'}>
<ShieldCheck size={18} />
</div>
<div className="max-w-16 truncate text-center text-[9px] font-bold text-slate-400">{user?.userName || '未登录'}</div>
</div>
<nav className="hidden md:flex flex-col items-center w-16 bg-white border-r border-gray-100 fixed top-0 left-0 h-full z-50 py-6 gap-2">
{modules.map((m) => {
const Icon = m.icon;
const isActive = m.id === activeModule;
return (
<button
key={m.id}
onClick={() => switchModule(m.id)}
className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${
isActive
? 'bg-blue-50 text-blue-600'
: 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
}`}
>
<Icon size={22} />
<span className="text-[10px] mt-1 leading-tight">{m.label}</span>
</button>
);
})}
</nav>
{/* 内容区 */}
<main className="min-w-0 flex-1 pb-16 md:ml-20 md:pb-0" style={{ overflowX: 'clip' }}>
<div className="pointer-events-none fixed left-20 right-0 top-0 z-20 hidden h-12 items-center border-b border-white/60 bg-white/55 px-6 text-xs font-bold text-slate-400 backdrop-blur-xl md:flex">
<span className="text-slate-600"> BI</span>
<span className="mx-2 text-slate-300">/</span>
<span>{activeLabel}</span>
</div>
<Suspense fallback={<div className="p-3 md:p-6"><LoadingState label="正在加载业务模块" /></div>}>
{ActiveComponent && <ActiveComponent />}
</Suspense>
<main className="flex-1 md:ml-16 pb-16 md:pb-0 min-w-0" style={{ overflowX: 'clip' }}>
{ActiveComponent && <ActiveComponent />}
<FeedbackFab module={activeModule} />
</main>
{/* 移动端底部导航 (md 以下) */}
<nav className="fixed inset-x-0 bottom-0 z-50 border-t border-white/70 bg-white/92 px-3 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 shadow-[0_-18px_40px_rgba(15,23,42,0.08)] backdrop-blur-xl md:hidden">
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${modules.length}, minmax(0, 1fr))` }}>
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-2 flex justify-around items-center md:hidden z-50">
{modules.map((m) => {
const Icon = m.icon;
const isGroup = Boolean(m.children?.length);
const isActive = m.id === activeModule || Boolean(m.children?.some((child) => child.id === activeModule));
const isOpen = openGroupId === m.id;
const isActive = m.id === activeModule;
return (
<div key={m.id} className="relative flex justify-center">
<button
type="button"
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)}
aria-expanded={isGroup ? isOpen : undefined}
aria-haspopup={isGroup ? 'menu' : undefined}
className={cn('relative flex min-h-12 w-full flex-col items-center justify-center gap-0.5 rounded-2xl text-[10px] font-medium transition-colors', isActive ? 'text-blue-700' : 'text-slate-400')}
>
{isActive ? (
<motion.span layoutId="mobile-shell-active" className="absolute inset-0 rounded-2xl bg-blue-50 ring-1 ring-blue-100" transition={{ type: 'spring', stiffness: 430, damping: 34 }} />
) : null}
<Icon size={20} className="relative" />
<span className="relative">{m.label}</span>
</button>
{isGroup && isOpen ? (
<div role="menu" aria-label={`${m.label}二级菜单`} className="absolute bottom-[calc(100%+14px)] left-1/2 w-36 -translate-x-1/2 rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl shadow-slate-900/15">
{m.children!.map((child) => {
const ChildIcon = child.icon;
const childActive = child.id === activeModule;
return (
<button key={child.id} type="button" role="menuitem" onClick={() => switchModule(child.id)} className={cn('flex h-11 w-full items-center gap-2 rounded-xl px-3 text-xs font-medium transition-colors', childActive ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-50')}>
<ChildIcon size={16} />{child.label}
</button>
);
})}
</div>
) : null}
</div>
<button
key={m.id}
onClick={() => switchModule(m.id)}
className={`flex flex-col items-center ${isActive ? 'text-blue-600' : 'text-gray-400'}`}
>
<Icon size={20} />
<span className="text-[10px] mt-1">{m.label}</span>
</button>
);
})}
</div>
</nav>
</div>
</DemoModeProvider>

View File

@@ -1,287 +0,0 @@
import { useState, type ComponentType, type ReactNode } from 'react';
import { AlertCircle, Info, Loader2, SearchX, X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { cn } from '../../lib/cn';
export type SurfaceIcon = ComponentType<{ size?: number; className?: string }>;
export function PageFrame({
title,
subtitle,
icon: Icon,
eyebrow,
meta,
actions,
children,
maxWidth = 'max-w-6xl',
compactInfo = false,
}: {
title: string;
subtitle?: string;
icon?: SurfaceIcon;
eyebrow?: string;
meta?: ReactNode;
actions?: ReactNode;
children: ReactNode;
maxWidth?: string;
compactInfo?: boolean;
}) {
const [infoOpen, setInfoOpen] = useState(false);
return (
<div className="min-h-screen bg-[var(--app-bg)] text-slate-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
<div className={cn(maxWidth, 'mx-auto flex flex-col gap-4 pb-20 md:pb-6')}>
<motion.header
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28, ease: 'easeOut' }}
className={cn(
'relative rounded-2xl border border-white/70 bg-white/86 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl',
compactInfo ? 'overflow-visible' : 'overflow-hidden',
)}
>
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-blue-600 via-cyan-400 to-emerald-400" />
{compactInfo ? (
<>
<div className="flex min-h-[48px] items-center gap-2 px-3 py-1.5 md:min-h-[54px] md:gap-3 md:px-4 md:py-2">
{Icon ? (
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100 md:h-9 md:w-9">
<Icon size={17} />
</span>
) : null}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
{eyebrow ? <span className="shrink-0 text-[11px] font-black text-blue-600">{eyebrow}</span> : null}
{meta ? <span className="hidden truncate text-[11px] font-bold text-slate-400 sm:inline">{meta}</span> : null}
</div>
<h1 className="mt-0.5 truncate text-base font-black tracking-tight text-slate-950 md:text-lg">{title}</h1>
</div>
{actions ? <div className="hidden shrink-0 items-center gap-2 md:flex">{actions}</div> : null}
{subtitle ? (
<button
type="button"
onClick={() => setInfoOpen(open => !open)}
className={cn(
'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border text-slate-400 transition-colors md:h-9 md:w-9',
infoOpen ? 'border-blue-100 bg-blue-50 text-blue-600' : 'border-slate-100 bg-slate-50 hover:bg-blue-50 hover:text-blue-600',
)}
aria-label={infoOpen ? '收起页面说明' : '展开页面说明'}
title={infoOpen ? '收起说明' : '页面说明'}
>
{infoOpen ? <X size={16} /> : <Info size={16} />}
</button>
) : null}
</div>
<AnimatePresence initial={false}>
{infoOpen && subtitle ? (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="absolute left-0 right-0 top-full z-40 mt-2 overflow-hidden rounded-2xl border border-slate-100 bg-white shadow-xl md:static md:mt-0 md:rounded-none md:border-x-0 md:border-b-0 md:shadow-none"
>
<div className="px-4 py-3 text-xs font-bold leading-relaxed text-slate-500 md:text-sm">
{subtitle}
</div>
</motion.div>
) : null}
</AnimatePresence>
</>
) : (
<div className="flex flex-col gap-4 p-4 md:flex-row md:items-end md:justify-between md:p-5">
<div className="min-w-0">
<div className="mb-2 flex flex-wrap items-center gap-2">
{Icon ? (
<span className="inline-flex h-8 w-8 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
<Icon size={17} />
</span>
) : null}
{eyebrow ? <span className="text-[11px] font-black text-blue-600">{eyebrow}</span> : null}
{meta ? <span className="text-[11px] font-bold text-slate-400">{meta}</span> : null}
</div>
<h1 className="truncate text-xl font-black tracking-tight text-slate-950 md:text-2xl">{title}</h1>
{subtitle ? <p className="mt-2 max-w-3xl text-xs font-bold leading-relaxed text-slate-500 md:text-sm">{subtitle}</p> : null}
</div>
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
</div>
)}
</motion.header>
{children}
</div>
</div>
);
}
export function SurfaceCard({
title,
subtitle,
actions,
children,
className,
}: {
title?: string;
subtitle?: string;
actions?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<section className={cn('rounded-2xl border border-slate-100 bg-white shadow-sm', className)}>
{(title || subtitle || actions) && (
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-slate-100 px-4 py-3">
<div>
{title ? <h2 className="text-sm font-black text-slate-900">{title}</h2> : null}
{subtitle ? <p className="mt-1 text-[11px] font-bold text-slate-400">{subtitle}</p> : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</div>
)}
{children}
</section>
);
}
export function MetricTile({
label,
value,
unit,
helper,
icon: Icon,
tone = 'blue',
}: {
label: string;
value: ReactNode;
unit?: string;
helper?: string;
icon?: SurfaceIcon;
tone?: 'blue' | 'emerald' | 'amber' | 'rose' | 'slate';
}) {
const toneClass = {
blue: 'bg-blue-50 text-blue-600 ring-blue-100',
emerald: 'bg-emerald-50 text-emerald-600 ring-emerald-100',
amber: 'bg-amber-50 text-amber-600 ring-amber-100',
rose: 'bg-rose-50 text-rose-600 ring-rose-100',
slate: 'bg-slate-100 text-slate-600 ring-slate-200',
}[tone];
return (
<div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 text-[11px] font-black text-slate-400">{label}</div>
{Icon ? (
<span className={cn('inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1', toneClass)}>
<Icon size={18} />
</span>
) : null}
</div>
<div className="mt-3 flex min-w-0 items-end gap-1">
<span className="min-w-0 whitespace-nowrap text-[clamp(1.65rem,6vw,2rem)] font-black leading-none tracking-tight text-slate-950 tabular-nums">
{value}
</span>
{unit ? <span className="shrink-0 pb-0.5 text-[11px] font-black text-slate-400">{unit}</span> : null}
</div>
{helper ? <div className="mt-3 text-[11px] font-bold leading-relaxed text-slate-500">{helper}</div> : null}
</div>
);
}
export function SegmentedNav<T extends string>({
tabs,
active,
onChange,
className,
}: {
tabs: readonly { id: T; label: string; icon?: SurfaceIcon }[];
active: T;
onChange: (id: T) => void;
className?: string;
}) {
return (
<div className={cn('rounded-2xl border border-white/70 bg-white/90 p-1 shadow-sm backdrop-blur-xl', className)}>
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))` }}>
{tabs.map(({ id, label, icon: Icon }) => {
const isActive = active === id;
return (
<button
key={id}
type="button"
onClick={() => onChange(id)}
className={cn(
'relative flex min-h-10 items-center justify-center gap-1.5 rounded-xl px-2 text-[12px] font-black transition-colors',
isActive ? 'text-blue-700' : 'text-slate-400 hover:bg-slate-50 hover:text-slate-600',
)}
>
{isActive ? (
<motion.span
layoutId="segmented-active-pill"
className="absolute inset-0 rounded-xl bg-blue-50 shadow-sm ring-1 ring-blue-100"
transition={{ type: 'spring', stiffness: 430, damping: 34 }}
/>
) : null}
{Icon ? <Icon size={15} className="relative" /> : null}
<span className="relative truncate">{label}</span>
</button>
);
})}
</div>
</div>
);
}
export function SkeletonBlock({ className }: { className?: string }) {
return <div className={cn('overflow-hidden rounded-xl bg-slate-100 shimmer', className)} />;
}
export function LoadingState({ label = '数据加载中' }: { label?: string }) {
return (
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-sm">
<Loader2 className="mx-auto mb-3 animate-spin text-blue-500" size={22} />
<div className="text-xs font-black text-slate-500">{label}</div>
</div>
);
}
export function EmptyState({
title = '暂无数据',
description = '换个筛选条件或稍后再试',
}: {
title?: string;
description?: string;
}) {
return (
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-sm">
<SearchX className="mx-auto mb-3 text-slate-300" size={26} />
<div className="text-sm font-black text-slate-700">{title}</div>
<div className="mt-1 text-xs font-bold text-slate-400">{description}</div>
</div>
);
}
export function ErrorState({ message }: { message: string }) {
return (
<div className="rounded-2xl border border-rose-100 bg-rose-50 p-4 text-rose-700 shadow-sm">
<div className="flex items-start gap-2">
<AlertCircle size={18} className="mt-0.5 shrink-0" />
<div>
<div className="text-sm font-black"></div>
<div className="mt-1 text-xs font-bold leading-relaxed">{message}</div>
</div>
</div>
</div>
);
}
export function FadeIn({ children, className }: { children: ReactNode; className?: string }) {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
className={cn('w-full min-w-0', className)}
>
{children}
</motion.div>
);
}

View File

@@ -1,15 +1,8 @@
@import "tailwindcss";
:root {
--app-bg: #f4f7fb;
--panel-bg: rgba(255, 255, 255, 0.9);
--hairline: rgba(148, 163, 184, 0.18);
}
html, body {
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
background: var(--app-bg);
}
html {
@@ -20,79 +13,4 @@ html {
body {
overflow: auto;
height: 100%;
color: #0f172a;
background: #fff;
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button, input, select {
font-family: inherit;
}
.amap-logo,
.amap-copyright {
opacity: 0.82;
}
.amap-toolbar {
border: 1px solid #e2e8f0 !important;
border-radius: 10px !important;
box-shadow: 0 8px 24px rgb(15 23 42 / 10%) !important;
overflow: hidden;
}
@media (max-width: 767px) {
.amap-logo,
.amap-copyright {
bottom: 60px !important;
}
}
@keyframes marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@keyframes floatUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@utility animate-marquee {
animation: marquee 30s linear infinite;
}
@utility no-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar { display: none; }
}
.shimmer {
position: relative;
}
.shimmer::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.72), transparent);
animation: shimmer 1.4s infinite;
}
.enterprise-grid-bg {
background:
radial-gradient(circle at 16% 0%, rgba(59, 130, 246, 0.08), transparent 28%),
radial-gradient(circle at 90% 12%, rgba(20, 184, 166, 0.08), transparent 26%),
linear-gradient(180deg, #f8fbff 0%, var(--app-bg) 42%, #f7f9fc 100%);
}
.asset-date-input::-webkit-calendar-picker-indicator {
cursor: pointer;
opacity: 0;
}

View File

@@ -1,3 +0,0 @@
export function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ');
}

View File

@@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react';
import { fetchJson } from '../../auth/api-client';
import { EmptyState, LoadingState, PageFrame, SurfaceCard } from '../../components/ui/surface';
interface FeedbackItem {
id: number;
@@ -21,10 +20,10 @@ interface FeedbackItem {
}
const TYPE_LABEL: Record<string, string> = {
dimension: '新维度',
bug: 'Bug',
ux: '体验',
other: '其他',
dimension: '💡 新维度',
bug: '🐛 Bug',
ux: '🎨 体验',
other: '📝 其他',
};
const STATUS_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [
@@ -127,36 +126,36 @@ export default function FeedbackAdminPage() {
}, {});
return (
<PageFrame
title="用户反馈管理"
subtitle="查看、回复、跟进用户提交的建议,形成从问题发现到处理闭环的运营后台。"
icon={Inbox}
eyebrow="FEEDBACK OPS"
meta={`当前 ${items.length} 条反馈`}
actions={(
<div className="flex items-center gap-2">
<div className="min-h-screen bg-[#F8F9FB] p-4 md:p-8">
<div className="max-w-5xl mx-auto space-y-4">
<header className="flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0">
<button
onClick={() => {
// 优先 history.back来自 SPA 内部跳转);否则回到主页
if (window.history.length > 1) window.history.back();
else { window.location.hash = '#mileage'; }
}}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-100 bg-white text-slate-500 transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600"
className="w-9 h-9 rounded-xl bg-white border border-slate-100 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 text-slate-500 flex items-center justify-center transition-colors flex-shrink-0"
title="返回"
>
<ArrowLeft size={16} />
</button>
<button onClick={reload} className="flex h-9 w-9 items-center justify-center rounded-xl bg-slate-900 text-white shadow-sm transition-colors hover:bg-slate-800" title="刷新">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center flex-shrink-0">
<Inbox size={18} className="text-white" />
</div>
<div className="min-w-0">
<h1 className="text-lg font-black text-slate-900 leading-tight"></h1>
<p className="text-[11px] font-bold text-slate-400"></p>
</div>
</div>
<button onClick={reload} className="p-2 text-slate-400 hover:text-blue-500 flex-shrink-0" title="刷新">
<RotateCcw size={16} className={loading ? 'animate-spin' : ''} />
</button>
</div>
)}
maxWidth="max-w-5xl"
>
</header>
{/* 状态过滤 */}
<SurfaceCard className="p-2">
<div className="flex items-center gap-1 overflow-x-auto">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-2 flex items-center gap-1 overflow-x-auto">
<button
onClick={() => setStatusFilter('')}
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === '' ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
@@ -171,7 +170,6 @@ export default function FeedbackAdminPage() {
</button>
))}
</div>
</SurfaceCard>
{error && (
<div className="bg-rose-50 border border-rose-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-rose-600">
@@ -194,9 +192,9 @@ export default function FeedbackAdminPage() {
{/* 列表 */}
<div className="space-y-2">
{loading && items.length === 0 ? (
<LoadingState label="正在加载用户反馈" />
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold"></div>
) : items.length === 0 ? (
<EmptyState title="还没有反馈" description="新的用户反馈会出现在这里" />
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold"></div>
) : items.map(it => {
const shots = parseScreenshots(it.screenshots);
const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status);
@@ -220,7 +218,7 @@ export default function FeedbackAdminPage() {
{(shots.length > 0 || it.contact) && (
<div className="flex items-center gap-3 mt-2 text-[10px] text-slate-400 font-bold">
{shots.length > 0 && <span className="flex items-center gap-0.5"><ImageIcon size={11} />{shots.length} </span>}
{it.contact && <span> {it.contact}</span>}
{it.contact && <span>📞 {it.contact}</span>}
</div>
)}
{it.reply_content && (
@@ -238,6 +236,8 @@ export default function FeedbackAdminPage() {
);
})}
</div>
</div>
{/* 详情 / 回复弹窗 */}
<AnimatePresence>
{active && (
@@ -336,6 +336,6 @@ export default function FeedbackAdminPage() {
</motion.div>
)}
</AnimatePresence>
</PageFrame>
</div>
);
}

View File

@@ -14,12 +14,8 @@ import {
Filter,
ArrowRightLeft,
MapPin,
Download,
CalendarDays,
X,
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import * as XLSX from 'xlsx';
import {
BarChart,
Bar,
@@ -34,13 +30,12 @@ import {
LabelList,
} from 'recharts';
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types';
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, fetchFlowStats, type SubjectOption } from './api';
import type { FlowDetailItem, FlowStatsResponse, FlowType, WeeklyDetailItem } from './api';
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, type SubjectOption } from './api';
import type { WeeklyDetailItem } from './api';
import { SearchSelect } from '../../components/SearchSelect';
import { MultiSearchSelect } from '../../components/MultiSearchSelect';
import Blur from '../../components/Blur';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { ErrorState, LoadingState, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface';
// --- Constants ---
@@ -51,79 +46,6 @@ const TABS = [
{ id: 'customer', label: '按客户' },
];
function MarqueeBanner() {
const trackRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const [overflow, setOverflow] = useState(false);
useEffect(() => {
const check = () => {
if (!trackRef.current || !innerRef.current) return;
setOverflow(innerRef.current.scrollWidth > trackRef.current.clientWidth);
};
check();
const ro = new ResizeObserver(check);
ro.observe(trackRef.current!);
return () => ro.disconnect();
}, []);
const text = '车辆资产已于 2026 年 6 月 18 日完成“运营状态”与“业务关联”校验';
return (
<div className="relative -mx-6 mb-4 bg-green-50 border-y border-green-200">
<div ref={trackRef} className="overflow-hidden">
<div className={`flex w-max py-2 ${overflow ? 'animate-marquee' : 'w-full justify-center'}`}>
<span ref={innerRef} className="inline-block whitespace-nowrap px-6 text-xs text-green-700 font-medium">
{text}
</span>
{overflow && (
<span className="inline-block whitespace-nowrap px-6 text-xs text-green-700 font-medium">
{text}
</span>
)}
</div>
</div>
</div>
);
}
function formatLocalDateTime(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const hh = String(date.getHours()).padStart(2, '0');
const mm = String(date.getMinutes()).padStart(2, '0');
const ss = String(date.getSeconds()).padStart(2, '0');
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`;
}
function formatLocalDate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function addDays(date: Date, days: number): Date {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
function getWeeklyFlowRange() {
const now = new Date();
const day = now.getDay();
const end = day === 6 ? addDays(now, -1) : day === 0 ? addDays(now, -2) : addDays(now, 5 - day);
const start = addDays(end, -6);
return { start: formatLocalDate(start), end: formatLocalDate(end) };
}
const FLOW_META: Record<FlowType, { label: string; tone: string; chip: string }> = {
delivered: { label: '交车', tone: 'text-blue-600 bg-blue-50 border-blue-100', chip: 'bg-blue-50 text-blue-700 border-blue-100' },
returned: { label: '还车', tone: 'text-orange-600 bg-orange-50 border-orange-100', chip: 'bg-orange-50 text-orange-700 border-orange-100' },
replaced: { label: '替换', tone: 'text-violet-600 bg-violet-50 border-violet-100', chip: 'bg-violet-50 text-violet-700 border-violet-100' },
};
export default function AssetsModule() {
const [activeTab, setActiveTab] = useState<'overview' | 'department' | 'region' | 'customer'>('overview');
const [tabReady, setTabReady] = useState(true);
@@ -170,13 +92,8 @@ export default function AssetsModule() {
const [modalWeeklyDetail, setModalWeeklyDetail] = useState<WeeklyDetailItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdate, setLastUpdate] = useState<string>(() => formatLocalDateTime(new Date()));
const [lastUpdate, setLastUpdate] = useState<string>('');
const [modalLoading, setModalLoading] = useState(false);
const [flowRange, setFlowRange] = useState(() => getWeeklyFlowRange());
const [flowStats, setFlowStats] = useState<FlowStatsResponse | null>(null);
const [flowLoading, setFlowLoading] = useState(false);
const [flowDailyExpanded, setFlowDailyExpanded] = useState(false);
const [selectedFlow, setSelectedFlow] = useState<{ date: string; type: FlowType } | null>(null);
// Dept/Region/Customer data
const [deptData, setDeptData] = useState<DeptGroup[]>([]);
@@ -245,7 +162,7 @@ export default function AssetsModule() {
setRegionData(region);
setCustomerData(cust);
setInventoryData(inv);
setLastUpdate(formatLocalDateTime(new Date()));
setLastUpdate(new Date().toLocaleString('zh-CN'));
} catch (e) {
setError(e instanceof Error ? e.message : '数据加载失败');
} finally {
@@ -259,24 +176,6 @@ export default function AssetsModule() {
return () => clearInterval(interval);
}, [loadData]);
useEffect(() => {
let cancelled = false;
setFlowLoading(true);
fetchFlowStats({ start: flowRange.start, end: flowRange.end, subject: selectedSubject })
.then((data) => {
if (!cancelled) setFlowStats(data);
})
.catch(() => {
if (!cancelled) setFlowStats(null);
})
.finally(() => {
if (!cancelled) setFlowLoading(false);
});
return () => {
cancelled = true;
};
}, [flowRange.start, flowRange.end, selectedSubject]);
// 归属公司列表(仅首次加载,公司集合相对稳定)
useEffect(() => {
fetchSubjects().then(setSubjects).catch(() => setSubjects([]));
@@ -576,40 +475,6 @@ export default function AssetsModule() {
return mp;
}), [modalWeeklyDetail, modalFilters.plateNumber]);
const selectedFlowDetails = useMemo(() => {
if (!flowStats || !selectedFlow) return [];
return flowStats.details.filter((item) => item.date === selectedFlow.date && item.type === selectedFlow.type);
}, [flowStats, selectedFlow]);
const exportFlowDetails = useCallback((rows?: FlowDetailItem[], title = '资产流转明细') => {
const source = rows ?? flowStats?.details ?? [];
if (source.length === 0) return;
const table = source.map((item) => ({
日期: item.date,
类型: item.typeLabel,
车牌: item.plateNumber,
流转时间: item.eventTime || '',
提交时间: item.submitTime || '',
部门: item.department || '',
业务负责人: item.manager || '',
客户: item.customerName || '',
}));
const ws = XLSX.utils.json_to_sheet(table);
ws['!cols'] = [
{ wch: 14 },
{ wch: 8 },
{ wch: 14 },
{ wch: 20 },
{ wch: 20 },
{ wch: 16 },
{ wch: 14 },
{ wch: 24 },
];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '明细');
XLSX.writeFile(wb, `${title}-${flowRange.start}-${flowRange.end}.xlsx`);
}, [flowRange.end, flowRange.start, flowStats]);
const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]);
useEffect(() => {
if (customerChartView === 'province') {
@@ -630,60 +495,38 @@ export default function AssetsModule() {
if (loading && !summary) {
return (
<PageFrame
title="车辆资产中心"
subtitle="正在同步车辆、部门、区域与客户归属数据,加载完成后可继续穿透查看明细。"
icon={Truck}
eyebrow="ASSET INTELLIGENCE"
meta="数据准备中"
>
<SurfaceCard className="min-h-[360px]">
<div className="grid gap-4 md:grid-cols-4">
{[0, 1, 2, 3].map(item => (
<SkeletonBlock key={item} className="h-28" />
))}
</div>
<div className="mt-8">
<LoadingState label="正在加载车辆资产数据" />
</div>
</SurfaceCard>
</PageFrame>
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="animate-spin text-blue-500" size={32} />
<span className="text-sm text-gray-500">...</span>
</div>
</div>
);
}
if (error && !summary) {
return (
<PageFrame
title="车辆资产中心"
subtitle="车辆资产数据暂时没有返回,请重试或稍后再看。"
icon={Truck}
eyebrow="ASSET INTELLIGENCE"
meta="加载失败"
>
<SurfaceCard className="min-h-[360px]">
<div className="mt-6 flex flex-col items-center gap-4">
<ErrorState message={error} />
<button onClick={loadData} className="rounded-xl bg-slate-900 px-4 py-2 text-xs font-black text-white shadow-sm transition-colors hover:bg-slate-800">
</button>
</div>
</SurfaceCard>
</PageFrame>
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
<div className="flex flex-col items-center gap-3 text-center">
<div className="text-red-500 text-lg font-bold"></div>
<div className="text-sm text-gray-500">{error}</div>
<button onClick={loadData} className="mt-2 px-4 py-2 bg-blue-500 text-white rounded text-sm hover:bg-blue-600">
</button>
</div>
</div>
);
}
const SUMMARY = summary!;
const operatingRate = SUMMARY.totalAssets > 0 ? SUMMARY.operating.total / SUMMARY.totalAssets * 100 : 0;
const inventoryRate = SUMMARY.totalAssets > 0 ? SUMMARY.inventory.total / SUMMARY.totalAssets * 100 : 0;
const pendingRate = SUMMARY.totalAssets > 0 ? SUMMARY.pendingDelivery / SUMMARY.totalAssets * 100 : 0;
return (
<div className="min-h-screen bg-[var(--app-bg)] text-gray-800 font-sans p-3 md:p-6 relative">
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-6 relative">
{/* Compact Header Bar */}
<div className="sticky top-0 z-40 -mx-3 -mt-3 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80 md:-mx-6 md:-mt-6">
<div className="sticky top-0 z-40 -mx-6 -mt-6 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80">
{/* Title row */}
<div className="relative flex items-center justify-center px-4 pt-3 pb-1">
<h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide">-BI</h1>
<h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide"></h1>
{/* Right: status + theme */}
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-2">
<div className="hidden sm:flex items-center gap-1 text-[10px] text-gray-400">
@@ -846,12 +689,13 @@ export default function AssetsModule() {
<span className="w-1 h-1 rounded-full bg-blue-400 inline-block" />
: {lastUpdate}
</div>
<div className="flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-green-400 animate-pulse inline-block" />
</div>
</div>
</div>
{/* OneOS 迁移提示滚动条 */}
<MarqueeBanner />
{/* Main Content Area */}
<div className="flex flex-col gap-6">
@@ -864,11 +708,11 @@ export default function AssetsModule() {
{tabReady && activeTab === 'overview' && (
<>
{/* Header Summary - Ultra Compact */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-2">
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-2 mb-2">
{/* Total Assets */}
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-gray-50 transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', source: 'asset', title: '资产概览' })}>
<div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
<div className="w-7 h-7 rounded bg-gray-50 flex items-center justify-center text-gray-400">
<Truck size={14} />
</div>
<div>
@@ -878,9 +722,9 @@ export default function AssetsModule() {
</div>
{/* Operating */}
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-blue-50 transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Operating', source: 'asset', title: '正在运营' })}>
<div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500">
<div className="w-7 h-7 rounded bg-blue-50 flex items-center justify-center text-blue-500">
<Activity size={14} />
</div>
<div>
@@ -893,9 +737,9 @@ export default function AssetsModule() {
</div>
{/* Inventory */}
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-gray-50 transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}>
<div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
<div className="w-7 h-7 rounded bg-gray-50 flex items-center justify-center text-gray-500">
<Warehouse size={14} />
</div>
<div>
@@ -908,9 +752,9 @@ export default function AssetsModule() {
</div>
{/* Pending */}
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-blue-50 transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset', title: '待交车' })}>
<div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500">
<div className="w-7 h-7 rounded bg-blue-50 flex items-center justify-center text-blue-500">
<PlusCircle size={14} />
</div>
<div>
@@ -919,163 +763,41 @@ export default function AssetsModule() {
</div>
</div>
</div>
<div data-testid="asset-operation-ratio-strip" className="mb-3 rounded-2xl border border-slate-100 bg-white/85 px-4 py-3 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="shrink-0 text-[11px] font-black text-slate-400"></div>
<div className="grid min-w-0 flex-1 grid-cols-3 divide-x divide-slate-100 text-center">
<div className="px-2">
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div>
{/* Dynamics */}
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm col-span-2">
<div className="flex items-center justify-between mb-1.5">
<div className="text-[9px] text-gray-400 font-bold uppercase tracking-tight"></div>
<div className="text-[7px] text-gray-300 font-normal italic">-</div>
</div>
<div className="flex justify-between items-center gap-1">
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-green-50 py-1 rounded transition-all group">
<span className="text-xs font-bold text-gray-800 group-hover:text-green-600">{SUMMARY.weeklyNew}</span>
<span className="text-[8px] text-green-500/80 font-bold mt-0.5"></span>
</div>
<div className="px-2">
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
<div className="w-[1px] h-3 bg-gray-100"></div>
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-blue-50 py-1 rounded transition-all group"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Delivered', source: 'asset', title: '本周交车' })}>
<span className="text-xs font-bold text-gray-800 group-hover:text-blue-600">{SUMMARY.weeklyDelivered}</span>
<span className="text-[8px] text-blue-500/80 font-bold mt-0.5"></span>
</div>
<div className="px-2">
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 text-base font-black text-amber-600">{pendingRate.toFixed(1)}%</div>
<div className="w-[1px] h-3 bg-gray-100"></div>
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-orange-50 py-1 rounded transition-all group"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Returned', source: 'asset', title: '本周还车' })}>
<span className="text-xs font-bold text-gray-800 group-hover:text-orange-600">{SUMMARY.weeklyReturned}</span>
<span className="text-[8px] text-orange-500/80 font-bold mt-0.5"></span>
</div>
<div className="w-[1px] h-3 bg-gray-100"></div>
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-purple-50 py-1 rounded transition-all group"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Replaced', source: 'asset', title: '本周替换' })}>
<span className="text-xs font-bold text-gray-800 group-hover:text-purple-600">{SUMMARY.weeklyReplaced}</span>
<span className="text-[8px] text-purple-500/80 font-bold mt-0.5"></span>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-3 mb-4">
<div data-testid="asset-flow-card" className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 text-[13px] font-black text-slate-700">
<CalendarDays size={14} className="text-blue-500" />
<span></span>
{flowLoading && <Loader2 size={12} className="animate-spin text-slate-400" />}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => exportFlowDetails()}
disabled={!flowStats?.details.length}
className="inline-flex h-8 items-center justify-center gap-1 rounded-xl border border-slate-200 bg-slate-50 px-2.5 text-[11px] font-black text-slate-600 transition hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
>
<Download size={13} />
</button>
</div>
</div>
<div className="mt-2 rounded-xl border border-slate-100 bg-slate-50/80 px-2 py-1.5">
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
<span className="block text-[9px] font-black text-slate-400"></span>
<span className="mt-0.5 flex items-center justify-between gap-2">
<span className="text-[12px] font-black text-slate-700">{flowRange.start.replaceAll('-', '/')}</span>
<CalendarDays size={12} className="text-slate-300" />
</span>
<input
type="date"
value={flowRange.start}
onChange={(e) => setFlowRange((prev) => ({ ...prev, start: e.target.value }))}
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</label>
<div className="rounded-full bg-slate-200/70 px-2 py-0.5 text-[9px] font-black text-slate-400"></div>
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
<span className="block text-[9px] font-black text-slate-400"></span>
<span className="mt-0.5 flex items-center justify-between gap-2">
<span className="text-[12px] font-black text-slate-700">{flowRange.end.replaceAll('-', '/')}</span>
<CalendarDays size={12} className="text-slate-300" />
</span>
<input
type="date"
value={flowRange.end}
onChange={(e) => setFlowRange((prev) => ({ ...prev, end: e.target.value }))}
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</label>
</div>
</div>
<div className="mt-2 rounded-2xl border border-slate-100 bg-slate-50/70 px-2 py-2.5">
<div className="grid grid-cols-4 items-center text-center">
<div className="px-2">
<div className="text-lg font-black leading-none text-slate-950">{flowStats?.totals.total ?? 0}</div>
<div className="mt-1 text-[10px] font-black text-slate-400"></div>
</div>
{(['delivered', 'returned', 'replaced'] as FlowType[]).map((type) => (
<div key={type} className="border-l border-slate-200/70 px-2">
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>
{flowStats?.totals[type] ?? 0}
</div>
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
</div>
))}
</div>
</div>
<button
type="button"
data-testid="asset-flow-daily-toggle"
onClick={() => setFlowDailyExpanded((prev) => !prev)}
className="mt-2 flex w-full items-center justify-between rounded-xl border border-slate-100 bg-white px-3 py-1.5 text-[12px] font-black text-slate-500 transition hover:border-blue-100 hover:bg-blue-50/50 hover:text-blue-600"
>
<span>{flowDailyExpanded ? '收起每日明细' : '展开每日明细'}</span>
<span className="flex items-center gap-2 text-[11px] text-slate-400">
{flowStats?.daily.length ?? 0}
<ChevronDown size={15} className={`transition-transform ${flowDailyExpanded ? 'rotate-180' : ''}`} />
</span>
</button>
<AnimatePresence initial={false}>
{flowDailyExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="mt-3 max-h-[280px] space-y-2 overflow-y-auto pr-1">
{flowLoading && (
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400">...</div>
)}
{!flowLoading && flowStats?.daily.map((day) => (
<div key={day.date} className="rounded-2xl border border-slate-100 bg-white px-3 py-3 shadow-sm">
<div className="flex items-center justify-between">
<div className="text-[11px] font-black text-slate-400">{day.date}</div>
<div className="text-[10px] font-bold italic text-slate-300"></div>
</div>
<div className="mt-2 grid grid-cols-4 items-center text-center">
<div className="px-2">
<div className="text-lg font-black leading-none text-slate-900">{day.total}</div>
<div className="mt-1 text-[10px] font-black text-slate-400"></div>
</div>
{(['delivered', 'returned', 'replaced'] as FlowType[]).map((type) => {
const count = day[type];
return (
<button
key={type}
type="button"
data-testid={`asset-flow-cell-${day.date}-${type}`}
disabled={count === 0}
onClick={() => setSelectedFlow({ date: day.date, type })}
className="border-l border-slate-100 px-2 text-center transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-35"
>
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>{count}</div>
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
</button>
);
})}
</div>
</div>
))}
{!flowLoading && !flowStats?.daily.length && (
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400"></div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Asset Summary Table */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden mb-6">
<div className="bg-white rounded-sm border border-gray-100 shadow-sm overflow-hidden mb-6">
<div className="p-4 border-b border-gray-50 bg-gray-50/50 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
<div className="flex flex-wrap items-center gap-4 sm:gap-6">
<h2 className="text-sm font-bold text-gray-700"></h2>
@@ -2880,123 +2602,6 @@ export default function AssetsModule() {
)}
{/* Flow Detail Modal */}
<AnimatePresence>
{selectedFlow && (
<div className="fixed inset-0 z-[1000] flex items-end justify-center bg-slate-950/45 p-0 backdrop-blur-sm sm:items-center sm:p-4">
<motion.div
data-testid="asset-flow-detail-modal"
initial={{ opacity: 0, y: 28, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.98 }}
className="flex max-h-[88vh] w-full flex-col overflow-hidden rounded-t-3xl bg-white shadow-2xl sm:max-w-5xl sm:rounded-3xl"
>
<div className="border-b border-slate-100 bg-slate-950 px-4 py-4 text-white sm:px-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-black uppercase tracking-wide text-slate-400"></div>
<h3 className="mt-1 text-lg font-black">
{selectedFlow.date} · {FLOW_META[selectedFlow.type].label}
</h3>
<div className="mt-1 text-[12px] font-bold text-slate-300">
{selectedFlowDetails.length}
</div>
</div>
<button
type="button"
onClick={() => setSelectedFlow(null)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
>
<X size={18} />
</button>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => exportFlowDetails(selectedFlowDetails, `${selectedFlow.date}-${FLOW_META[selectedFlow.type].label}明细`)}
disabled={selectedFlowDetails.length === 0}
className="inline-flex h-8 items-center gap-1 rounded-xl bg-white px-3 text-[11px] font-black text-slate-900 transition hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-40"
>
<Download size={13} />
</button>
</div>
</div>
<div className="overflow-auto bg-slate-50 p-3 sm:p-4">
<div className="hidden overflow-hidden rounded-2xl border border-slate-100 bg-white lg:block">
<table className="w-full table-fixed text-left">
<thead className="bg-slate-50 text-[11px] font-black text-slate-400">
<tr>
<th className="w-28 px-3 py-3"></th>
<th className="w-40 px-3 py-3">{FLOW_META[selectedFlow.type].label}</th>
<th className="w-40 px-3 py-3"></th>
<th className="w-36 px-3 py-3"></th>
<th className="w-28 px-3 py-3"></th>
<th className="px-3 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 text-[12px] font-bold text-slate-700">
{selectedFlowDetails.map((item) => (
<tr key={item.id} className="hover:bg-blue-50/40">
<td className="px-3 py-3 font-black text-slate-950">{item.plateNumber}</td>
<td className="px-3 py-3 text-slate-500">{item.eventTime || '-'}</td>
<td className="px-3 py-3 text-blue-600">{item.submitTime || '-'}</td>
<td className="px-3 py-3">{item.department || '-'}</td>
<td className="px-3 py-3">{item.manager || '-'}</td>
<td className="px-3 py-3">{item.customerName || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="space-y-2 lg:hidden">
{selectedFlowDetails.map((item) => (
<div key={item.id} className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-base font-black text-slate-950">{item.plateNumber}</div>
<div className={`mt-1 inline-flex rounded-full border px-2 py-0.5 text-[10px] font-black ${FLOW_META[item.type].chip}`}>
{item.typeLabel}
</div>
</div>
<div className="text-right text-[10px] font-bold text-slate-400">
<div></div>
<div className="mt-0.5 text-[11px] text-blue-600">{item.submitTime?.slice(5, 16) || '-'}</div>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px]">
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400">{item.typeLabel}</div>
<div className="mt-1 font-bold text-slate-700">{item.eventTime || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400"></div>
<div className="mt-1 font-bold text-slate-700">{item.manager || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400"></div>
<div className="mt-1 font-bold text-slate-700">{item.department || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400"></div>
<div className="mt-1 line-clamp-2 font-bold text-slate-700">{item.customerName || '-'}</div>
</div>
</div>
</div>
))}
</div>
{selectedFlowDetails.length === 0 && (
<div className="rounded-2xl bg-white px-4 py-10 text-center text-sm font-bold text-slate-400"></div>
)}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
{/* Vehicle Detail Modal */}
<AnimatePresence>
{showPlateNumbers && (

View File

@@ -78,43 +78,6 @@ export interface WeeklyDetailItem {
customer_name: string | null;
}
export type FlowType = 'delivered' | 'returned' | 'replaced';
export interface FlowDailyPoint {
date: string;
delivered: number;
returned: number;
replaced: number;
total: number;
}
export interface FlowDetailItem {
id: string;
type: FlowType;
typeLabel: string;
date: string;
truckId: string;
plateNumber: string;
eventTime: string | null;
submitTime: string | null;
department: string;
manager: string;
customerName: string | null;
}
export interface FlowStatsResponse {
start: string;
end: string;
daily: FlowDailyPoint[];
totals: {
delivered: number;
returned: number;
replaced: number;
total: number;
};
details: FlowDetailItem[];
}
export async function fetchDeptStats(subject?: string | null): Promise<DeptGroup[]> {
return fetchJson<DeptGroup[]>(withSubject(`${BASE}/dept-stats`, subject));
}
@@ -162,13 +125,3 @@ export async function fetchWeeklyDetail(
if (filters?.source) params.set('source', filters.source);
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?${params.toString()}`);
}
export async function fetchFlowStats(params: {
start: string;
end: string;
subject?: string | null;
}): Promise<FlowStatsResponse> {
const query = new URLSearchParams({ start: params.start, end: params.end });
if (params.subject) query.set('subject', params.subject);
return fetchJson<FlowStatsResponse>(`${BASE}/flow-stats?${query.toString()}`);
}

View File

@@ -106,7 +106,6 @@ export interface VehicleListItem {
city: string | null;
status: string;
ownership: string;
rentCompany?: string | null;
contractNo: string | null;
customerName: string | null;
subjectOrg: string | null;

View File

@@ -8,7 +8,6 @@ import { fetchJson } from '../../auth/api-client';
import { useAuth } from '../../auth/useAuth';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import FeedbackFab from '../../components/FeedbackFab';
import { PageFrame } from '../../components/ui/surface';
function getJwt(): string | null {
return sessionStorage.getItem('bi_jwt');
@@ -136,34 +135,30 @@ export default function EleImportPage() {
const totalFee = overall.reduce((s, o) => s + Number(o.total_fee || 0), 0);
return (
<PageFrame
title="充电记录导入"
subtitle="每日上传 xlsx按订单编号去重并自动匹配内部/外部车辆归属。"
icon={Zap}
eyebrow="ELECTRIC IMPORT"
meta={user?.userName || '导入工作台'}
actions={(
<div className="flex items-center gap-2">
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 p-4 md:p-8">
<div className="max-w-6xl mx-auto space-y-4">
<header className="flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0">
<button
onClick={() => {
if (window.history.length > 1) window.history.back();
else { window.location.hash = '#mileage'; }
}}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-100 bg-white text-slate-500 transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600"
className="w-9 h-9 rounded-xl bg-white border border-slate-100 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 text-slate-500 flex items-center justify-center transition-colors flex-shrink-0"
title="返回"
>
<ArrowLeft size={16} />
</button>
<button
onClick={() => inputRef.current?.click()}
className="inline-flex h-9 items-center gap-1.5 rounded-xl bg-slate-900 px-3 text-xs font-black text-white shadow-sm transition-colors hover:bg-slate-800"
>
<Upload size={14} />
</button>
</div>
)}
>
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center flex-shrink-0">
<Zap size={18} className="text-white" />
</div>
<div className="min-w-0">
<h1 className="text-lg font-black text-slate-900 leading-tight"></h1>
<p className="text-[11px] font-bold text-slate-400"> xlsx · · </p>
</div>
</div>
<span className="text-[10px] font-bold text-slate-400 flex-shrink-0">{user?.userName || ''}</span>
</header>
{/* 上传区 */}
<section
@@ -364,8 +359,9 @@ export default function EleImportPage() {
</section>
<RotatingFooterHint className="pb-4" />
</div>
<FeedbackFab module="ele" />
</PageFrame>
</div>
);
}

View File

@@ -1,11 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Truck, Wallet } from 'lucide-react';
import { ChevronRight, Plug } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import TrendBadge from './TrendBadge';
import { fetchElectricMonthly } from './api';
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'thisWeek', label: '本周' },
@@ -13,61 +12,26 @@ const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'last15', label: '近 15 天' },
];
type RangeMode = DateQuickPick | 'custom';
function fmtYmd(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function addDays(d: Date, days: number): Date {
const next = new Date(d);
next.setDate(next.getDate() + days);
return next;
}
function getQuickRange(pick: DateQuickPick): { start: string; end: string } {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (pick === 'thisWeek') {
const day = today.getDay() || 7;
return { start: fmtYmd(addDays(today, -(day - 1))), end: fmtYmd(today) };
}
if (pick === 'thisMonth') {
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end: fmtYmd(today) };
}
return { start: fmtYmd(addDays(today, -14)), end: fmtYmd(today) };
}
function normalizeRange(start: string, end: string): { start: string; end: string } {
return start <= end ? { start, end } : { start: end, end: start };
}
export default function ElectricDaily() {
const [customer, setCustomer] = useState<CustomerType>('lingniu');
const [pick, setPick] = useState<RangeMode>('last15');
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
const [pick, setPick] = useState<DateQuickPick>('last15');
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
useEffect(() => {
let cancelled = false;
setError(null);
const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick };
fetchElectricMonthly(customer, query)
fetchElectricMonthly(customer, pick)
.then(m => {
if (cancelled) return;
setMonths(m);
// 默认展开最新一个月
if (m.length > 0) setOpenMonths(new Set([m[0].month]));
if (m.length > 0) setOpenMonths(prev => prev.size > 0 ? prev : new Set([m[0].month]));
})
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [customer, pick, effectiveRange.start, effectiveRange.end]);
}, [customer, pick]);
const toggleMonth = (m: string) => setOpenMonths(prev => {
const next = new Set(prev);
@@ -76,108 +40,40 @@ export default function ElectricDaily() {
});
const totalKwh = useMemo(() => (months ?? []).reduce((s, m) => s + (m.kwh || 0), 0), [months]);
const totalFee = useMemo(() => (months ?? []).reduce((s, m) => s + (m.fee || 0), 0), [months]);
const activeDays = useMemo(() => (months ?? []).reduce((sum, m) => sum + m.rows.filter(r => r.kwh > 0).length, 0), [months]);
const abnormalDays = useMemo(() => (months ?? []).reduce((sum, m) => sum + m.rows.filter(r => Math.abs(r.chainPct) >= 0.3).length, 0), [months]);
const avgKwh = activeDays > 0 ? totalKwh / activeDays : 0;
const avgPrice = totalKwh > 0 ? totalFee / totalKwh : 0;
const scopeLabel = pick === 'custom'
? '自定义区间'
: QUICK_PICK_OPTIONS.find(item => item.id === pick)?.label ?? '当前时段';
const rangeText = `${effectiveRange.start}${effectiveRange.end}`;
const hasFeeDetail = totalFee > 0;
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
const applyQuickPick = (nextPick: DateQuickPick) => {
setPick(nextPick);
setDateRange(getQuickRange(nextPick));
};
const updateDateRange = (field: 'start' | 'end', value: string) => {
if (!value) return;
setPick('custom');
setDateRange(prev => ({ ...prev, [field]: value }));
};
return (
<div className="flex flex-col gap-3">
<SurfaceCard className="p-2 md:p-3">
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{QUICK_PICK_OPTIONS.map(opt => (
<button
key={opt.id}
onClick={() => applyQuickPick(opt.id)}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === opt.id
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
{/* 日期速选 */}
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
{QUICK_PICK_OPTIONS.map(opt => (
<button
onClick={() => setPick('custom')}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === 'custom'
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
key={opt.id}
onClick={() => setPick(opt.id)}
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
pick === opt.id
? 'bg-blue-50 text-blue-600 border-blue-200'
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
</div>
))}
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
type="date"
value={dateRange.start}
onChange={e => updateDateRange('start', e.target.value)}
onInput={e => updateDateRange('start', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
type="date"
value={dateRange.end}
onChange={e => updateDateRange('end', e.target.value)}
onInput={e => updateDateRange('end', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
</div>
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
{(['lingniu', 'external'] as const).map(c => (
<button
key={c}
onClick={() => setCustomer(c)}
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
customer === c ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
<Truck size={14} />
{c === 'external' ? '外部车辆' : '羚牛车辆'}
</button>
))}
</div>
</SurfaceCard>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={BatteryCharging} label={`${scopeLabel}充电量`} value={totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={rangeText} />
<MetricTile
icon={Wallet}
label="充电费用"
value={hasFeeDetail ? `¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '待同步'}
helper={hasFeeDetail ? `均价 ${avgPrice.toFixed(2)} 元/度` : '当前明细仅返回充电量'}
tone={hasFeeDetail ? 'emerald' : 'slate'}
/>
<MetricTile icon={CalendarDays} label="有效天数" value={`${activeDays}`} unit="天" helper={`日均 ${avgKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} tone="amber" />
<MetricTile icon={TrendingUp} label="波动提醒" value={abnormalDays} unit="天" helper="环比超过 30% 标记" tone={abnormalDays > 0 ? 'rose' : 'slate'} />
{/* 客户类型 */}
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
{(['lingniu', 'external'] as const).map(c => (
<button
key={c}
onClick={() => setCustomer(c)}
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
}`}
>
{c === 'external' ? '外部车辆' : '羚牛车辆'}
</button>
))}
</div>
{/* 外部车辆 数据未就绪 */}
@@ -210,11 +106,11 @@ export default function ElectricDaily() {
<span className="text-right"></span>
</div>
{error ? (
<div className="p-3"><ErrorState message={error} /></div>
<div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">{error}</div>
) : months === null ? (
<div className="p-3"><LoadingState label="正在加载充电明细" /></div>
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div>
) : months.length === 0 ? (
<div className="p-3"><EmptyState title="暂无充电数据" description="请切换时间范围或车辆归属" /></div>
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div>
) : months.map(m => {
const open = openMonths.has(m.month);
return (

View File

@@ -1,33 +0,0 @@
import { LayoutDashboard, CalendarDays } from 'lucide-react';
import { AnimatePresence } from 'motion/react';
import ElectricView, { type ElectricSubTab } from './ElectricView';
import SubTabs from './SubTabs';
import { useHashSubTab } from './useHashSubTab';
import { FadeIn, PageFrame } from '../../components/ui/surface';
const SUB_TABS = [
{ id: 'daily', label: '每日', icon: CalendarDays },
{ id: 'overview', label: '总览', icon: LayoutDashboard },
] as const satisfies readonly { id: ElectricSubTab; label: string; icon: typeof CalendarDays }[];
const SUB_IDS: readonly ElectricSubTab[] = ['daily', 'overview'];
export default function ElectricModule() {
const [sub, setSub] = useHashSubTab<ElectricSubTab>('electric', SUB_IDS);
return (
<PageFrame
title="电能成本看板"
subtitle="围绕充电量、费用、日趋势和车辆归属展示电能支出结构,辅助识别费用波动。"
icon={CalendarDays}
eyebrow="ELECTRIC BI"
meta="时间单位清晰标注 · 支持日/总览切换"
>
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<AnimatePresence mode="wait">
<FadeIn key={sub}>
<ElectricView sub={sub} />
</FadeIn>
</AnimatePresence>
</PageFrame>
);
}

View File

@@ -1,9 +1,8 @@
import { useEffect, useState } from 'react';
import { BatteryCharging, Gauge, Wallet, CalendarClock } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
import { Wallet, CalendarClock } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
function fmtYuan(yuan: number) {
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
@@ -25,10 +24,10 @@ export default function ElectricOverview() {
}, []);
if (error) {
return <ErrorState message={error} />;
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">{error}</div>;
}
if (!data) {
return <LoadingState label="正在加载电能总览" />;
return <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6 text-center text-slate-400 text-sm"></div>;
}
const k = data.kpi;
const trendData = data.trend;
@@ -38,12 +37,6 @@ export default function ElectricOverview() {
const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
? `${trendMonthLabel} 每日充电`
: '本月每日充电';
const activeDays = trendData.filter(item => item.kwh > 0).length;
const avgDailyKwh = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.kwh, 0) / activeDays : 0;
const avgDailyFee = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.fee, 0) / activeDays : 0;
const peakDay = trendData.reduce<typeof trendData[number] | null>((best, item) => (!best || item.kwh > best.kwh ? item : best), null);
const avgPrice = k.totalKwh > 0 ? k.totalFee / k.totalKwh : 0;
const monthPrice = k.monthKwh > 0 ? k.monthFee / k.monthKwh : 0;
return (
<div className="flex flex-col gap-3">
@@ -51,36 +44,28 @@ export default function ElectricOverview() {
2025-01-01
</div>
{/* 横向 mini KPI 头 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={Wallet} label="累计充电费" value={fmtYuan(k.totalFee)} helper={fmtKwh(k.totalKwh)} />
<MetricTile icon={CalendarClock} label="本月充电费" value={fmtYuan(k.monthFee)} helper={fmtKwh(k.monthKwh)} tone="emerald" />
<MetricTile icon={Gauge} label="累计均价" value={avgPrice.toFixed(2)} unit="元/度" helper={`本月 ${monthPrice.toFixed(2)} 元/度`} tone="amber" />
<MetricTile icon={BatteryCharging} label="今日充电" value={k.todayKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={`${fmtYuan(k.todayFee)} · ${k.todayChainPct >= 0 ? '+' : ''}${(k.todayChainPct * 100).toFixed(1)}%`} tone={Math.abs(k.todayChainPct) >= 0.3 ? 'rose' : 'slate'} />
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<SurfaceCard className="p-3">
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-xl font-black text-slate-900">{activeDays}<span className="ml-1 text-[11px] text-slate-400"></span></div>
<div className="mt-1 text-[11px] font-bold text-slate-500"> {avgDailyKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} </div>
</SurfaceCard>
<SurfaceCard className="p-3">
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-xl font-black text-blue-600">{peakDay ? peakDay.date.slice(5) : '—'}</div>
<div className="mt-1 text-[11px] font-bold text-slate-500">{peakDay ? `${peakDay.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度 · ${fmtYuan(peakDay.fee)}` : '暂无数据'}</div>
</SurfaceCard>
<SurfaceCard className="p-3">
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-xl font-black text-emerald-600">{k.totalFee > 0 ? (k.monthFee / k.totalFee * 100).toFixed(1) : '0.0'}%</div>
<div className="mt-1 text-[11px] font-bold text-slate-500"> / </div>
</SurfaceCard>
<div className="grid grid-cols-2 gap-2 md:gap-3">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
<Wallet size={11} className="text-blue-600" />
</div>
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.totalFee)}</div>
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.totalKwh)}</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
<CalendarClock size={11} className="text-blue-600" />
</div>
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.monthFee)}</div>
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.monthKwh)}</div>
</div>
</div>
{/* 本月每日充电柱图 */}
<SurfaceCard className="p-4">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700">{chartTitle}</span>
<span className="text-[11px] text-slate-400 font-bold"> · · 线</span>
<span className="text-[11px] text-slate-400 font-bold"> </span>
</div>
<ResponsiveContainer width="100%" height={160}>
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
@@ -100,14 +85,6 @@ export default function ElectricOverview() {
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
/>
{avgDailyFee > 0 && (
<ReferenceLine
y={avgDailyFee}
stroke="#f59e0b"
strokeDasharray="4 4"
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
/>
)}
<Bar dataKey="fee" radius={[4, 4, 0, 0]}>
{trendData.map((_, i) => (
<Cell key={i} fill="url(#electricBarGrad)" />
@@ -121,7 +98,7 @@ export default function ElectricOverview() {
</defs>
</BarChart>
</ResponsiveContainer>
</SurfaceCard>
</div>
<RotatingFooterHint />
</div>
);

View File

@@ -0,0 +1,86 @@
import { useState } from 'react';
import { Fuel, BatteryCharging, Receipt, LayoutDashboard, CalendarDays } from 'lucide-react';
import { motion } from 'motion/react';
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
import ElectricView, { type ElectricSubTab } from './ElectricView';
import ETCView from './ETCView';
type TopTab = 'hydrogen' | 'electric' | 'etc';
type SubTabId = HydrogenSubTab | ElectricSubTab; // 'daily' | 'overview'
const TABS: { key: TopTab; label: string; icon: typeof Fuel }[] = [
{ key: 'hydrogen', label: '氢能', icon: Fuel },
{ key: 'electric', label: '电能', icon: BatteryCharging },
{ key: 'etc', label: 'ETC', icon: Receipt },
];
const SUB_TABS: { id: SubTabId; label: string; icon: typeof LayoutDashboard }[] = [
{ id: 'daily', label: '每日', icon: CalendarDays },
{ id: 'overview', label: '总览', icon: LayoutDashboard },
];
export default function EnergyModule() {
const [activeTab, setActiveTab] = useState<TopTab>('hydrogen');
const [hydroSub, setHydroSub] = useState<HydrogenSubTab>('daily');
const [electricSub, setElectricSub] = useState<ElectricSubTab>('daily');
const showSubTabs = activeTab === 'hydrogen' || activeTab === 'electric';
const currentSub: SubTabId = activeTab === 'electric' ? electricSub : hydroSub;
const setSub = (id: SubTabId) => activeTab === 'electric' ? setElectricSub(id) : setHydroSub(id);
return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
{/* 统一 sticky 头部top tab + (氢能时) 子 tab同一张卡片无间隙 */}
{/* pb-4 留一点底部缓冲,避免下方快捷选按钮在滚动时贴着 sticky 半截露脸 */}
<div className="sticky top-0 z-30 -mx-3 md:-mx-6 px-3 md:px-6 -mt-3 md:-mt-6 pt-3 md:pt-6 pb-4 bg-[#F8F9FB] shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)]">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
{/* 顶部 tab氢能 / 电能 / ETC */}
<div className={`px-4 py-2 flex items-center gap-6 ${showSubTabs ? 'border-b border-slate-50' : ''}`}>
{TABS.map(tab => {
const Icon = tab.icon;
const active = activeTab === tab.key;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-2 py-1 transition-colors relative ${active ? 'text-blue-600' : 'text-slate-400 hover:text-slate-600'}`}
>
<Icon size={14} />
<span className="text-[11px] font-bold">{tab.label}</span>
{active && (
<motion.div layoutId="activeEnergyTopTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
);
})}
</div>
{/* 子 tab氢能 / 电能 都显示 每日 / 总览 */}
{showSubTabs && (
<div className="p-1 flex gap-1">
{SUB_TABS.map(({ id, label, icon: Icon }) => {
const active = currentSub === id;
return (
<button
key={id}
onClick={() => setSub(id)}
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
active ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
}`}
>
<Icon size={14} />
<span>{label}</span>
</button>
);
})}
</div>
)}
</div>
</div>
{activeTab === 'hydrogen' && <HydrogenView sub={hydroSub} />}
{activeTab === 'electric' && <ElectricView sub={electricSub} />}
{activeTab === 'etc' && <ETCView />}
</div>
</div>
);
}

View File

@@ -1,17 +0,0 @@
import { Receipt } from 'lucide-react';
import ETCView from './ETCView';
import { PageFrame } from '../../components/ui/surface';
export default function EtcModule() {
return (
<PageFrame
title="ETC 通行费看板"
subtitle="规划按车、按月、按线路拆分通行费,让车辆运营成本口径逐步完整。"
icon={Receipt}
eyebrow="ETC BI"
meta="数据对接中 · 页面能力预留"
>
<ETCView />
</PageFrame>
);
}

View File

@@ -1,12 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-react';
import { ChevronRight, Plug } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
import TrendBadge from './TrendBadge';
import { fetchHydrogenDaily } from './api';
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'thisWeek', label: '本周' },
@@ -14,76 +13,25 @@ const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'last15', label: '近 15 天' },
];
type RangeMode = DateQuickPick | 'custom';
function fmtYmd(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function addDays(d: Date, days: number): Date {
const next = new Date(d);
next.setDate(next.getDate() + days);
return next;
}
function getQuickRange(pick: DateQuickPick): { start: string; end: string } {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (pick === 'thisWeek') {
const day = today.getDay() || 7;
return { start: fmtYmd(addDays(today, -(day - 1))), end: fmtYmd(today) };
}
if (pick === 'thisMonth') {
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end: fmtYmd(today) };
}
return { start: fmtYmd(addDays(today, -14)), end: fmtYmd(today) };
}
function normalizeRange(start: string, end: string): { start: string; end: string } {
return start <= end ? { start, end } : { start: end, end: start };
}
export default function HydrogenDaily() {
const [pick, setPick] = useState<RangeMode>('last15');
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
const [pick, setPick] = useState<DateQuickPick>('last15');
const [customer, setCustomer] = useState<CustomerType>('lingniu');
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
useEffect(() => {
let cancelled = false;
setError(null);
const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick };
fetchHydrogenDaily(query, customer)
fetchHydrogenDaily(pick, customer)
.then(r => { if (!cancelled) setRows(r); })
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [pick, customer, effectiveRange.start, effectiveRange.end]);
}, [pick, customer]);
// 柱图:按日期升序,用于"从左到右时间流"
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0);
const activeDays = (rows ?? []).filter(r => r.totalKg > 0).length;
const stationCount = useMemo(() => {
const names = new Set<string>();
(rows ?? []).forEach(r => r.stations.forEach(s => names.add(s.name)));
return names.size;
}, [rows]);
const avgKg = activeDays > 0 ? totalKg / activeDays : 0;
const scopeLabel = pick === 'custom'
? '自定义区间'
: QUICK_PICK_OPTIONS.find(item => item.id === pick)?.label ?? '当前时段';
const rangeText = `${effectiveRange.start}${effectiveRange.end}`;
const peakDay = trendData.reduce<HydrogenDailyRow | null>((best, item) => (!best || item.totalKg > best.totalKg ? item : best), null);
const lowDay = trendData
.filter(item => item.totalKg > 0)
.reduce<HydrogenDailyRow | null>((low, item) => (!low || item.totalKg < low.totalKg ? item : low), null);
const zeroDays = (rows ?? []).filter(r => r.totalKg === 0).length;
const toggle = (date: string) => setExpanded(prev => {
const next = new Set(prev);
@@ -91,90 +39,38 @@ export default function HydrogenDaily() {
return next;
});
const applyQuickPick = (nextPick: DateQuickPick) => {
setPick(nextPick);
setDateRange(getQuickRange(nextPick));
};
const updateDateRange = (field: 'start' | 'end', value: string) => {
if (!value) return;
setPick('custom');
setDateRange(prev => ({ ...prev, [field]: value }));
};
return (
<div className="flex flex-col gap-3">
<SurfaceCard className="p-2 md:p-3">
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{QUICK_PICK_OPTIONS.map(opt => (
<button
key={opt.id}
onClick={() => applyQuickPick(opt.id)}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === opt.id
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
{/* 日期速选 */}
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
{QUICK_PICK_OPTIONS.map(opt => (
<button
onClick={() => setPick('custom')}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === 'custom'
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
key={opt.id}
onClick={() => setPick(opt.id)}
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
pick === opt.id
? 'bg-blue-50 text-blue-600 border-blue-200'
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
</div>
))}
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
type="date"
value={dateRange.start}
onChange={e => updateDateRange('start', e.target.value)}
onInput={e => updateDateRange('start', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
type="date"
value={dateRange.end}
onChange={e => updateDateRange('end', e.target.value)}
onInput={e => updateDateRange('end', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
</div>
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
{(['lingniu', 'external'] as const).map(c => (
<button
key={c}
onClick={() => setCustomer(c)}
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
customer === c ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
<Truck size={14} />
{c === 'external' ? '外部车辆' : '羚牛车辆'}
</button>
))}
</div>
</SurfaceCard>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
{/* 客户类型 segmented */}
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
{(['lingniu', 'external'] as const).map(c => (
<button
key={c}
onClick={() => setCustomer(c)}
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
}`}
>
{c === 'external' ? '外部车辆' : '羚牛车辆'}
</button>
))}
</div>
{/* 外部车辆:新系统数据还没准备好 */}
@@ -200,34 +96,13 @@ export default function HydrogenDaily() {
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
<SurfaceCard>
<div className="flex items-center justify-between px-4 pt-4 mb-2">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700"></span>
<span className="text-[11px] text-slate-400 font-bold"> · Kg</span>
<span className="text-[11px] text-slate-400 font-bold"> Kg</span>
</div>
<div className="mx-4 mb-2 grid grid-cols-3 gap-2 rounded-xl bg-slate-50 p-2">
<div>
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
{peakDay ? `${peakDay.date.slice(5)} · ${peakDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
</div>
</div>
<div>
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
{lowDay ? `${lowDay.date.slice(5)} · ${lowDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
</div>
</div>
<div>
<div className="text-[10px] font-black text-slate-400"></div>
<div className={`mt-0.5 text-[11px] font-black ${zeroDays > 0 ? 'text-amber-600' : 'text-emerald-600'}`}>
{zeroDays}
</div>
</div>
</div>
<div className="h-[180px] min-w-0 px-2 pb-2">
<ResponsiveContainer width="100%" height={180} minWidth={0}>
<BarChart data={trendData} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<ResponsiveContainer width="100%" height={160}>
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
<XAxis
dataKey="date"
tickFormatter={(v: string) => v.slice(5)}
@@ -237,27 +112,13 @@ export default function HydrogenDaily() {
interval="preserveStartEnd"
minTickGap={8}
/>
<YAxis
width={42}
axisLine={false}
tickLine={false}
tick={{ fontSize: 9, fill: '#94a3b8' }}
tickFormatter={(v: number) => v >= 1000 ? `${Math.round(v / 1000)}k` : `${Math.round(v)}`}
/>
<YAxis hide />
<Tooltip
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']}
labelFormatter={(d) => `日期 ${d}`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
/>
{avgKg > 0 && (
<ReferenceLine
y={avgKg}
stroke="#f59e0b"
strokeDasharray="4 4"
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
/>
)}
<Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
{trendData.map((_, i) => (
<Cell key={i} fill="url(#hydrogenBarGrad)" />
@@ -271,8 +132,7 @@ export default function HydrogenDaily() {
</defs>
</BarChart>
</ResponsiveContainer>
</div>
</SurfaceCard>
</div>
)}
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
@@ -294,11 +154,11 @@ export default function HydrogenDaily() {
</div>
{/* 主行 + 子行 */}
{error ? (
<div className="p-3"><ErrorState message={error} /></div>
<div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">{error}</div>
) : rows === null ? (
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div>
) : rows.length === 0 ? (
<div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div>
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div>
) : rows.map(r => {
const open = expanded.has(r.date);
const isAbnormal = Math.abs(r.chainPct) >= 0.3;

View File

@@ -1,33 +0,0 @@
import { LayoutDashboard, CalendarDays } from 'lucide-react';
import { AnimatePresence } from 'motion/react';
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
import SubTabs from './SubTabs';
import { useHashSubTab } from './useHashSubTab';
import { FadeIn, PageFrame } from '../../components/ui/surface';
const SUB_TABS = [
{ id: 'daily', label: '每日', icon: CalendarDays },
{ id: 'overview', label: '总览', icon: LayoutDashboard },
] as const satisfies readonly { id: HydrogenSubTab; label: string; icon: typeof CalendarDays }[];
const SUB_IDS: readonly HydrogenSubTab[] = ['daily', 'overview'];
export default function HydrogenModule() {
const [sub, setSub] = useHashSubTab<HydrogenSubTab>('hydrogen', SUB_IDS);
return (
<PageFrame
title="氢能经营看板"
subtitle="按时间、车辆归属、加氢站和区域统一展示加氢量、费用、收入与异常波动。"
icon={CalendarDays}
eyebrow="ENERGY BI"
meta="数据单位清晰标注 · 支持日/总览切换"
>
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<AnimatePresence mode="wait">
<FadeIn key={sub}>
<HydrogenView sub={sub} />
</FadeIn>
</AnimatePresence>
</PageFrame>
);
}

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import {
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
} from 'recharts';
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw, Gauge, AlertTriangle, Building2 } from 'lucide-react';
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
import RotatingFooterHint from '../../components/RotatingFooterHint';
@@ -147,14 +147,6 @@ export default function HydrogenOverview() {
const yearRevenueFmt = fmtYuan(k.yearRevenue);
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
const monthAvgKg = monthly.length > 0 ? monthly.reduce((sum, m) => sum + m.kg, 0) / monthly.length : 0;
const bestMonth = monthly.reduce<typeof monthly[number] | null>((best, item) => (!best || item.kg > best.kg ? item : best), null);
const latestMonth = monthly[monthly.length - 1];
const prevMonth = monthly[monthly.length - 2];
const monthMomentum = latestMonth && prevMonth && prevMonth.kg > 0 ? (latestMonth.kg - prevMonth.kg) / prevMonth.kg * 100 : null;
const top5Share = top5.reduce((sum, item) => sum + item.kg, 0) / Math.max(1, k.yearKg) * 100;
const profitYield = k.yearRevenue > 0 ? k.yearProfit / k.yearRevenue * 100 : 0;
const stationAvgKg = stations.length > 0 ? k.yearKg / stations.length : 0;
// 月度收支组合数据(推算"年内每月"图)
const monthlyDual = monthly.map(m => ({
@@ -166,7 +158,7 @@ export default function HydrogenOverview() {
<div className="flex flex-col gap-3 relative">
{/* 顶部说明条 + 年份切换 + 刷新按钮 */}
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRefreshTime(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRelative(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
{availableYears.map(y => {
@@ -255,59 +247,6 @@ export default function HydrogenOverview() {
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 md:gap-3">
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-lg font-black text-slate-900">
{monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`}
</div>
</div>
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
<Gauge size={18} />
</span>
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
{latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'}
{bestMonth ? ` · 峰值 ${bestMonth.month}` : ''}
{monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''}
</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-lg font-black text-slate-900">Top5 {top5Share.toFixed(1)}%</div>
</div>
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-cyan-50 text-cyan-600 ring-1 ring-cyan-100">
<Building2 size={18} />
</span>
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
{stations.length} · {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit}
{top5Share >= 70 ? ' · 头部站点依赖偏高' : ' · 分布相对健康'}
</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-[11px] font-black text-slate-400"></div>
<div className={`mt-1 text-lg font-black ${profitYield >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{profitYield.toFixed(1)}%
</div>
</div>
<span className={`flex h-9 w-9 items-center justify-center rounded-xl ring-1 ${profitYield >= 0 ? 'bg-emerald-50 text-emerald-600 ring-emerald-100' : 'bg-rose-50 text-rose-600 ring-rose-100'}`}>
<AlertTriangle size={18} />
</span>
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
{yearProfitFmt.value}{yearProfitFmt.unit} · {yearRevenueFmt.value}{yearRevenueFmt.unit}
{profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'}
</div>
</div>
</div>
{/* 月度趋势:年内每月加氢量 */}
{monthly.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
@@ -620,18 +559,6 @@ function formatRelative(ts: number): string {
return new Date(ts).toLocaleString('zh-CN', { hour12: false });
}
function formatRefreshTime(ts: number): string {
const exactTime = new Date(ts).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
return `${formatRelative(ts)} · ${exactTime.replace(/\//g, '-')}`;
}
function HydrogenOverviewSkeleton() {
return (
<div className="flex flex-col gap-3 animate-pulse">

View File

@@ -1,22 +0,0 @@
import type { ComponentType } from 'react';
import { SegmentedNav } from '../../components/ui/surface';
interface SubTab<T extends string> {
id: T;
label: string;
icon: ComponentType<{ size?: number; className?: string }>;
}
interface Props<T extends string> {
tabs: readonly SubTab<T>[];
active: T;
onChange: (id: T) => void;
}
export default function SubTabs<T extends string>({ tabs, active, onChange }: Props<T>) {
return (
<div className="sticky top-0 z-30 -mx-3 bg-[var(--app-bg)] px-3 pb-2 pt-1 shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)] md:-mx-6 md:top-12 md:px-6">
<SegmentedNav tabs={tabs} active={active} onChange={onChange} />
</div>
);
}

View File

@@ -27,17 +27,8 @@ export function fetchHydrogenOverview(year?: number, force = false): Promise<Hyd
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`);
}
export interface HydrogenDailyQuery {
range?: DateQuickPick;
startDate?: string;
endDate?: string;
}
export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise<HydrogenDailyRow[]> {
const q = new URLSearchParams({ customer });
if (query.range) q.set('range', query.range);
if (query.startDate) q.set('startDate', query.startDate);
if (query.endDate) q.set('endDate', query.endDate);
export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise<HydrogenDailyRow[]> {
const q = new URLSearchParams({ range, customer });
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
}
@@ -50,10 +41,7 @@ export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
}
export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> {
const q = new URLSearchParams({ customer });
if (query.range) q.set('range', query.range);
if (query.startDate) q.set('startDate', query.startDate);
if (query.endDate) q.set('endDate', query.endDate);
export function fetchElectricMonthly(customer: CustomerType, range: DateQuickPick = 'last15'): Promise<ElectricMonthGroup[]> {
const q = new URLSearchParams({ customer, range });
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
}

View File

@@ -1,38 +0,0 @@
import { useEffect, useState } from 'react';
/**
* 把模块内子 tab 状态同步到 URL hash 二级段。
* hash 形如 `#<moduleId>`= 默认 sub或 `#<moduleId>/<sub>`。
* 默认值不写入 hash刷新页面可恢复。
*/
export function useHashSubTab<T extends string>(
moduleId: string,
subs: readonly T[],
): [T, (sub: T) => void] {
const defaultSub = subs[0];
const parse = (): T => {
const hash = window.location.hash.slice(1);
const [first, second] = hash.split('/');
if (first !== moduleId) return defaultSub;
if (second && (subs as readonly string[]).includes(second)) return second as T;
return defaultSub;
};
const [sub, setSubState] = useState<T>(parse);
useEffect(() => {
const onChange = () => setSubState(parse());
window.addEventListener('hashchange', onChange);
return () => window.removeEventListener('hashchange', onChange);
}, [moduleId]);
const setSub = (next: T) => {
const { pathname, search } = window.location;
const newHash = next === defaultSub ? `#${moduleId}` : `#${moduleId}/${next}`;
window.history.replaceState(null, '', `${pathname}${search}${newHash}`);
setSubState(next);
};
return [sub, setSub];
}

View File

@@ -1,148 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { AmapConfig, HydrogenHeatmapMetric, HydrogenHeatmapPoint } from './types';
type Props = {
config: AmapConfig;
points: HydrogenHeatmapPoint[];
max: number;
metric: HydrogenHeatmapMetric;
focusQuery: string;
onMapClick: (longitude: number, latitude: number) => void;
};
type AmapInstance = {
Map: new (container: HTMLElement, options: Record<string, unknown>) => any;
HeatMap: new (map: any, options: Record<string, unknown>) => any;
ToolBar: new (options?: Record<string, unknown>) => any;
Scale: new (options?: Record<string, unknown>) => any;
Bounds: new (southWest: [number, number], northEast: [number, number]) => any;
};
declare global {
interface Window {
_AMapSecurityConfig?: { securityJsCode: string };
}
}
function getBounds(points: HydrogenHeatmapPoint[]) {
if (!points.length) return null;
return points.reduce((bounds, point) => ({
minLng: Math.min(bounds.minLng, point.lng),
maxLng: Math.max(bounds.maxLng, point.lng),
minLat: Math.min(bounds.minLat, point.lat),
maxLat: Math.max(bounds.maxLat, point.lat),
}), { minLng: points[0].lng, maxLng: points[0].lng, minLat: points[0].lat, maxLat: points[0].lat });
}
export default function HydrogenAmapCanvas({ config, points, max, metric, focusQuery, onMapClick }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<any>(null);
const heatmapRef = useRef<any>(null);
const amapRef = useRef<AmapInstance | null>(null);
const clickHandlerRef = useRef(onMapClick);
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const intensity = useMemo(() => {
const transform = metric === 'kg'
? (value: number) => Math.log1p(value)
: (value: number) => Math.sqrt(value);
return {
points: points.map((point) => ({ ...point, count: transform(point.count) })),
max: transform(Math.max(1, max)),
};
}, [max, metric, points]);
useEffect(() => {
clickHandlerRef.current = onMapClick;
}, [onMapClick]);
useEffect(() => {
let cancelled = false;
const container = containerRef.current;
if (!container) return undefined;
async function initialize() {
try {
window._AMapSecurityConfig = { securityJsCode: config.securityCode };
const loaderModule = await import('@amap/amap-jsapi-loader');
const AMap = await loaderModule.default.load({
key: config.key,
version: '2.0',
plugins: ['AMap.HeatMap', 'AMap.ToolBar', 'AMap.Scale'],
}) as unknown as AmapInstance;
if (cancelled || !container) return;
const map = new AMap.Map(container, {
viewMode: '2D',
zoom: 5,
center: [105.4, 34.4],
mapStyle: 'amap://styles/whitesmoke',
resizeEnable: true,
showLabel: true,
});
map.addControl(new AMap.ToolBar({ position: { right: '20px', bottom: '76px' } }));
map.addControl(new AMap.Scale({ position: { right: '18px', bottom: '24px' } }));
const heatmap = new AMap.HeatMap(map, {
radius: 34,
opacity: [0.14, 0.84],
gradient: {
0.1: '#2563eb',
0.3: '#0891b2',
0.5: '#16a34a',
0.68: '#eab308',
0.84: '#f97316',
1: '#dc2626',
},
});
map.on('click', (event: any) => clickHandlerRef.current(event.lnglat.getLng(), event.lnglat.getLat()));
mapRef.current = map;
heatmapRef.current = heatmap;
amapRef.current = AMap;
setStatus('ready');
} catch (error) {
console.error('Hydrogen heatmap AMap initialization failed', error);
if (!cancelled) setStatus('error');
}
}
initialize();
return () => {
cancelled = true;
heatmapRef.current?.setMap?.(null);
mapRef.current?.destroy?.();
heatmapRef.current = null;
mapRef.current = null;
amapRef.current = null;
};
}, [config.key, config.securityCode]);
useEffect(() => {
if (status !== 'ready' || !heatmapRef.current) return;
heatmapRef.current.setDataSet({ data: intensity.points, max: intensity.max });
if (!focusQuery || !points.length || !mapRef.current || !amapRef.current) return;
const bounds = getBounds(points);
if (!bounds) return;
if (points.length === 1) {
mapRef.current.setZoomAndCenter(12, [points[0].lng, points[0].lat]);
return;
}
mapRef.current.setBounds(new amapRef.current.Bounds(
[bounds.minLng, bounds.minLat],
[bounds.maxLng, bounds.maxLat],
), false, [80, 80, 80, 80]);
}, [focusQuery, intensity, points, status]);
return (
<div className="absolute inset-0 bg-[#eef3f7]">
<div ref={containerRef} className="h-full w-full" aria-label="加氢站加氢热力图地图" />
{status === 'loading' ? (
<div className="absolute inset-0 grid place-items-center bg-white/76 text-sm font-medium text-slate-500 backdrop-blur-[2px]">
<span className="flex items-center gap-2.5"><i className="h-4 w-4 animate-spin rounded-full border-2 border-cyan-600 border-t-transparent" /></span>
</div>
) : null}
{status === 'error' ? (
<div className="absolute inset-0 grid place-items-center bg-slate-50 text-center">
<div><p className="text-sm font-semibold text-slate-700"></p><p className="mt-1 text-xs text-slate-400"> Key</p></div>
</div>
) : null}
</div>
);
}

View File

@@ -1,85 +0,0 @@
import { ChevronRight, Fuel, X } from 'lucide-react';
import type { HydrogenHeatmapMetric, HydrogenStationRank } from './types';
type Props = {
title: string;
subtitle: string;
kg: number;
refuelCount: number;
vehicleCount: number;
stationCount: number;
stations: HydrogenStationRank[];
metric: HydrogenHeatmapMetric;
selected: boolean;
loading: boolean;
coverageText: string;
onClose: () => void;
onHide: () => void;
};
const integerFormat = new Intl.NumberFormat('zh-CN');
const kgFormat = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 });
function metricLabel(metric: HydrogenHeatmapMetric) {
if (metric === 'refuels') return '按加氢次数排序';
if (metric === 'vehicles') return '按车辆数排序';
return '按加氢量排序';
}
function metricValue(station: HydrogenStationRank, metric: HydrogenHeatmapMetric) {
if (metric === 'kg') return `${kgFormat.format(station.kg)} kg`;
if (metric === 'refuels') return `${integerFormat.format(station.refuelCount)}`;
return `${integerFormat.format(station.vehicleCount)}`;
}
export default function HydrogenHeatmapDetailPanel(props: Props) {
return (
<aside className="relative z-20 flex h-full w-[310px] shrink-0 flex-col border-l border-slate-200 bg-white max-lg:absolute max-lg:bottom-0 max-lg:left-0 max-lg:right-0 max-lg:h-[43vh] max-lg:w-full max-lg:rounded-t-2xl max-lg:border-l-0 max-lg:border-t max-lg:shadow-[0_-10px_30px_rgba(15,23,42,0.14)] max-md:h-[50dvh]">
<div className="border-b border-slate-100 px-5 pb-4 pt-5 max-md:px-4 max-md:pb-3 max-md:pt-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate text-[17px] font-semibold tracking-tight text-slate-900">{props.title}</h2>
{props.loading ? <i className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-cyan-600 border-t-transparent" /> : null}
</div>
<p className="mt-1 text-[11px] text-slate-400">{props.subtitle}</p>
<p className="mt-1 text-[10px] font-medium text-emerald-600">{props.coverageText}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{props.selected ? (
<button type="button" onClick={props.onClose} aria-label="关闭加氢区域详情" className="grid h-8 w-8 place-items-center rounded-lg text-slate-400 hover:bg-slate-50 hover:text-slate-700"><X size={18} strokeWidth={1.8} /></button>
) : null}
<button type="button" onClick={props.onHide} aria-label="隐藏加氢详情" title="隐藏详情" className="grid h-8 w-8 place-items-center rounded-lg text-slate-400 transition hover:bg-cyan-50 hover:text-cyan-700 max-md:h-10 max-md:w-10"><ChevronRight size={18} strokeWidth={1.8} /></button>
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-x-4 gap-y-3 border-t border-slate-100 pt-4 max-md:mt-3 max-md:gap-y-2 max-md:pt-3">
<div><p className="text-[10px] text-slate-400"></p><p className="mt-0.5 text-[19px] font-semibold tracking-tight text-cyan-700">{kgFormat.format(props.kg)} <small className="text-[10px] font-medium">kg</small></p></div>
<div><p className="text-[10px] text-slate-400"></p><p className="mt-0.5 text-[19px] font-semibold tracking-tight text-cyan-700">{integerFormat.format(props.refuelCount)}</p></div>
<div><p className="text-[10px] text-slate-400"></p><p className="mt-0.5 text-[16px] font-semibold text-slate-700">{integerFormat.format(props.vehicleCount)}</p></div>
<div><p className="text-[10px] text-slate-400"></p><p className="mt-0.5 text-[16px] font-semibold text-slate-700">{integerFormat.format(props.stationCount)}</p></div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto px-5 py-4 max-md:px-4 max-md:py-3">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[13px] font-semibold text-slate-800"> TOP10</h3>
<span className="text-[10px] text-slate-400">{metricLabel(props.metric)}</span>
</div>
{props.stations.length ? (
<ol className="divide-y divide-slate-100">
{props.stations.map((station, index) => (
<li key={station.stationId} className="grid grid-cols-[24px_minmax(0,1fr)_68px] items-center gap-2 py-2.5">
<span className={`text-[11px] font-medium ${index < 3 ? 'text-cyan-700' : 'text-slate-400'}`}>{index + 1}</span>
<span className="min-w-0"><span className="block truncate text-[12px] font-medium text-slate-700" title={station.stationName}>{station.stationName}</span><span className="mt-0.5 block truncate text-[9px] text-slate-400" title={station.address}>{station.address || '地址未维护'}</span></span>
<span className="text-right text-[10px] tabular-nums text-slate-600">{metricValue(station, props.metric)}</span>
</li>
))}
</ol>
) : (
<div className="grid h-44 place-items-center text-center text-xs text-slate-400"><span><Fuel className="mx-auto mb-2 text-slate-300" size={22} /></span></div>
)}
</div>
</aside>
);
}

View File

@@ -1,85 +0,0 @@
import { CalendarDays, ChevronUp, RotateCcw, Search } from 'lucide-react';
import type { HydrogenHeatmapMetric, HydrogenPayer, HydrogenStationOption } from './types';
type Props = {
startDate: string;
endDate: string;
minDate: string;
maxDate: string;
query: string;
payer: HydrogenPayer;
metric: HydrogenHeatmapMetric;
stations: HydrogenStationOption[];
onStartDateChange: (value: string) => void;
onEndDateChange: (value: string) => void;
onQueryChange: (value: string) => void;
onPayerChange: (value: HydrogenPayer) => void;
onMetricChange: (value: HydrogenHeatmapMetric) => void;
onReset: () => void;
onHide: () => void;
};
const FIELD = 'relative h-11 min-w-0 rounded-lg border border-slate-200 bg-white transition focus-within:border-cyan-400 focus-within:ring-2 focus-within:ring-cyan-100';
const LABEL = 'pointer-events-none absolute left-3 top-1 z-10 text-[9px] font-medium leading-none text-slate-400';
const CONTROL = 'h-full w-full min-w-0 rounded-lg border-0 bg-transparent px-3 pt-3 text-[12px] text-slate-700 outline-none';
export default function HydrogenHeatmapFilters(props: Props) {
return (
<div className="pointer-events-auto grid max-h-[calc(100dvh-180px)] w-full grid-cols-2 gap-2 overflow-y-auto rounded-xl border border-slate-200/80 bg-white/96 p-2 shadow-[0_8px_28px_rgba(15,23,42,0.12)] backdrop-blur-md md:flex md:w-auto md:min-w-max md:items-center md:overflow-visible">
<label className={`${FIELD} col-span-2 md:w-[320px]`}>
<span className={`${LABEL} left-9`}></span>
<span className="flex h-full min-w-0 items-center pl-8 pt-2">
<CalendarDays className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={15} strokeWidth={1.8} />
<input aria-label="加氢开始日期" type="date" min={props.minDate} max={props.endDate} value={props.startDate} onChange={(event) => props.onStartDateChange(event.target.value)} className="h-full w-[124px] min-w-0 flex-1 border-0 bg-transparent px-1 text-[12px] text-slate-700 outline-none" />
<span className="shrink-0 px-0.5 text-slate-300"></span>
<input aria-label="加氢结束日期" type="date" min={props.startDate} max={props.maxDate} value={props.endDate} onChange={(event) => props.onEndDateChange(event.target.value)} className="h-full w-[124px] min-w-0 flex-1 border-0 bg-transparent px-1 text-[12px] text-slate-700 outline-none" />
</span>
</label>
<label className={`${FIELD} col-span-2 md:w-[145px]`}>
<span className={LABEL}></span>
<span className="relative block h-full">
<input
aria-label="搜索加氢站"
value={props.query}
list="hydrogen-heatmap-stations"
onChange={(event) => props.onQueryChange(event.target.value)}
placeholder="输入站点名称"
className={`${CONTROL} pr-9`}
/>
<Search className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} strokeWidth={1.8} />
<datalist id="hydrogen-heatmap-stations">
{props.stations.map((station) => <option key={station.stationId} value={station.stationName} />)}
</datalist>
</span>
</label>
<label className={`${FIELD} md:w-[100px]`}>
<span className={LABEL}></span>
<select aria-label="费用承担" value={props.payer} onChange={(event) => props.onPayerChange(event.target.value as HydrogenPayer)} className={CONTROL}>
<option value="all"></option>
<option value="lingniu"></option>
<option value="customer"></option>
</select>
</label>
<label className={`${FIELD} md:w-[100px]`}>
<span className={LABEL}></span>
<select aria-label="加氢统计维度" value={props.metric} onChange={(event) => props.onMetricChange(event.target.value as HydrogenHeatmapMetric)} className={CONTROL}>
<option value="kg"></option>
<option value="refuels"></option>
<option value="vehicles"></option>
</select>
</label>
<div className="col-span-2 flex min-w-0 justify-end gap-2 md:w-auto">
<button type="button" onClick={props.onReset} className="flex h-11 min-w-[72px] items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 text-[12px] font-medium text-slate-600 transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700">
<RotateCcw size={14} strokeWidth={1.8} />
</button>
<button type="button" onClick={props.onHide} aria-label="隐藏加氢筛选" title="隐藏筛选" className="grid h-11 w-11 shrink-0 place-items-center rounded-lg border border-slate-200 bg-white text-slate-400 transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700 md:w-10">
<ChevronUp size={16} strokeWidth={1.8} />
</button>
</div>
</div>
);
}

View File

@@ -1,222 +0,0 @@
import { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, ChevronLeft, Clock3, Fuel, Maximize2, Minimize2, SlidersHorizontal } from 'lucide-react';
import HydrogenAmapCanvas from './HydrogenAmapCanvas';
import HydrogenHeatmapDetailPanel from './HydrogenHeatmapDetailPanel';
import HydrogenHeatmapFilters from './HydrogenHeatmapFilters';
import { fetchAmapConfig, fetchHydrogenHeatmapMeta, fetchHydrogenHeatmapPoints, fetchNearbyHydrogenStations } from './api';
import type { AmapConfig, HydrogenHeatmapMeta, HydrogenHeatmapMetric, HydrogenHeatmapResponse, HydrogenNearbyResponse, HydrogenPayer } from './types';
const DEFAULT_START = '2026-01-01';
const DEFAULT_END = '2026-07-13';
const integerFormat = new Intl.NumberFormat('zh-CN');
const kgFormat = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 });
function Metric({ value, label }: { value: string; label: string }) {
return (
<div className="min-w-[88px] text-center max-md:min-w-[68px]">
<p className="text-[21px] font-semibold leading-none tracking-tight text-cyan-700 tabular-nums max-md:text-[16px]">{value}</p>
<p className="mt-1.5 text-[11px] text-slate-500 max-md:mt-1 max-md:text-[9px]">{label}</p>
</div>
);
}
function metricName(metric: HydrogenHeatmapMetric) {
if (metric === 'refuels') return '加氢频次';
if (metric === 'vehicles') return '车辆覆盖';
return '加氢量强度';
}
function metricDescription(metric: HydrogenHeatmapMetric) {
if (metric === 'refuels') return '每个站点累计有效加氢订单数;平方根平滑强度。';
if (metric === 'vehicles') return '每个站点按车牌去重统计车辆数;平方根平滑强度。';
return '每个站点累计有效加氢量kg对数平滑强度。';
}
export default function HydrogenHeatmapModule() {
const [meta, setMeta] = useState<HydrogenHeatmapMeta | null>(null);
const [config, setConfig] = useState<AmapConfig | null>(null);
const [data, setData] = useState<HydrogenHeatmapResponse | null>(null);
const [nearby, setNearby] = useState<HydrogenNearbyResponse | null>(null);
const [startDate, setStartDate] = useState(DEFAULT_START);
const [endDate, setEndDate] = useState(DEFAULT_END);
const [query, setQuery] = useState('');
const [payer, setPayer] = useState<HydrogenPayer>('all');
const [metric, setMetric] = useState<HydrogenHeatmapMetric>('kg');
const [isFullscreen, setIsFullscreen] = useState(false);
const [showFilters, setShowFilters] = useState(() => typeof window === 'undefined' || !window.matchMedia('(max-width: 767px)').matches);
const [showDetails, setShowDetails] = useState(() => typeof window === 'undefined' || !window.matchMedia('(max-width: 767px)').matches);
const [loading, setLoading] = useState(true);
const [nearbyLoading, setNearbyLoading] = useState(false);
const [error, setError] = useState('');
const deferredQuery = useDeferredValue(query.trim());
useEffect(() => {
if (!isFullscreen) return undefined;
const previousOverflow = document.body.style.overflow;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsFullscreen(false);
};
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
};
}, [isFullscreen]);
useEffect(() => {
const controller = new AbortController();
Promise.all([fetchHydrogenHeatmapMeta(controller.signal), fetchAmapConfig(controller.signal)])
.then(([nextMeta, nextConfig]) => {
setMeta(nextMeta);
setConfig(nextConfig);
setStartDate(nextMeta.startDate);
setEndDate(nextMeta.endDate);
})
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('加氢热力图基础配置加载失败');
});
return () => controller.abort();
}, []);
useEffect(() => {
const controller = new AbortController();
const timer = window.setTimeout(() => {
setLoading(true);
setError('');
fetchHydrogenHeatmapPoints({ startDate, endDate, query: deferredQuery, payer, metric }, controller.signal)
.then((response) => {
setData(response);
setNearby(null);
})
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('加氢热力图数据加载失败,请稍后重试');
})
.finally(() => setLoading(false));
}, 220);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [deferredQuery, endDate, metric, payer, startDate]);
const handleMapClick = useCallback((longitude: number, latitude: number) => {
const controller = new AbortController();
setNearbyLoading(true);
fetchNearbyHydrogenStations({ lng: longitude, lat: latitude, startDate, endDate, query: deferredQuery, payer, metric }, controller.signal)
.then(setNearby)
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('区域加氢站明细加载失败');
})
.finally(() => setNearbyLoading(false));
}, [deferredQuery, endDate, metric, payer, startDate]);
const reset = () => {
setStartDate(meta?.startDate || DEFAULT_START);
setEndDate(meta?.endDate || DEFAULT_END);
setQuery('');
setPayer('all');
setMetric('kg');
setNearby(null);
};
const coverageText = meta
? `GPS覆盖 ${(meta.gpsCoverageRate * 100).toFixed(3)}% · 已排除 ${integerFormat.format(meta.excludedRefuelCount)} 笔缺坐标数据`
: '正在核对站点 GPS 覆盖率';
const detail = useMemo(() => {
if (nearby) {
return {
title: '选中区域',
subtitle: `${nearby.center.lng.toFixed(3)}, ${nearby.center.lat.toFixed(3)} · 半径 ${nearby.radiusKm} 公里`,
kg: nearby.kg,
refuelCount: nearby.refuelCount,
vehicleCount: nearby.vehicleCount,
stationCount: nearby.stationCount,
stations: nearby.topStations,
selected: true,
};
}
return {
title: deferredQuery ? '站点筛选结果' : '全国 · 当前范围',
subtitle: `${payer === 'all' ? '' : `${payer === 'lingniu' ? '羚牛承担' : '客户承担'} · `}${startDate} ${endDate}`,
kg: data?.kg || 0,
refuelCount: data?.refuelCount || 0,
vehicleCount: data?.vehicleCount || 0,
stationCount: data?.stationCount || 0,
stations: data?.topStations || [],
selected: false,
};
}, [data, deferredQuery, endDate, nearby, payer, startDate]);
return (
<section className={`flex flex-col overflow-hidden bg-white ${isFullscreen ? 'fixed inset-0 z-[100] h-screen w-screen min-h-0' : 'h-[100dvh] min-h-[620px] max-md:h-[calc(100dvh-4rem)] max-md:min-h-[520px]'}`}>
<header className="relative z-30 flex min-h-[98px] shrink-0 items-center justify-between border-b border-slate-200 bg-white px-8 max-md:min-h-[104px] max-md:flex-wrap max-md:gap-y-2 max-md:px-3 max-md:py-3">
<div className="min-w-0">
<h1 className="truncate text-[25px] font-semibold tracking-[-0.025em] text-slate-950 max-md:text-[18px]"></h1>
<p className="mt-1.5 flex items-center gap-1.5 text-[11px] text-slate-400 max-md:mt-1 max-md:text-[9px]"><Fuel size={13} /> × GPS · </p>
</div>
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-7 max-md:order-3 max-md:static max-md:w-full max-md:translate-x-0 max-md:translate-y-0 max-md:justify-center max-md:gap-2">
<Metric value={kgFormat.format(data?.kg ?? meta?.eligibleKg ?? 0)} label="加氢量 kg" />
<span className="h-8 w-px bg-slate-200" />
<Metric value={integerFormat.format(data?.refuelCount ?? meta?.eligibleRefuelCount ?? 0)} label="加氢次数" />
<span className="h-8 w-px bg-slate-200" />
<Metric value={integerFormat.format(data?.stationCount ?? meta?.eligibleStationCount ?? 0)} label="站点" />
</div>
<div className="flex items-center gap-3">
<p className="flex items-center gap-2 text-[12px] text-slate-500 max-xl:hidden"><Clock3 size={15} strokeWidth={1.7} /> {meta?.endDate || DEFAULT_END}</p>
<button type="button" onClick={() => setIsFullscreen((current) => !current)} className="grid h-9 w-9 place-items-center rounded-lg border border-slate-200 text-slate-500 transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700" title={isFullscreen ? '退出全屏' : '地图全屏'} aria-label={isFullscreen ? '退出加氢地图全屏' : '加氢地图全屏'} aria-pressed={isFullscreen}>
{isFullscreen ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
</button>
</div>
</header>
<div className="relative flex min-h-0 flex-1">
<div className="relative z-0 min-w-0 flex-1 overflow-hidden">
{config ? <HydrogenAmapCanvas config={config} points={data?.points || []} max={data?.max || 1} metric={metric} focusQuery={deferredQuery} onMapClick={handleMapClick} /> : <div className="absolute inset-0 bg-slate-100" />}
{showFilters ? (
<div className="pointer-events-none absolute left-3 right-3 top-3 z-10 md:left-5 md:right-auto md:top-5">
<HydrogenHeatmapFilters
startDate={startDate}
endDate={endDate}
minDate={meta?.startDate || DEFAULT_START}
maxDate={meta?.endDate || DEFAULT_END}
query={query}
payer={payer}
metric={metric}
stations={meta?.stations || []}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onQueryChange={setQuery}
onPayerChange={setPayer}
onMetricChange={setMetric}
onReset={reset}
onHide={() => setShowFilters(false)}
/>
</div>
) : (
<button type="button" onClick={() => setShowFilters(true)} aria-label="显示加氢筛选" className="absolute left-3 top-3 z-20 flex h-11 items-center gap-2 rounded-lg border border-slate-200 bg-white/95 px-3 text-xs font-medium text-slate-600 shadow-lg backdrop-blur-sm transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700 md:left-5 md:top-5 md:h-10"><SlidersHorizontal size={15} strokeWidth={1.8} /></button>
)}
{!showDetails ? (
<button type="button" onClick={() => setShowDetails(true)} aria-label="显示加氢详情" className={`absolute right-3 top-3 z-20 flex h-11 items-center gap-2 rounded-lg border border-slate-200 bg-white/95 px-3 text-xs font-medium text-slate-600 shadow-lg backdrop-blur-sm transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700 md:right-5 md:top-5 md:h-10 ${showFilters ? 'max-md:hidden' : ''}`}><ChevronLeft size={16} strokeWidth={1.8} /></button>
) : null}
<div className="pointer-events-none absolute bottom-5 left-5 z-10 w-[300px] rounded-xl border border-slate-200/80 bg-white/95 p-3.5 shadow-[0_8px_30px_rgba(15,23,42,0.11)] backdrop-blur-sm max-lg:bottom-[calc(43vh+16px)] max-md:hidden">
<div className="flex items-center justify-between text-[11px] font-medium text-slate-700"><span>{metricName(metric)}</span>{loading ? <i className="h-3 w-3 animate-spin rounded-full border-2 border-cyan-600 border-t-transparent" /> : null}</div>
<p className="mt-1 text-[10px] leading-4 text-slate-500">{metricDescription(metric)}</p>
<div className="mt-2.5 h-2.5 rounded-full bg-[linear-gradient(90deg,#2563eb_0%,#0891b2_25%,#16a34a_45%,#eab308_65%,#f97316_82%,#dc2626_100%)]" />
<div className="mt-1.5 flex justify-between text-[10px] text-slate-400"><span></span><span></span></div>
</div>
{error ? <div className="absolute bottom-5 left-1/2 z-30 flex -translate-x-1/2 items-center gap-2 rounded-lg bg-slate-900 px-4 py-2.5 text-xs text-white shadow-xl"><AlertTriangle size={15} className="text-amber-300" />{error}</div> : null}
</div>
{showDetails ? (
<HydrogenHeatmapDetailPanel {...detail} metric={metric} loading={nearbyLoading} coverageText={coverageText} onClose={() => setNearby(null)} onHide={() => setShowDetails(false)} />
) : null}
</div>
</section>
);
}

View File

@@ -1,51 +0,0 @@
import { fetchJson } from '../../auth/api-client';
import type {
AmapConfig,
HydrogenHeatmapMeta,
HydrogenHeatmapMetric,
HydrogenHeatmapResponse,
HydrogenNearbyResponse,
HydrogenPayer,
} from './types';
const BASE = '/api/hydrogen-heatmap';
export function fetchAmapConfig(signal?: AbortSignal) {
return fetchJson<AmapConfig>(`${BASE}/config`, { signal });
}
export function fetchHydrogenHeatmapMeta(signal?: AbortSignal) {
return fetchJson<HydrogenHeatmapMeta>(`${BASE}/meta`, { signal });
}
export function fetchHydrogenHeatmapPoints(
params: { startDate: string; endDate: string; query: string; payer: HydrogenPayer; metric: HydrogenHeatmapMetric },
signal?: AbortSignal,
) {
return fetchJson<HydrogenHeatmapResponse>(`${BASE}/points?${new URLSearchParams(params)}`, { signal });
}
export function fetchNearbyHydrogenStations(
params: {
lng: number;
lat: number;
startDate: string;
endDate: string;
query: string;
payer: HydrogenPayer;
metric: HydrogenHeatmapMetric;
},
signal?: AbortSignal,
) {
const search = new URLSearchParams({
lng: String(params.lng),
lat: String(params.lat),
startDate: params.startDate,
endDate: params.endDate,
query: params.query,
payer: params.payer,
metric: params.metric,
radiusKm: '50',
});
return fetchJson<HydrogenNearbyResponse>(`${BASE}/nearby?${search}`, { signal });
}

View File

@@ -1,72 +0,0 @@
export type HydrogenHeatmapMetric = 'kg' | 'refuels' | 'vehicles';
export type HydrogenPayer = 'all' | 'lingniu' | 'customer';
export type HydrogenHeatmapPoint = {
lng: number;
lat: number;
count: number;
};
export type HydrogenStationRank = {
stationId: string;
stationName: string;
address: string;
lng: number;
lat: number;
kg: number;
refuelCount: number;
vehicleCount: number;
firstRefuel: string;
lastRefuel: string;
metricValue: number;
};
export type HydrogenStationOption = {
stationId: string;
stationName: string;
};
export type HydrogenHeatmapMeta = {
startDate: string;
endDate: string;
totalRefuelCount: number;
eligibleRefuelCount: number;
excludedRefuelCount: number;
gpsCoverageRate: number;
totalKg: number;
eligibleKg: number;
vehicleCount: number;
totalStationCount: number;
eligibleStationCount: number;
stations: HydrogenStationOption[];
};
export type HydrogenHeatmapResponse = {
startDate: string;
endDate: string;
metric: HydrogenHeatmapMetric;
payer: HydrogenPayer;
kg: number;
refuelCount: number;
vehicleCount: number;
stationCount: number;
dayCount: number;
points: HydrogenHeatmapPoint[];
max: number;
topStations: HydrogenStationRank[];
};
export type HydrogenNearbyResponse = {
center: { lng: number; lat: number };
radiusKm: number;
kg: number;
refuelCount: number;
vehicleCount: number;
stationCount: number;
topStations: HydrogenStationRank[];
};
export type AmapConfig = {
key: string;
securityCode: string;
};

View File

@@ -1,18 +1,13 @@
import { FileText } from 'lucide-react';
import { SurfaceCard } from '../../components/ui/surface';
export default function DailyReportView() {
return (
<SurfaceCard className="min-h-[360px]">
<div className="flex min-h-[320px] flex-col items-center justify-center px-6 py-10 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<FileText size={26} />
</div>
<div className="mt-4 text-base font-black text-slate-800"></div>
<div className="mt-2 max-w-md text-xs font-bold leading-relaxed text-slate-400">
</div>
<div className="flex items-center justify-center py-20">
<div className="text-center">
<FileText size={48} className="mx-auto text-gray-300 mb-4" />
<h2 className="text-lg font-semibold text-gray-500"></h2>
<p className="text-sm text-gray-400 mt-2">...</p>
</div>
</SurfaceCard>
</div>
);
}

View File

@@ -1,50 +1,60 @@
import { useState } from 'react';
import { LayoutDashboard, BarChart3, FileText } from 'lucide-react';
import { AnimatePresence } from 'motion/react';
import { motion } from 'motion/react';
import MonitoringView from './MonitoringView';
import StatisticsView from './StatisticsView';
import DailyReportView from './DailyReportView';
import { useHashSubTab } from '../energy/useHashSubTab';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { FadeIn, PageFrame, SegmentedNav } from '../../components/ui/surface';
type MileageSubTab = 'monitoring' | 'statistics' | 'report';
const MILEAGE_TABS = [
{ id: 'monitoring', label: '实时监控', icon: LayoutDashboard },
{ id: 'statistics', label: '统计报表', icon: BarChart3 },
{ id: 'report', label: '每日汇报', icon: FileText },
] as const satisfies readonly { id: MileageSubTab; label: string; icon: typeof LayoutDashboard }[];
const MILEAGE_SUB_IDS: readonly MileageSubTab[] = ['monitoring', 'statistics', 'report'];
export default function MileageModule() {
const [activeSubTab, setActiveSubTab] = useHashSubTab<MileageSubTab>('mileage', MILEAGE_SUB_IDS);
const [activeSubTab, setActiveSubTab] = useState<'monitoring' | 'statistics' | 'report'>('monitoring');
return (
<PageFrame
title="车辆里程中心"
subtitle="统一监控车辆日里程、累计里程、考核进度与日报经营口径,突出异常车辆和任务压力。"
icon={LayoutDashboard}
eyebrow="MILEAGE BI"
meta="实时监控 · 统计报表 · 每日汇报"
compactInfo
>
<div className="sticky top-0 z-30 -mx-3 bg-[var(--app-bg)] px-3 pb-2 pt-1 shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)] md:-mx-6 md:top-12 md:px-6">
<SegmentedNav tabs={MILEAGE_TABS} active={activeSubTab} onChange={setActiveSubTab} />
</div>
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 landscape:pb-0 landscape:h-full landscape:flex-1 landscape:overflow-hidden">
{/* Sub-navigation — sticky */}
<div className="bg-white px-4 py-2 rounded-2xl border border-slate-100 shadow-sm flex items-center gap-6 sticky top-0 z-30">
<button
onClick={() => setActiveSubTab('monitoring')}
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'monitoring' ? 'text-blue-600' : 'text-slate-400'}`}
>
<LayoutDashboard size={14} />
<span className="text-[11px] font-bold"></span>
{activeSubTab === 'monitoring' && (
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
<button
onClick={() => setActiveSubTab('statistics')}
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'statistics' ? 'text-blue-600' : 'text-slate-400'}`}
>
<BarChart3 size={14} />
<span className="text-[11px] font-bold"></span>
{activeSubTab === 'statistics' && (
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
<button
onClick={() => setActiveSubTab('report')}
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'report' ? 'text-blue-600' : 'text-slate-400'}`}
>
<FileText size={14} />
<span className="text-[11px] font-bold"></span>
{activeSubTab === 'report' && (
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
</div>
<AnimatePresence mode="wait">
<FadeIn key={activeSubTab}>
{activeSubTab === 'monitoring' ? (
<MonitoringView />
) : activeSubTab === 'statistics' ? (
<StatisticsView />
) : (
<DailyReportView />
)}
</FadeIn>
</AnimatePresence>
<RotatingFooterHint />
</PageFrame>
{activeSubTab === 'monitoring' ? (
<MonitoringView />
) : activeSubTab === 'statistics' ? (
<StatisticsView />
) : (
<DailyReportView />
)}
<RotatingFooterHint />
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@ import {
Truck, ChevronDown, Maximize2, Minimize2,
Search, ArrowUpDown, X, RotateCcw, Calendar,
} from 'lucide-react';
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from './types';
import type { TargetSummary, TargetVehicle, TrendPoint } from './types';
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
import Blur from '../../components/Blur';
@@ -19,31 +19,11 @@ function getDefaultDate(): string {
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
function getCurrentDateLabel(): string {
const now = new Date();
return `${now.getFullYear()}.${now.getMonth() + 1}.${now.getDate()}`;
}
function fmtKm(value: number): string {
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
return value.toLocaleString();
}
function fmtPercent(value: number): string {
return `${value.toFixed(1)}%`;
}
function getTargetAssessment(target: TargetSummary, selectedYear?: number): TargetYearlyAssessment | null {
if (target.yearlyAssessments.length === 0) return null;
return target.yearlyAssessments.find(item => item.yearNumber === selectedYear) || target.yearlyAssessments[0];
}
function fmtDateLabel(date: string | null): string {
if (!date) return '';
const [year, month, day] = date.split('-');
return `${year}.${Number(month)}.${Number(day)}`;
}
function shortTargetName(name: string): string {
// Extract the number and a short description
const match = name.match(/(\d+)[辆台](.+)/);
@@ -59,7 +39,6 @@ function shortTargetName(name: string): string {
}
export default function StatisticsView() {
const currentDateLabel = getCurrentDateLabel();
const [targets, setTargets] = useState<TargetSummary[]>([]);
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
@@ -67,8 +46,7 @@ export default function StatisticsView() {
const [chartType, setChartType] = useState<'bar' | 'line' | 'area'>('bar');
const [isTableFullscreen, setIsTableFullscreen] = useState(false);
const [expandedTargetId, setExpandedTargetId] = useState<number | null>(null);
const [assessmentYearMap, setAssessmentYearMap] = useState<Record<number, number>>({});
const [expandedModel, setExpandedModel] = useState<string | null>(null);
const [viewAllTargetId, setViewAllTargetId] = useState<number | null>(null);
const [viewAllTargetName, setViewAllTargetName] = useState<string>('');
const [viewAllSearch, setViewAllSearch] = useState('');
@@ -76,33 +54,12 @@ export default function StatisticsView() {
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
const [viewAllLoading, setViewAllLoading] = useState(false);
const selectedTarget = targets.find(t => t.id === selectedTargetId);
const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null;
const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0;
const selectedRemaining = selectedAssessment?.remaining ?? 0;
const selectedDaysLeft = selectedAssessment?.daysLeft ?? selectedTarget?.daysLeft ?? 0;
const selectedDailyTarget = selectedAssessment?.dailyTarget ?? (selectedDaysLeft > 0 ? selectedRemaining / selectedDaysLeft : 0);
const selectedQualifiedRate = selectedAssessment?.qualifiedRate ?? (selectedTarget?.vehicleCount ? selectedTarget.yearQualifiedCount / selectedTarget.vehicleCount * 100 : 0);
const latestTrend = trendData[trendData.length - 1];
const previousTrend = trendData[trendData.length - 2];
const trendDelta = latestTrend && previousTrend ? latestTrend.mileage - previousTrend.mileage : 0;
const pressureLevel = selectedCompletion >= 90 ? '健康' : selectedCompletion >= 70 ? '关注' : '高压';
// Load targets on mount
useEffect(() => {
fetchTargets().then(data => {
const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0];
const ordered = focused
? [focused, ...data.filter(item => item.id !== focused.id)]
: data;
setTargets(ordered);
if (ordered.length > 0 && !selectedTargetId) {
setSelectedTargetId(focused.id);
setExpandedTargetId(focused.id);
setAssessmentYearMap(Object.fromEntries(ordered.map(item => [item.id, item.yearlyAssessments[0]?.yearNumber || 1])));
fetchTargetVehicles(focused.id).then(vehicles => {
setTargetVehiclesMap(prev => ({ ...prev, [focused.id]: vehicles }));
}).catch(() => {});
setTargets(data);
if (data.length > 0 && !selectedTargetId) {
setSelectedTargetId(data[0].id);
}
}).catch(() => {});
}, []);
@@ -110,12 +67,6 @@ export default function StatisticsView() {
// Load trend when selectedTargetId changes
useEffect(() => {
if (selectedTargetId === null) return;
setExpandedTargetId(selectedTargetId);
if (!targetVehiclesMap[selectedTargetId]) {
fetchTargetVehicles(selectedTargetId).then(vehicles => {
setTargetVehiclesMap(prev => ({ ...prev, [selectedTargetId]: vehicles }));
}).catch(() => {});
}
fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
}, [selectedTargetId]);
@@ -129,16 +80,13 @@ export default function StatisticsView() {
}, [viewAllTargetId, viewAllDate]);
return (
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1 [overflow-anchor:none]" style={{ overflowX: 'clip' }}>
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1" style={{ overflowX: 'clip' }}>
{/* Project Selector */}
<div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-100 flex gap-1 overflow-x-auto no-scrollbar flex-shrink-0">
{targets.map(target => (
<button
key={target.id}
onClick={() => {
setSelectedTargetId(target.id);
setExpandedTargetId(target.id);
}}
onClick={() => setSelectedTargetId(target.id)}
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
selectedTargetId === target.id
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
@@ -150,51 +98,12 @@ export default function StatisticsView() {
))}
</div>
{selectedTarget && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="grid grid-cols-2 gap-2 md:grid-cols-4"
>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400"></div>
<div className={`mt-1 text-lg font-black ${pressureLevel === '健康' ? 'text-emerald-600' : pressureLevel === '关注' ? 'text-amber-600' : 'text-rose-600'}`}>
{pressureLevel}
</div>
<div className="mt-1 text-[10px] font-bold text-slate-400"> {fmtPercent(selectedCompletion)}</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400"></div>
<div className="mt-1 text-lg font-black text-slate-900">{fmtKm(Math.max(0, selectedRemaining))}<span className="ml-1 text-[10px] text-slate-400">km</span></div>
<div className="mt-1 text-[10px] font-bold text-slate-400"> {selectedDaysLeft} </div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400"></div>
<div className="mt-1 text-lg font-black text-blue-600">
{selectedDaysLeft > 0 ? (
<>
{fmtKm(selectedDailyTarget)}<span className="ml-1 text-[10px] text-slate-400">km</span>
</>
) : '已到期'}
</div>
<div className="mt-1 text-[10px] font-bold text-slate-400"></div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400"></div>
<div className={`mt-1 text-lg font-black ${trendDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{trendDelta >= 0 ? '+' : ''}{fmtKm(trendDelta)}
</div>
<div className="mt-1 text-[10px] font-bold text-slate-400"> {fmtPercent(selectedQualifiedRate)}</div>
</div>
</motion.div>
)}
<div className="flex flex-col landscape:flex-row gap-4 flex-1 landscape:overflow-hidden">
{/* Left Side: Trend Chart / Dashboard Sidebar */}
<div className="flex-none landscape:flex-1 landscape:w-2/3 space-y-4 flex flex-col overflow-y-auto no-scrollbar min-w-0">
{/* KPI Cards in Landscape — linked to selected target */}
{(() => {
const sel = selectedTarget;
const sel = targets.find(t => t.id === selectedTargetId);
return (
<div className="hidden landscape:grid grid-cols-4 gap-3 flex-shrink-0">
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
@@ -221,7 +130,7 @@ export default function StatisticsView() {
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1"></div>
<div className="text-lg font-black text-slate-900 tracking-tighter">
{selectedCompletion.toFixed(1)}
{(sel?.avgCompletion ?? 0).toFixed(1)}
<span className="text-blue-500 text-[10px] ml-1">%</span>
</div>
</div>
@@ -249,8 +158,9 @@ export default function StatisticsView() {
))}
</div>
</div>
<div className="h-[280px] w-full min-w-0">
<ResponsiveContainer width="100%" height={280} minWidth={0}>
<div className="flex-1 w-full min-h-[250px] relative">
<div className="absolute inset-0">
<ResponsiveContainer width="100%" height="100%">
{chartType === 'bar' ? (
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
@@ -292,6 +202,7 @@ export default function StatisticsView() {
</AreaChart>
)}
</ResponsiveContainer>
</div>
</div>
</div>
</div>
@@ -299,12 +210,9 @@ export default function StatisticsView() {
{/* Right Side: Summary Section */}
<div className="w-full landscape:w-1/3 flex-shrink-0 space-y-2 flex flex-col landscape:overflow-hidden">
<div className="flex items-center justify-between px-2 flex-shrink-0">
<div className="flex min-w-0 items-center gap-2">
<div className="flex items-center gap-2">
<div className="w-1 h-4 bg-blue-600 rounded-full" />
<div className="min-w-0">
<h3 className="truncate text-xs font-black text-slate-700"></h3>
<p className="mt-0.5 truncate text-[9px] font-bold text-slate-400">{selectedTarget?.targetName || '请选择车型'}</p>
</div>
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest"></h3>
</div>
<button
onClick={() => setIsTableFullscreen(true)}
@@ -315,18 +223,13 @@ export default function StatisticsView() {
</div>
<div className="grid grid-cols-1 gap-1.5 overflow-y-auto no-scrollbar pb-2">
{(selectedTarget ? [selectedTarget] : []).map((target, idx) => (
(() => {
const assessment = getTargetAssessment(target, assessmentYearMap[target.id]);
const primaryCompletion = assessment?.completionRate ?? target.avgCompletion;
const primaryQualified = assessment?.qualifiedCount ?? target.yearQualifiedCount;
const primaryQualifiedLabel = assessment ? `${assessment.label}达标:` : '达标:';
return (
{targets.map((target, idx) => (
<div
key={idx}
className="bg-white px-3 py-2 rounded-xl border border-slate-100 shadow-sm flex flex-col active:bg-slate-50 transition-all cursor-pointer"
onClick={() => {
setExpandedTargetId(target.id);
const name = target.targetName;
setExpandedModel(expandedModel === name ? null : name);
if (!targetVehiclesMap[target.id]) {
fetchTargetVehicles(target.id).then(data => {
setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data }));
@@ -346,12 +249,12 @@ export default function StatisticsView() {
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<span className="text-[9px] text-slate-400">{assessment ? `${assessment.label}完成:` : '完成率:'}</span>
<span className={`text-[9px] font-bold ${primaryCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{fmtPercent(primaryCompletion)}</span>
<span className="text-[9px] text-slate-400">:</span>
<span className={`text-[9px] font-bold ${target.avgCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{target.avgCompletion.toFixed(1)}%</span>
</div>
<div className="flex items-center gap-1">
<span className="text-[9px] text-slate-400">{primaryQualifiedLabel}</span>
<span className="text-[9px] font-bold text-slate-600">{primaryQualified}</span>
<span className="text-[9px] text-slate-400">:</span>
<span className="text-[9px] font-bold text-slate-600">{target.yearQualifiedCount}</span>
</div>
</div>
</div>
@@ -366,7 +269,7 @@ export default function StatisticsView() {
</div>
</div>
<motion.div
animate={{ rotate: 180 }}
animate={{ rotate: expandedModel === target.targetName ? 180 : 0 }}
className="text-slate-300"
>
<ChevronDown size={14} />
@@ -374,103 +277,52 @@ export default function StatisticsView() {
</div>
</div>
<AnimatePresence initial={false} mode="wait">
{expandedTargetId === target.id && (
<AnimatePresence>
{expandedModel === target.targetName && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="overflow-hidden"
>
<div className="pt-3 mt-2 border-t border-slate-50 grid grid-cols-2 gap-x-4 gap-y-3">
<div className="col-span-2 flex items-center justify-between gap-3 bg-blue-50/70 p-2 rounded-lg">
<span className="text-[10px] font-black text-blue-700"></span>
<select
value={assessment?.yearNumber || ''}
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
e.stopPropagation();
setAssessmentYearMap(prev => ({ ...prev, [target.id]: Number(e.target.value) }));
}}
className="bg-white border border-blue-100 rounded-lg px-2 py-1 text-[10px] font-bold text-blue-700 outline-none"
>
{target.yearlyAssessments.map(item => (
<option key={item.yearNumber} value={item.yearNumber}>
{item.label}
</option>
))}
</select>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
{target.periods.map((p, i) => (
<p key={i} className="text-[10px] font-black text-slate-700">{p}</p>
))}
</div>
<div className="col-span-2 bg-slate-50/80 rounded-lg p-2 grid grid-cols-2 gap-3">
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
{target.periods.map((p, i) => (
<p key={i} className="text-[10px] font-black text-slate-700">{p}</p>
))}
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">/</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">50%</p>
<p className="text-[10px] font-black text-blue-600">{target.halfQualifiedCount} </p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.currentYearTarget)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">(3.31)</p>
<p className="text-[10px] font-black text-emerald-600">{fmtKm(target.currentYearCompleted)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-rose-500">{fmtKm(target.remaining)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-blue-500">{fmtKm(target.dailyTarget)} km</p>
</div>
{assessment ? (
<>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p>
{(assessment.periods.length > 0 ? assessment.periods : [`${assessment.startDate} ~ ${assessment.endDate}`]).map((period, i) => (
<p key={i} className="text-[10px] font-black text-slate-700">{period}</p>
))}
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}/</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle * assessment.yearNumber)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p>
<p className="text-[10px] font-black text-blue-600">{assessment.vehicleCount} </p>
{assessment.vehicleCount < target.vehicleCount && (
<p className="text-[8px] font-bold text-slate-400"> {target.vehicleCount - assessment.vehicleCount} {assessment.label}</p>
)}
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(assessment.target)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p>
<p className="text-[10px] font-black text-emerald-600">{fmtKm(assessment.completed)} km</p>
<p className="text-[8px] font-bold text-slate-300">
{assessment.daysLeft === 0 ? fmtDateLabel(assessment.endDate) : currentDateLabel}
</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p>
<p className="text-[10px] font-black text-blue-600">{fmtPercent(assessment.completionRate)}</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p>
<p className="text-[10px] font-black text-emerald-600">{fmtPercent(assessment.qualifiedRate)} ({assessment.qualifiedCount})</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p>
<p className="text-[10px] font-black text-rose-500">{fmtKm(assessment.remaining)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p>
<p className="text-[10px] font-black text-blue-500">
{assessment.daysLeft > 0 ? `${fmtKm(assessment.dailyTarget)} km` : '考核已到期'}
</p>
</div>
</>
) : (
<div className="col-span-2 bg-slate-50 p-2 rounded-lg text-[10px] font-bold text-slate-400">
</div>
)}
<div className="col-span-2 flex items-center justify-between bg-slate-50 p-2 rounded-lg">
<span className="text-[9px] font-bold text-slate-500">{assessment ? `${assessment.label}剩余考核天数` : '剩余考核天数'}</span>
<span className="text-[10px] font-black text-slate-900">{assessment?.daysLeft ?? target.daysLeft} </span>
<span className="text-[9px] font-bold text-slate-500"></span>
<span className="text-[10px] font-black text-slate-900">{target.daysLeft} </span>
</div>
{/* Vehicle List Detail */}
@@ -511,8 +363,6 @@ export default function StatisticsView() {
)}
</AnimatePresence>
</div>
);
})()
))}
</div>
</div>
@@ -541,7 +391,7 @@ export default function StatisticsView() {
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{targets.reduce((sum, t) => sum + t.vehicleCount, 0)}</span> </span>
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{targets.length > 0 ? (targets.reduce((sum, t) => sum + (getTargetAssessment(t, assessmentYearMap[t.id])?.completionRate ?? t.avgCompletion), 0) / targets.length).toFixed(1) : '0.0'}</span> <span className="text-blue-400">%</span></span>
<span className="text-slate-500"> <span className="text-white font-black">{targets.length > 0 ? (targets.reduce((sum, t) => sum + t.avgCompletion, 0) / targets.length).toFixed(1) : '0.0'}</span> <span className="text-blue-400">%</span></span>
</div>
</div>
<div className="flex items-center gap-2">
@@ -567,12 +417,12 @@ export default function StatisticsView() {
<tr className="border-b border-slate-800/60">
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase sticky left-0 bg-slate-900 z-20 min-w-[100px]"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-12"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase min-w-[140px]"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">/</th>
<th className="px-3 py-2 text-[10px] font-bold text-emerald-500 uppercase text-center"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase min-w-[140px]"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">/</th>
<th className="px-3 py-2 text-[10px] font-bold text-emerald-500 uppercase text-center"></th>
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-center">50%</th>
<th className="px-3 py-2 text-[10px] font-bold text-white uppercase text-right"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right"></th>
<th className="px-3 py-2 text-[10px] font-bold text-rose-400 uppercase text-right"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-14"></th>
@@ -580,22 +430,10 @@ export default function StatisticsView() {
</tr>
</thead>
<tbody className="divide-y divide-slate-800/30">
{targets.map((target, idx) => {
const assessment = getTargetAssessment(target, assessmentYearMap[target.id]);
const completion = assessment?.completionRate ?? target.avgCompletion;
const qualified = assessment?.qualifiedCount ?? target.yearQualifiedCount;
const halfQualified = assessment?.halfQualifiedCount ?? target.halfQualifiedCount;
const goal = assessment?.target ?? target.currentYearTarget;
const completed = assessment?.completed ?? target.currentYearCompleted;
const remainingMileage = assessment?.remaining ?? target.remaining;
const days = assessment?.daysLeft ?? target.daysLeft;
const daily = assessment?.dailyTarget ?? target.dailyTarget;
const taskPerVehicle = target.annualMileagePerVehicle * (assessment?.yearNumber || 1);
return (
{targets.map((target, idx) => (
<tr key={idx} className="hover:bg-slate-800/20 transition-colors">
<td className="px-3 py-3 sticky left-0 bg-slate-950 z-10 border-r border-slate-800/40">
<div className="text-xs font-bold text-white whitespace-nowrap">{target.targetName}</div>
<div className="text-[9px] text-blue-400 font-bold mt-0.5">{assessment?.label || '当前年度'}</div>
<div className="text-[9px] text-slate-500 mt-0.5">{target.periods.map((p, i) => <span key={i} className="block">{p}</span>)}</div>
</td>
<td className="px-3 py-3 text-xs font-bold text-slate-300 text-center">{target.vehicleCount}</td>
@@ -603,31 +441,28 @@ export default function StatisticsView() {
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${completion >= 90 ? 'bg-emerald-500' : completion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
style={{ width: `${Math.min(completion, 100)}%` }}
className={`h-full rounded-full ${target.avgCompletion >= 90 ? 'bg-emerald-500' : target.avgCompletion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
style={{ width: `${Math.min(target.avgCompletion, 100)}%` }}
/>
</div>
<span className="text-[10px] font-black text-white w-10 text-right">{completion.toFixed(1)}%</span>
<span className="text-[10px] font-black text-white w-10 text-right">{target.avgCompletion.toFixed(1)}%</span>
</div>
<div className="flex justify-between mt-1 text-[9px] text-slate-500">
<span>{fmtKm(completed)}</span>
<span>/ {fmtKm(goal)} km</span>
<span>{fmtKm(target.cumulativeTotal)}</span>
<span>/ {fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</span>
</div>
</td>
<td className="px-3 py-3 text-xs text-slate-300 text-right">{fmtKm(taskPerVehicle)} km</td>
<td className="px-3 py-3 text-xs font-black text-emerald-400 text-center">{qualified}</td>
<td className="px-3 py-3 text-xs font-black text-blue-400 text-center">{halfQualified}</td>
<td className="px-3 py-3 text-xs text-slate-300 text-right">{fmtKm(target.annualMileagePerVehicle)} km</td>
<td className="px-3 py-3 text-xs font-black text-emerald-400 text-center">{target.yearQualifiedCount}</td>
<td className="px-3 py-3 text-xs font-black text-blue-400 text-center">{target.halfQualifiedCount}</td>
<td className="px-3 py-3 text-xs font-black text-white text-right">{fmtKm(target.todayTotal)} km</td>
<td className="px-3 py-3 text-xs text-slate-400 text-right">{fmtKm(goal)} km</td>
<td className="px-3 py-3 text-xs text-emerald-400/80 text-right">{fmtKm(completed)} km</td>
<td className="px-3 py-3 text-xs font-bold text-rose-400 text-right">{fmtKm(remainingMileage)} km</td>
<td className="px-3 py-3 text-xs text-slate-300 text-center">{days}</td>
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">
{assessment && days === 0 ? '考核已到期' : `${fmtKm(daily)} km`}
</td>
<td className="px-3 py-3 text-xs text-slate-400 text-right">{fmtKm(target.currentYearTarget)} km</td>
<td className="px-3 py-3 text-xs text-emerald-400/80 text-right">{fmtKm(target.currentYearCompleted)} km</td>
<td className="px-3 py-3 text-xs font-bold text-rose-400 text-right">{fmtKm(target.remaining)} km</td>
<td className="px-3 py-3 text-xs text-slate-300 text-center">{target.daysLeft}</td>
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">{fmtKm(target.dailyTarget)} km</td>
</tr>
);
})}
))}
</tbody>
</table>
</div>

View File

@@ -4,14 +4,13 @@ import { X, Truck } from 'lucide-react';
import {
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
} from 'recharts';
import type { MileageSourceGroup, MonitoringVehicle } from './types';
import type { MonitoringVehicle } from './types';
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
import Blur from '../../components/Blur';
interface Props {
vehicle: MonitoringVehicle | null;
onClose: () => void;
sourcePriority: MileageSourceGroup[];
}
type RangeKey = 'last15' | 'month' | 'quarter';
@@ -57,13 +56,7 @@ function formatLabel(date: string, key: RangeKey): string {
return date.slice(5);
}
function daySourceLabel(day: VehicleRecentDay): string {
if (day.sourceCategory === 'INSTRUMENT') return '仪表数据';
if (day.sourceCategory === 'GPS') return 'GPS数据';
return day.isDataSynced ? '来源待接口' : '无数据';
}
export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: Props) {
export default function VehicleDetailModal({ vehicle, onClose }: Props) {
const [days, setDays] = useState<VehicleRecentDay[]>([]);
const [loading, setLoading] = useState(false);
const [range, setRange] = useState<RangeKey>('last15');
@@ -81,12 +74,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
setLoading(true);
setDays([]);
let cancelled = false;
fetchVehicleRecent(vehicle.plate, { start, end, sourcePriority })
fetchVehicleRecent(vehicle.plate, { start, end })
.then(d => { if (!cancelled) setDays(d.days); })
.catch(() => { if (!cancelled) setDays([]); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [vehicle?.plate, range, sourcePriority]); // eslint-disable-line react-hooks/exhaustive-deps
}, [vehicle?.plate, range]); // eslint-disable-line react-hooks/exhaustive-deps
// 锁滚动
useEffect(() => {
@@ -287,21 +280,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
transition={{ delay: Math.min(i * 0.012, 0.4), duration: 0.18 }}
className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-slate-50"
>
<div className="w-[88px] flex-shrink-0">
<div className="text-[11px] font-mono font-bold text-slate-600">{d.date}</div>
<div
className={`text-[8px] font-bold ${
d.sourceCategory === 'INSTRUMENT'
? 'text-violet-500'
: d.sourceCategory === 'GPS'
? 'text-emerald-500'
: 'text-amber-500'
}`}
title={d.sourceProtocol || 'OneOS 当前未返回 sourceProtocol'}
>
{daySourceLabel(d)}
</div>
</div>
<span className="text-[11px] font-mono font-bold text-slate-600">{d.date}</span>
<div className="flex items-center gap-2 flex-1 ml-3">
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
<motion.div

View File

@@ -1,19 +1,11 @@
import type {
MileageSourceCategory,
MileageSourceGroup,
MileageSourceProtocol,
MonitoringData,
TargetSummary,
TargetVehicle,
TrendPoint,
} from './types';
import type { MonitoringData, TargetSummary, TargetVehicle, TrendPoint } from './types';
import { fetchJson } from '../../auth/api-client';
const BASE = '/api/mileage';
export async function fetchMonitoring(params?: {
sortBy?: 'today' | 'total' | 'statisticTime';
sortOrder?: 'asc' | 'desc';
sortBy?: string;
sortOrder?: string;
limit?: number;
page?: number;
search?: string;
@@ -24,16 +16,11 @@ export async function fetchMonitoring(params?: {
rentStatus?: string;
platePrefix?: string;
targetName?: string;
targetNames?: string[];
region?: string;
plate?: string;
mileageMin?: string;
mileageMax?: string;
date?: string;
startDate?: string;
endDate?: string;
brands?: string[];
sourcePriority?: MileageSourceGroup[];
}): Promise<MonitoringData> {
const query = new URLSearchParams();
if (params?.sortBy) query.set('sortBy', params.sortBy);
@@ -48,24 +35,11 @@ export async function fetchMonitoring(params?: {
if (params?.rentStatus) query.set('rentStatus', params.rentStatus);
if (params?.platePrefix) query.set('platePrefix', params.platePrefix);
if (params?.targetName) query.set('targetName', params.targetName);
if (params?.targetNames) {
params.targetNames.forEach(name => {
if (name) query.append('targetName', name);
});
}
if (params?.region) query.set('region', params.region);
if (params?.brands) {
params.brands.forEach(brand => {
if (brand) query.append('brand', brand);
});
}
if (params?.plate) query.set('plate', params.plate);
if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
if (params?.mileageMax) query.set('mileageMax', params.mileageMax);
if (params?.date) query.set('date', params.date);
if (params?.startDate) query.set('startDate', params.startDate);
if (params?.endDate) query.set('endDate', params.endDate);
if (params?.sourcePriority?.length) query.set('sourcePriority', params.sourcePriority.join(','));
const qs = query.toString();
return fetchJson<MonitoringData>(`${BASE}/monitoring${qs ? `?${qs}` : ''}`);
}
@@ -92,8 +66,6 @@ export interface VehicleRecentDay {
date: string;
dailyKm: number;
isDataSynced: boolean;
sourceProtocol: MileageSourceProtocol | null;
sourceCategory: Exclude<MileageSourceCategory, 'MIXED'> | null;
}
export interface VehicleRecentResponse {
@@ -101,156 +73,17 @@ export interface VehicleRecentResponse {
start?: string;
end?: string;
days: VehicleRecentDay[];
sourcePriority?: MileageSourceGroup[];
}
export async function fetchVehicleRecent(
plate: string,
range: {
days?: number;
start?: string;
end?: string;
sourcePriority?: MileageSourceGroup[];
} = { days: 15 },
range: { days?: number; start?: string; end?: string } = { days: 15 },
): Promise<VehicleRecentResponse> {
const params = new URLSearchParams();
if (range.start) params.set('start', range.start);
if (range.end) params.set('end', range.end);
if (range.days != null) params.set('days', String(range.days));
if (range.sourcePriority?.length) params.set('sourcePriority', range.sourcePriority.join(','));
return fetchJson<VehicleRecentResponse>(
`${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}`
);
}
export interface DailyReportModel {
id: number;
name: string;
count: number;
today: number;
total: number;
completion: number;
active: number;
zero: number;
dailyNeed: number;
}
export interface DailyReportVehicle {
plate: string;
model: string;
status: string;
customer: string;
today?: number;
completion?: number;
}
export interface DailyReportData {
reportDate: string;
updatedAt: string;
models: DailyReportModel[];
trend: { date: string; value: number }[];
topVehicles: DailyReportVehicle[];
zeroRisk: DailyReportVehicle[];
qualifiedCount: number;
halfQualifiedCount: number;
}
function reportDateFromUpdatedAt(updatedAt: string): string {
if (/^\d{4}-\d{2}-\d{2}$/.test(updatedAt)) return updatedAt;
const d = new Date(updatedAt);
if (!Number.isNaN(d.getTime())) return d.toISOString().slice(0, 10);
return new Date().toISOString().slice(0, 10);
}
function compactTargetName(name: string): string {
return name
.replace(/^羚牛/, '')
.replace(/辆/g, '台')
.replace(/4\.5T普货/g, '普货')
.replace(/4\.5T冷链车/g, '冷链车')
.replace(/4\.5T冷链/g, '冷链车');
}
function normalizeStatus(status: string | null): string {
if (!status) return '未标注';
if (status === '自营' || status === '租赁') return status;
if (/租/.test(status)) return '租赁';
if (/自/.test(status)) return '自营';
if (/库|Inventory/i.test(status)) return '在库';
return status;
}
export async function fetchDailyReport(): Promise<DailyReportData> {
const [targets, trend, monitoring, topMonitoring] = await Promise.all([
fetchTargets(),
fetchTrend(undefined, 7),
fetchMonitoring({ limit: 1 }),
fetchMonitoring({ sortBy: 'today', sortOrder: 'desc', limit: 5 }),
]);
const targetVehiclesEntries = await Promise.all(
targets.map(async target => {
const vehicles = await fetchTargetVehicles(target.id);
return [target.id, vehicles] as const;
}),
);
const targetVehiclesMap = new Map(targetVehiclesEntries);
const models: DailyReportModel[] = targets.map(target => {
const vehicles = targetVehiclesMap.get(target.id) ?? [];
const active = vehicles.filter(vehicle => vehicle.todayMileage > 0).length;
return {
id: target.id,
name: compactTargetName(target.targetName),
count: target.vehicleCount,
today: target.todayTotal,
total: target.cumulativeTotal,
completion: target.avgCompletion,
active,
zero: Math.max(0, target.vehicleCount - active),
dailyNeed: target.dailyTarget,
};
});
const targetNameByPlate = new Map<string, string>();
for (const target of targets) {
const vehicles = targetVehiclesMap.get(target.id) ?? [];
for (const vehicle of vehicles) targetNameByPlate.set(vehicle.plateNumber, compactTargetName(target.targetName));
}
const topVehicles: DailyReportVehicle[] = topMonitoring.vehicles.map(vehicle => ({
plate: vehicle.plate,
model: targetNameByPlate.get(vehicle.plate) || vehicle.project || '未归入考核',
status: normalizeStatus(vehicle.rentStatus),
today: vehicle.dailyKm,
customer: vehicle.customer || '未绑定客户',
}));
const zeroRisk = targetVehiclesEntries
.flatMap(([targetId, vehicles]) => {
const target = targets.find(item => item.id === targetId);
const model = target ? compactTargetName(target.targetName) : '未归入考核';
return vehicles
.filter(vehicle => vehicle.todayMileage <= 0 && ['自营', '租赁'].includes(normalizeStatus(vehicle.rentStatus)))
.map(vehicle => ({
plate: vehicle.plateNumber,
model,
status: normalizeStatus(vehicle.rentStatus),
customer: vehicle.customer || '未绑定客户',
completion: vehicle.completionRate,
}));
})
.sort((a, b) => (b.completion ?? 0) - (a.completion ?? 0))
.slice(0, 5);
return {
reportDate: reportDateFromUpdatedAt(monitoring.updatedAt),
updatedAt: monitoring.updatedAt,
models,
trend: trend.map(item => ({ date: item.date, value: item.mileage })),
topVehicles,
zeroRisk,
qualifiedCount: targets.reduce((sum, target) => sum + target.yearQualifiedCount, 0),
halfQualifiedCount: targets.reduce((sum, target) => sum + target.halfQualifiedCount, 0),
};
}

View File

@@ -1,20 +1,9 @@
export type MileageSourceGroup = 'instrument' | 'gps';
export type MileageSourceCategory = 'INSTRUMENT' | 'GPS' | 'MIXED';
export type MileageSourceProtocol = 'GB32960' | 'MQTT' | 'JT808';
export interface MonitoringVehicle {
plate: string;
vin: string;
dailyKm: number;
dailyMileage?: Record<string, number>;
totalKm: number | null;
source: string;
sourceProtocol: MileageSourceProtocol | null;
sourceCategory: MileageSourceCategory | null;
dailySourceProtocols?: Record<string, MileageSourceProtocol | null>;
dataTime: string | null;
calculatedAt: string | null;
updatedAt: string | null;
isOnline: boolean;
isDataSynced: boolean;
customer: string | null;
@@ -24,8 +13,6 @@ export interface MonitoringVehicle {
entity: string | null;
project: string | null;
region: string | null;
brand: string | null;
targetNames: string[];
}
export interface MonitoringStats {
@@ -45,20 +32,16 @@ export interface MonitoringFilters {
platePrefixes: { prefix: string; count: number }[];
targetNames: string[];
regions: string[];
brands: string[];
}
export interface MonitoringData {
vehicles: MonitoringVehicle[];
stats: MonitoringStats;
filters: MonitoringFilters;
rangeDailyTotals?: { date: string; totalKm: number }[];
dateRange?: { start: string; end: string };
total: number;
page: number;
totalPages: number;
updatedAt: string;
sourcePriority: MileageSourceGroup[];
}
export interface TargetSummary {
@@ -80,37 +63,6 @@ export interface TargetSummary {
remaining: number;
daysLeft: number;
dailyTarget: number;
firstYearVehicleCount: number;
firstYearTarget: number;
firstYearCompleted: number;
firstYearRemaining: number;
firstYearCompletionRate: number;
firstYearQualifiedCount: number;
firstYearQualifiedRate: number;
firstYearHalfQualifiedCount: number;
firstYearDaysLeft: number;
firstYearDailyTarget: number;
firstYearStartDate: string | null;
firstYearEndDate: string | null;
yearlyAssessments: TargetYearlyAssessment[];
}
export interface TargetYearlyAssessment {
yearNumber: number;
label: string;
vehicleCount: number;
target: number;
completed: number;
remaining: number;
completionRate: number;
qualifiedCount: number;
qualifiedRate: number;
halfQualifiedCount: number;
daysLeft: number;
dailyTarget: number;
startDate: string | null;
endDate: string | null;
periods: string[];
}
export interface TargetVehicle {

View File

@@ -2,24 +2,15 @@ import * as XLSX from 'xlsx';
import type { MonitoringVehicle } from './types';
interface ExportContext {
date?: string;
startDate?: string;
endDate?: string;
sortBy: 'today' | 'total' | 'statisticTime';
date: string;
sortBy: 'today' | 'total';
}
const BASE_HEADERS = [
'状态', '数据来源', '车牌号', '客户', '业务部门', '项目', '租赁状态',
'运营区域',
const HEADERS = [
'状态', '车牌号', '客户', '业务部门', '项目', '租赁状态',
'运营区域', '今日里程(km)', '累计里程(km)',
] as const;
function sourceLabel(v: MonitoringVehicle): string {
if (v.sourceCategory === 'INSTRUMENT') return '仪表数据';
if (v.sourceCategory === 'GPS') return 'GPS数据';
if (v.sourceCategory === 'MIXED') return '仪表数据+GPS数据';
return v.isDataSynced ? '来源待接口' : '无数据';
}
function statusLabel(v: MonitoringVehicle): string {
if (!v.isDataSynced) return '未对接';
return v.isOnline ? '在线' : '离线';
@@ -27,83 +18,47 @@ function statusLabel(v: MonitoringVehicle): string {
function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | number {
if (kind === 'today') {
// 当日未对接但有历史累计,视作今日 0只有完全无数据才标「未对接」
if (!v.isDataSynced && v.totalKm == null) return '未对接';
return Math.max(0, v.dailyKm || 0);
return Math.max(0, Math.round(v.dailyKm || 0));
}
return v.totalKm != null ? v.totalKm : '未对接';
return v.totalKm != null ? Math.round(v.totalKm) : '未对接';
}
export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void {
const start = ctx.startDate || ctx.date || '';
const end = ctx.endDate || ctx.date || '';
const isRange = !!start && !!end && start !== end;
const dayKeys = Array.from(
new Set(vehicles.flatMap(v => Object.keys(v.dailyMileage || {})))
).sort();
// 车辆汇总 sheet基础列 + 每日里程列 + 区间合计 + 累计里程
const dailyHeaders = dayKeys.map(day => `${day.slice(5)}里程(km)`);
const summaryHeaders = [
...BASE_HEADERS,
...(dayKeys.length > 0 ? dailyHeaders : []),
isRange ? '区间合计(km)' : '当日里程(km)',
'累计里程(km)',
const data: (string | number)[][] = [
[...HEADERS],
...vehicles.map(v => [
statusLabel(v),
v.plate,
v.customer || '',
v.department || '',
v.project || '',
v.rentStatus || '',
v.region || '',
mileageCell(v, 'today'),
mileageCell(v, 'total'),
]),
];
const summaryData: (string | number)[][] = [
summaryHeaders,
...vehicles.map(v => {
const baseRow = [
statusLabel(v),
sourceLabel(v),
v.plate,
v.customer || '',
v.department || '',
v.project || '',
v.rentStatus || '',
v.region || '',
];
const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? 0);
return [
...baseRow,
...dailyCols,
mileageCell(v, 'today'),
mileageCell(v, 'total'),
];
}),
];
const ws = XLSX.utils.aoa_to_sheet(data);
const ws = XLSX.utils.aoa_to_sheet(summaryData);
const numFixedCols = BASE_HEADERS.length;
const wsCols: { wch: number }[] = [
ws['!cols'] = [
{ wch: 8 }, // 状态
{ wch: 16 }, // 数据来源
{ wch: 12 }, // 车牌号
{ wch: 28 }, // 客户
{ wch: 14 }, // 业务部门
{ wch: 16 }, // 项目
{ wch: 10 }, // 租赁状态
{ wch: 12 }, // 运营区域
{ wch: 14 }, // 今日里程
{ wch: 14 }, // 累计里程
];
for (let i = 0; i < dayKeys.length; i++) wsCols.push({ wch: 12 });
wsCols.push({ wch: 14 }); // 区间合计
wsCols.push({ wch: 14 }); // 累计里程
ws['!cols'] = wsCols;
// 冻结首行 + 固定列
ws['!freeze'] = { xSplit: numFixedCols, ySplit: 1 } as never;
ws['!freeze'] = { xSplit: 0, ySplit: 1 } as never;
for (let r = 1; r < summaryData.length; r++) {
for (let c = numFixedCols; c < summaryHeaders.length; c++) {
const ref = XLSX.utils.encode_cell({ r, c });
if (ws[ref]?.t === 'n') ws[ref].z = '0.##########';
}
}
// 表头样式
for (let c = 0; c < summaryHeaders.length; c++) {
// 表头样式(在客户端 SheetJS 社区版仅基本样式生效)
for (let c = 0; c < HEADERS.length; c++) {
const ref = XLSX.utils.encode_cell({ r: 0, c });
if (ws[ref]) {
(ws[ref] as { s?: unknown }).s = {
@@ -115,53 +70,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
}
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '车辆汇总');
// 每日明细 sheet保留原有格式
if (dayKeys.length > 0) {
const detailHeaders = [
'车牌号', '数据来源', '客户', '业务部门', '项目', '租赁状态', '运营区域',
...dayKeys.map(day => `${day}里程(km)`),
'区间合计(km)',
'累计里程(km)',
];
const detailData: (string | number)[][] = [
detailHeaders,
...vehicles.map(v => [
v.plate,
sourceLabel(v),
v.customer || '',
v.department || '',
v.project || '',
v.rentStatus || '',
v.region || '',
...dayKeys.map(day => v.dailyMileage?.[day] || 0),
Math.max(0, v.dailyKm || 0),
v.totalKm != null ? v.totalKm : '',
]),
];
const detailWs = XLSX.utils.aoa_to_sheet(detailData);
detailWs['!cols'] = [
{ wch: 12 },
{ wch: 16 },
{ wch: 28 },
{ wch: 14 },
{ wch: 16 },
{ wch: 10 },
{ wch: 12 },
...dayKeys.map(() => ({ wch: 14 })),
{ wch: 14 },
{ wch: 14 },
];
detailWs['!freeze'] = { xSplit: 7, ySplit: 1 } as never;
for (let r = 1; r < detailData.length; r++) {
for (let c = 7; c < detailHeaders.length; c++) {
const ref = XLSX.utils.encode_cell({ r, c });
if (detailWs[ref]?.t === 'n') detailWs[ref].z = '0.##########';
}
}
XLSX.utils.book_append_sheet(wb, detailWs, '每日明细');
}
XLSX.utils.book_append_sheet(wb, ws, '里程明细');
const now = new Date();
const y = now.getFullYear();
@@ -169,14 +78,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
const d = String(now.getDate()).padStart(2, '0');
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const dateTag = start && end
? `${start.replace(/-/g, '')}-${end.replace(/-/g, '')}`
: `${y}${m}${d}`;
const sortLabel = ctx.sortBy === 'total'
? '累计'
: ctx.sortBy === 'statisticTime'
? '统计时间'
: isRange ? '区间' : '今日';
const filename = `里程看板_${dateTag}_${hh}${mm}_${sortLabel}.xlsx`;
const dateTag = ctx.date ? ctx.date.replace(/-/g, '') : `${y}${m}${d}`;
const filename = `里程看板_${dateTag}_${hh}${mm}_${ctx.sortBy === 'today' ? '今日' : '累计'}.xlsx`;
XLSX.writeFile(wb, filename);
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Activity, AlertTriangle, CheckCircle2, Filter, RotateCcw, X, Search, ChevronDown, CheckSquare, Send, Clock, Download, SendHorizonal } from 'lucide-react';
import { Filter, RotateCcw, X, Search, ChevronDown, CheckSquare, Send, Clock, Download } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { fetchSuggestions, sendNotifyBatch } from './api';
import type { SchedulingResponse, SchedulingSuggestion, CandidateVehicle } from './types';
@@ -9,7 +9,6 @@ import NotificationHistory from './NotificationHistory';
import { exportSuggestionsCsv } from './csv-export';
import Blur from '../../components/Blur';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { MetricTile, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface';
type TypeFilter = 'all' | 'qualified' | 'hopeless';
@@ -88,43 +87,63 @@ function FilterSelect({ label, options, value, onChange, placeholder }: {
);
}
/** Skeleton pulse block */
function Sk({ className }: { className?: string }) {
return <div className={`animate-pulse bg-slate-200/70 rounded ${className ?? ''}`} />;
}
function SkeletonPage() {
return (
<PageFrame
title="智能调度工作台"
subtitle="自动识别高里程可释放车辆与低里程待救援车辆,形成可登记、可追踪的运营干预建议。"
icon={Activity}
eyebrow="SCHEDULING OPS"
meta="建议生成中 · 正在计算候选车辆"
>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => <SkeletonBlock key={i} className="h-28" />)}
</div>
<SurfaceCard>
<div className="space-y-3 p-4">
<SkeletonBlock className="h-5 w-40" />
<div className="flex gap-2">
{[0, 1, 2, 3].map(i => <SkeletonBlock key={i} className="h-8 w-24 rounded-full" />)}
<div className="min-h-screen bg-[#F0F4F8] font-sans p-3 md:p-6">
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">
{/* Cards skeleton */}
<div className="grid grid-cols-3 gap-2.5">
{[0, 1, 2].map(i => (
<div key={i} className="p-4 rounded-2xl bg-white border border-slate-100 space-y-2.5">
<Sk className="h-3 w-16" />
<Sk className="h-7 w-12" />
<Sk className="h-2.5 w-24" />
</div>
))}
</div>
{/* List card skeleton */}
<div className="bg-white rounded-2xl border border-slate-200/60 overflow-hidden">
{/* Header */}
<div className="px-4 py-3 border-b border-slate-100 space-y-3">
<div className="flex items-center justify-between">
<Sk className="h-4 w-28" />
<div className="flex gap-2"><Sk className="h-6 w-6 rounded-lg" /><Sk className="h-6 w-6 rounded-lg" /></div>
</div>
<div className="flex gap-2">
{[0, 1, 2, 3].map(i => <Sk key={i} className="h-7 w-20 rounded-full" />)}
</div>
</div>
{/* Rows */}
<div className="divide-y divide-slate-50">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 py-3">
<SkeletonBlock className="h-10 w-1 rounded-full" />
<div key={i} className="px-4 py-3 flex items-center gap-3">
<Sk className="w-1 h-10 rounded-full" />
<div className="flex-1 space-y-2">
<SkeletonBlock className="h-3.5 w-48" />
<SkeletonBlock className="h-2.5 w-72 max-w-full" />
<div className="flex items-center gap-2">
<Sk className="h-3.5 w-20" />
<Sk className="h-3 w-10 rounded-full" />
<Sk className="h-3 w-14" />
</div>
<div className="flex items-center gap-3">
<Sk className="h-2.5 w-28" />
<Sk className="h-2.5 w-16" />
<Sk className="h-2.5 w-14" />
</div>
</div>
<SkeletonBlock className="h-6 w-16" />
<Sk className="h-4 w-8" />
</div>
))}
</div>
</div>
</SurfaceCard>
</PageFrame>
</div>
</div>
);
}
@@ -256,33 +275,89 @@ export default function SchedulingModule() {
if (loading && !data) return <SkeletonPage />;
return (
<PageFrame
title="智能调度工作台"
subtitle="自动识别高里程可释放车辆与低里程待救援车辆,形成可登记、可追踪的运营干预建议。"
icon={Activity}
eyebrow="SCHEDULING OPS"
meta={`当前 ${filteredSuggestions.length} 条建议 · ${activeFilterCount} 个筛选条件`}
actions={(
<button onClick={loadData} disabled={loading} className="inline-flex items-center gap-1.5 rounded-xl bg-slate-900 px-3 py-2 text-xs font-black text-white shadow-sm transition-colors hover:bg-slate-800 disabled:opacity-60">
<RotateCcw size={14} className={loading ? 'animate-spin' : ''} />
</button>
)}
>
<div className="min-h-screen bg-[#F0F4F8] text-slate-800 font-sans p-3 md:p-6" style={{ overflowX: 'clip' }}>
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">
{/* ===== Summary Cards ===== */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<button type="button" onClick={() => setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')} className={typeFilter === 'qualified' ? 'rounded-2xl ring-2 ring-orange-400 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
<MetricTile icon={CheckCircle2} label="已完成考核目标" value={summary?.qualifiedCount ?? 0} unit="台" helper="换下,腾位给待达标车" tone="amber" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-2.5">
{/* 里程高·换下 — warm orange */}
<button
onClick={() => setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')}
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
typeFilter === 'qualified'
? 'bg-orange-500 text-white shadow-lg shadow-orange-500/25'
: 'bg-gradient-to-br from-orange-50 to-amber-50 border border-orange-200/60'
}`}
>
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'qualified' ? 'text-orange-100' : 'text-orange-600'}`}>
</div>
<div className={`text-2xl font-black ${typeFilter === 'qualified' ? 'text-white' : 'text-orange-700'}`}>
{loading && !data ? '-' : summary?.qualifiedCount ?? 0}
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'qualified' ? 'text-orange-200' : 'text-orange-400'}`}></span>
</div>
<div className={`text-[9px] mt-0.5 ${typeFilter === 'qualified' ? 'text-orange-200' : 'text-orange-400'}`}>
</div>
</button>
<button type="button" onClick={() => setTypeFilter(typeFilter === 'hopeless' ? 'all' : 'hopeless')} className={typeFilter === 'hopeless' ? 'rounded-2xl ring-2 ring-blue-500 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
<MetricTile icon={AlertTriangle} label="预估无法达标" value={summary?.hopelessCount ?? 0} unit="台" helper="换走,换上快达标的车" tone="blue" />
{/* 里程低·换走 — cool blue */}
<button
onClick={() => setTypeFilter(typeFilter === 'hopeless' ? 'all' : 'hopeless')}
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
typeFilter === 'hopeless'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/25'
: 'bg-gradient-to-br from-blue-50 to-indigo-50 border border-blue-200/60'
}`}
>
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'hopeless' ? 'text-blue-100' : 'text-blue-600'}`}>
</div>
<div className={`text-2xl font-black ${typeFilter === 'hopeless' ? 'text-white' : 'text-blue-700'}`}>
{loading && !data ? '-' : summary?.hopelessCount ?? 0}
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'hopeless' ? 'text-blue-200' : 'text-blue-400'}`}></span>
</div>
<div className={`text-[9px] mt-0.5 ${typeFilter === 'hopeless' ? 'text-blue-200' : 'text-blue-400'}`}>
</div>
</button>
<button type="button" onClick={() => setTypeFilter('all')} className={typeFilter === 'all' ? 'rounded-2xl ring-2 ring-slate-700 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
<MetricTile icon={Activity} label="替换建议" value={summary?.suggestionCount ?? 0} unit="条" helper={`执行后预计 +${summary?.estimatedGain ?? 0} 台达标`} tone="slate" />
{/* 替换建议 — neutral dark */}
<button
onClick={() => setTypeFilter('all')}
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
typeFilter === 'all'
? 'bg-slate-800 text-white shadow-lg shadow-slate-800/25'
: 'bg-gradient-to-br from-slate-50 to-slate-100 border border-slate-200/60'
}`}
>
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'all' ? 'text-slate-300' : 'text-slate-500'}`}>
</div>
<div className={`text-2xl font-black ${typeFilter === 'all' ? 'text-white' : 'text-slate-800'}`}>
{loading && !data ? '-' : summary?.suggestionCount ?? 0}
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'all' ? 'text-slate-400' : 'text-slate-400'}`}></span>
</div>
<div className={`text-[9px] mt-0.5 ${typeFilter === 'all' ? 'text-slate-400' : 'text-slate-400'}`}>
+{summary?.estimatedGain ?? 0}
</div>
</button>
<button type="button" onClick={() => { setShowHistory(true); setHistoryRecentOnly(true); }} className="rounded-2xl">
<MetricTile icon={SendHorizonal} label="近期已干预" value={summary?.recentInterventionCount ?? 0} unit="条" helper="最近 7 天 · 点击查看" tone="emerald" />
{/* 近期已干预 — emerald */}
<button
onClick={() => { setShowHistory(true); setHistoryRecentOnly(true); }}
className="p-3.5 rounded-2xl text-left transition-all cursor-pointer bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-200/60"
>
<div className="text-[10px] font-bold mb-1 text-emerald-600">
</div>
<div className="text-2xl font-black text-emerald-700">
{loading && !data ? '-' : summary?.recentInterventionCount ?? 0}
<span className="text-[10px] font-normal ml-1 text-emerald-400"></span>
</div>
<div className="text-[9px] mt-0.5 text-emerald-400">
7 ·
</div>
</button>
</div>
@@ -557,7 +632,8 @@ export default function SchedulingModule() {
</motion.div>
</div>
)}
</div>
<RotatingFooterHint className="pb-4" />
</PageFrame>
</div>
);
}

View File

@@ -1,158 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { AmapConfig, HeatmapMetric, HeatmapPoint } from './types';
type Props = {
config: AmapConfig;
points: HeatmapPoint[];
max: number;
metric: HeatmapMetric;
focusQuery: string;
onMapClick: (longitude: number, latitude: number) => void;
};
type AmapInstance = {
Map: new (container: HTMLElement, options: Record<string, unknown>) => any;
HeatMap: new (map: any, options: Record<string, unknown>) => any;
ToolBar: new (options?: Record<string, unknown>) => any;
Scale: new (options?: Record<string, unknown>) => any;
Bounds: new (southWest: [number, number], northEast: [number, number]) => any;
};
declare global {
interface Window {
_AMapSecurityConfig?: { securityJsCode: string };
}
}
function getBounds(points: HeatmapPoint[]) {
if (points.length === 0) return null;
let minLng = points[0].lng;
let maxLng = points[0].lng;
let minLat = points[0].lat;
let maxLat = points[0].lat;
for (let index = 1; index < points.length; index += 1) {
const point = points[index];
minLng = Math.min(minLng, point.lng);
maxLng = Math.max(maxLng, point.lng);
minLat = Math.min(minLat, point.lat);
maxLat = Math.max(maxLat, point.lat);
}
return { minLng, maxLng, minLat, maxLat };
}
export default function AmapHeatmapCanvas({ config, points, max, metric, focusQuery, onMapClick }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<any>(null);
const heatmapRef = useRef<any>(null);
const amapRef = useRef<AmapInstance | null>(null);
const clickHandlerRef = useRef(onMapClick);
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const intensity = useMemo(() => {
const transform = metric === 'locations'
? (value: number) => Math.log1p(value)
: (value: number) => Math.sqrt(value);
return {
points: points.map((point) => ({ ...point, count: transform(point.count) })),
max: transform(Math.max(1, max)),
};
}, [max, metric, points]);
useEffect(() => {
clickHandlerRef.current = onMapClick;
}, [onMapClick]);
useEffect(() => {
let cancelled = false;
const container = containerRef.current;
if (!container) return undefined;
async function initialize() {
try {
window._AMapSecurityConfig = { securityJsCode: config.securityCode };
const loaderModule = await import('@amap/amap-jsapi-loader');
const AMap = await loaderModule.default.load({
key: config.key,
version: '2.0',
plugins: ['AMap.HeatMap', 'AMap.ToolBar', 'AMap.Scale'],
}) as unknown as AmapInstance;
if (cancelled || !container) return;
const map = new AMap.Map(container, {
viewMode: '2D',
zoom: 5,
center: [105.4, 34.4],
mapStyle: 'amap://styles/whitesmoke',
resizeEnable: true,
showLabel: true,
});
map.addControl(new AMap.ToolBar({ position: { right: '20px', bottom: '76px' } }));
map.addControl(new AMap.Scale({ position: { right: '18px', bottom: '24px' } }));
const heatmap = new AMap.HeatMap(map, {
radius: 25,
opacity: [0.12, 0.82],
gradient: {
0.1: '#2563eb',
0.3: '#0891b2',
0.5: '#16a34a',
0.68: '#eab308',
0.84: '#f97316',
1: '#dc2626',
},
});
map.on('click', (event: any) => {
clickHandlerRef.current(event.lnglat.getLng(), event.lnglat.getLat());
});
mapRef.current = map;
heatmapRef.current = heatmap;
amapRef.current = AMap;
setStatus('ready');
} catch (error) {
console.error('AMap initialization failed', error);
if (!cancelled) setStatus('error');
}
}
initialize();
return () => {
cancelled = true;
heatmapRef.current?.setMap?.(null);
mapRef.current?.destroy?.();
heatmapRef.current = null;
mapRef.current = null;
amapRef.current = null;
};
}, [config.key, config.securityCode]);
useEffect(() => {
if (status !== 'ready' || !heatmapRef.current) return;
heatmapRef.current.setDataSet({ data: intensity.points, max: intensity.max });
if (!focusQuery || points.length === 0 || !mapRef.current || !amapRef.current) return;
const bounds = getBounds(points);
if (!bounds) return;
if (points.length === 1) {
mapRef.current.setZoomAndCenter(12, [points[0].lng, points[0].lat]);
return;
}
mapRef.current.setBounds(new amapRef.current.Bounds(
[bounds.minLng, bounds.minLat],
[bounds.maxLng, bounds.maxLat],
), false, [80, 80, 80, 80]);
}, [focusQuery, intensity, points, status]);
return (
<div className="absolute inset-0 bg-[#eef3f7]">
<div ref={containerRef} className="h-full w-full" aria-label="车辆运营热力图地图" />
{status === 'loading' ? (
<div className="absolute inset-0 grid place-items-center bg-white/76 text-sm font-medium text-slate-500 backdrop-blur-[2px]">
<span className="flex items-center gap-2.5"><i className="h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" /></span>
</div>
) : null}
{status === 'error' ? (
<div className="absolute inset-0 grid place-items-center bg-slate-50 text-center">
<div><p className="text-sm font-semibold text-slate-700"></p><p className="mt-1 text-xs text-slate-400"> Key</p></div>
</div>
) : null}
</div>
);
}

View File

@@ -1,86 +0,0 @@
import { ChevronRight, Crosshair, X } from 'lucide-react';
import type { VehicleRank } from './types';
type Props = {
title: string;
subtitle: string;
locationCount: number;
vehicleCount: number;
vehicles: VehicleRank[];
selected: boolean;
loading: boolean;
onClose: () => void;
onHide: () => void;
};
const numberFormat = new Intl.NumberFormat('zh-CN');
export default function HeatmapDetailPanel(props: Props) {
return (
<aside className="relative z-20 flex h-full w-[300px] shrink-0 flex-col border-l border-slate-200 bg-white max-lg:absolute max-lg:bottom-0 max-lg:left-0 max-lg:right-0 max-lg:h-[43vh] max-lg:w-full max-lg:rounded-t-2xl max-lg:border-l-0 max-lg:border-t max-lg:shadow-[0_-10px_30px_rgba(15,23,42,0.14)] max-md:h-[50dvh]">
<div className="border-b border-slate-100 px-5 pb-4 pt-5 max-md:px-4 max-md:pb-3 max-md:pt-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h2 className="text-[17px] font-semibold tracking-tight text-slate-900">{props.title}</h2>
{props.loading ? <i className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" /> : null}
</div>
<p className="mt-1 text-[11px] text-slate-400">{props.subtitle}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{props.selected ? (
<button type="button" onClick={props.onClose} aria-label="关闭区域详情" className="grid h-8 w-8 place-items-center rounded-lg text-slate-400 hover:bg-slate-50 hover:text-slate-700">
<X size={18} strokeWidth={1.8} />
</button>
) : null}
<button
type="button"
onClick={props.onHide}
aria-label="隐藏详情"
title="隐藏详情"
className="grid h-8 w-8 place-items-center rounded-lg text-slate-400 transition hover:bg-blue-50 hover:text-blue-600 max-md:h-10 max-md:w-10"
>
<ChevronRight size={18} strokeWidth={1.8} />
</button>
</div>
</div>
<div className="mt-5 grid grid-cols-2 divide-x divide-slate-200 border-t border-slate-100 pt-4 max-md:mt-3 max-md:pt-3">
<div>
<p className="text-[11px] text-slate-400"></p>
<p className="mt-1 text-[22px] font-semibold tracking-tight text-blue-600">{numberFormat.format(props.locationCount)}</p>
</div>
<div className="pl-5">
<p className="text-[11px] text-slate-400"></p>
<p className="mt-1 text-[22px] font-semibold tracking-tight text-blue-600">{numberFormat.format(props.vehicleCount)}</p>
</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto px-5 py-4 max-md:px-4 max-md:py-3">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[13px] font-semibold text-slate-800"> TOP10</h3>
<span className="text-[10px] text-slate-400"></span>
</div>
{props.vehicles.length ? (
<ol className="divide-y divide-slate-100">
{props.vehicles.map((vehicle, index) => (
<li key={vehicle.vin} className="grid grid-cols-[24px_minmax(0,1fr)_42px] items-center gap-2 py-2.5">
<span className={`text-[11px] font-medium ${index < 3 ? 'text-blue-600' : 'text-slate-400'}`}>{index + 1}</span>
<span className="min-w-0">
<span className="block truncate text-[12px] font-medium text-slate-700">{vehicle.plateNumber || '未绑定车牌'}</span>
<span className="mt-0.5 block truncate font-mono text-[9px] text-slate-400">{vehicle.vin}</span>
</span>
<span className="text-right text-[12px] tabular-nums text-slate-600">{vehicle.locationCount}</span>
</li>
))}
</ol>
) : (
<div className="grid h-44 place-items-center text-center text-xs text-slate-400">
<span><Crosshair className="mx-auto mb-2 text-slate-300" size={22} /></span>
</div>
)}
</div>
</aside>
);
}

View File

@@ -1,115 +0,0 @@
import { CalendarDays, ChevronUp, RotateCcw, Search } from 'lucide-react';
import type { HeatmapMetric, VehicleOption } from './types';
type Props = {
startDate: string;
endDate: string;
minDate: string;
maxDate: string;
query: string;
batchModel: string;
batchModels: string[];
metric: HeatmapMetric;
vehicles: VehicleOption[];
onStartDateChange: (value: string) => void;
onEndDateChange: (value: string) => void;
onQueryChange: (value: string) => void;
onBatchModelChange: (value: string) => void;
onMetricChange: (value: HeatmapMetric) => void;
onReset: () => void;
onHide: () => void;
};
const FIELD = 'relative h-11 min-w-0 rounded-lg border border-slate-200 bg-white transition focus-within:border-blue-400 focus-within:ring-2 focus-within:ring-blue-100';
const LABEL = 'pointer-events-none absolute left-3 top-1 z-10 text-[9px] font-medium leading-none text-slate-400';
const CONTROL = 'h-full w-full min-w-0 rounded-lg border-0 bg-transparent px-3 pt-3 text-[12px] text-slate-700 outline-none';
export default function HeatmapFilters(props: Props) {
return (
<div className="pointer-events-auto grid max-h-[calc(100dvh-180px)] w-full grid-cols-2 gap-2 overflow-y-auto rounded-xl border border-slate-200/80 bg-white/96 p-2 shadow-[0_8px_28px_rgba(15,23,42,0.12)] backdrop-blur-md md:flex md:w-auto md:min-w-max md:items-center md:overflow-visible">
<label className={`${FIELD} col-span-2 md:w-[320px]`}>
<span className={`${LABEL} left-9`}></span>
<span className="flex h-full min-w-0 items-center pl-8 pt-2">
<CalendarDays className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={15} strokeWidth={1.8} />
<input
aria-label="开始日期"
type="date"
min={props.minDate}
max={props.endDate}
value={props.startDate}
onChange={(event) => props.onStartDateChange(event.target.value)}
className="h-full w-[124px] min-w-0 flex-1 border-0 bg-transparent px-1 text-[12px] text-slate-700 outline-none"
/>
<span className="shrink-0 px-0.5 text-slate-300"></span>
<input
aria-label="结束日期"
type="date"
min={props.startDate}
max={props.maxDate}
value={props.endDate}
onChange={(event) => props.onEndDateChange(event.target.value)}
className="h-full w-[124px] min-w-0 flex-1 border-0 bg-transparent px-1 text-[12px] text-slate-700 outline-none"
/>
</span>
</label>
<label className={`${FIELD} col-span-2 md:w-[145px]`}>
<span className={LABEL}>VIN / </span>
<span className="relative block h-full">
<input
aria-label="搜索 VIN 或车牌"
value={props.query}
list="vehicle-heatmap-options"
onChange={(event) => props.onQueryChange(event.target.value)}
placeholder="输入 VIN 或车牌"
className={`${CONTROL} pr-9`}
/>
<Search className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} strokeWidth={1.8} />
<datalist id="vehicle-heatmap-options">
{props.vehicles.map((vehicle) => (
<option key={vehicle.vin} value={vehicle.plateNumber || vehicle.vin}>{vehicle.vin}</option>
))}
</datalist>
</span>
</label>
<label className={`${FIELD} col-span-2 md:w-[150px]`}>
<span className={LABEL}> / </span>
<select
aria-label="车型 / 批次型号"
value={props.batchModel}
onChange={(event) => props.onBatchModelChange(event.target.value)}
className={CONTROL}
title={props.batchModel || '全部车型'}
>
<option value=""></option>
{props.batchModels.map((batchModel) => (
<option key={batchModel} value={batchModel}>{batchModel}</option>
))}
</select>
</label>
<label className={`${FIELD} md:w-[110px]`}>
<span className={LABEL}></span>
<select
aria-label="统计维度"
value={props.metric}
onChange={(event) => props.onMetricChange(event.target.value as HeatmapMetric)}
className={CONTROL}
>
<option value="locations"></option>
<option value="vehicles"></option>
</select>
</label>
<div className="flex min-w-0 gap-2 md:w-auto">
<button type="button" onClick={props.onReset} className="flex h-11 min-w-0 flex-1 items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 text-[12px] font-medium text-slate-600 transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 md:flex-none">
<RotateCcw size={14} strokeWidth={1.8} />
</button>
<button type="button" onClick={props.onHide} aria-label="隐藏筛选" title="隐藏筛选" className="grid h-11 w-11 shrink-0 place-items-center rounded-lg border border-slate-200 bg-white text-slate-400 transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 md:w-10">
<ChevronUp size={16} strokeWidth={1.8} />
</button>
</div>
</div>
);
}

View File

@@ -1,249 +0,0 @@
import { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, ChevronLeft, Clock3, MapPinned, Maximize2, Minimize2, SlidersHorizontal } from 'lucide-react';
import AmapHeatmapCanvas from './AmapHeatmapCanvas';
import HeatmapDetailPanel from './HeatmapDetailPanel';
import HeatmapFilters from './HeatmapFilters';
import { fetchAmapConfig, fetchHeatmapMeta, fetchHeatmapPoints, fetchNearbyVehicles } from './api';
import type { AmapConfig, HeatmapMeta, HeatmapMetric, HeatmapResponse, NearbyResponse } from './types';
const DEFAULT_START = '2026-01-01';
const DEFAULT_END = '2026-07-13';
const numberFormat = new Intl.NumberFormat('zh-CN');
function Metric({ value, label }: { value: number; label: string }) {
return (
<div className="min-w-[82px] text-center max-md:min-w-[64px]">
<p className="text-[22px] font-semibold leading-none tracking-tight text-blue-600 tabular-nums max-md:text-[16px]">{numberFormat.format(value)}</p>
<p className="mt-1.5 text-[11px] text-slate-500 max-md:mt-1 max-md:text-[9px]">{label}</p>
</div>
);
}
export default function VehicleHeatmapModule() {
const [meta, setMeta] = useState<HeatmapMeta | null>(null);
const [config, setConfig] = useState<AmapConfig | null>(null);
const [data, setData] = useState<HeatmapResponse | null>(null);
const [nearby, setNearby] = useState<NearbyResponse | null>(null);
const [startDate, setStartDate] = useState(DEFAULT_START);
const [endDate, setEndDate] = useState(DEFAULT_END);
const [query, setQuery] = useState('');
const [batchModel, setBatchModel] = useState('');
const [metric, setMetric] = useState<HeatmapMetric>('locations');
const [isFullscreen, setIsFullscreen] = useState(false);
const [showFilters, setShowFilters] = useState(() => typeof window === 'undefined' || !window.matchMedia('(max-width: 767px)').matches);
const [showDetails, setShowDetails] = useState(() => typeof window === 'undefined' || !window.matchMedia('(max-width: 767px)').matches);
const [loading, setLoading] = useState(true);
const [nearbyLoading, setNearbyLoading] = useState(false);
const [error, setError] = useState('');
const deferredQuery = useDeferredValue(query.trim());
useEffect(() => {
if (!isFullscreen) return undefined;
const previousOverflow = document.body.style.overflow;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsFullscreen(false);
};
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
};
}, [isFullscreen]);
const toggleFullscreen = useCallback(() => {
setIsFullscreen((current) => !current);
}, []);
useEffect(() => {
const controller = new AbortController();
Promise.all([fetchHeatmapMeta(controller.signal), fetchAmapConfig(controller.signal)])
.then(([nextMeta, nextConfig]) => {
setMeta(nextMeta);
setConfig(nextConfig);
setStartDate(nextMeta.startDate);
setEndDate(nextMeta.endDate);
})
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('热力图基础配置加载失败');
});
return () => controller.abort();
}, []);
useEffect(() => {
const controller = new AbortController();
const timer = window.setTimeout(() => {
setLoading(true);
setError('');
fetchHeatmapPoints({ startDate, endDate, query: deferredQuery, batchModel, metric }, controller.signal)
.then((response) => {
setData(response);
setNearby(null);
})
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('热力图数据加载失败,请稍后重试');
})
.finally(() => setLoading(false));
}, 220);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [batchModel, deferredQuery, endDate, metric, startDate]);
const handleMapClick = useCallback((longitude: number, latitude: number) => {
const controller = new AbortController();
setNearbyLoading(true);
fetchNearbyVehicles({ lng: longitude, lat: latitude, startDate, endDate, query: deferredQuery, batchModel }, controller.signal)
.then(setNearby)
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('区域车辆明细加载失败');
})
.finally(() => setNearbyLoading(false));
}, [batchModel, deferredQuery, endDate, startDate]);
const reset = () => {
setStartDate(meta?.startDate || DEFAULT_START);
setEndDate(meta?.endDate || DEFAULT_END);
setQuery('');
setBatchModel('');
setMetric('locations');
setNearby(null);
};
const detail = useMemo(() => {
if (nearby) {
return {
title: '选中区域',
subtitle: `${nearby.center.lng.toFixed(3)}, ${nearby.center.lat.toFixed(3)} · 半径 ${nearby.radiusKm} 公里`,
locationCount: nearby.locationCount,
vehicleCount: nearby.vehicleCount,
vehicles: nearby.topVehicles,
selected: true,
};
}
return {
title: deferredQuery ? '车辆定位概览' : '全国 · 当前范围',
subtitle: `${batchModel ? `${batchModel} · ` : ''}${startDate} ${endDate}`,
locationCount: data?.locationCount || 0,
vehicleCount: data?.vehicleCount || 0,
vehicles: data?.topVehicles || [],
selected: false,
};
}, [batchModel, data, deferredQuery, endDate, nearby, startDate]);
return (
<section className={`flex flex-col overflow-hidden bg-white ${isFullscreen ? 'fixed inset-0 z-[100] h-screen w-screen min-h-0' : 'h-[100dvh] min-h-[620px] max-md:h-[calc(100dvh-4rem)] max-md:min-h-[520px]'}`}>
<header className="relative z-30 flex min-h-[98px] shrink-0 items-center justify-between border-b border-slate-200 bg-white px-8 max-md:min-h-[104px] max-md:flex-wrap max-md:gap-y-2 max-md:px-3 max-md:py-3">
<div className="min-w-0">
<h1 className="truncate text-[25px] font-semibold tracking-[-0.025em] text-slate-950 max-md:text-[18px]"></h1>
<p className="mt-1.5 flex items-center gap-1.5 text-[11px] text-slate-400 max-md:mt-1 max-md:text-[9px]"><MapPinned size={13} /> · </p>
</div>
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-7 max-md:order-3 max-md:static max-md:w-full max-md:translate-x-0 max-md:translate-y-0 max-md:justify-center max-md:gap-2">
<Metric value={data?.locationCount ?? meta?.locationCount ?? 0} label="定位点" />
<span className="h-8 w-px bg-slate-200" />
<Metric value={data?.vehicleCount ?? meta?.vehicleCount ?? 0} label="辆车" />
<span className="h-8 w-px bg-slate-200" />
<Metric value={data?.dayCount ?? meta?.dayCount ?? 0} label="天" />
</div>
<div className="flex items-center gap-3">
<p className="flex items-center gap-2 text-[12px] text-slate-500 max-xl:hidden"><Clock3 size={15} strokeWidth={1.7} /> {meta?.endDate || DEFAULT_END}</p>
<button
type="button"
onClick={toggleFullscreen}
className="grid h-9 w-9 place-items-center rounded-lg border border-slate-200 text-slate-500 transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600"
title={isFullscreen ? '退出全屏' : '地图全屏'}
aria-label={isFullscreen ? '退出地图全屏' : '地图全屏'}
aria-pressed={isFullscreen}
>
{isFullscreen ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
</button>
</div>
</header>
<div className="relative flex min-h-0 flex-1">
<div className="relative z-0 min-w-0 flex-1 overflow-hidden">
{config ? (
<AmapHeatmapCanvas
config={config}
points={data?.points || []}
max={data?.max || 1}
metric={metric}
focusQuery={deferredQuery}
onMapClick={handleMapClick}
/>
) : <div className="absolute inset-0 bg-slate-100" />}
{showFilters ? (
<div className="pointer-events-none absolute left-3 right-3 top-3 z-10 md:left-5 md:right-auto md:top-5">
<HeatmapFilters
startDate={startDate}
endDate={endDate}
minDate={meta?.startDate || DEFAULT_START}
maxDate={meta?.endDate || DEFAULT_END}
query={query}
batchModel={batchModel}
batchModels={meta?.batchModels || []}
metric={metric}
vehicles={meta?.vehicles || []}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onQueryChange={setQuery}
onBatchModelChange={setBatchModel}
onMetricChange={setMetric}
onReset={reset}
onHide={() => setShowFilters(false)}
/>
</div>
) : (
<button
type="button"
onClick={() => setShowFilters(true)}
aria-label="显示筛选"
className="absolute left-3 top-3 z-20 flex h-11 items-center gap-2 rounded-lg border border-slate-200 bg-white/95 px-3 text-xs font-medium text-slate-600 shadow-lg backdrop-blur-sm transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 md:left-5 md:top-5 md:h-10"
>
<SlidersHorizontal size={15} strokeWidth={1.8} />
</button>
)}
{!showDetails ? (
<button
type="button"
onClick={() => setShowDetails(true)}
aria-label="显示详情"
className={`absolute right-3 top-3 z-20 flex h-11 items-center gap-2 rounded-lg border border-slate-200 bg-white/95 px-3 text-xs font-medium text-slate-600 shadow-lg backdrop-blur-sm transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 md:right-5 md:top-5 md:h-10 ${showFilters ? 'max-md:hidden' : ''}`}
>
<ChevronLeft size={16} strokeWidth={1.8} />
</button>
) : null}
<div className="pointer-events-none absolute bottom-5 left-5 z-10 w-[286px] rounded-xl border border-slate-200/80 bg-white/95 p-3.5 shadow-[0_8px_30px_rgba(15,23,42,0.11)] backdrop-blur-sm max-lg:bottom-[calc(43vh+16px)] max-md:hidden">
<div className="flex items-center justify-between text-[11px] font-medium text-slate-700"><span>{metric === 'locations' ? '定位活跃度' : '车辆覆盖度'}</span>{loading ? <i className="h-3 w-3 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" /> : null}</div>
<p className="mt-1 text-[10px] leading-4 text-slate-500">
{metric === 'locations'
? '同一网格累计每日首个定位,同车跨天重复计数;对数平滑强度。'
: '同一网格按 VIN 去重,同车仅计 1 辆;平方根平滑强度。'}
</p>
<div className="mt-2.5 h-2.5 rounded-full bg-[linear-gradient(90deg,#2563eb_0%,#0891b2_25%,#16a34a_45%,#eab308_65%,#f97316_82%,#dc2626_100%)]" />
<div className="mt-1.5 flex justify-between text-[10px] text-slate-400"><span></span><span></span></div>
</div>
{error ? (
<div className="absolute bottom-5 left-1/2 z-30 flex -translate-x-1/2 items-center gap-2 rounded-lg bg-slate-900 px-4 py-2.5 text-xs text-white shadow-xl">
<AlertTriangle size={15} className="text-amber-300" />{error}
</div>
) : null}
</div>
{showDetails ? (
<HeatmapDetailPanel
{...detail}
loading={nearbyLoading}
onClose={() => setNearby(null)}
onHide={() => setShowDetails(false)}
/>
) : null}
</div>
</section>
);
}

View File

@@ -1,34 +0,0 @@
import { fetchJson } from '../../auth/api-client';
import type { AmapConfig, HeatmapMeta, HeatmapMetric, HeatmapResponse, NearbyResponse } from './types';
export function fetchAmapConfig(signal?: AbortSignal) {
return fetchJson<AmapConfig>('/api/vehicle-heatmap/config', { signal });
}
export function fetchHeatmapMeta(signal?: AbortSignal) {
return fetchJson<HeatmapMeta>('/api/vehicle-heatmap/meta', { signal });
}
export function fetchHeatmapPoints(
params: { startDate: string; endDate: string; query: string; batchModel: string; metric: HeatmapMetric },
signal?: AbortSignal,
) {
const search = new URLSearchParams(params);
return fetchJson<HeatmapResponse>(`/api/vehicle-heatmap/points?${search}`, { signal });
}
export function fetchNearbyVehicles(
params: { lng: number; lat: number; startDate: string; endDate: string; query: string; batchModel: string },
signal?: AbortSignal,
) {
const search = new URLSearchParams({
lng: String(params.lng),
lat: String(params.lat),
startDate: params.startDate,
endDate: params.endDate,
query: params.query,
batchModel: params.batchModel,
radiusKm: '50',
});
return fetchJson<NearbyResponse>(`/api/vehicle-heatmap/nearby?${search}`, { signal });
}

View File

@@ -1,59 +0,0 @@
export type HeatmapMetric = 'locations' | 'vehicles';
export type HeatmapPoint = {
lng: number;
lat: number;
count: number;
};
export type VehicleRank = {
vin: string;
plateNumber: string;
locationCount: number;
firstSeen: string;
lastSeen: string;
};
export type VehicleOption = {
vin: string;
plateNumber: string;
};
export type HeatmapMeta = {
startDate: string;
endDate: string;
locationCount: number;
vehicleCount: number;
dayCount: number;
totalLocationCount: number;
excludedLocationCount: number;
outsideMainlandCount: number;
tibetCount: number;
batchModels: string[];
vehicles: VehicleOption[];
};
export type HeatmapResponse = {
startDate: string;
endDate: string;
metric: HeatmapMetric;
locationCount: number;
vehicleCount: number;
dayCount: number;
points: HeatmapPoint[];
max: number;
topVehicles: VehicleRank[];
};
export type NearbyResponse = {
center: { lng: number; lat: number };
radiusKm: number;
locationCount: number;
vehicleCount: number;
topVehicles: VehicleRank[];
};
export type AmapConfig = {
key: string;
securityCode: string;
};

View File

@@ -52,15 +52,11 @@ app.get('/exchange', async (c) => {
// 查询 depCode 对应的部门名称
let depName = '';
if (userInfo.depCode) {
try {
const [rows] = await pool.execute(
'SELECT dep_name FROM tab_department WHERE dep_code = ? AND is_deleted = 0 LIMIT 1',
[userInfo.depCode]
) as [{ dep_name: string }[], unknown];
depName = rows[0]?.dep_name || '';
} catch (e: any) {
if (e?.code !== 'ER_NO_SUCH_TABLE') throw e;
}
const [rows] = await pool.execute(
'SELECT dep_name FROM tab_department WHERE dep_code = ? AND is_deleted = 0 LIMIT 1',
[userInfo.depCode]
) as [{ dep_name: string }[], unknown];
depName = rows[0]?.dep_name || '';
}
const payload: JwtPayload = {

View File

@@ -5,7 +5,8 @@ import type { JwtPayload, AuthUser } from './types.js';
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
// 临时:跳过所有认证(保留完整逻辑便于快速恢复)
const BYPASS_AUTH = false;
// 临时:本地开发跳过认证
const BYPASS_AUTH = true;
export async function authMiddleware(c: Context, next: Next) {
const path = c.req.path;

View File

@@ -1,29 +0,0 @@
import dotenv from 'dotenv';
import pg from 'pg';
dotenv.config();
const { Pool } = pg;
const heatmapPool = new Pool({
host: process.env.HEATMAP_DB_HOST,
port: Number(process.env.HEATMAP_DB_PORT) || 5432,
user: process.env.HEATMAP_DB_USER,
password: process.env.HEATMAP_DB_PASSWORD,
database: process.env.HEATMAP_DB_NAME,
ssl: process.env.HEATMAP_DB_SSL === 'true'
? { rejectUnauthorized: false }
: false,
max: 5,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 10_000,
statement_timeout: 30_000,
application_name: 'ln-bi-vehicle-heatmap',
options: '-c timezone=Asia/Shanghai -c default_transaction_read_only=on',
});
heatmapPool.on('error', (error) => {
console.error('[vehicle-heatmap] idle PostgreSQL client error:', error);
});
export default heatmapPool;

View File

@@ -1,17 +0,0 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const hydrogenPool = mysql.createPool({
host: process.env.HYDROGEN_DB_HOST || '47.99.185.173',
port: Number(process.env.HYDROGEN_DB_PORT) || 3306,
user: process.env.HYDROGEN_DB_USER || 'root',
password: process.env.HYDROGEN_DB_PASSWORD || 'lnMysql.',
database: process.env.HYDROGEN_DB_NAME || 'ln_asset_management',
waitForConnections: true,
connectionLimit: 5,
queueLimit: 0,
});
export default hydrogenPool;

View File

@@ -9,8 +9,6 @@ import schedulingRouter from './routes/scheduling/index.js';
import energyRouter from './routes/energy/index.js';
import eleRouter from './routes/ele/index.js';
import feedbackRouter from './routes/feedback/index.js';
import vehicleHeatmapRouter from './routes/vehicle-heatmap.js';
import hydrogenHeatmapRouter from './routes/hydrogen-heatmap.js';
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
import authRouter from './auth/login.js';
import { authMiddleware } from './auth/middleware.js';
@@ -33,8 +31,6 @@ app.route('/api/scheduling', schedulingRouter);
app.route('/api/energy', energyRouter);
app.route('/api/ele', eleRouter);
app.route('/api/feedback', feedbackRouter);
app.route('/api/vehicle-heatmap', vehicleHeatmapRouter);
app.route('/api/hydrogen-heatmap', hydrogenHeatmapRouter);
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));

View File

@@ -1,14 +1,11 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const mileagePool = mysql.createPool({
host: process.env.MILEAGE_DB_HOST || '101.133.130.65',
port: Number(process.env.MILEAGE_DB_PORT) || 3306,
user: process.env.MILEAGE_DB_USER || 'bi_reader_02',
password: process.env.MILEAGE_DB_PASSWORD || 'bi_reader_02_Pass',
database: process.env.MILEAGE_DB_NAME || 'hydrogen_energy',
host: '101.133.130.65',
port: 3306,
user: 'bi_reader_02',
password: 'bi_reader_02_Pass',
database: 'hydrogen_energy',
waitForConnections: true,
connectionLimit: 5,
queueLimit: 0,

View File

@@ -149,8 +149,8 @@ async function buildPlateLookup(plates: Set<string>): Promise<Map<string, string
const placeholders = arr.map(() => '?').join(',');
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT plate_number, CAST(id AS CHAR) AS truck_id
FROM vehicle_info
WHERE del_flag = '0' AND plate_number IN (${placeholders})`,
FROM tab_truck
WHERE is_deleted = 0 AND plate_number IN (${placeholders})`,
arr,
);
const map = new Map<string, string>();

View File

@@ -1,7 +1,6 @@
import { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2';
import pool from '../../db.js';
import hydrogenPool from '../../hydrogen-db.js';
import { cached } from './cache.js';
import type { AuthUser } from '../../auth/types.js';
import { canAccessEnergy } from '../../auth/types.js';
@@ -20,70 +19,20 @@ app.use('*', async (c, next) => {
const HYDROGEN_MIN_DATE = '2024-01-01';
// hydrogen_fuel_ledger.refuel_time 已是业务本地时间字面值,直接使用即可(不再 +8 小时)
const HYDROGEN_TABLE = 'hydrogen_fuel_ledger';
const HYDROGEN_LOCAL = `refuel_time`;
const HYDROGEN_BASE_WHERE = `del_flag = '0'`;
const HYDROGEN_BASE_WHERE_B = `b.del_flag = '0'`;
// hydrogen_time 已是 CST 字面值,直接使用即可(不再 +8 小时)
const HYDROGEN_LOCAL = `hydrogen_time`;
const ELECTRIC_LOCAL = `charging_start_time`;
type CustomerKind = 'external' | 'lingniu' | 'all';
// 新账本 hydrogen_fuel_ledger 当前只承载羚牛车辆订单;外部车辆数据源待接入。
function customerClause(customer: CustomerKind): string {
if (customer === 'external') return '1=0';
if (customer === 'lingniu') return '1=1';
// 外部/我司判定truck_id 为空 = 外部truck_id 非空 = 我司(羚牛车辆)
function customerClause(field: string, customer: CustomerKind): string {
if (customer === 'external') return `${field} IS NULL`;
if (customer === 'lingniu') return `${field} IS NOT NULL`;
return '1=1';
}
type Range = 'thisWeek' | 'thisMonth' | 'last15';
interface DateRange {
start: string;
end: string;
}
const YMD_RE = /^\d{4}-\d{2}-\d{2}$/;
function fmtYmd(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function addDays(d: Date, days: number): Date {
const next = new Date(d);
next.setDate(next.getDate() + days);
return next;
}
function parseYmd(value: string | undefined): string | null {
if (!value || !YMD_RE.test(value)) return null;
const d = new Date(`${value}T00:00:00`);
return Number.isNaN(d.getTime()) ? null : value;
}
function resolveDateRange(range: Range, startParam?: string, endParam?: string): DateRange {
const customStart = parseYmd(startParam);
const customEnd = parseYmd(endParam);
if (customStart && customEnd) {
return customStart <= customEnd
? { start: customStart, end: customEnd }
: { start: customEnd, end: customStart };
}
const today = new Date();
today.setHours(0, 0, 0, 0);
if (range === 'thisWeek') {
const day = today.getDay() || 7;
return { start: fmtYmd(addDays(today, -(day - 1))), end: fmtYmd(today) };
}
if (range === 'thisMonth') {
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end: fmtYmd(today) };
}
return { start: fmtYmd(addDays(today, -14)), end: fmtYmd(today) };
}
function dateRangeClause(localExpr: string): string {
return `${localExpr} >= ? AND ${localExpr} < DATE_ADD(?, INTERVAL 1 DAY)`;
}
function rangeClause(localExpr: string, range: Range): string {
switch (range) {
@@ -95,16 +44,25 @@ function rangeClause(localExpr: string, range: Range): string {
/** 列出某 range 在当前时点下的全部日期YYYY-MM-DD用于补零 */
function enumerateDates(range: Range): string[] {
const { start, end } = resolveDateRange(range);
return enumerateDateRange(start, end);
}
function enumerateDateRange(startYmd: string, endYmd: string): string[] {
const today = new Date();
today.setHours(0, 0, 0, 0);
const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
let start: Date;
if (range === 'thisWeek') {
// 周一为一周开始(与 YEARWEEK(?, 1) 一致)
const day = today.getDay() || 7; // 周日 7
start = new Date(today);
start.setDate(today.getDate() - (day - 1));
} else if (range === 'thisMonth') {
start = new Date(today.getFullYear(), today.getMonth(), 1);
} else {
start = new Date(today);
start.setDate(today.getDate() - 14);
}
const result: string[] = [];
const cur = new Date(`${startYmd}T00:00:00`);
const end = new Date(`${endYmd}T00:00:00`);
while (cur <= end) {
result.push(fmtYmd(cur));
const cur = new Date(start);
while (cur <= today) {
result.push(fmt(cur));
cur.setDate(cur.getDate() + 1);
}
return result;
@@ -122,10 +80,10 @@ app.get('/hydrogen/overview', async (c) => {
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
const [yearListRows] = await hydrogenPool.query<RowDataPacket[]>(
const [yearListRows] = await pool.query<RowDataPacket[]>(
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?
FROM tab_energy_hydrogen_bill
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?
ORDER BY y DESC`,
[HYDROGEN_MIN_DATE],
);
@@ -134,46 +92,44 @@ app.get('/hydrogen/overview', async (c) => {
const isCurrentYear = year === todayYear;
// KPI按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
const [kpiRows] = await hydrogenPool.query<RowDataPacket[]>(
const [kpiRows] = await pool.query<RowDataPacket[]>(
`SELECT
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN amount_kg ELSE 0 END) AS yearKg,
THEN hydrogen_quantity ELSE 0 END) AS yearKg,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN cost_total ELSE 0 END) AS yearFee,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN cost_total ELSE 0 END) AS yearCustomerCost,
THEN cost_expense ELSE 0 END) AS yearFee,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
THEN cost_expense ELSE 0 END) AS yearCustomerCost,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN fee_total ELSE 0 END) AS yearRevenue,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
THEN amount_kg ELSE 0 END) AS ourYearKg,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
THEN cost_total ELSE 0 END) AS ourYearFee,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN amount_kg ELSE 0 END) AS customerYearKg,
THEN customer_expense ELSE 0 END) AS yearRevenue,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
THEN hydrogen_quantity ELSE 0 END) AS ourYearKg,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
THEN cost_expense ELSE 0 END) AS ourYearFee,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
THEN hydrogen_quantity ELSE 0 END) AS customerYearKg,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN amount_kg ELSE 0 END) AS monthKg,
THEN hydrogen_quantity ELSE 0 END) AS monthKg,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN cost_total ELSE 0 END) AS monthFee,
THEN cost_expense ELSE 0 END) AS monthFee,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') AND cost_type = 2
THEN cost_expense ELSE 0 END) AS monthCustomerCost,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN cost_total ELSE 0 END) AS monthCustomerCost,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN fee_total ELSE 0 END) AS monthRevenue,
THEN customer_expense ELSE 0 END) AS monthRevenue,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN amount_kg ELSE 0 END) AS todayKg,
THEN hydrogen_quantity ELSE 0 END) AS todayKg,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN cost_total ELSE 0 END) AS todayFee,
THEN cost_expense ELSE 0 END) AS todayFee,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE() AND cost_type = 2
THEN cost_expense ELSE 0 END) AS todayCustomerCost,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN cost_total ELSE 0 END) AS todayCustomerCost,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN fee_total ELSE 0 END) AS todayRevenue,
SUM(CASE WHEN vehicle_id IS NOT NULL
THEN amount_kg ELSE 0 END) AS lingniuBornKg,
SUM(CASE WHEN vehicle_id IS NOT NULL
THEN cost_total ELSE 0 END) AS lingniuBornFee
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?`,
THEN customer_expense ELSE 0 END) AS todayRevenue,
SUM(CASE WHEN truck_id IS NOT NULL
THEN hydrogen_quantity ELSE 0 END) AS lingniuBornKg,
SUM(CASE WHEN truck_id IS NOT NULL
THEN cost_expense ELSE 0 END) AS lingniuBornFee
FROM tab_energy_hydrogen_bill
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?`,
[year, year, year, year, year, year, year,
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
@@ -210,19 +166,23 @@ app.get('/hydrogen/overview', async (c) => {
};
// Top5 加氢站(指定年份)
const [top5Rows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT b.station_id AS id,
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
CASE WHEN b.station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
SUM(b.amount_kg) AS kg,
SUM(b.cost_total) AS fee
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${HYDROGEN_BASE_WHERE_B}
const [top5Rows] = await pool.query<RowDataPacket[]>(
`SELECT b.hydrogen_station_id AS id,
COALESCE(MAX(s.short_name), MAX(s.name),
MAX(os.fixed_station_name), MAX(os.station_name),
MAX(i.hydrogen_station_name),
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS name,
SUM(b.hydrogen_quantity) AS kg,
SUM(b.cost_expense) AS fee
FROM tab_energy_hydrogen_bill b
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
WHERE b.is_deleted = 0
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
GROUP BY b.station_id
GROUP BY b.hydrogen_station_id
ORDER BY kg DESC
LIMIT 5`,
[HYDROGEN_MIN_DATE, year],
@@ -237,19 +197,23 @@ app.get('/hydrogen/overview', async (c) => {
}));
// 加氢站全量汇总(同年所有站,按加氢量降序)
const [stationFullRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT b.station_id AS id,
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
CASE WHEN b.station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
SUM(b.amount_kg) AS kg,
SUM(b.fee_total) AS revenue
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${HYDROGEN_BASE_WHERE_B}
const [stationFullRows] = await pool.query<RowDataPacket[]>(
`SELECT b.hydrogen_station_id AS id,
COALESCE(MAX(s.short_name), MAX(s.name),
MAX(os.fixed_station_name), MAX(os.station_name),
MAX(i.hydrogen_station_name),
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS name,
SUM(b.hydrogen_quantity) AS kg,
SUM(b.customer_expense) AS revenue
FROM tab_energy_hydrogen_bill b
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
WHERE b.is_deleted = 0
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
GROUP BY b.station_id
GROUP BY b.hydrogen_station_id
ORDER BY kg DESC`,
[HYDROGEN_MIN_DATE, year],
);
@@ -264,22 +228,14 @@ app.get('/hydrogen/overview', async (c) => {
}));
// 区域占比(按城市,指定年份)— 取前 8其余合并为"其他"
const [regionRows] = await hydrogenPool.query<RowDataPacket[]>(
const [regionRows] = await pool.query<RowDataPacket[]>(
`SELECT region, SUM(kg) AS kg FROM (
SELECT CASE
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%嘉兴%' OR COALESCE(s.station_name, b.station_name, '') LIKE '%平湖%' THEN '嘉兴'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%广州%' THEN '广州'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%佛山%' THEN '佛山'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%成都%' THEN '成都'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%重庆%' THEN '重庆'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%乌鲁木齐%' THEN '乌鲁木齐'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%昆山%' THEN '昆山'
ELSE COALESCE(NULLIF(s.station_name, ''), NULLIF(b.station_name, ''), '未知')
END AS region,
b.amount_kg AS kg
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${HYDROGEN_BASE_WHERE_B}
SELECT REPLACE(REPLACE(SUBSTRING_INDEX(COALESCE(s.city, os.city, '未知'), '-', -1), '市', ''), '省', '') AS region,
b.hydrogen_quantity AS kg
FROM tab_energy_hydrogen_bill b
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
WHERE b.is_deleted = 0
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
) r
@@ -301,15 +257,15 @@ app.get('/hydrogen/overview', async (c) => {
];
// 月度趋势(指定年份内 12 个月,缺失月补 0含成本/收入/利润
// 利润 = 客户单收入 - 客户单成本( customer_price/fee_total 判断客户承担
const [monthRows] = await hydrogenPool.query<RowDataPacket[]>(
// 利润 = 客户单收入 - 客户单成本( cost_type = 2
const [monthRows] = await pool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
ROUND(SUM(amount_kg), 2) AS kg,
ROUND(SUM(cost_total), 2) AS fee,
ROUND(SUM(CASE WHEN COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0 THEN cost_total ELSE 0 END), 2) AS customerCost,
ROUND(SUM(fee_total), 2) AS revenue
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE}
ROUND(SUM(hydrogen_quantity), 2) AS kg,
ROUND(SUM(cost_expense), 2) AS fee,
ROUND(SUM(CASE WHEN cost_type = 2 THEN cost_expense ELSE 0 END), 2) AS customerCost,
ROUND(SUM(customer_expense), 2) AS revenue
FROM tab_energy_hydrogen_bill
WHERE is_deleted = 0
AND ${HYDROGEN_LOCAL} >= ?
AND YEAR(${HYDROGEN_LOCAL}) = ?
GROUP BY m
@@ -334,16 +290,16 @@ app.get('/hydrogen/overview', async (c) => {
}
// 客户账单 Top指定年份按加氢量降序前 30
// payer有客户单价/收入 → 客户承担;否则 → 羚牛承担
const [customerRows] = await hydrogenPool.query<RowDataPacket[]>(
// payercost_type=2 → 客户承担cost_type=3 → 羚牛承担;其他 → 客户(默认)
const [customerRows] = await pool.query<RowDataPacket[]>(
`SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name,
CASE WHEN MAX(COALESCE(customer_price, 0)) <= 0 AND MAX(COALESCE(fee_total, 0)) <= 0 THEN 'lingniu'
CASE WHEN MAX(cost_type) = 3 AND MIN(cost_type) = 3 THEN 'lingniu'
ELSE 'customer' END AS payer,
SUM(amount_kg) AS kg,
SUM(cost_total) AS cost,
SUM(fee_total) AS revenue
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE}
SUM(hydrogen_quantity) AS kg,
SUM(cost_expense) AS cost,
SUM(customer_expense) AS revenue
FROM tab_energy_hydrogen_bill
WHERE is_deleted = 0
AND ${HYDROGEN_LOCAL} >= ?
AND YEAR(${HYDROGEN_LOCAL}) = ?
GROUP BY name
@@ -369,37 +325,39 @@ app.get('/hydrogen/overview', async (c) => {
// =========================================================
app.get('/hydrogen/daily', async (c) => {
const range = (c.req.query('range') || 'last15') as Range;
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
const customer = (c.req.query('customer') || 'external') as CustomerKind;
const force = c.req.query('force') === '1';
const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}`, async () => {
const data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => {
const where = [
HYDROGEN_BASE_WHERE_B,
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
dateRangeClause(`b.${HYDROGEN_LOCAL}`),
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
'b.is_deleted = 0',
`b.hydrogen_time >= '${HYDROGEN_MIN_DATE}'`,
rangeClause(`b.hydrogen_time`, range),
customerClause('b.truck_id', customer),
].join(' AND ');
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
// 站点名 fallback站点主数据 → 账本冗余站点名 → 未关联站点
// 单价不重算:直接取账本成本价。
const [stationRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS d,
COALESCE(b.station_id, 0) AS stationId,
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
CASE WHEN MAX(b.station_id) IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', MAX(b.station_id)) END) AS stationName,
ROUND(SUM(b.amount_kg), 2) AS kg,
// 站点名 fallback内部站表 → 外部站表 → 导入订单表tab_import_hydrogen_order按 bill_code 关联)
// 单价不重算:同价组显示原价,混合价组返回 NULL前端显示「—」
const [stationRows] = await pool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(b.hydrogen_time, '%Y-%m-%d') AS d,
b.hydrogen_station_id AS stationId,
COALESCE(MAX(s.short_name), MAX(s.name),
MAX(os.fixed_station_name), MAX(os.station_name),
MAX(i.hydrogen_station_name),
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS stationName,
ROUND(SUM(b.hydrogen_quantity), 2) AS kg,
-- 单价直接取订单中的成本价不重算。MAX 自然忽略 0 元的免费/赠送单
MAX(b.cost_price) AS pricePerKg
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
FROM tab_energy_hydrogen_bill b
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
WHERE ${where}
GROUP BY d, COALESCE(b.station_id, 0)
GROUP BY d, b.hydrogen_station_id
ORDER BY d DESC, kg DESC`,
[dateRange.start, dateRange.end],
);
// 站点环比:同站点上一条记录的 kg
@@ -449,14 +407,14 @@ app.get('/hydrogen/daily', async (c) => {
}
// 补零:列出 range 内全部日期,缺失日期返回 totalKg=0、stations=[]
const allDates = enumerateDateRange(dateRange.start, dateRange.end);
const allDates = enumerateDates(range);
const fullDays = allDates.map(date => {
const info = dayMap.get(date);
return {
date,
totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0,
chainPct: dayChainPct.get(date) ?? 0,
customerType: customer,
customerType: customer === 'lingniu' ? 'lingniu' : 'external',
stations: info
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
name: s.name,
@@ -573,10 +531,9 @@ app.get('/electric/overview', async (c) => {
app.get('/electric/monthly', async (c) => {
const customer = (c.req.query('customer') || 'lingniu') as CustomerKind;
const range = (c.req.query('range') || 'last15') as Range;
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
const force = c.req.query('force') === '1';
const data = await cached(`electric/monthly?customer=${customer}&start=${dateRange.start}&end=${dateRange.end}`, async () => {
const data = await cached(`electric/monthly?customer=${customer}&range=${range}`, async () => {
// bi_ele_charge_record 用 vehicle_kind 区分internal=我司external=外部
let kindClause = '1=1';
@@ -589,9 +546,8 @@ app.get('/electric/monthly', async (c) => {
SUM(fee) AS fee
FROM bi_ele_charge_record
WHERE ${kindClause}
AND ${dateRangeClause('start_time')}
AND ${rangeClause('start_time', range)}
GROUP BY date`,
[dateRange.start, dateRange.end],
);
// 实际数据 map
@@ -604,7 +560,7 @@ app.get('/electric/monthly', async (c) => {
}
// 补零:枚举 range 全部日期
const allDates = enumerateDateRange(dateRange.start, dateRange.end);
const allDates = enumerateDates(range);
const fullDays = allDates.map(date => {
const d = dataMap.get(date);
return {

View File

@@ -1,290 +0,0 @@
import { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2';
import pool from '../db.js';
import type { AuthUser } from '../auth/types.js';
import { canAccessEnergy } from '../auth/types.js';
type HydrogenHeatmapMetric = 'kg' | 'refuels' | 'vehicles';
type Payer = 'all' | 'lingniu' | 'customer';
type StationRow = RowDataPacket & {
station_id: string;
station_name: string;
address: string;
longitude: string | number;
latitude: string | number;
kg: string | number;
refuel_count: string | number;
vehicle_count: string | number;
first_refuel: string;
last_refuel: string;
};
const DEFAULT_START = '2026-01-01';
const DEFAULT_END = '2026-07-13';
const VALID_COORDINATE = `
o.longitude BETWEEN 73.5 AND 135.1
AND o.latitude BETWEEN 18 AND 53.6
AND COALESCE(o.province, '') NOT LIKE '%西藏%'
`;
function isDate(value: string | undefined): value is string {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
}
function parseMetric(value: string | undefined): HydrogenHeatmapMetric {
if (value === 'refuels' || value === 'vehicles') return value;
return 'kg';
}
function parsePayer(value: string | undefined): Payer {
if (value === 'lingniu' || value === 'customer') return value;
return 'all';
}
function buildWhere(startDate: string, endDate: string, query: string, payer: Payer) {
const clauses = [
`b.del_flag = '0'`,
`DATE(b.refuel_time) BETWEEN ? AND ?`,
VALID_COORDINATE,
];
const params: Array<string | number> = [startDate, endDate];
if (query) {
clauses.push(`CONVERT(CONCAT_WS(' ', s.station_name, s.station_short_name, b.station_name, o.station_name, o.fixed_station_name, o.station_address, o.city) USING utf8mb4) COLLATE utf8mb4_unicode_ci LIKE CONVERT(? USING utf8mb4) COLLATE utf8mb4_unicode_ci`);
params.push(`%${query}%`);
}
if (payer === 'lingniu') {
clauses.push(`COALESCE(b.customer_price, 0) <= 0 AND COALESCE(b.fee_total, 0) <= 0`);
} else if (payer === 'customer') {
clauses.push(`(COALESCE(b.customer_price, 0) > 0 OR COALESCE(b.fee_total, 0) > 0)`);
}
return { sql: clauses.join(' AND '), params };
}
async function loadStations(startDate: string, endDate: string, query: string, payer: Payer): Promise<StationRow[]> {
const where = buildWhere(startDate, endDate, query, payer);
const [rows] = await pool.execute<StationRow[]>(`
SELECT
CAST(b.station_id AS CHAR) AS station_id,
COALESCE(
NULLIF(MAX(s.station_short_name), ''),
NULLIF(MAX(s.station_name), ''),
NULLIF(MAX(b.station_name), ''),
NULLIF(MAX(o.fixed_station_name), ''),
CONCAT('未知站点 #', b.station_id)
) AS station_name,
COALESCE(NULLIF(MAX(o.station_address), ''), NULLIF(MAX(s.station_address), ''), '') AS address,
MAX(o.longitude) AS longitude,
MAX(o.latitude) AS latitude,
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg,
COUNT(*) AS refuel_count,
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
DATE_FORMAT(MIN(b.refuel_time), '%Y-%m-%d %H:%i:%s') AS first_refuel,
DATE_FORMAT(MAX(b.refuel_time), '%Y-%m-%d %H:%i:%s') AS last_refuel
FROM hydrogen_fuel_ledger b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
WHERE ${where.sql}
GROUP BY b.station_id
`, where.params);
return rows;
}
function metricValue(row: StationRow, metric: HydrogenHeatmapMetric): number {
if (metric === 'refuels') return Number(row.refuel_count) || 0;
if (metric === 'vehicles') return Number(row.vehicle_count) || 0;
return Number(row.kg) || 0;
}
function serializeStation(row: StationRow, metric: HydrogenHeatmapMetric) {
return {
stationId: row.station_id,
stationName: row.station_name,
address: row.address,
lng: Number(row.longitude),
lat: Number(row.latitude),
kg: Number(row.kg) || 0,
refuelCount: Number(row.refuel_count) || 0,
vehicleCount: Number(row.vehicle_count) || 0,
firstRefuel: row.first_refuel,
lastRefuel: row.last_refuel,
metricValue: metricValue(row, metric),
};
}
function haversineKm(leftLng: number, leftLat: number, rightLng: number, rightLat: number): number {
const radians = (degrees: number) => degrees * Math.PI / 180;
const earthRadius = 6371;
const dLat = radians(rightLat - leftLat);
const dLng = radians(rightLng - leftLng);
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(radians(leftLat)) * Math.cos(radians(rightLat)) * Math.sin(dLng / 2) ** 2;
return earthRadius * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
const router = new Hono();
router.use('*', async (c, next) => {
const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined;
if (user && !canAccessEnergy(user.roles)) {
return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403);
}
return next();
});
router.get('/config', (c) => {
const key = process.env.AMAP_WEB_KEY;
const securityCode = process.env.AMAP_SECURITY_JS_CODE;
if (!key || !securityCode) return c.json({ error: '高德地图配置缺失' }, 503);
return c.json({ key, securityCode });
});
router.get('/meta', async (c) => {
const [profileRows, optionRows] = await Promise.all([
pool.query<RowDataPacket[]>(`
SELECT
DATE_FORMAT(MIN(b.refuel_time), '%Y-%m-%d') AS start_date,
DATE_FORMAT(MAX(b.refuel_time), '%Y-%m-%d') AS end_date,
COUNT(*) AS total_refuel_count,
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS total_kg,
COUNT(DISTINCT b.station_id) AS total_station_count,
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
SUM(CASE WHEN ${VALID_COORDINATE} THEN 1 ELSE 0 END) AS eligible_refuel_count,
ROUND(SUM(CASE WHEN ${VALID_COORDINATE} THEN COALESCE(b.amount_kg, 0) ELSE 0 END), 2) AS eligible_kg,
COUNT(DISTINCT CASE WHEN ${VALID_COORDINATE} THEN b.station_id END) AS eligible_station_count
FROM hydrogen_fuel_ledger b
LEFT JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
WHERE b.del_flag = '0'
`),
pool.query<RowDataPacket[]>(`
SELECT
CAST(s.id AS CHAR) AS station_id,
COALESCE(NULLIF(s.station_short_name, ''), s.station_name) AS station_name
FROM hydrogen_station s
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = s.id
INNER JOIN hydrogen_fuel_ledger b ON b.station_id = s.id AND b.del_flag = '0'
WHERE s.del_flag = '0' AND ${VALID_COORDINATE}
GROUP BY s.id, s.station_short_name, s.station_name
ORDER BY station_name
`),
]);
const profile = profileRows[0][0] || {};
const totalRefuelCount = Number(profile.total_refuel_count) || 0;
const eligibleRefuelCount = Number(profile.eligible_refuel_count) || 0;
return c.json({
startDate: profile.start_date || DEFAULT_START,
endDate: profile.end_date || DEFAULT_END,
totalRefuelCount,
eligibleRefuelCount,
excludedRefuelCount: Math.max(0, totalRefuelCount - eligibleRefuelCount),
gpsCoverageRate: totalRefuelCount ? eligibleRefuelCount / totalRefuelCount : 0,
totalKg: Number(profile.total_kg) || 0,
eligibleKg: Number(profile.eligible_kg) || 0,
vehicleCount: Number(profile.vehicle_count) || 0,
totalStationCount: Number(profile.total_station_count) || 0,
eligibleStationCount: Number(profile.eligible_station_count) || 0,
stations: optionRows[0].map((row) => ({
stationId: String(row.station_id),
stationName: String(row.station_name),
})),
});
});
router.get('/points', async (c) => {
const startDate = isDate(c.req.query('startDate')) ? c.req.query('startDate')! : DEFAULT_START;
const endDate = isDate(c.req.query('endDate')) ? c.req.query('endDate')! : DEFAULT_END;
if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400);
const query = (c.req.query('query') || '').trim();
const payer = parsePayer(c.req.query('payer'));
const metric = parseMetric(c.req.query('metric'));
const stations = await loadStations(startDate, endDate, query, payer);
const ranked = [...stations].sort((left, right) => metricValue(right, metric) - metricValue(left, metric));
const points = ranked.map((row) => ({
lng: Number(row.longitude),
lat: Number(row.latitude),
count: metricValue(row, metric),
}));
const where = buildWhere(startDate, endDate, query, payer);
const [summaryRows] = await pool.execute<RowDataPacket[]>(`
SELECT
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg,
COUNT(*) AS refuel_count,
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
COUNT(DISTINCT b.station_id) AS station_count,
COUNT(DISTINCT DATE(b.refuel_time)) AS day_count
FROM hydrogen_fuel_ledger b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
WHERE ${where.sql}
`, where.params);
const summary = summaryRows[0] || {};
return c.json({
startDate,
endDate,
metric,
payer,
kg: Number(summary.kg) || 0,
refuelCount: Number(summary.refuel_count) || 0,
vehicleCount: Number(summary.vehicle_count) || 0,
stationCount: Number(summary.station_count) || 0,
dayCount: Number(summary.day_count) || 0,
points,
max: Math.max(1, ...points.map((point) => point.count)),
topStations: ranked.slice(0, 10).map((row) => serializeStation(row, metric)),
});
});
router.get('/nearby', async (c) => {
const longitude = Number(c.req.query('lng'));
const latitude = Number(c.req.query('lat'));
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return c.json({ error: '经纬度参数无效' }, 400);
const startDate = isDate(c.req.query('startDate')) ? c.req.query('startDate')! : DEFAULT_START;
const endDate = isDate(c.req.query('endDate')) ? c.req.query('endDate')! : DEFAULT_END;
const query = (c.req.query('query') || '').trim();
const payer = parsePayer(c.req.query('payer'));
const metric = parseMetric(c.req.query('metric'));
const radiusKm = Math.min(200, Math.max(5, Number(c.req.query('radiusKm')) || 50));
const stations = await loadStations(startDate, endDate, query, payer);
const nearby = stations.filter((row) => haversineKm(
longitude,
latitude,
Number(row.longitude),
Number(row.latitude),
) <= radiusKm);
const nearbyIds = nearby.map((row) => row.station_id);
let nearbySummary = { kg: 0, refuelCount: 0, vehicleCount: 0, stationCount: 0 };
if (nearbyIds.length) {
const placeholders = nearbyIds.map(() => '?').join(',');
const where = buildWhere(startDate, endDate, query, payer);
const [rows] = await pool.execute<RowDataPacket[]>(`
SELECT
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg,
COUNT(*) AS refuel_count,
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
COUNT(DISTINCT b.station_id) AS station_count
FROM hydrogen_fuel_ledger b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
WHERE ${where.sql} AND CAST(b.station_id AS CHAR) IN (${placeholders})
`, [...where.params, ...nearbyIds]);
nearbySummary = {
kg: Number(rows[0]?.kg) || 0,
refuelCount: Number(rows[0]?.refuel_count) || 0,
vehicleCount: Number(rows[0]?.vehicle_count) || 0,
stationCount: Number(rows[0]?.station_count) || 0,
};
}
const ranked = [...nearby].sort((left, right) => metricValue(right, metric) - metricValue(left, metric));
return c.json({
center: { lng: longitude, lat: latitude },
radiusKm,
kg: nearbySummary.kg,
refuelCount: nearbySummary.refuelCount,
vehicleCount: nearbySummary.vehicleCount,
stationCount: nearbySummary.stationCount,
topStations: ranked.slice(0, 10).map((row) => serializeStation(row, metric)),
});
});
export default router;

View File

@@ -2,13 +2,8 @@ import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
import { fetchVehicleInfoMap } from './vehicle-info.js';
import { fetchOneOsDailyMileage, fetchOneOsMileageDates, type OneOsDailyMileage } from './oneos-api.js';
import {
sourceCategoryFromProtocol,
type MileageSourceCategory,
type OneOsProtocol,
} from './source-policy.js';
import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix, VehicleInfoRow } from './types.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -42,7 +37,6 @@ function buildFilters(vehicles: CachedVehicle[], targetNames: string[]): Monitor
const projects = Array.from(new Set(vehicles.map(v => v.project).filter((p): p is string => p !== null)));
const entities = Array.from(new Set(vehicles.map(v => v.entity).filter((e): e is string => e !== null)));
const rentStatuses = Array.from(new Set(vehicles.map(v => v.rentStatus).filter((r): r is string => r !== null)));
const brands = Array.from(new Set(vehicles.map(v => v.brand).filter((b): b is string => b !== null))).sort();
const prefixCount = new Map<string, number>();
for (const v of vehicles) {
@@ -60,7 +54,7 @@ function buildFilters(vehicles: CachedVehicle[], targetNames: string[]): Monitor
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
});
return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames, regions, brands };
return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames, regions };
}
interface MileageRow {
@@ -69,103 +63,48 @@ interface MileageRow {
daily_km: string;
total_km: string | null;
source: string;
source_protocol: OneOsProtocol | null;
data_time: string | null;
calculated_at: string | null;
updated_at: string | null;
}
interface DailyMileageRow {
plate: string;
vin: string | null;
date: string;
daily_km: string | number | null;
source: string | null;
source_protocol: OneOsProtocol | null;
data_time: string | null;
calculated_at: string | null;
updated_at: string | null;
}
function toMileageRows(rows: OneOsDailyMileage[]): MileageRow[] {
return rows.map(row => ({
plate: row.plateNumber,
vin: row.vin,
daily_km: String(row.dailyMileageKm ?? 0),
total_km: row.totalMileageKm == null ? null : String(row.totalMileageKm),
source: row.status === 'NORMAL' ? 'ONEOS_API' : 'NONE',
source_protocol: row.sourceProtocol,
data_time: row.dataTime,
calculated_at: row.calculatedAt,
updated_at: row.updatedAt,
}));
}
function previousDate(date: string): string {
const [year, month, day] = date.split('-').map(Number);
const value = new Date(Date.UTC(year, month - 1, day));
value.setUTCDate(value.getUTCDate() - 1);
return value.toISOString().slice(0, 10);
}
function shanghaiDate(): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
}
function dailyMileageMap(rows: OneOsDailyMileage[]): Map<string, number> {
async function fetchBizTotalMileageMap(): Promise<Map<string, number>> {
// v_vehicle_daily_stats.total_km 对 G7S 数据源常为 NULLG7 只回传日增量),
// 业务库 tab_mileage_assessment_vehicle.vehicle_total_mileage 是累加后的权威累计值,
// 用它兜底保证 totalKm 汇总完整。
const [rows] = await pool.execute(
'SELECT plate_number, vehicle_total_mileage FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0'
) as [{ plate_number: string; vehicle_total_mileage: string | number | null }[], unknown];
const map = new Map<string, number>();
for (const row of rows) {
if (row.status !== 'NORMAL') continue;
const km = Math.max(0, Number(row.dailyMileageKm) || 0);
const existing = map.get(row.plateNumber) || 0;
if (km > existing) map.set(row.plateNumber, km);
for (const r of rows) {
const km = Number(r.vehicle_total_mileage);
if (Number.isFinite(km) && km > 0) map.set(r.plate_number, km);
}
return map;
}
export interface RangeMileageResult {
vehicles: CachedVehicle[];
dailyTotals: { date: string; totalKm: number }[];
start: string;
end: string;
}
interface TargetRow {
id: number;
target_name: string;
plate_number: string;
}
async function fetchTargetRows(): Promise<TargetRow[]> {
return pool.execute(
`SELECT t.id, t.target_name, v.plate_number
FROM lingniu_prod.tab_mileage_assessment_target t
JOIN lingniu_prod.tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
WHERE t.is_deleted = 0`
).then(([rows]) => rows as TargetRow[]);
}
function buildTargetPlatesMap(targetRows: TargetRow[]): Map<string, Set<string>> {
const targetPlatesMap = new Map<string, Set<string>>();
for (const r of targetRows) {
const set = targetPlatesMap.get(r.target_name) || new Set();
set.add(r.plate_number);
targetPlatesMap.set(r.target_name, set);
}
return targetPlatesMap;
}
function buildPlateTargetNamesMap(targetRows: TargetRow[]): Map<string, string[]> {
const map = new Map<string, string[]>();
for (const r of targetRows) {
const list = map.get(r.plate_number) || [];
list.push(r.target_name);
map.set(r.plate_number, list);
async function fetchLatestPgTotalMileageMap(asOf?: string): Promise<Map<string, number>> {
// 当日 ln_vehicle_day_total_pg 无记录或 total_mileage 为 NULL 时,
// 回填该车 dates <= asOf 的最近一条非空 total_mileage÷1000 转 km
// 让视图 total_km 为 NULL 的车也能显示历史累计。
// MySQL 5.7 无窗口函数,用 GROUP BY MAX(dates) + JOIN 取每车最近一条。
const sql = `
SELECT t.plate_number, t.total_mileage
FROM ln_vehicle_day_total_pg t
INNER JOIN (
SELECT plate_number, MAX(dates) AS max_dates
FROM ln_vehicle_day_total_pg
WHERE total_mileage IS NOT NULL
${asOf ? 'AND dates <= ?' : ''}
GROUP BY plate_number
) m ON m.plate_number = t.plate_number AND m.max_dates = t.dates
WHERE t.total_mileage IS NOT NULL`;
const params = asOf ? [asOf] : [];
const [rows] = await mileagePool.execute(sql, params) as [
{ plate_number: string; total_mileage: string | number | null }[],
unknown,
];
const map = new Map<string, number>();
for (const r of rows) {
const km = Number(r.total_mileage) / 1000;
if (Number.isFinite(km) && km > 0) map.set(r.plate_number, km);
}
return map;
}
@@ -174,7 +113,8 @@ function mergeVehicles(
mileageRows: MileageRow[],
infoMap: Map<string, VehicleInfoRow>,
yesterdayMap: Map<string, number>,
targetNamesByPlate: Map<string, string[]>,
bizTotalMap: Map<string, number>,
latestPgTotalMap: Map<string, number>,
): CachedVehicle[] {
const mileageMap = new Map<string, MileageRow>();
for (const row of mileageRows) {
@@ -184,38 +124,30 @@ function mergeVehicles(
}
}
return Array.from(infoMap.values()).map(info => {
const m = mileageMap.get(info.plate);
const plate = info.plate;
const dailyKm = Number(m?.daily_km) || 0;
const source = m?.source || 'NONE';
const gpsTotal = m?.total_km != null ? Number(m.total_km) : null;
return Array.from(mileageMap.values()).map(m => {
const info = infoMap.get(m.plate);
const dailyKm = Number(m.daily_km) || 0;
const source = m.source || 'NONE';
const gpsTotal = m.total_km !== null ? Number(m.total_km) : null;
const latestPgTotal = latestPgTotalMap.get(m.plate);
const bizTotal = bizTotalMap.get(m.plate);
return {
plate,
vin: m?.vin || info.vin || '',
plate: m.plate,
vin: m.vin,
dailyKm,
// The OneOS daily mileage API does not currently return cumulative mileage.
// Never backfill it from another mileage source outside the assessment page.
totalKm: gpsTotal,
totalKm: gpsTotal !== null ? gpsTotal : (latestPgTotal ?? bizTotal ?? null),
source,
sourceProtocol: m?.source_protocol || null,
sourceCategory: sourceCategoryFromProtocol(m?.source_protocol || null),
dataTime: m?.data_time || null,
calculatedAt: m?.calculated_at || null,
updatedAt: m?.updated_at || null,
isOnline: source !== 'NONE' && dailyKm > 0,
isDataSynced: source !== 'NONE',
customer: info.customer || null,
department: info.department || null,
manager: info.manager || null,
managerId: info.manager_id || null,
rentStatus: info.rent_status || null,
entity: info.entity || null,
project: info.project || null,
region: regionMap[plate] || null,
brand: info.brand_label || null,
targetNames: targetNamesByPlate.get(plate) || [],
yesterdayKm: yesterdayMap.get(plate) || 0,
customer: info?.customer || null,
department: info?.department || null,
manager: info?.manager || null,
managerId: info?.manager_id || null,
rentStatus: info?.rent_status || null,
entity: info?.entity || null,
project: info?.project || null,
region: regionMap[m.plate] || null,
yesterdayKm: yesterdayMap.get(m.plate) || 0,
};
});
}
@@ -225,23 +157,53 @@ export async function refreshMonitoringCache(): Promise<void> {
console.log('[mileage] refreshing monitoring cache...');
const start = Date.now();
const date = shanghaiDate();
const [apiRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
fetchOneOsDailyMileage(date),
fetchOneOsDailyMileage(previousDate(date)),
const [mileageRows, yesterdayMap, infoMap, targetRows, bizTotalMap, latestPgTotalMap] = await Promise.all([
(async () => {
const [dateRows] = await mileagePool.execute(
'SELECT MAX(stat_date) as latest FROM v_vehicle_daily_stats'
) as [{ latest: string | null }[], unknown];
const latestDate = dateRows[0]?.latest;
if (!latestDate) return [];
const [rows] = await mileagePool.execute(
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
[latestDate]
) as [MileageRow[], unknown];
return rows;
})(),
(async () => {
const [rows] = await mileagePool.execute(
`SELECT plate, daily_km FROM v_vehicle_daily_stats
WHERE stat_date = DATE_SUB((SELECT MAX(stat_date) FROM v_vehicle_daily_stats), INTERVAL 1 DAY)`
) as [{ plate: string; daily_km: string }[], unknown];
const map = new Map<string, number>();
for (const r of rows) {
const km = Number(r.daily_km) || 0;
const existing = map.get(r.plate) || 0;
if (km > existing) map.set(r.plate, km);
}
return map;
})(),
fetchVehicleInfoMap(),
fetchTargetRows(),
pool.execute(
`SELECT t.id, t.target_name, v.plate_number
FROM tab_mileage_assessment_target t
JOIN tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
WHERE t.is_deleted = 0`
).then(([rows]) => rows as { id: number; target_name: string; plate_number: string }[]),
fetchBizTotalMileageMap(),
fetchLatestPgTotalMileageMap(),
]);
const mileageRows = toMileageRows(apiRows);
const yesterdayMap = dailyMileageMap(yesterdayRows);
const targetPlatesMap = buildTargetPlatesMap(targetRows);
const targetNamesByPlate = buildPlateTargetNamesMap(targetRows);
const targetPlatesMap = new Map<string, Set<string>>();
for (const r of targetRows) {
const set = targetPlatesMap.get(r.target_name) || new Set();
set.add(r.plate_number);
targetPlatesMap.set(r.target_name, set);
}
const targetNames = Array.from(targetPlatesMap.keys());
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, targetNamesByPlate);
const totalToday = Math.round(vehicles.reduce((sum, v) => sum + v.dailyKm, 0));
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap, latestPgTotalMap);
const totalToday = vehicles.reduce((sum, v) => sum + v.dailyKm, 0);
const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
monitoringCache = {
@@ -258,196 +220,29 @@ export async function refreshMonitoringCache(): Promise<void> {
}
}
export async function queryDateMileage(
dateStr: string,
protocolPriority?: OneOsProtocol[],
): Promise<CachedVehicle[]> {
const [apiRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
fetchOneOsDailyMileage(dateStr, undefined, protocolPriority),
fetchOneOsDailyMileage(previousDate(dateStr), undefined, protocolPriority),
export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]> {
const [mileageRows, yesterdayRows, infoMap, bizTotalMap, latestPgTotalMap] = await Promise.all([
mileagePool.execute(
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
[dateStr]
).then(([r]) => r as MileageRow[]),
mileagePool.execute(
'SELECT plate, daily_km FROM v_vehicle_daily_stats WHERE stat_date = DATE_SUB(?, INTERVAL 1 DAY)',
[dateStr]
).then(([r]) => r as { plate: string; daily_km: string }[]),
fetchVehicleInfoMap(),
fetchTargetRows(),
fetchBizTotalMileageMap(),
fetchLatestPgTotalMileageMap(dateStr),
]);
const mileageRows = toMileageRows(apiRows);
const yesterdayMap = dailyMileageMap(yesterdayRows);
return mergeVehicles(
mileageRows,
infoMap,
yesterdayMap,
buildPlateTargetNamesMap(targetRows),
);
}
function datesBetween(start: string, end: string): string[] {
const result: string[] = [];
const [sy, sm, sd] = start.split('-').map(Number);
const [ey, em, ed] = end.split('-').map(Number);
const cursor = new Date(sy, sm - 1, sd);
const last = new Date(ey, em - 1, ed);
cursor.setHours(0, 0, 0, 0);
last.setHours(0, 0, 0, 0);
while (cursor <= last) {
result.push(`${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}-${String(cursor.getDate()).padStart(2, '0')}`);
cursor.setDate(cursor.getDate() + 1);
}
return result;
}
export async function queryRangeMileage(
startDate: string,
endDate: string,
protocolPriority?: OneOsProtocol[],
): Promise<RangeMileageResult> {
if (startDate === endDate) {
const vehicles = (await queryDateMileage(startDate, protocolPriority)).map(vehicle => ({
...vehicle,
dailyMileage: { [startDate]: vehicle.dailyKm },
dailySourceProtocols: { [startDate]: vehicle.sourceProtocol },
}));
return {
vehicles,
dailyTotals: [{
date: startDate,
totalKm: Math.round(vehicles.reduce((sum, vehicle) => sum + vehicle.dailyKm, 0)),
}],
start: startDate,
end: endDate,
};
const yesterdayMap = new Map<string, number>();
for (const r of yesterdayRows) {
const km = Number(r.daily_km) || 0;
const existing = yesterdayMap.get(r.plate) || 0;
if (km > existing) yesterdayMap.set(r.plate, km);
}
const days = datesBetween(startDate, endDate);
const [apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
fetchOneOsMileageDates(days, undefined, protocolPriority),
fetchOneOsDailyMileage(endDate, undefined, protocolPriority),
fetchOneOsDailyMileage(previousDate(startDate), undefined, protocolPriority),
fetchVehicleInfoMap(),
fetchTargetRows(),
]);
const dailyRows: DailyMileageRow[] = [];
for (const [date, apiRows] of apiRowsByDate) {
for (const row of apiRows) {
dailyRows.push({
plate: row.plateNumber,
vin: row.vin,
date,
daily_km: row.dailyMileageKm,
source: row.status === 'NORMAL' ? 'ONEOS_API' : 'NONE',
source_protocol: row.sourceProtocol,
data_time: row.dataTime,
calculated_at: row.calculatedAt,
updated_at: row.updatedAt,
});
}
}
const perVehicleDaily = new Map<string, Record<string, number>>();
const perVehicleDailySources = new Map<string, Record<string, OneOsProtocol | null>>();
const perVehicleSourceCategories = new Map<string, Set<MileageSourceCategory>>();
const perVehicleSum = new Map<string, {
plate: string;
vin: string;
daily_km: string;
total_km: string | null;
source: string;
source_protocol: OneOsProtocol | null;
data_time: string | null;
calculated_at: string | null;
updated_at: string | null;
}>();
const dailyTotals = new Map<string, number>();
const bestDailyRows = new Map<string, DailyMileageRow>();
for (const day of days) dailyTotals.set(day, 0);
for (const row of dailyRows) {
const key = `${row.plate}\u0000${row.date}`;
const km = Math.max(0, Number(row.daily_km) || 0);
const existing = bestDailyRows.get(key);
if (!existing || km > Math.max(0, Number(existing.daily_km) || 0)) {
bestDailyRows.set(key, row);
}
}
for (const row of bestDailyRows.values()) {
const km = Math.max(0, Number(row.daily_km) || 0);
const date = row.date;
const plate = row.plate;
dailyTotals.set(date, (dailyTotals.get(date) || 0) + km);
const daily = perVehicleDaily.get(plate) || {};
daily[date] = km;
perVehicleDaily.set(plate, daily);
const dailySources = perVehicleDailySources.get(plate) || {};
dailySources[date] = row.source_protocol;
perVehicleDailySources.set(plate, dailySources);
const category = sourceCategoryFromProtocol(row.source_protocol);
if (category) {
const categories = perVehicleSourceCategories.get(plate) || new Set<MileageSourceCategory>();
categories.add(category);
perVehicleSourceCategories.set(plate, categories);
}
const existing = perVehicleSum.get(plate);
perVehicleSum.set(plate, {
plate,
vin: existing?.vin || row.vin || '',
daily_km: String((Number(existing?.daily_km) || 0) + km),
total_km: null,
source: existing?.source !== 'NONE' && existing?.source ? existing.source : (row.source || 'NONE'),
source_protocol: row.source_protocol || existing?.source_protocol || null,
data_time: row.data_time || existing?.data_time || null,
calculated_at: row.calculated_at || existing?.calculated_at || null,
updated_at: row.updated_at || existing?.updated_at || null,
});
}
const endDateMap = new Map(endDateRows.map(row => [row.plateNumber, row]));
for (const [plate, aggregate] of perVehicleSum) {
const endDateRow = endDateMap.get(plate);
if (!endDateRow) continue;
perVehicleSum.set(plate, {
...aggregate,
vin: endDateRow.vin || aggregate.vin,
total_km: endDateRow.totalMileageKm == null ? null : String(endDateRow.totalMileageKm),
source_protocol: endDateRow.sourceProtocol || aggregate.source_protocol,
data_time: endDateRow.dataTime || aggregate.data_time,
updated_at: endDateRow.updatedAt || aggregate.updated_at,
});
}
const yesterdayMap = dailyMileageMap(yesterdayRows);
const vehicles = mergeVehicles(
Array.from(perVehicleSum.values()),
infoMap,
yesterdayMap,
buildPlateTargetNamesMap(targetRows),
).map(vehicle => {
const dailyMileage = perVehicleDaily.get(vehicle.plate) || {};
const dailySources = perVehicleDailySources.get(vehicle.plate) || {};
const categories = perVehicleSourceCategories.get(vehicle.plate);
const completedDailyMileage: Record<string, number> = {};
const completedDailySources: Record<string, OneOsProtocol | null> = {};
for (const day of days) completedDailyMileage[day] = dailyMileage[day] || 0;
for (const day of days) completedDailySources[day] = dailySources[day] || null;
return {
...vehicle,
dailyMileage: completedDailyMileage,
dailySourceProtocols: completedDailySources,
sourceCategory: categories && categories.size > 1
? 'MIXED' as const
: categories?.values().next().value || vehicle.sourceCategory,
};
});
return {
vehicles,
dailyTotals: days.map(date => ({ date, totalKm: Math.round(dailyTotals.get(date) || 0) })),
start: startDate,
end: endDate,
};
return mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap, latestPgTotalMap);
}
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {

View File

@@ -1,32 +1,25 @@
import { Hono } from 'hono';
import { getCache, queryDateMileage, queryRangeMileage, buildDateFilters } from './cache.js';
import { getCache, queryDateMileage, buildDateFilters } from './cache.js';
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
import type { AuthUser } from '../../auth/types.js';
import type { CachedVehicle, MonitoringFilters, MonitoringResponse } from './types.js';
import {
DEFAULT_MILEAGE_SOURCE_PRIORITY,
parseMileageSourcePriority,
protocolsForSourcePriority,
} from './source-policy.js';
const app = new Hono();
const EMPTY_RESPONSE: MonitoringResponse = {
vehicles: [],
stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 },
filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [], brands: [] },
filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] },
total: 0,
page: 1,
totalPages: 1,
updatedAt: new Date().toISOString(),
sourcePriority: [...DEFAULT_MILEAGE_SOURCE_PRIORITY],
};
function applyFilters(vehicles: CachedVehicle[], params: {
search: string; dept: string; customer: string; project: string;
entity: string; rentStatus: string; plate: string; platePrefix: string;
targetNames: string[]; region: string; mileageMin: string; mileageMax: string;
brands: string[];
targetName: string; region: string; mileageMin: string; mileageMax: string;
}): CachedVehicle[] {
let result = vehicles;
@@ -34,13 +27,12 @@ function applyFilters(vehicles: CachedVehicle[], params: {
const q = params.search.toLowerCase();
result = result.filter(v =>
v.plate.toLowerCase().includes(q) ||
v.vin.toLowerCase().includes(q) ||
(v.customer || '').toLowerCase().includes(q) ||
(v.project || '').toLowerCase().includes(q)
);
}
if (params.dept) result = result.filter(v => params.dept === '__EMPTY__' ? !v.department : v.department === params.dept);
if (params.customer) result = result.filter(v => params.customer === '__EMPTY__' ? !v.customer : (v.customer || '').toLowerCase().includes(params.customer.toLowerCase()));
if (params.customer) result = result.filter(v => params.customer === '__EMPTY__' ? !v.customer : v.customer === params.customer);
if (params.project) result = result.filter(v => v.project === params.project);
if (params.entity) result = result.filter(v => v.entity === params.entity);
if (params.rentStatus) result = result.filter(v => v.rentStatus === params.rentStatus);
@@ -50,13 +42,10 @@ function applyFilters(vehicles: CachedVehicle[], params: {
}
if (params.platePrefix) result = result.filter(v => v.plate.startsWith(params.platePrefix));
if (params.region) result = result.filter(v => v.region === params.region);
if (params.targetNames.length > 0) {
const selectedTargets = new Set(params.targetNames);
result = result.filter(v => v.targetNames.some(targetName => selectedTargets.has(targetName)));
}
if (params.brands.length > 0) {
const selectedBrands = new Set(params.brands);
result = result.filter(v => v.brand != null && selectedBrands.has(v.brand));
if (params.targetName) {
const cache = getCache();
const tPlates = cache?.targetPlatesMap.get(params.targetName);
result = tPlates ? result.filter(v => tPlates.has(v.plate)) : [];
}
if (params.mileageMin) result = result.filter(v => v.dailyKm >= Number(params.mileageMin));
if (params.mileageMax) result = result.filter(v => v.dailyKm <= Number(params.mileageMax));
@@ -64,57 +53,12 @@ function applyFilters(vehicles: CachedVehicle[], params: {
return result;
}
function parseTargetNames(reqUrl: string): string[] {
const params = new URL(reqUrl).searchParams;
const raw = [
...params.getAll('targetName'),
...params.getAll('targetNames'),
];
const names = raw.flatMap(item => item.split(','))
.map(item => item.trim())
.filter(Boolean);
return Array.from(new Set(names));
}
function parseYmd(value: string): Date | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) return null;
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
date.setHours(0, 0, 0, 0);
return Number.isFinite(date.getTime()) ? date : null;
}
function fmtYmd(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function normalizeRange(startQuery: string, endQuery: string): { start: string; end: string } | null {
if (!startQuery && !endQuery) return null;
const start = parseYmd(startQuery || endQuery);
const end = parseYmd(endQuery || startQuery);
if (!start || !end) return null;
const a = start <= end ? start : end;
let b = start <= end ? end : start;
const span = Math.round((b.getTime() - a.getTime()) / 86400000) + 1;
if (span > 366) {
b = new Date(a);
b.setDate(a.getDate() + 365);
}
return { start: fmtYmd(a), end: fmtYmd(b) };
}
app.get('/', async (c) => {
const requestedSortBy = c.req.query('sortBy');
const sortBy = requestedSortBy === 'total' || requestedSortBy === 'statisticTime'
? requestedSortBy
: 'today';
const sortOrder = c.req.query('sortOrder') === 'asc' ? 'asc' : 'desc';
const sortBy = c.req.query('sortBy') || 'today';
const sortOrder = c.req.query('sortOrder') || 'desc';
const limit = Number(c.req.query('limit')) || 50;
const page = Number(c.req.query('page')) || 1;
const date = c.req.query('date') || '';
const range = normalizeRange(c.req.query('startDate') || '', c.req.query('endDate') || '');
const sourcePriority = parseMileageSourcePriority(c.req.query('sourcePriority'));
const protocolPriority = protocolsForSourcePriority(sourcePriority);
const filterParams = {
search: c.req.query('search') || '',
@@ -125,32 +69,18 @@ app.get('/', async (c) => {
rentStatus: c.req.query('rentStatus') || '',
plate: c.req.query('plate') || '',
platePrefix: c.req.query('platePrefix') || '',
targetNames: parseTargetNames(c.req.url),
targetName: c.req.query('targetName') || '',
region: c.req.query('region') || '',
mileageMin: c.req.query('mileageMin') || '',
mileageMax: c.req.query('mileageMax') || '',
brands: c.req.queries('brand') || [],
};
let allVehicles: CachedVehicle[];
let filters: MonitoringFilters;
let rangeDailyTotals: { date: string; totalKm: number }[] | undefined;
let dateRange: { start: string; end: string } | undefined;
if (range) {
if (date) {
try {
const result = await queryRangeMileage(range.start, range.end, protocolPriority);
allVehicles = result.vehicles;
rangeDailyTotals = result.dailyTotals;
dateRange = { start: result.start, end: result.end };
filters = buildDateFilters(allVehicles);
} catch (e: unknown) {
console.error('monitoring range query error:', e);
return c.json(EMPTY_RESPONSE, 500);
}
} else if (date) {
try {
allVehicles = await queryDateMileage(date, protocolPriority);
allVehicles = await queryDateMileage(date);
filters = buildDateFilters(allVehicles);
} catch (e: unknown) {
console.error('monitoring date query error:', e);
@@ -177,41 +107,18 @@ app.get('/', async (c) => {
}
const filtered = applyFilters(allVehicles, filterParams);
if (rangeDailyTotals && filtered.length !== allVehicles.length) {
rangeDailyTotals = rangeDailyTotals.map(item => ({
...item,
totalKm: Math.round(filtered.reduce((sum, vehicle) => sum + (vehicle.dailyMileage?.[item.date] || 0), 0)),
}));
}
const stats = {
totalToday: Math.round(filtered.reduce((sum, v) => sum + v.dailyKm, 0)),
totalToday: filtered.reduce((sum, v) => sum + v.dailyKm, 0),
totalAll: filtered.reduce((sum, v) => sum + (v.totalKm || 0), 0),
vehicleCount: filtered.length,
yesterdayTotal: filtered.reduce((sum, v) => sum + v.yesterdayKm, 0),
};
const sorted = [...filtered].sort((a, b) => {
if (sortBy === 'statisticTime') {
const rawTimeA = a.dataTime || a.updatedAt || a.calculatedAt;
const rawTimeB = b.dataTime || b.updatedAt || b.calculatedAt;
const parsedTimeA = rawTimeA ? Date.parse(rawTimeA) : Number.NaN;
const parsedTimeB = rawTimeB ? Date.parse(rawTimeB) : Number.NaN;
const timeA = Number.isFinite(parsedTimeA) ? parsedTimeA : null;
const timeB = Number.isFinite(parsedTimeB) ? parsedTimeB : null;
// Missing/invalid statistic times always stay at the end for both directions.
if (timeA == null && timeB == null) return a.plate.localeCompare(b.plate, 'zh-CN');
if (timeA == null) return 1;
if (timeB == null) return -1;
const timeDiff = sortOrder === 'desc' ? timeB - timeA : timeA - timeB;
return timeDiff || a.plate.localeCompare(b.plate, 'zh-CN');
}
const valA = sortBy === 'today' ? a.dailyKm : (a.totalKm || 0);
const valB = sortBy === 'today' ? b.dailyKm : (b.totalKm || 0);
const mileageDiff = sortOrder === 'desc' ? valB - valA : valA - valB;
return mileageDiff || a.plate.localeCompare(b.plate, 'zh-CN');
return sortOrder === 'desc' ? valB - valA : valA - valB;
});
const offset = (page - 1) * limit;
@@ -222,13 +129,10 @@ app.get('/', async (c) => {
vehicles: maskCustomerNames(paged),
stats,
filters,
rangeDailyTotals,
dateRange,
total,
page,
totalPages: Math.ceil(total / limit),
updatedAt: dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(),
sourcePriority,
updatedAt: date || getCache()?.updatedAt || new Date().toISOString(),
});
});

View File

@@ -1,392 +0,0 @@
import dotenv from 'dotenv';
import {
DEFAULT_ONEOS_PROTOCOL_PRIORITY,
normalizeOneOsProtocol,
type OneOsProtocol,
} from './source-policy.js';
dotenv.config();
const ENDPOINT = '/api/v1/vehicles/mileage/query';
const RANGE_ENDPOINT = '/api/v1/vehicles/mileage/range/query';
const DEFAULT_TIMEOUT_MS = 20_000;
const CURRENT_DAY_TTL_MS = 0;
const HISTORICAL_TTL_MS = 6 * 60 * 60 * 1000;
const MAX_CACHE_DATES = 400;
const RANGE_PAGE_SIZE = 5000;
export interface OneOsDailyMileage {
vin: string;
plateNumber: string;
date: string;
dailyMileageKm: number | null;
totalMileageKm: number | null;
status: 'NORMAL' | 'NO_DATA';
dataTime: string | null;
calculatedAt: string | null;
updatedAt: string | null;
sourceProtocol: OneOsProtocol | null;
}
interface OneOsMileageResponse {
code?: string;
message?: string;
data?: unknown;
traceId?: string;
}
interface OneOsMileageRangeResponse extends OneOsMileageResponse {
snapshotId?: string;
nextCursor?: string | null;
}
interface CacheEntry {
expiresAt: number;
rows: OneOsDailyMileage[];
}
const cache = new Map<string, CacheEntry>();
const inflight = new Map<string, Promise<OneOsDailyMileage[]>>();
const rangeInflight = new Map<string, Promise<Map<string, OneOsDailyMileage[]>>>();
let protocolPrioritySupported: boolean | null = null;
let protocolFallbackWarned = false;
function markProtocolPriorityUnsupported(): void {
protocolPrioritySupported = false;
if (protocolFallbackWarned) return;
protocolFallbackWarned = true;
console.warn(
'[mileage] OneOS does not support protocolPriority/sourceProtocol yet; using the legacy request contract',
);
}
function apiConfig(): { baseUrl: string; apiKey: string; timeoutMs: number } {
const baseUrl = (process.env.ONEOS_MILEAGE_API_BASE_URL || '').replace(/\/+$/, '');
const apiKey = process.env.ONEOS_MILEAGE_API_KEY || '';
const timeoutMs = Number(process.env.ONEOS_MILEAGE_API_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS;
if (!baseUrl) throw new Error('ONEOS_MILEAGE_API_BASE_URL is not configured');
if (!apiKey) throw new Error('ONEOS_MILEAGE_API_KEY is not configured');
return { baseUrl, apiKey, timeoutMs };
}
function shanghaiDate(): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
}
function normalizeRows(value: unknown, requestedDate: string): OneOsDailyMileage[] {
if (!Array.isArray(value)) throw new Error('OneOS mileage API returned a non-array data field');
const best = new Map<string, OneOsDailyMileage>();
for (const item of value) {
if (!item || typeof item !== 'object') continue;
const row = item as Record<string, unknown>;
const vin = typeof row.vin === 'string' ? row.vin.trim() : '';
const plateNumber = typeof row.plateNumber === 'string' ? row.plateNumber.trim() : '';
const date = typeof row.date === 'string' ? row.date : requestedDate;
const status = row.status === 'NORMAL' ? 'NORMAL' : 'NO_DATA';
const rawKm = row.dailyMileageKm;
const numericKm = rawKm === null || rawKm === undefined ? null : Number(rawKm);
const dailyMileageKm = status === 'NORMAL' && numericKm !== null && Number.isFinite(numericKm)
? Math.max(0, numericKm)
: null;
const rawTotalKm = row.totalMileageKm;
const numericTotalKm = rawTotalKm === null || rawTotalKm === undefined ? null : Number(rawTotalKm);
const totalMileageKm = status === 'NORMAL' && numericTotalKm !== null && Number.isFinite(numericTotalKm)
? Math.max(0, numericTotalKm)
: null;
const stringField = (...names: string[]): string | null => {
for (const name of names) {
const field = row[name];
if (typeof field === 'string' && field.trim()) return field.trim();
}
return null;
};
// dataTime is the preferred contract. Aliases keep the BI forward-compatible
// while the Open API rolls out the formal field name.
const dataTime = stringField('dataTime', 'statisticTime', 'recordTime');
const calculatedAt = stringField('calculatedAt', 'calculationTime');
const updatedAt = stringField('updatedAt');
const sourceProtocol = normalizeOneOsProtocol(
stringField('sourceProtocol', 'protocol', 'dataSource'),
);
if (!plateNumber || date !== requestedDate) continue;
const normalized: OneOsDailyMileage = {
vin,
plateNumber,
date,
dailyMileageKm,
totalMileageKm,
status,
dataTime,
calculatedAt,
updatedAt,
sourceProtocol,
};
const existing = best.get(plateNumber);
if (!existing || (dailyMileageKm ?? -1) > (existing.dailyMileageKm ?? -1)) {
best.set(plateNumber, normalized);
}
}
return Array.from(best.values());
}
function normalizeRangeRows(value: unknown, startDate: string, endDate: string): Map<string, OneOsDailyMileage[]> {
if (!Array.isArray(value)) throw new Error('OneOS mileage range API returned a non-array data field');
const rawByDate = new Map<string, unknown[]>();
for (const item of value) {
if (!item || typeof item !== 'object') continue;
const date = (item as Record<string, unknown>).date;
if (typeof date !== 'string' || date < startDate || date > endDate) continue;
const rows = rawByDate.get(date) || [];
rows.push(item);
rawByDate.set(date, rows);
}
const result = new Map<string, OneOsDailyMileage[]>();
for (const [date, rows] of rawByDate) result.set(date, normalizeRows(rows, date));
return result;
}
async function requestDate(
date: string,
protocolPriority: OneOsProtocol[],
): Promise<OneOsDailyMileage[]> {
const { baseUrl, apiKey, timeoutMs } = apiConfig();
let lastError: unknown;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const includeProtocolPriority = protocolPrioritySupported !== false;
const body: Record<string, unknown> = { date };
if (includeProtocolPriority) body.protocolPriority = protocolPriority;
const response = await fetch(`${baseUrl}${ENDPOINT}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
// Intentionally omit plateNumbers: the API then returns every vehicle
// authorized for this application on the requested natural day.
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
const payload = await response.json().catch(() => null) as OneOsMileageResponse | null;
if (
includeProtocolPriority &&
response.status === 400 &&
payload?.code === 'INVALID_REQUEST'
) {
markProtocolPriorityUnsupported();
continue;
}
if (!response.ok || payload?.code !== 'SUCCESS') {
const trace = payload?.traceId ? `, traceId=${payload.traceId}` : '';
const error = new Error(
`OneOS mileage API failed: HTTP ${response.status}, code=${payload?.code || 'UNKNOWN'}${trace}`,
);
if (response.status < 500 || attempt === 2) throw error;
lastError = error;
} else {
if (includeProtocolPriority) protocolPrioritySupported = true;
return normalizeRows(payload.data, date);
}
} catch (error) {
lastError = error;
const nonRetryable = error instanceof Error && /HTTP (400|401|403)/.test(error.message);
if (nonRetryable || attempt === 2) throw error;
}
await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** attempt));
}
throw lastError instanceof Error ? lastError : new Error('OneOS mileage API request failed');
}
async function requestRange(
startDate: string,
endDate: string,
plateNumbers: string[],
protocolPriority: OneOsProtocol[],
): Promise<Map<string, OneOsDailyMileage[]>> {
const { baseUrl, apiKey, timeoutMs } = apiConfig();
const allRows: unknown[] = [];
const seenCursors = new Set<string>();
let cursor: string | null = null;
let snapshotId: string | null = null;
for (let page = 0; page < 10_000; page += 1) {
let payload: OneOsMileageRangeResponse | null = null;
let lastError: unknown;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const body: Record<string, unknown> = {
startDate,
endDate,
pageSize: RANGE_PAGE_SIZE,
};
const includeProtocolPriority = protocolPrioritySupported !== false;
if (includeProtocolPriority) body.protocolPriority = protocolPriority;
if (plateNumbers.length > 0) body.plateNumbers = plateNumbers;
if (cursor) body.cursor = cursor;
const response = await fetch(`${baseUrl}${RANGE_ENDPOINT}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
payload = await response.json().catch(() => null) as OneOsMileageRangeResponse | null;
if (
includeProtocolPriority &&
response.status === 400 &&
payload?.code === 'INVALID_REQUEST'
) {
markProtocolPriorityUnsupported();
continue;
}
if (!response.ok || payload?.code !== 'SUCCESS') {
const trace = payload?.traceId ? `, traceId=${payload.traceId}` : '';
const error = new Error(
`OneOS mileage range API failed: HTTP ${response.status}, code=${payload?.code || 'UNKNOWN'}${trace}`,
);
if (response.status < 500 || attempt === 2) throw error;
lastError = error;
} else {
if (includeProtocolPriority) protocolPrioritySupported = true;
break;
}
} catch (error) {
lastError = error;
const nonRetryable = error instanceof Error && /HTTP (400|401|403)/.test(error.message);
if (nonRetryable || attempt === 2) throw error;
}
await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** attempt));
}
if (!payload || payload.code !== 'SUCCESS') {
throw lastError instanceof Error ? lastError : new Error('OneOS mileage range API request failed');
}
if (!Array.isArray(payload.data)) throw new Error('OneOS mileage range API returned a non-array data field');
if (snapshotId && payload.snapshotId !== snapshotId) {
throw new Error('OneOS mileage range API snapshot changed during pagination');
}
snapshotId = payload.snapshotId || snapshotId;
allRows.push(...payload.data);
const nextCursor = payload.nextCursor || null;
if (!nextCursor) return normalizeRangeRows(allRows, startDate, endDate);
if (seenCursors.has(nextCursor)) throw new Error('OneOS mileage range API returned a repeated cursor');
seenCursors.add(nextCursor);
cursor = nextCursor;
}
throw new Error('OneOS mileage range API exceeded the pagination safety limit');
}
function trimCache(): void {
while (cache.size > MAX_CACHE_DATES) {
const oldestKey = cache.keys().next().value as string | undefined;
if (!oldestKey) break;
cache.delete(oldestKey);
}
}
export async function fetchOneOsDailyMileage(
date: string,
plateNumbers?: string[],
protocolPriority: OneOsProtocol[] = DEFAULT_ONEOS_PROTOCOL_PRIORITY,
): Promise<OneOsDailyMileage[]> {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error(`Invalid mileage date: ${date}`);
const policyKey = protocolPriority.join('>');
const cacheKey = `${date}:${policyKey}`;
const hit = cache.get(cacheKey);
let rows: OneOsDailyMileage[];
if (hit && hit.expiresAt > Date.now()) {
rows = hit.rows;
} else {
let pending = inflight.get(cacheKey);
if (!pending) {
pending = requestDate(date, protocolPriority).then(result => {
const ttl = date === shanghaiDate() ? CURRENT_DAY_TTL_MS : HISTORICAL_TTL_MS;
cache.delete(cacheKey);
if (ttl > 0) {
cache.set(cacheKey, { rows: result, expiresAt: Date.now() + ttl });
trimCache();
}
return result;
}).finally(() => inflight.delete(cacheKey));
inflight.set(cacheKey, pending);
}
rows = await pending;
}
if (!plateNumbers?.length) return rows;
const selected = new Set(plateNumbers.map(plate => plate.trim()).filter(Boolean));
return rows.filter(row => selected.has(row.plateNumber));
}
export async function fetchOneOsMileageDates(
dates: string[],
plateNumbers?: string[],
protocolPriority: OneOsProtocol[] = DEFAULT_ONEOS_PROTOCOL_PRIORITY,
): Promise<Map<string, OneOsDailyMileage[]>> {
const result = new Map<string, OneOsDailyMileage[]>();
const uniqueDates = Array.from(new Set(dates)).sort();
const selectedPlates = Array.from(new Set(
(plateNumbers || []).map(plate => plate.trim()).filter(Boolean),
)).sort();
if (uniqueDates.length === 0) return result;
for (const date of uniqueDates) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error(`Invalid mileage date: ${date}`);
result.set(date, []);
}
const groups: string[][] = [];
for (const date of uniqueDates) {
const current = groups[groups.length - 1];
if (!current) {
groups.push([date]);
continue;
}
const previous = new Date(`${current[current.length - 1]}T00:00:00Z`);
previous.setUTCDate(previous.getUTCDate() + 1);
if (previous.toISOString().slice(0, 10) === date && current.length < 366) current.push(date);
else groups.push([date]);
}
await Promise.all(groups.map(async group => {
const startDate = group[0];
const endDate = group[group.length - 1];
const key = [
startDate,
endDate,
selectedPlates.length > 0 ? selectedPlates.join('\u0000') : '*',
protocolPriority.join('>'),
].join(':');
let pending = rangeInflight.get(key);
if (!pending) {
pending = requestRange(
startDate,
endDate,
selectedPlates,
protocolPriority,
).finally(() => rangeInflight.delete(key));
rangeInflight.set(key, pending);
}
const rowsByDate = await pending;
for (const date of group) result.set(date, rowsByDate.get(date) || []);
}));
return result;
}
export function clearOneOsMileageCache(): void {
cache.clear();
rangeInflight.clear();
}

View File

@@ -1,38 +0,0 @@
export type MileageSourceGroup = 'instrument' | 'gps';
export type OneOsProtocol = 'GB32960' | 'MQTT' | 'JT808';
export type MileageSourceCategory = 'INSTRUMENT' | 'GPS';
export const DEFAULT_MILEAGE_SOURCE_PRIORITY: MileageSourceGroup[] = ['instrument'];
export const DEFAULT_ONEOS_PROTOCOL_PRIORITY: OneOsProtocol[] = ['GB32960', 'MQTT'];
export function parseMileageSourcePriority(value?: string): MileageSourceGroup[] {
if (!value) return [...DEFAULT_MILEAGE_SOURCE_PRIORITY];
const groups = value.split(',')
.map(item => item.trim().toLowerCase())
.filter((item): item is MileageSourceGroup => item === 'instrument' || item === 'gps');
const unique = Array.from(new Set(groups));
return unique.length > 0 ? unique : [...DEFAULT_MILEAGE_SOURCE_PRIORITY];
}
export function protocolsForSourcePriority(groups: MileageSourceGroup[]): OneOsProtocol[] {
return groups.flatMap(group => group === 'instrument'
? ['GB32960', 'MQTT'] as OneOsProtocol[]
: ['JT808'] as OneOsProtocol[]);
}
export function normalizeOneOsProtocol(value: unknown): OneOsProtocol | null {
if (typeof value !== 'string') return null;
const normalized = value.trim().toUpperCase().replace(/[^A-Z0-9]/g, '');
if (normalized === 'GB32960' || normalized === '32960') return 'GB32960';
if (normalized === 'MQTT') return 'MQTT';
if (normalized === 'JT808' || normalized === '808') return 'JT808';
return null;
}
export function sourceCategoryFromProtocol(
protocol: OneOsProtocol | null,
): MileageSourceCategory | null {
if (protocol === 'GB32960' || protocol === 'MQTT') return 'INSTRUMENT';
if (protocol === 'JT808') return 'GPS';
return null;
}

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono';
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
import { getCache } from './cache.js';
import { fetchOneOsDailyMileage } from './oneos-api.js';
import { fetchVehicleInfoByPlates } from './vehicle-info.js';
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
@@ -10,7 +10,7 @@ const app = new Hono();
app.get('/', async (c) => {
try {
const [targets] = await pool.execute(
'SELECT * FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id'
'SELECT * FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id'
) as [any[], unknown];
const [vehicleStats] = await pool.execute(`
@@ -25,96 +25,19 @@ app.get('/', async (c) => {
SUM(current_year_mileage_task) as current_year_target,
SUM(current_year_mileage) as current_year_completed,
MAX(current_year_assessment_end_date) as year_end_date
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
GROUP BY target_id
`) as [any[], unknown];
const statsMap = new Map<number, any>();
for (const s of vehicleStats) statsMap.set(s.target_id, s);
const [firstYearRows] = await pool.execute(`
SELECT
v.target_id,
COUNT(*) as first_year_total,
SUM(t.annual_mileage_per_vehicle) as first_year_target,
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) as first_year_completed,
SUM(GREATEST(t.annual_mileage_per_vehicle - v.current_mileage, 0)) as first_year_remaining,
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) / NULLIF(SUM(t.annual_mileage_per_vehicle), 0) as first_year_completion_rate,
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle THEN 1 ELSE 0 END) as first_year_qualified_count,
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * 0.5 THEN 1 ELSE 0 END) as first_year_half_qualified_count,
DATE_FORMAT(MIN(v.assessment_start_date), '%Y-%m-%d') as first_year_start_date,
DATE_FORMAT(MAX(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL 1 YEAR), INTERVAL 1 DAY)), '%Y-%m-%d') as first_year_end_date
FROM lingniu_prod.tab_mileage_assessment_vehicle v
JOIN lingniu_prod.tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
WHERE v.is_deleted = 0
GROUP BY v.target_id
`) as [any[], unknown];
const firstYearMap = new Map<number, any>();
for (const s of firstYearRows) firstYearMap.set(s.target_id, s);
const [yearlyRows] = await pool.execute(`
SELECT
v.target_id,
y.year_number,
COUNT(*) as vehicle_count,
SUM(t.annual_mileage_per_vehicle * y.year_number) as target_mileage,
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle * y.year_number)) as completed_mileage,
SUM(GREATEST(t.annual_mileage_per_vehicle * y.year_number - v.current_mileage, 0)) as remaining_mileage,
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle * y.year_number))
/ NULLIF(SUM(t.annual_mileage_per_vehicle * y.year_number), 0) as completion_rate,
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * y.year_number THEN 1 ELSE 0 END) as qualified_count,
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * y.year_number * 0.5 THEN 1 ELSE 0 END) as half_qualified_count,
DATE_FORMAT(MIN(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number - 1 YEAR)), '%Y-%m-%d') as start_date,
DATE_FORMAT(MAX(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number YEAR), INTERVAL 1 DAY)), '%Y-%m-%d') as end_date
FROM lingniu_prod.tab_mileage_assessment_vehicle v
JOIN lingniu_prod.tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
JOIN (
SELECT 1 as year_number UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
) y ON y.year_number <= LEAST(t.assessment_years, v.current_year_number)
WHERE v.is_deleted = 0
GROUP BY v.target_id, y.year_number
ORDER BY v.target_id, y.year_number
`) as [any[], unknown];
const yearlyMap = new Map<number, any[]>();
for (const row of yearlyRows) {
const list = yearlyMap.get(row.target_id) || [];
list.push(row);
yearlyMap.set(row.target_id, list);
}
const [yearlyPeriodRows] = await pool.execute(`
SELECT
v.target_id,
y.year_number,
DATE_FORMAT(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number - 1 YEAR), '%Y-%m-%d') as start_date,
DATE_FORMAT(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number YEAR), INTERVAL 1 DAY), '%Y-%m-%d') as end_date,
COUNT(*) as cnt
FROM lingniu_prod.tab_mileage_assessment_vehicle v
JOIN lingniu_prod.tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
JOIN (
SELECT 1 as year_number UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
) y ON y.year_number <= LEAST(t.assessment_years, v.current_year_number)
WHERE v.is_deleted = 0
GROUP BY v.target_id, y.year_number, v.assessment_start_date
ORDER BY v.target_id, y.year_number, v.assessment_start_date
`) as [any[], unknown];
const yearlyPeriodsMap = new Map<string, string[]>();
for (const row of yearlyPeriodRows) {
const key = `${row.target_id}-${row.year_number}`;
const list = yearlyPeriodsMap.get(key) || [];
list.push(`${row.start_date} ~ ${row.end_date} (${row.cnt}台)`);
yearlyPeriodsMap.set(key, list);
}
const [periodRows] = await pool.execute(`
SELECT target_id,
DATE_FORMAT(assessment_start_date, '%Y-%m-%d') as start_date,
DATE_FORMAT(assessment_end_date, '%Y-%m-%d') as end_date,
COUNT(*) as cnt
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
GROUP BY target_id, assessment_start_date, assessment_end_date
ORDER BY target_id, assessment_start_date
`) as [any[], unknown];
@@ -135,7 +58,7 @@ app.get('/', async (c) => {
}
const [targetVehicleRows] = await pool.execute(
'SELECT target_id, plate_number FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0'
'SELECT target_id, plate_number FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0'
) as [{ target_id: number; plate_number: string }[], unknown];
const targetIdPlatesMap = new Map<number, string[]>();
@@ -148,44 +71,12 @@ app.get('/', async (c) => {
const now = new Date();
const result = targets.map((t: any) => {
const s = statsMap.get(t.id) || {};
const fy = firstYearMap.get(t.id) || {};
const currentYearTarget = Number(s.current_year_target) || 0;
const currentYearCompleted = Number(s.current_year_completed) || 0;
const remaining = Math.max(0, currentYearTarget - currentYearCompleted);
const yearEnd = s.year_end_date ? new Date(s.year_end_date) : now;
const daysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000));
const dailyTarget = remaining / daysLeft;
const firstYearEnd = fy.first_year_end_date ? new Date(fy.first_year_end_date) : now;
const firstYearDaysLeft = Math.max(0, Math.ceil((firstYearEnd.getTime() - now.getTime()) / 86400000));
const firstYearRemaining = Number(fy.first_year_remaining) || 0;
const firstYearVehicleCount = Number(fy.first_year_total) || 0;
const firstYearQualifiedCount = Number(fy.first_year_qualified_count) || 0;
const yearlyAssessments = (yearlyMap.get(t.id) || []).map((row: any) => {
const vehicleCount = Number(row.vehicle_count) || 0;
const qualifiedCount = Number(row.qualified_count) || 0;
const remainingMileage = Number(row.remaining_mileage) || 0;
const endDate = row.end_date ? new Date(row.end_date) : now;
const assessmentDaysLeft = Math.max(0, Math.ceil((endDate.getTime() - now.getTime()) / 86400000));
const yearNumber = Number(row.year_number) || 0;
return {
yearNumber,
label: `${yearNumber}`,
vehicleCount,
target: Number(row.target_mileage) || 0,
completed: Number(row.completed_mileage) || 0,
remaining: remainingMileage,
completionRate: (Number(row.completion_rate) || 0) * 100,
qualifiedCount,
qualifiedRate: vehicleCount > 0 ? (qualifiedCount / vehicleCount) * 100 : 0,
halfQualifiedCount: Number(row.half_qualified_count) || 0,
daysLeft: assessmentDaysLeft,
dailyTarget: assessmentDaysLeft > 0 ? Math.round((remainingMileage / assessmentDaysLeft) * 10) / 10 : 0,
startDate: row.start_date || null,
endDate: row.end_date || null,
periods: yearlyPeriodsMap.get(`${row.target_id}-${row.year_number}`) || [],
};
});
const periods = periodsMap.get(t.id) || [];
if (periods.length === 0) {
@@ -202,7 +93,7 @@ app.get('/', async (c) => {
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
assessmentYears: t.assessment_years,
periods,
todayTotal: Math.round((targetIdPlatesMap.get(t.id) || []).reduce((sum, plate) => sum + (cacheVehicleMap.get(plate) || 0), 0)),
todayTotal: (targetIdPlatesMap.get(t.id) || []).reduce((sum, plate) => sum + (cacheVehicleMap.get(plate) || 0), 0),
cumulativeTotal: Number(s.cumulative_total) || 0,
avgCompletion: (Number(s.avg_completion) || 0) * 100,
qualifiedCount: Number(s.qualified_count) || 0,
@@ -213,19 +104,6 @@ app.get('/', async (c) => {
remaining,
daysLeft,
dailyTarget: Math.round(dailyTarget * 10) / 10,
firstYearVehicleCount,
firstYearTarget: Number(fy.first_year_target) || 0,
firstYearCompleted: Number(fy.first_year_completed) || 0,
firstYearRemaining,
firstYearCompletionRate: (Number(fy.first_year_completion_rate) || 0) * 100,
firstYearQualifiedCount,
firstYearQualifiedRate: firstYearVehicleCount > 0 ? (firstYearQualifiedCount / firstYearVehicleCount) * 100 : 0,
firstYearHalfQualifiedCount: Number(fy.first_year_half_qualified_count) || 0,
firstYearDaysLeft,
firstYearDailyTarget: firstYearDaysLeft > 0 ? Math.round((firstYearRemaining / firstYearDaysLeft) * 10) / 10 : 0,
firstYearStartDate: fy.first_year_start_date || null,
firstYearEndDate: fy.first_year_end_date || null,
yearlyAssessments,
};
});
@@ -245,7 +123,7 @@ app.get('/:id/vehicles', async (c) => {
`SELECT plate_number, today_mileage, vehicle_total_mileage,
completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage
FROM lingniu_prod.tab_mileage_assessment_vehicle
FROM tab_mileage_assessment_vehicle
WHERE target_id = ? AND is_deleted = 0
ORDER BY today_mileage DESC`,
[targetId]
@@ -256,15 +134,20 @@ app.get('/:id/vehicles', async (c) => {
const dateMileageMap = new Map<string, { dailyKm: number; totalKm: number | null; isOnline: boolean }>();
if (date && plates.length > 0) {
const mileageRows = await fetchOneOsDailyMileage(date, plates);
const [mileageRows] = await mileagePool.execute(
`SELECT plate, daily_km, total_km, source FROM v_vehicle_daily_stats
WHERE stat_date = ? AND plate IN (${plates.map(() => '?').join(',')})`,
[date, ...plates]
) as [any[], unknown];
for (const m of mileageRows) {
const existing = dateMileageMap.get(m.plateNumber);
const dailyKm = m.status === 'NORMAL' ? (Number(m.dailyMileageKm) || 0) : 0;
const existing = dateMileageMap.get(m.plate);
const dailyKm = Number(m.daily_km) || 0;
if (!existing || dailyKm > existing.dailyKm) {
dateMileageMap.set(m.plateNumber, {
const source = m.source || 'NONE';
dateMileageMap.set(m.plate, {
dailyKm,
totalKm: m.totalMileageKm,
isOnline: m.status === 'NORMAL' && dailyKm > 0,
totalKm: m.total_km !== null ? Number(m.total_km) : null,
isOnline: source !== 'NONE' && dailyKm > 0,
});
}
}

View File

@@ -1,41 +1,45 @@
import { Hono } from 'hono';
import pool from '../../db.js';
import { fetchOneOsMileageDates } from './oneos-api.js';
import mileagePool from '../../mileage-db.js';
const app = new Hono();
app.get('/', async (c) => {
const targetId = c.req.query('targetId');
const days = Math.min(Math.max(Number(c.req.query('days')) || 7, 1), 366);
const days = Number(c.req.query('days')) || 7;
try {
let plates: string[] = [];
if (targetId) {
const [vehicleRows] = await pool.execute(
'SELECT plate_number FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0',
'SELECT plate_number FROM tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0',
[targetId]
) as [{ plate_number: string }[], unknown];
plates = vehicleRows.map(r => r.plate_number);
if (plates.length === 0) return c.json([]);
}
const today = new Date(new Date().toLocaleString('en-US', { timeZone: 'Asia/Shanghai' }));
today.setHours(0, 0, 0, 0);
const dates: string[] = [];
for (let offset = days; offset >= 1; offset -= 1) {
const date = new Date(today);
date.setDate(today.getDate() - offset);
dates.push(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`);
// 单车日里程负值视为脏数据(里程表回滚 / 换 GPS 设备),不纳入统计
let sql = `
SELECT DATE_FORMAT(stat_date, '%m-%d') as date,
SUM(IF(daily_km < 0, 0, daily_km)) as mileage
FROM v_vehicle_daily_stats
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) AND stat_date < CURDATE()
`;
const params: (string | number)[] = [days];
if (plates.length > 0) {
sql += ` AND plate IN (${plates.map(() => '?').join(',')})`;
params.push(...plates);
}
const rowsByDate = await fetchOneOsMileageDates(dates);
const selectedPlates = plates.length > 0 ? new Set(plates) : null;
return c.json(dates.map(date => ({
date: date.slice(5),
mileage: Math.round((rowsByDate.get(date) || []).reduce((sum, row) => {
if (selectedPlates && !selectedPlates.has(row.plateNumber)) return sum;
return sum + (row.status === 'NORMAL' ? (row.dailyMileageKm || 0) : 0);
}, 0)),
sql += ' GROUP BY stat_date ORDER BY stat_date';
const [rows] = await mileagePool.execute(sql, params) as [any[], unknown];
return c.json(rows.map((r: any) => ({
date: r.date,
mileage: Math.round(Number(r.mileage) || 0),
})));
} catch (e: unknown) {
console.error('trend error:', e);

View File

@@ -3,15 +3,8 @@ export interface CachedVehicle {
plate: string;
vin: string;
dailyKm: number;
dailyMileage?: Record<string, number>;
totalKm: number | null;
source: string;
sourceProtocol: 'GB32960' | 'MQTT' | 'JT808' | null;
sourceCategory: 'INSTRUMENT' | 'GPS' | 'MIXED' | null;
dailySourceProtocols?: Record<string, 'GB32960' | 'MQTT' | 'JT808' | null>;
dataTime: string | null;
calculatedAt: string | null;
updatedAt: string | null;
isOnline: boolean;
isDataSynced: boolean;
customer: string | null;
@@ -22,8 +15,6 @@ export interface CachedVehicle {
entity: string | null;
project: string | null;
region: string | null;
brand: string | null;
targetNames: string[];
yesterdayKm: number;
}
@@ -44,7 +35,6 @@ export interface MonitoringFilters {
platePrefixes: PlatePrefix[];
targetNames: string[];
regions: string[];
brands: string[];
}
/** 监控缓存 */
@@ -69,19 +59,15 @@ export interface MonitoringResponse {
vehicles: CachedVehicle[];
stats: MonitoringStats;
filters: MonitoringFilters;
rangeDailyTotals?: { date: string; totalKm: number }[];
dateRange?: { start: string; end: string };
total: number;
page: number;
totalPages: number;
updatedAt: string;
sourcePriority: ('instrument' | 'gps')[];
}
/** 车辆关联信息(从 lingniu_prod 查出的原始行) */
export interface VehicleInfoRow {
plate: string;
vin: string | null;
customer: string | null;
department: string | null;
manager: string | null;
@@ -89,5 +75,4 @@ export interface VehicleInfoRow {
rent_status: string | null;
entity: string | null;
project: string | null;
brand_label: string | null;
}

View File

@@ -1,68 +1,26 @@
import pool from '../../db.js';
import type { VehicleInfoRow } from './types.js';
/** 车辆关联信息 SQL客户名、部门、经理、租赁状态、主体、项目、品牌 */
/** 车辆关联信息 SQL客户名、部门、经理、租赁状态、主体、项目 */
export const VEHICLE_INFO_SQL = `SELECT
vi.plate_number AS plate,
vi.vin AS vin,
COALESCE(c.customer_name, vor.customer_name, ci.customer_name) AS customer,
COALESCE(c.business_department_name, vor.business_dept) AS department,
COALESCE(c.business_manager_name, vor.business_manager) AS manager,
CAST(COALESCE(c.business_manager_id, vi.business_id) AS CHAR) AS manager_id,
CASE vs.operation_status
WHEN '1' THEN '租赁'
WHEN '2' THEN '自营'
WHEN '3' THEN '可运营'
WHEN '4' THEN '待运营'
WHEN '5' THEN '退出运营'
ELSE vs.operation_status
END AS rent_status,
NULLIF(vi.registered_ownership, '') AS entity,
COALESCE(c.project_name, vor.project_name) AS project,
CASE vm.brand
WHEN 'hyundai' THEN CASE WHEN vm.model LIKE '%帕力安%' OR vm.model LIKE '%冷链%' OR vm.model LIKE '%双飞翼%' THEN '帕力安牌' ELSE '现代' END
WHEN 'yuejin' THEN '跃进'
WHEN 'feichi' THEN '飞驰'
WHEN 'sulong' THEN '苏龙'
WHEN 'higer' THEN '海格'
WHEN 'dongfeng' THEN '东风'
WHEN 'yutong' THEN '宇通'
WHEN 'chufeng' THEN '楚风'
WHEN 'tonghua' THEN '通华'
WHEN 'maxus' THEN '大通'
WHEN 'mingwei' THEN '明威'
WHEN 'wanfeng' THEN '万风'
WHEN 'shujie' THEN '舒捷'
WHEN 'denza' THEN '腾势'
WHEN 'hongyan' THEN '红岩'
WHEN 'yuanchang brand' THEN '远程牌'
WHEN 'others' THEN '其他'
ELSE vm.brand
END AS brand_label
FROM vehicle_info vi
LEFT JOIN vehicle_status vs
ON vs.vehicle_id = vi.id
AND vs.del_flag = 0
LEFT JOIN vehicle_model vm
ON vm.id = vi.vehicle_model_id
AND vm.del_flag = '0'
LEFT JOIN vehicle_lease_order_record vor
ON vor.vehicle_id = vi.id
AND vor.del_flag = '0'
AND vor.id = (
SELECT MAX(vor2.id)
FROM vehicle_lease_order_record vor2
WHERE vor2.vehicle_id = vi.id
AND vor2.del_flag = '0'
)
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = vor.contract_id
AND c.del_flag = '0'
LEFT JOIN customer_info ci
ON ci.id = vi.customer_id
AND ci.del_flag = '0'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') <> '5'`;
truck.plate_number AS plate,
cus.customer_name AS customer,
dep.dep_name AS department,
u.user_name AS manager,
CAST(c.bd AS CHAR) AS manager_id,
dic_status.dic_name AS rent_status,
org_truck.org_name AS entity,
c.project_name AS project
FROM tab_truck truck
LEFT JOIN tab_truck_status_info si ON si.truck_id = truck.id AND si.is_deleted = 0
LEFT JOIN tab_contract c ON c.id = si.contract_id AND c.is_deleted = 0
LEFT JOIN tab_customer cus ON cus.id = c.customer_id AND cus.is_deleted = 0
LEFT JOIN tab_user u ON u.id = c.bd AND u.is_deleted = 0
LEFT JOIN tab_department dep ON dep.id = u.dep_id AND dep.is_deleted = 0
LEFT JOIN tab_dic dic_status ON dic_status.parent_code = 'dic_truck_rent_status'
AND dic_status.dic_code = truck.truck_rent_status AND dic_status.is_deleted = 0
LEFT JOIN tab_org org_truck ON org_truck.id = truck.org_id AND org_truck.is_deleted = 0
WHERE truck.is_deleted = 0 AND truck.is_operation = 1`;
/** 查询所有车辆关联信息,返回 plate→info 的 Map */
export async function fetchVehicleInfoMap(): Promise<Map<string, VehicleInfoRow>> {
@@ -78,7 +36,7 @@ export async function fetchVehicleInfoMap(): Promise<Map<string, VehicleInfoRow>
export async function fetchVehicleInfoByPlates(plates: string[]): Promise<Map<string, VehicleInfoRow>> {
if (plates.length === 0) return new Map();
const [rows] = await pool.execute(
`${VEHICLE_INFO_SQL} AND vi.plate_number IN (${plates.map(() => '?').join(',')})`,
`${VEHICLE_INFO_SQL} AND truck.plate_number IN (${plates.map(() => '?').join(',')})`,
plates
) as [VehicleInfoRow[], unknown];
const map = new Map<string, VehicleInfoRow>();

View File

@@ -1,13 +1,14 @@
import { Hono } from 'hono';
import { fetchOneOsMileageDates } from './oneos-api.js';
import {
parseMileageSourcePriority,
protocolsForSourcePriority,
sourceCategoryFromProtocol,
} from './source-policy.js';
import mileagePool from '../../mileage-db.js';
const app = new Hono();
interface DayRow {
date: string;
daily_km: string | number | null;
source: string | null;
}
function fmt(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
@@ -28,8 +29,6 @@ const MAX_DAYS = 366;
app.get('/:plate/recent', async (c) => {
const plate = c.req.param('plate');
if (!plate) return c.json({ plate: '', days: [] }, 400);
const sourcePriority = parseMileageSourcePriority(c.req.query('sourcePriority'));
const protocolPriority = protocolsForSourcePriority(sourcePriority);
const today = new Date();
today.setHours(0, 0, 0, 0);
@@ -59,43 +58,40 @@ app.get('/:plate/recent', async (c) => {
}
try {
const dates: string[] = [];
const dateCursor = new Date(start);
while (dateCursor <= end) {
dates.push(fmt(dateCursor));
dateCursor.setDate(dateCursor.getDate() + 1);
const [rows] = await mileagePool.execute(
`SELECT DATE_FORMAT(stat_date, '%Y-%m-%d') AS date, daily_km, source
FROM v_vehicle_daily_stats
WHERE plate = ? AND stat_date >= ? AND stat_date <= ?
ORDER BY stat_date`,
[plate, fmt(start), fmt(end)]
) as [DayRow[], unknown];
// 同一 plate 同一天可能有多个数据源,取最大 daily_km
const map = new Map<string, { dailyKm: number; source: string }>();
for (const r of rows) {
const km = Number(r.daily_km) || 0;
const src = r.source || 'NONE';
const existing = map.get(r.date);
if (!existing || km > existing.dailyKm) {
map.set(r.date, { dailyKm: km, source: src });
}
}
const rowsByDate = await fetchOneOsMileageDates(dates, [plate], protocolPriority);
// 补全:从 start 到 end 每天一条
const result: {
date: string;
dailyKm: number;
isDataSynced: boolean;
sourceProtocol: 'GB32960' | 'MQTT' | 'JT808' | null;
sourceCategory: 'INSTRUMENT' | 'GPS' | null;
}[] = [];
const result: { date: string; dailyKm: number; isDataSynced: boolean }[] = [];
const cursor = new Date(start);
while (cursor <= end) {
const key = fmt(cursor);
const hit = (rowsByDate.get(key) || []).find(row => row.plateNumber === plate);
const hit = map.get(key);
result.push({
date: key,
dailyKm: hit?.status === 'NORMAL' ? (hit.dailyMileageKm || 0) : 0,
isDataSynced: hit?.status === 'NORMAL',
sourceProtocol: hit?.sourceProtocol || null,
sourceCategory: sourceCategoryFromProtocol(hit?.sourceProtocol || null),
dailyKm: hit?.dailyKm ?? 0,
isDataSynced: !!hit && hit.source !== 'NONE',
});
cursor.setDate(cursor.getDate() + 1);
}
return c.json({
plate,
start: fmt(start),
end: fmt(end),
sourcePriority,
days: result,
});
return c.json({ plate, start: fmt(start), end: fmt(end), days: result });
} catch (e: unknown) {
console.error('vehicle recent error:', e);
return c.json({ plate, days: [] }, 500);

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono';
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
import { fetchVehicleInfoMap } from '../mileage/vehicle-info.js';
import { fetchOneOsMileageDates } from '../mileage/oneos-api.js';
import { mapRegion } from '../vehicles.js';
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
import { classifyVehicle, generateSuggestions } from './algorithm.js';
@@ -28,38 +28,19 @@ function inferTypeFromTargetName(targetName: string): string {
}
/**
* Classify vehicle type from ln_asset_management.vehicle_model.
* modelRaw is vehicle_model.vehicle_type, which is not the old dic_truck_type code.
* Classify vehicle type from dic_type.dic_name (e.g. "4.5吨冷链车", "4.5吨货车", "18吨双飞翼货车").
* The typeName is the full label from the dictionary, modelRaw is the numeric dic_code.
*/
function classifyVehicleType(typeName: string, _modelRaw: string): string {
const t = (typeName || '').trim();
if (t.includes('4.5') && t.includes('冷链')) return '4.5T冷链';
if (t.includes('4.5')) return '4.5T普货';
if (t.includes('18')) return '18T';
if (t.includes('49') || t.includes('牵引')) return '49T';
if (t.includes('挂车')) return '挂车';
if (t.includes('49')) return '49T';
if (t.includes('35')) return '35T';
return t || '其他';
}
function recentCompletedDates(count: number): string[] {
const today = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
const [year, month, day] = today.split('-').map(Number);
const base = new Date(Date.UTC(year, month - 1, day));
const dates: string[] = [];
for (let offset = count; offset >= 1; offset -= 1) {
const date = new Date(base);
date.setUTCDate(base.getUTCDate() - offset);
dates.push(date.toISOString().slice(0, 10));
}
return dates;
}
// ---------------------------------------------------------------------------
// Route
// ---------------------------------------------------------------------------
@@ -73,7 +54,7 @@ app.get('/', async (c) => {
// ---- Query 1: Assessment targets ----
const [targets] = await pool.execute(
'SELECT id, target_name, annual_mileage_per_vehicle FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id',
'SELECT id, target_name, annual_mileage_per_vehicle FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id',
) as [any[], unknown];
const targetMap = new Map<number, { targetName: string; annualMileage: number }>();
@@ -90,20 +71,21 @@ app.get('/', async (c) => {
current_mileage, current_year_mileage, current_year_mileage_task,
completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage, current_year_assessment_end_date
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
`) as [any[], unknown];
// ---- Query 3: Vehicle info (customer, dept, manager) ----
const vehicleInfoMap = await fetchVehicleInfoMap();
// ---- Query 4: Vehicle types from vehicle_info ----
// ---- Query 4: Vehicle types from tab_truck ----
// Include soft-deleted trucks: many assessment vehicles have is_deleted=1 in tab_truck
// but are still active in the assessment. We need their type info.
const [truckTypeRows] = await pool.execute(`
SELECT vi.plate_number, vm.model AS type_name, vm.vehicle_type AS model_raw
FROM vehicle_info vi
LEFT JOIN vehicle_status vs ON vs.vehicle_id = vi.id AND vs.del_flag = 0
LEFT JOIN vehicle_model vm ON vm.id = vi.vehicle_model_id AND vm.del_flag = '0'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') <> '5'
SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw
FROM tab_truck truck
LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type'
AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0
WHERE truck.is_operation = 1
`) as [any[], unknown];
const truckTypeMap = new Map<string, { typeName: string; modelRaw: string }>();
@@ -132,33 +114,29 @@ app.get('/', async (c) => {
// ---- Collect all plates for Query 6 ----
const allPlates = assessmentRows.map((r: any) => r.plate_number as string);
// ---- Query 6: Customer daily avg (OneOS only) — 30d baseline + 7d recent ----
// ---- Query 6: Customer daily avg (from mileage DB) — 30d baseline + 7d recent ----
const customerAvgDailyMap = new Map<string, number>();
const customerAvgDaily7dMap = new Map<string, number>();
if (allPlates.length > 0) {
const dates = recentCompletedDates(30);
const sevenDayDates = new Set(dates.slice(-7));
const allowedPlates = new Set(allPlates);
const rowsByDate = await fetchOneOsMileageDates(dates);
const aggregates = new Map<string, { sum30: number; count30: number; sum7: number; count7: number }>();
for (const [date, rows] of rowsByDate) {
for (const row of rows) {
if (!allowedPlates.has(row.plateNumber) || row.status !== 'NORMAL' || row.dailyMileageKm === null) continue;
const current = aggregates.get(row.plateNumber) || { sum30: 0, count30: 0, sum7: 0, count7: 0 };
current.sum30 += row.dailyMileageKm;
current.count30 += 1;
if (sevenDayDates.has(date)) {
current.sum7 += row.dailyMileageKm;
current.count7 += 1;
}
aggregates.set(row.plateNumber, current);
}
}
const placeholders = allPlates.map(() => '?').join(',');
// Single query returning both windows per plate.
const [dailyRows] = await mileagePool.execute(
`SELECT plate,
AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) THEN daily_km END) AS avg_30d,
AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN daily_km END) AS avg_7d
FROM v_vehicle_daily_stats
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
AND stat_date < CURDATE()
AND plate IN (${placeholders})
GROUP BY plate`,
allPlates,
) as [any[], unknown];
const plateAvg30Map = new Map<string, number>();
const plateAvg7Map = new Map<string, number>();
for (const [plate, values] of aggregates) {
if (values.count30 > 0) plateAvg30Map.set(plate, values.sum30 / values.count30);
if (values.count7 > 0) plateAvg7Map.set(plate, values.sum7 / values.count7);
for (const row of dailyRows) {
if (row.avg_30d !== null) plateAvg30Map.set(row.plate, Number(row.avg_30d));
if (row.avg_7d !== null) plateAvg7Map.set(row.plate, Number(row.avg_7d));
}
const customerPlates30 = new Map<string, number[]>();
@@ -183,13 +161,12 @@ app.get('/', async (c) => {
// ---- Query 7: Inventory vehicles (rent_status = 0) ----
const [inventoryTruckRows] = await pool.execute(`
SELECT vi.plate_number, vm.model AS type_name, vm.vehicle_type AS model_raw
FROM vehicle_info vi
LEFT JOIN vehicle_status vs ON vs.vehicle_id = vi.id AND vs.del_flag = 0
LEFT JOIN vehicle_model vm ON vm.id = vi.vehicle_model_id AND vm.del_flag = '0'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') IN ('3','4')
AND COALESCE(vs.vehicle_status, '') <> '4'
SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw
FROM tab_truck truck
LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type'
AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0
WHERE truck.is_deleted = 0 AND truck.is_operation = 1
AND truck.truck_rent_status = 0
`) as [any[], unknown];
// ---- Build assessment vehicle lookup for inventory cross-reference ----

View File

@@ -1,299 +0,0 @@
import { Hono } from 'hono';
import pool from '../db.js';
import heatmapPool from '../heatmap-db.js';
import { getCache } from './mileage/cache.js';
type HeatmapRecord = {
date: string;
vin: string;
plate: string;
time: string;
longitude: number;
latitude: number;
source: string;
sourceRecordId: string;
};
type VehicleRank = {
vin: string;
plateNumber: string;
locationCount: number;
firstSeen: string;
lastSeen: string;
};
type MetaRow = {
start_date: string;
end_date: string;
location_count: string;
vehicle_count: string;
day_count: string;
total_location_count: string;
excluded_location_count: string;
outside_mainland_count: string;
tibet_count: string;
};
type VehicleOptionRow = {
vin: string;
plate_number: string;
};
type RecordRow = {
date: string;
vin: string;
plate: string;
time: string;
longitude: number;
latitude: number;
source: string;
source_record_id: string;
};
type BatchModelRow = {
target_name: string;
plate_number: string;
};
function isDate(value: string | undefined): value is string {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
}
function daysInclusive(startDate: string, endDate: string): number {
const start = Date.parse(`${startDate}T00:00:00+08:00`);
const end = Date.parse(`${endDate}T00:00:00+08:00`);
return Math.max(0, Math.floor((end - start) / 86_400_000) + 1);
}
async function loadBatchModelPlates(): Promise<Map<string, Set<string>>> {
const cached = getCache()?.targetPlatesMap;
if (cached?.size) return cached;
try {
const [rows] = await pool.execute(`
select t.target_name, v.plate_number
from lingniu_prod.tab_mileage_assessment_target t
join lingniu_prod.tab_mileage_assessment_vehicle v
on v.target_id = t.id and v.is_deleted = 0
where t.is_deleted = 0
`) as [BatchModelRow[], unknown];
const result = new Map<string, Set<string>>();
for (const row of rows) {
const plates = result.get(row.target_name) || new Set<string>();
plates.add(row.plate_number);
result.set(row.target_name, plates);
}
return result;
} catch (error) {
console.error('[vehicle-heatmap] batch model lookup failed', error);
return new Map();
}
}
async function loadRecords(
startDate: string,
endDate: string,
query: string,
batchModel: string,
): Promise<HeatmapRecord[]> {
let batchPlates: string[] = [];
if (batchModel) {
const modelPlates = await loadBatchModelPlates();
batchPlates = [...(modelPlates.get(batchModel) || [])];
if (batchPlates.length === 0) return [];
}
const result = await heatmapPool.query<RecordRow>(`
select
to_char(stat_date, 'YYYY-MM-DD') as date,
vin,
plate_number as plate,
to_char(first_event_time at time zone 'Asia/Shanghai',
'YYYY-MM-DD HH24:MI:SS') as time,
longitude,
latitude,
source_name as source,
coalesce(source_record_id::text, '') as source_record_id
from analytics.vehicle_daily_first_location
where stat_date between $1::date and $2::date
and is_heatmap_eligible
and ($4::boolean = false or plate_number = any($5::text[]))
and (
$3 = ''
or upper(vin) like '%' || upper($3) || '%'
or upper(plate_number) like '%' || upper($3) || '%'
)
order by stat_date, vin
`, [startDate, endDate, query.trim(), Boolean(batchModel), batchPlates]);
return result.rows.map((row) => ({
...row,
longitude: Number(row.longitude),
latitude: Number(row.latitude),
sourceRecordId: row.source_record_id,
}));
}
function buildRanking(records: HeatmapRecord[], limit = 10): VehicleRank[] {
const vehicles = new Map<string, VehicleRank>();
for (const record of records) {
const current = vehicles.get(record.vin);
if (!current) {
vehicles.set(record.vin, {
vin: record.vin,
plateNumber: record.plate,
locationCount: 1,
firstSeen: record.time,
lastSeen: record.time,
});
continue;
}
current.locationCount += 1;
if (record.time < current.firstSeen) current.firstSeen = record.time;
if (record.time > current.lastSeen) current.lastSeen = record.time;
if (!current.plateNumber && record.plate) current.plateNumber = record.plate;
}
return [...vehicles.values()]
.sort((left, right) => right.locationCount - left.locationCount
|| left.plateNumber.localeCompare(right.plateNumber, 'zh-CN'))
.slice(0, limit);
}
function haversineKm(leftLng: number, leftLat: number, rightLng: number, rightLat: number): number {
const radians = (degrees: number) => degrees * Math.PI / 180;
const earthRadius = 6371;
const dLat = radians(rightLat - leftLat);
const dLng = radians(rightLng - leftLng);
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(radians(leftLat)) * Math.cos(radians(rightLat)) * Math.sin(dLng / 2) ** 2;
return earthRadius * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
const router = new Hono();
router.get('/config', (c) => {
const key = process.env.AMAP_WEB_KEY;
const securityCode = process.env.AMAP_SECURITY_JS_CODE;
if (!key || !securityCode) {
return c.json({ error: '高德地图配置缺失' }, 503);
}
return c.json({ key, securityCode });
});
router.get('/meta', async (c) => {
const [metaResult, vehiclesResult, batchModelPlates] = await Promise.all([
heatmapPool.query<MetaRow>(`
select
to_char(min(stat_date) filter (where is_heatmap_eligible), 'YYYY-MM-DD') as start_date,
to_char(max(stat_date) filter (where is_heatmap_eligible), 'YYYY-MM-DD') as end_date,
count(*) filter (where is_heatmap_eligible)::text as location_count,
count(distinct vin) filter (where is_heatmap_eligible)::text as vehicle_count,
count(distinct stat_date) filter (where is_heatmap_eligible)::text as day_count,
count(*)::text as total_location_count,
count(*) filter (where not is_heatmap_eligible)::text as excluded_location_count,
count(*) filter (where exclusion_reason = 'outside_mainland_china')::text as outside_mainland_count,
count(*) filter (where exclusion_reason = 'tibet')::text as tibet_count
from analytics.vehicle_daily_first_location
`),
heatmapPool.query<VehicleOptionRow>(`
select distinct on (vin) vin, plate_number
from analytics.vehicle_daily_first_location
where is_heatmap_eligible
order by vin, stat_date desc
`),
loadBatchModelPlates(),
]);
const meta = metaResult.rows[0];
const vehicles = vehiclesResult.rows
.map(({ vin, plate_number: plateNumber }) => ({ vin, plateNumber }))
.sort((left, right) => left.plateNumber.localeCompare(right.plateNumber, 'zh-CN'));
return c.json({
startDate: meta.start_date,
endDate: meta.end_date,
locationCount: Number(meta.location_count),
vehicleCount: Number(meta.vehicle_count),
dayCount: Number(meta.day_count),
totalLocationCount: Number(meta.total_location_count),
excludedLocationCount: Number(meta.excluded_location_count),
outsideMainlandCount: Number(meta.outside_mainland_count),
tibetCount: Number(meta.tibet_count),
batchModels: [...batchModelPlates.keys()].sort((left, right) => left.localeCompare(right, 'zh-CN')),
vehicles,
});
});
router.get('/points', async (c) => {
const requestedStart = c.req.query('startDate');
const requestedEnd = c.req.query('endDate');
const startDate = isDate(requestedStart) ? requestedStart : '2026-01-01';
const endDate = isDate(requestedEnd) ? requestedEnd : '2026-07-13';
if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400);
const query = c.req.query('query') || '';
const batchModel = (c.req.query('batchModel') || '').trim();
const metric = c.req.query('metric') === 'vehicles' ? 'vehicles' : 'locations';
const records = await loadRecords(startDate, endDate, query, batchModel);
const precision = daysInclusive(startDate, endDate) <= 14 || Boolean(query || batchModel) ? 3 : 2;
const grid = new Map<string, { lng: number; lat: number; count: number; vins?: Set<string> }>();
for (const record of records) {
const lng = Number(record.longitude.toFixed(precision));
const lat = Number(record.latitude.toFixed(precision));
const key = `${lng},${lat}`;
let point = grid.get(key);
if (!point) {
point = { lng, lat, count: 0, vins: metric === 'vehicles' ? new Set<string>() : undefined };
grid.set(key, point);
}
if (point.vins) {
point.vins.add(record.vin);
point.count = point.vins.size;
} else {
point.count += 1;
}
}
const points = [...grid.values()].map(({ lng, lat, count }) => ({ lng, lat, count }));
let max = 1;
for (const point of points) max = Math.max(max, point.count);
return c.json({
startDate,
endDate,
metric,
locationCount: records.length,
vehicleCount: new Set(records.map((record) => record.vin)).size,
dayCount: new Set(records.map((record) => record.date)).size,
points,
max,
topVehicles: buildRanking(records),
});
});
router.get('/nearby', async (c) => {
const longitude = Number(c.req.query('lng'));
const latitude = Number(c.req.query('lat'));
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) {
return c.json({ error: '经纬度参数无效' }, 400);
}
const startDate = isDate(c.req.query('startDate')) ? c.req.query('startDate')! : '2026-01-01';
const endDate = isDate(c.req.query('endDate')) ? c.req.query('endDate')! : '2026-07-13';
const query = c.req.query('query') || '';
const batchModel = (c.req.query('batchModel') || '').trim();
const radiusKm = Math.min(200, Math.max(5, Number(c.req.query('radiusKm')) || 50));
const records = await loadRecords(startDate, endDate, query, batchModel);
const nearby = records.filter((record) => (
haversineKm(longitude, latitude, record.longitude, record.latitude) <= radiusKm
));
return c.json({
center: { lng: longitude, lat: latitude },
radiusKm,
locationCount: nearby.length,
vehicleCount: new Set(nearby.map((record) => record.vin)).size,
topVehicles: buildRanking(nearby),
});
});
export default router;

View File

@@ -17,101 +17,75 @@ import type { Context } from 'hono';
const app = new Hono();
const MAIN_SQL = `SELECT
CAST(vi.id AS CHAR) AS id,
vi.plate_number AS 车牌号,
vi.vin AS vin,
vm.brand AS 车辆品牌,
vm.model AS 车辆型号,
vi.body_color AS 车辆颜色,
vi.rental_company AS 租赁公司,
CASE vi.vehicle_source
WHEN '0' THEN '自有'
WHEN '1' THEN '挂靠'
WHEN '2' THEN '外租'
WHEN '3' THEN '自有'
ELSE vi.actual_ownership
END AS 车辆归属状态Label,
vm.model AS 车辆型号Label,
vm.vehicle_type AS 车辆类型参数,
vi.operation_city AS 库存区域,
vs.vehicle_status AS 车辆租赁状态,
vs.operation_status AS 车辆租赁状态Label,
CASE WHEN COALESCE(vs.operation_status, '') = '5' THEN 0 ELSE 1 END AS 是否营运,
COALESCE(info_province.NAME, NULLIF(info.province, ''), vi.province_name, vi_province.NAME, NULLIF(vi.province, '')) AS 省,
COALESCE(info_city.NAME, NULLIF(info.city, ''), vi.city_name, vi_city.NAME, vi_operation_city.NAME, NULLIF(vi.city, ''), NULLIF(vi.operation_city, '')) AS 市,
CAST(truck.id AS CHAR) AS id,
truck.plate_number AS 车牌号,
truck.vin AS vin,
truck.brand AS 车辆品牌,
truck.model AS 车辆型号,
truck.color AS 车辆颜色,
truck.rent_from_company AS 租赁公司,
dic_ascription_status.dic_name AS 车辆归属状态Label,
dic_type.dic_name AS 车辆型号Label,
truck.stock_area AS 库存区域,
truck.truck_rent_status AS 车辆租赁状态,
dic_status.dic_name AS 车辆租赁状态Label,
truck.is_operation AS 是否营运,
info.province AS 省,
info.city AS 市,
info.lat AS 纬度,
info.lng AS 经度,
CASE vm.brand
WHEN 'hyundai' THEN CASE WHEN vm.model LIKE '%帕力安%' OR vm.model LIKE '%冷链%' OR vm.model LIKE '%双飞翼%' THEN '帕力安牌' ELSE '现代' END
WHEN 'yuejin' THEN '跃进'
WHEN 'feichi' THEN '飞驰'
WHEN 'sulong' THEN '苏龙'
WHEN 'higer' THEN '海格'
WHEN 'dongfeng' THEN '东风'
WHEN 'yutong' THEN '宇通'
WHEN 'chufeng' THEN '楚风'
WHEN 'tonghua' THEN '通华'
WHEN 'maxus' THEN '大通'
WHEN 'mingwei' THEN '明威'
WHEN 'wanfeng' THEN '万风'
WHEN 'shujie' THEN '舒捷'
WHEN 'denza' THEN '腾势'
WHEN 'hongyan' THEN '红岩'
WHEN 'yuanchang brand' THEN '远程牌'
WHEN 'others' THEN '其他'
ELSE vm.brand
END AS 车辆品牌Label,
c.id AS 合同ID,
COALESCE(c.contract_code, vor.contract_code, vi.contract_code) AS 合同编码,
COALESCE(c.customer_name, vor.customer_name, ci.customer_name) AS 客户名称,
c.signing_company AS 合同归属公司,
COALESCE(c.business_department_name, vor.business_dept) AS 合同归属部门,
NULLIF(vi.registered_ownership, '') AS 主体,
COALESCE(c.project_name, vor.project_name) AS 项目名称,
COALESCE(c.business_manager_name, vor.business_manager) AS 客户经理,
CAST(COALESCE(c.business_manager_id, vi.business_id) AS CHAR) AS 经理ID
FROM vehicle_info vi
LEFT JOIN vehicle_status vs
ON vs.vehicle_id = vi.id
AND vs.del_flag = 0
LEFT JOIN vehicle_model vm
ON vm.id = vi.vehicle_model_id
AND vm.del_flag = '0'
dic_brand.dic_name AS 车辆品牌Label,
si.contract_id AS 合同ID,
COALESCE(c.contract_no, si.contract_no) AS 合同编码,
cus.customer_name AS 客户名称,
org.org_name AS 合同归属公司,
dep.dep_name AS 合同归属部门,
org_truck.org_name AS 主体,
c.project_name AS 项目名称,
u.user_name AS 客户经理,
CAST(c.bd AS CHAR) AS 经理ID
FROM tab_truck truck
LEFT JOIN tab_truck_remote_sync_realtime_info info
ON info.plate_number = vi.plate_number
AND info.is_deleted = 0
LEFT JOIN common_district info_province
ON info_province.CODE = info.province COLLATE utf8mb4_unicode_ci
AND info_province.STATUS = 'VALID'
LEFT JOIN common_district info_city
ON info_city.CODE = info.city COLLATE utf8mb4_unicode_ci
AND info_city.STATUS = 'VALID'
LEFT JOIN common_district vi_province
ON vi_province.CODE = vi.province COLLATE utf8mb4_unicode_ci
AND vi_province.STATUS = 'VALID'
LEFT JOIN common_district vi_city
ON vi_city.CODE = vi.city COLLATE utf8mb4_unicode_ci
AND vi_city.STATUS = 'VALID'
LEFT JOIN common_district vi_operation_city
ON vi_operation_city.CODE = vi.operation_city COLLATE utf8mb4_unicode_ci
AND vi_operation_city.STATUS = 'VALID'
LEFT JOIN vehicle_lease_order_record vor
ON vor.vehicle_id = vi.id
AND vor.del_flag = '0'
AND vor.id = (
SELECT MAX(vor2.id)
FROM vehicle_lease_order_record vor2
WHERE vor2.vehicle_id = vi.id
AND vor2.del_flag = '0'
)
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = vor.contract_id
AND c.del_flag = '0'
LEFT JOIN customer_info ci
ON ci.id = vi.customer_id
AND ci.del_flag = '0'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') <> '5'`;
ON info.id = truck.id
LEFT JOIN tab_dic dic_type
ON dic_type.parent_code = 'dic_truck_type'
AND dic_type.dic_code = truck.model
AND dic_type.is_deleted = 0
LEFT JOIN tab_dic dic_status
ON dic_status.parent_code = 'dic_truck_rent_status'
AND dic_status.dic_code = truck.truck_rent_status
AND dic_status.is_deleted = 0
LEFT JOIN tab_dic dic_brand
ON dic_brand.parent_code = 'dic_vehicle_brand'
AND dic_brand.dic_code = truck.brand
AND dic_brand.is_deleted = 0
LEFT JOIN tab_truck_status_info si
ON si.truck_id = truck.id
AND si.is_deleted = 0
LEFT JOIN tab_contract c
ON c.id = si.contract_id
AND c.is_deleted = 0
LEFT JOIN tab_customer cus
ON cus.id = c.customer_id
AND cus.is_deleted = 0
LEFT JOIN tab_org org
ON org.id = c.org_id
AND org.is_deleted = 0
LEFT JOIN tab_org org_truck
ON org_truck.id = truck.org_id
AND org_truck.is_deleted = 0
LEFT JOIN tab_dic dic_ascription_status
ON dic_ascription_status.parent_code = 'dic_truck_ascription_status'
AND dic_ascription_status.dic_code = truck.ascription_status
AND dic_ascription_status.is_deleted = 0
LEFT JOIN tab_user u
ON u.id = c.bd
AND u.is_deleted = 0
LEFT JOIN tab_department dep
ON dep.id = u.dep_id
AND dep.is_deleted = 0
WHERE truck.is_deleted = 0
AND truck.is_operation = 1`;
// Region mapping: province/city -> display region
const REGIONS = ['嘉兴', '广东', '北京', '新疆', '其他'] as const;
@@ -174,33 +148,23 @@ function countByType(vehicles: Vehicle[]): VehicleTypeCounts {
return counts;
}
// Map operation status to frontend status.
// ln_asset_management.vehicle_status.operation_status:
// 1=租赁, 2=自营, 3=可运营, 4=待运营, 5=退出运营.
function mapStatus(operationStatus: string | null, vehicleStatus: string | null): 'Operating' | 'Inventory' | 'Pending' | 'Abnormal' {
const op = (operationStatus || '').trim();
const vehicle = (vehicleStatus || '').trim();
if (vehicle === '4') return 'Pending';
if (vehicle === '14') return 'Abnormal';
if (op === '1' || op === '2') return 'Operating';
if (op === '3' || op === '4') return 'Inventory';
if (op === '租赁' || op === '自营') return 'Operating';
if (op === '可运营' || op === '待运营' || op === '在库') return 'Inventory';
if (op === '异动') return 'Abnormal';
// Map rental status to frontend status
// Actual DB values: 在库(0), 自营(1), 租赁(2), 待交车(7), 挂靠(8), 异动(12)
function mapStatus(rentStatus: string | null): 'Operating' | 'Inventory' | 'Pending' | 'Abnormal' {
if (!rentStatus) return 'Inventory';
const s = rentStatus.trim();
if (s === '租赁' || s === '自营' || s === '挂靠') return 'Operating';
if (s === '在库') return 'Inventory';
if (s === '待交车') return 'Pending';
if (s === '异动') return 'Abnormal';
return 'Inventory';
}
// Map ownership from vehicle_info.vehicle_source.
// ln_asset_management vehicle_source values: 1=挂靠, 2=外租, 3=自有. Keep 0=自有 for legacy rows.
// Map ownership from truck_rent_status (rentStatusLabel)
// DB values: 自营(1), 租赁(2), 挂靠(8) → these are the operating subtypes
function mapOwnership(rentStatusLabel: string | null): string {
if (!rentStatusLabel) return 'Unknown';
const s = rentStatusLabel.trim();
if (s === '0') return 'Self';
if (s === '1') return 'Hanging';
if (s === '2') return 'Leased';
if (s === '3') return 'Self';
if (s === '自有') return 'Self';
if (s === '外租') return 'Leased';
if (s === '自营') return 'Self';
if (s === '租赁') return 'Leased';
if (s === '挂靠') return 'Hanging';
@@ -215,41 +179,27 @@ function resolveCity(city: string | null, province: string | null): string {
return p || '其他';
}
// Derive page category from ln_asset_management.vehicle_model.
// vehicle_type is a new-system category code, not the old lingniu_prod.dic_truck_type code:
// 1 = 4.5T, 2 = 18T / other truck-like models, 3 = tractor head, 5/6 = trailers.
function deriveType(modelLabel: string | null, vehicleTypeCode: string | null): string {
// Derive vehicle type category from model label
// Actual DB values: 4.5吨冷链车, 4.5吨货车, 18吨双飞翼货车, 18吨厢式货车, 49吨牵引车头, 35吨牵引车头,
// 重型集装箱半挂车, 重型平板半挂车, 氢能叉车, SJ型蓄电池观光车, 公务用车/小客车, 挂靠油车
function deriveType(modelLabel: string | null, brandLabel: string | null): string {
const label = (modelLabel || '').trim();
const code = (vehicleTypeCode || '').trim();
if (label.includes('半挂车') || code === '5' || code === '6') return '挂车';
if (label.includes('4.5吨')) return '4.5T';
if (label.includes('18吨')) return '18T';
if (label.includes('49吨')) return '49T';
if (label.includes('35吨')) return '35T';
if (code === '1') return '4.5T';
if (code === '3') return '49T';
if (label.includes('叉车')) return '叉车';
if (label.includes('半挂车')) return '挂车';
return '其他车型';
}
function normalizeModelLabel(modelLabel: string | null): string | null {
const label = (modelLabel || '').trim();
if (label === '帕力安牌4.5吨冷链车') return '4.5吨冷链车';
if (label === '帕力安牌18吨双飞翼货车') return '18吨双飞翼货车';
if (label === '海格牌18吨双飞翼货车') return '18吨双飞翼货车';
return label || null;
}
// Tag → alias mapping with sort order
// tag is generated as: brand-modelLabel-color[+rentCompany if 外租]
// Some tags are merged (e.g. 嘉氢 red + 嘉氢 blue/green → one alias)
const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = {
// 4.5T 普货
'现代-4.5吨货车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T普货(交投)', order: 101 },
'现代-4.5吨货车-白恒运': { alias: '现代4.5T普货(恒运)', order: 102 },
'现代-4.5吨货车-白色恒运': { alias: '现代4.5T普货(恒运)', order: 102 },
'现代-4.5吨货车-白色': { alias: '现代4.5T普货', order: 101 },
'现代-4.5吨货车-白': { alias: '现代4.5T普货', order: 101 },
'现代-4.5吨货车-白': { alias: '现代4.5T普货(恒运)', order: 102 },
// 4.5T 冷链
'帕力安牌-4.5吨冷链车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T冷链(交投)', order: 201 },
'帕力安牌-4.5吨冷链车-白色': { alias: '现代4.5T冷链(羚牛)', order: 202 },
@@ -262,16 +212,13 @@ const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = {
'苏龙-18吨双飞翼货车-白色': { alias: '苏龙18T飞翼', order: 304 }, // dirty data, merge
'苏龙-18吨双飞翼货车-白安吉天地物流科技有限公司': { alias: '苏龙18T飞翼安吉', order: 305 },
'帕力安牌-18吨双飞翼货车-白': { alias: '现代18T双飞翼羚牛', order: 306 },
'帕力安牌-18吨双飞翼货车-白/绿': { alias: '现代18T双飞翼羚牛', order: 306 },
// 49T
'宇通-49吨牵引车头-白': { alias: '49T宇通', order: 401 },
'飞驰-49吨牵引车头-白/蓝/绿': { alias: '49T飞驰', order: 402 },
'飞驰-49吨牵引车头-白/蓝/绿嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰嘉氢', order: 403 },
'飞驰-49吨牵引车头-红': { alias: '49T飞驰', order: 402 },
'飞驰-49吨牵引车头-红嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰嘉氢', order: 403 }, // merge with above
'飞驰-49吨牵引车头-红浙江氢能产业发展有限公司': { alias: '49T飞驰浙氢-红)', order: 404 },
'飞驰-49吨牵引车头-白/蓝/绿浙江氢能产业发展有限公司': { alias: '49T飞驰浙氢-蓝白绿)', order: 405 },
'楚风-49吨牵引车头-蓝/黑': { alias: '49T楚风', order: 406 },
'楚风-49吨牵引车头-蓝/黑海珀特科技(北京)有限公司': { alias: '49T楚风海珀特', order: 406 },
// 其他
'红岩-35吨牵引车头-红色': { alias: '35T油车', order: 501 },
@@ -285,11 +232,9 @@ const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = {
'万风-重型平板半挂车-红': { alias: '挂车', order: 503 },
'舒捷-SJ型蓄电池观光车-蓝白': { alias: '观光车', order: 504 },
'东风-挂靠油车-白色': { alias: '公务车/挂靠车', order: 505 },
'东风-挂靠油车-白': { alias: '公务车/挂靠车', order: 505 },
'腾势-公务用车/小客车-黑': { alias: '公务车/挂靠车', order: 505 },
'腾势-公务用车/小客车-白': { alias: '公务车/挂靠车', order: 505 },
'其他-公务用车/小客车-蓝色': { alias: '公务车/挂靠车', order: 505 },
'其他-公务用车/小客车-灰': { alias: '公务车/挂靠车', order: 505 },
'远程牌-公务用车/小客车-白': { alias: '公务车/挂靠车', order: 505 },
'大通-公务用车/小客车-灰': { alias: '公务车/挂靠车', order: 505 },
};
@@ -302,9 +247,8 @@ function deriveModelTag(
rentCompany: string | null,
): string {
const brand = (brandLabel || '').trim();
const model = (normalizeModelLabel(modelLabel) || '').trim();
const model = (modelLabel || '').trim();
const c = (color || '').trim();
if (model === '公务用车/小客车') return '公务车/挂靠车';
const isRented = ownershipLabel?.trim() === '外租';
const company = isRented ? (rentCompany || '').trim() : '';
@@ -328,16 +272,15 @@ function transformRow(row: VehicleRow): Vehicle {
id: row.id,
plateNumber: row.车牌号 || '',
vin: row.vin || '',
type: deriveType(row.Label, row.),
type: deriveType(row.Label, row.Label),
model: deriveModelTag(row.Label, row.Label, row., row.Label, row.),
color: row.车辆颜色 || '',
location: region,
region,
province: row.省,
city: row.市,
status: mapStatus(row.Label, row.),
operationStatus: row.车辆租赁状态Label,
ownership: mapOwnership(row.Label),
status: mapStatus(row.Label),
ownership: mapOwnership(row.Label),
rentCompany: row.租赁公司 || '',
contractNo: row.合同编码,
customerName: row.客户名称,
@@ -375,7 +318,7 @@ async function getVehiclesForUser(c: Context): Promise<Vehicle[]> {
return maskCustomerNames(list);
}
// 归属公司筛选(所属公司 = vehicle_info.registered_ownership, 即 Vehicle.subjectOrg
// 归属公司筛选(所属公司 = tab_truck.org_id → org_name, 即 Vehicle.subjectOrg
function getSubjectParam(c: Context): string | null {
const raw = (c.req.query('subject') || '').trim();
return raw ? raw : null;
@@ -411,27 +354,24 @@ async function getWeeklyTruckIds(): Promise<WeeklyTruckIds> {
}
const [[pendingRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([
pool.query<any[]>(`SELECT CAST(vehicle_id AS CHAR) AS truck_id
FROM vehicle_status
WHERE del_flag=0 AND vehicle_status='4' AND COALESCE(operation_status, '') <> '5'`),
pool.query<any[]>(`SELECT CAST(vehicle_id AS CHAR) AS truck_id
FROM delivery_vehicle
WHERE del_flag='0'
AND vehicle_id IS NOT NULL
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}
AND delivery_status IN (2,3,5)`),
pool.query<any[]>(`SELECT CAST(vehicle_id AS CHAR) AS truck_id
FROM return_vehicle_task
WHERE del_flag='0'
AND vehicle_id IS NOT NULL
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}
AND status IN (2,3,5)`),
pool.query<any[]>(`SELECT CAST(new_vehicle_id AS CHAR) AS truck_id
FROM vehicle_replacement
WHERE del_flag='0'
AND new_vehicle_id IS NOT NULL
AND replace_time >= ${WEEK_START_SQL} AND replace_time < ${WEEK_END_SQL}
AND status=20`),
pool.query<any[]>(`SELECT CAST(id AS CHAR) AS truck_id FROM tab_truck WHERE is_deleted=0 AND is_operation=1 AND truck_rent_status=7`),
pool.query<any[]>(`SELECT CAST(rent_truck.truck_id AS CHAR) AS truck_id FROM tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL
AND task.task_type=1 AND task.task_status=1 AND take.update_time IS NOT NULL
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT CAST(rent_truck.truck_id AS CHAR) AS truck_id FROM tab_truck_rent_return r
LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
WHERE r.is_deleted=0 AND r.return_date IS NOT NULL
AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT CAST(rent_truck.truck_id AS CHAR) AS truck_id FROM tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL
AND task.task_type=3 AND task.task_status=1 AND take.update_time IS NOT NULL
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`),
]);
const toSet = (rows: any[]) => new Set((rows as any[]).map((r) => String(r.truck_id)).filter((s) => s && s !== 'null'));
@@ -468,59 +408,6 @@ function getStats(list: Vehicle[], weeklyIds?: WeeklyTruckIds) {
const WEEK_START_SQL = `DATE_SUB(CURDATE(), INTERVAL (WEEKDAY(CURDATE()) + 2) % 7 DAY)`;
const WEEK_END_SQL = `DATE_ADD(${WEEK_START_SQL}, INTERVAL 7 DAY)`;
type FlowType = 'delivered' | 'returned' | 'replaced';
interface FlowDetailRow {
id: string;
type: FlowType;
type_label: string;
stat_date: string;
truck_id: string;
plate_number: string;
event_time: string | null;
submit_time: string | null;
department: string | null;
manager: string | null;
customer_name: string | null;
}
function formatDateOnly(value: Date): string {
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, '0');
const d = String(value.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function isDateParam(value: string | undefined | null): value is string {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
}
function addDateDays(date: string, days: number): string {
const d = new Date(`${date}T00:00:00`);
d.setDate(d.getDate() + days);
return formatDateOnly(d);
}
function listDateRange(start: string, end: string): string[] {
const dates: string[] = [];
let cursor = start;
while (cursor <= end && dates.length <= 370) {
dates.push(cursor);
cursor = addDateDays(cursor, 1);
}
return dates;
}
function normalizeDateRange(startRaw: string | undefined, endRaw: string | undefined): { start: string; end: string } {
const today = formatDateOnly(new Date());
const defaultStart = addDateDays(today, -29);
let start = isDateParam(startRaw) ? startRaw : defaultStart;
let end = isDateParam(endRaw) ? endRaw : today;
if (start > end) [start, end] = [end, start];
if (listDateRange(start, end).length > 370) start = addDateDays(end, -369);
return { start, end };
}
interface WeeklyStats {
pendingDelivery: number;
weeklyNew: number;
@@ -532,54 +419,59 @@ interface WeeklyStats {
// 交车单 SQL
const DELIVERED_SQL = `SELECT
dv.id, DATE(dv.delivery_time) AS handover_date,
CAST(dv.vehicle_id AS CHAR) AS truck_id, dv.plate_number,
c.contract_type,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM delivery_vehicle dv
LEFT JOIN delivery_task_subject dts
ON dts.id = dv.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = dv.contract_id
AND c.del_flag = '0'
WHERE dv.del_flag = '0'
AND dv.vehicle_id IS NOT NULL
AND dv.delivery_time IS NOT NULL
AND dv.delivery_status IN (2,3,5)`;
take.id, DATE(take.handover_date) AS handover_date,
CAST(truck.id AS CHAR) AS truck_id, truck.plate_number,
dic_contract_type.dic_name AS contract_type,
customer.customer_name
FROM tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
LEFT JOIN tab_contract contract ON task.contract_id = contract.id
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
LEFT JOIN tab_dic dic_contract_type
ON dic_contract_type.parent_code = 'dic_contract_type'
AND dic_contract_type.dic_code = contract.contract_type
AND dic_contract_type.is_deleted = 0
WHERE take.is_deleted = 0 AND take.take_name IS NOT NULL
AND task.task_type = 1 AND task.task_status = 1 AND take.update_time IS NOT NULL`;
// 还车单 SQL
const RETURNED_SQL = `SELECT
r.id, DATE(r.arrival_time) AS handover_date,
CAST(r.vehicle_id AS CHAR) AS truck_id, r.plate_number,
c.contract_type,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM return_vehicle_task r
LEFT JOIN delivery_task_subject dts
ON dts.id = r.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = r.contract_id
AND c.del_flag = '0'
WHERE r.del_flag = '0'
AND r.vehicle_id IS NOT NULL
AND r.arrival_time IS NOT NULL
AND r.status IN (2,3,5)`;
r.id, DATE(r.return_date) AS handover_date,
CAST(truck.id AS CHAR) AS truck_id, truck.plate_number,
dic_contract_type.dic_name AS contract_type,
customer.customer_name
FROM tab_truck_rent_return r
LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
LEFT JOIN tab_contract contract ON task.contract_id = contract.id
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
LEFT JOIN tab_dic dic_contract_type
ON dic_contract_type.parent_code = 'dic_contract_type'
AND dic_contract_type.dic_code = contract.contract_type
AND dic_contract_type.is_deleted = 0
WHERE r.is_deleted = 0 AND r.return_date IS NOT NULL`;
// 替换车单 SQL
const REPLACED_SQL = `SELECT
vr.id, DATE(vr.replace_time) AS handover_date,
CAST(vr.new_vehicle_id AS CHAR) AS truck_id, vr.new_vehicle_plate AS plate_number,
c.contract_type,
c.customer_name
FROM vehicle_replacement vr
LEFT JOIN vehicle_lease_contract_info c
ON c.id = vr.contract_id
AND c.del_flag = '0'
WHERE vr.del_flag = '0'
AND vr.new_vehicle_id IS NOT NULL
AND vr.replace_time IS NOT NULL
AND vr.status = 20`;
take.id, DATE(take.handover_date) AS handover_date,
CAST(truck.id AS CHAR) AS truck_id, truck.plate_number,
dic_contract_type.dic_name AS contract_type,
customer.customer_name
FROM tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
LEFT JOIN tab_contract contract ON task.contract_id = contract.id
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
LEFT JOIN tab_dic dic_contract_type
ON dic_contract_type.parent_code = 'dic_contract_type'
AND dic_contract_type.dic_code = contract.contract_type
AND dic_contract_type.is_deleted = 0
WHERE take.is_deleted = 0 AND take.take_name IS NOT NULL
AND task.task_type = 3 AND task.task_status = 1 AND take.update_time IS NOT NULL`;
let cachedWeeklyStats: WeeklyStats | null = null;
let weeklyStatsLastFetch = 0;
@@ -591,18 +483,23 @@ async function getWeeklyStats(): Promise<WeeklyStats> {
}
const [[pendingRows], [newRows], [removedRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_status WHERE del_flag=0 AND vehicle_status='4' AND COALESCE(operation_status, '') <> '5'`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_info WHERE del_flag='0' AND create_time >= ${WEEK_START_SQL} AND create_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_info vi LEFT JOIN vehicle_status vs ON vs.vehicle_id=vi.id AND vs.del_flag=0 WHERE (vi.del_flag='1' OR vs.operation_status='5') AND vi.update_time >= ${WEEK_START_SQL} AND vi.update_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM return_vehicle_task
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_replacement
WHERE del_flag='0' AND replace_time IS NOT NULL AND status=20
AND replace_time >= ${WEEK_START_SQL} AND replace_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck WHERE is_deleted=0 AND is_operation=1 AND truck_rent_status=7`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck WHERE is_deleted=0 AND is_operation=1 AND create_time >= ${WEEK_START_SQL} AND create_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck WHERE (is_operation=0 OR is_deleted=1) AND update_time >= ${WEEK_START_SQL} AND update_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL
AND task.task_type=1 AND task.task_status=1 AND take.update_time IS NOT NULL
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_return r
LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id
WHERE r.is_deleted=0 AND r.return_date IS NOT NULL
AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL
AND task.task_type=3 AND task.task_status=1 AND take.update_time IS NOT NULL
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`),
]);
cachedWeeklyStats = {
@@ -623,13 +520,13 @@ app.get('/summary', async (c) => {
const vehicleIds = new Set(vehicles.map(v => String(v.id)));
const summary: SummaryData = {
totalAssets: vehicles.length,
operating: {
total: vehicles.filter((v) => v.status === 'Operating' && (v.operationStatus === '1' || v.operationStatus === '2')).length,
self: vehicles.filter((v) => v.status === 'Operating' && v.operationStatus === '2').length,
leased: vehicles.filter((v) => v.status === 'Operating' && v.operationStatus === '1').length,
public: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Public').length,
hanging: 0,
},
operating: {
total: vehicles.filter((v) => v.status === 'Operating').length,
self: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Self').length,
leased: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Leased').length,
public: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Public').length,
hanging: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Hanging').length,
},
inventory: {
total: vehicles.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal').length,
inStock: vehicles.filter((v) => v.status === 'Inventory').length,
@@ -798,8 +695,7 @@ app.get('/dept-stats', async (c) => {
const deptMap = new Map<string, Map<string, Vehicle[]>>();
for (const v of withManager) {
const isPublicServiceVehicle = v.model === '公务车/挂靠车';
const dept = isPublicServiceVehicle ? '公务车' : (v.departmentName || '未分配部门');
const dept = v.departmentName || '公务车';
const mgr = v.customerManager || '未分配';
if (EXCLUDED_MANAGERS.has(mgr)) continue;
if (!deptMap.has(dept)) deptMap.set(dept, new Map());
@@ -808,6 +704,29 @@ app.get('/dept-stats', async (c) => {
mgrMap.get(mgr)!.push(v);
}
// 补齐:业务部门内所有在职用户,即使当前无车辆也需显示
const deptNames = Array.from(deptMap.keys()).filter((d) => d !== '公务车');
if (deptNames.length > 0) {
const placeholders = deptNames.map(() => '?').join(',');
const [userRows] = await pool.query<any[]>(
`SELECT u.user_name, dep.dep_name
FROM tab_user u
LEFT JOIN tab_department dep ON dep.id = u.dep_id AND dep.is_deleted = 0
WHERE u.is_deleted = 0
AND dep.dep_name IN (${placeholders})`,
deptNames,
);
for (const r of userRows as any[]) {
const dept = r.dep_name as string | null;
const mgr = r.user_name as string | null;
if (!dept || !mgr) continue;
if (EXCLUDED_MANAGERS.has(mgr)) continue;
const mgrMap = deptMap.get(dept);
if (!mgrMap) continue;
if (!mgrMap.has(mgr)) mgrMap.set(mgr, []);
}
}
// Compute attendance & avg mileage from realtime data
const getMileageStats = (vList: Vehicle[]) => {
const todayActive = vList.filter((v) => (todayMileageMap.get(v.plateNumber) || 0) > 0).length;
@@ -1052,11 +971,7 @@ app.get('/list', async (c) => {
filtered = filtered.filter((v) => customer === '未分配客户' ? !v.customerName : v.customerName === customer);
}
if (department) {
filtered = filtered.filter((v) => {
if (department === '公务车') return v.model === '公务车/挂靠车';
if (department === '未分配部门') return v.model !== '公务车/挂靠车' && !v.departmentName;
return v.departmentName === department;
});
filtered = filtered.filter((v) => department === '公务车' ? !v.departmentName : v.departmentName === department);
}
if (isColdChain !== undefined) {
const wantCold = isColdChain === 'true';
@@ -1077,10 +992,9 @@ app.get('/list', async (c) => {
location: v.location,
province: v.province,
city: v.city,
status: v.status,
ownership: v.ownership,
rentCompany: v.rentCompany,
contractNo: v.contractNo,
status: v.status,
ownership: v.ownership,
contractNo: v.contractNo,
customerName: v.customerName,
subjectOrg: v.subjectOrg,
departmentName: v.departmentName,
@@ -1135,22 +1049,18 @@ app.get('/weekly-detail', async (c) => {
const source = c.req.query('source');
let sql: string;
if (type === 'delivered') {
sql = `${DELIVERED_SQL} AND dv.delivery_time >= ${WEEK_START_SQL} AND dv.delivery_time < ${WEEK_END_SQL} ORDER BY dv.delivery_time DESC`;
sql = `${DELIVERED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`;
} else if (type === 'returned') {
sql = `${RETURNED_SQL} AND r.arrival_time >= ${WEEK_START_SQL} AND r.arrival_time < ${WEEK_END_SQL} ORDER BY r.arrival_time DESC`;
sql = `${RETURNED_SQL} AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL} ORDER BY r.return_date DESC`;
} else if (type === 'replaced') {
sql = `${REPLACED_SQL} AND vr.replace_time >= ${WEEK_START_SQL} AND vr.replace_time < ${WEEK_END_SQL} ORDER BY vr.replace_time DESC`;
sql = `${REPLACED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`;
} else if (type === 'pending') {
sql = `SELECT CAST(vi.id AS CHAR) AS truck_id, vi.plate_number, NULL AS handover_date, NULL AS contract_type, NULL AS customer_name
FROM vehicle_info vi
LEFT JOIN vehicle_status vs ON vs.vehicle_id=vi.id AND vs.del_flag=0
WHERE vi.del_flag='0' AND vs.vehicle_status='4' AND COALESCE(vs.operation_status, '') <> '5'`;
sql = `SELECT CAST(truck.id AS CHAR) AS truck_id, truck.plate_number, NULL AS handover_date, NULL AS contract_type, NULL AS customer_name
FROM tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1 AND truck.truck_rent_status=7`;
} else if (type === 'new') {
sql = `SELECT CAST(vi.id AS CHAR) AS truck_id, vi.plate_number, vi.create_time AS handover_date, NULL AS contract_type, NULL AS customer_name
FROM vehicle_info vi
LEFT JOIN vehicle_status vs ON vs.vehicle_id=vi.id AND vs.del_flag=0
WHERE vi.del_flag='0' AND COALESCE(vs.operation_status, '') <> '5'
AND vi.create_time >= ${WEEK_START_SQL} AND vi.create_time < ${WEEK_END_SQL} ORDER BY vi.create_time DESC`;
sql = `SELECT CAST(truck.id AS CHAR) AS truck_id, truck.plate_number, truck.create_time AS handover_date, NULL AS contract_type, NULL AS customer_name
FROM tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1
AND truck.create_time >= ${WEEK_START_SQL} AND truck.create_time < ${WEEK_END_SQL} ORDER BY truck.create_time DESC`;
} else {
return c.json([]);
}
@@ -1175,141 +1085,6 @@ app.get('/weekly-detail', async (c) => {
return c.json(masked);
});
// GET /api/vehicles/flow-stats?start=YYYY-MM-DD&end=YYYY-MM-DD
// 资产流转日报:按提交时间(create_time)统计交车、还车、替换车,并返回可点击明细。
app.get('/flow-stats', async (c) => {
const { start, end } = normalizeDateRange(c.req.query('start'), c.req.query('end'));
const allowedVehicles = await getVehiclesForUser(c);
const allowedTruckIds = new Set(allowedVehicles.map((v) => String(v.id)));
const sql = `
SELECT *
FROM (
SELECT
CONCAT('delivered-', dv.id) AS id,
'delivered' AS type,
'交车' AS type_label,
DATE_FORMAT(dv.create_time, '%Y-%m-%d') AS stat_date,
CAST(dv.vehicle_id AS CHAR) AS truck_id,
dv.plate_number,
DATE_FORMAT(dv.delivery_time, '%Y-%m-%d %H:%i:%s') AS event_time,
DATE_FORMAT(dv.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
c.business_department_name AS department,
c.business_manager_name AS manager,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM delivery_vehicle dv
LEFT JOIN delivery_task_subject dts
ON dts.id = dv.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = dv.contract_id
AND c.del_flag = '0'
WHERE dv.del_flag = '0'
AND dv.vehicle_id IS NOT NULL
AND dv.create_time IS NOT NULL
AND dv.delivery_status IN (2,3,5)
AND dv.create_time >= ?
AND dv.create_time < DATE_ADD(?, INTERVAL 1 DAY)
UNION ALL
SELECT
CONCAT('returned-', r.id) AS id,
'returned' AS type,
'还车' AS type_label,
DATE_FORMAT(r.create_time, '%Y-%m-%d') AS stat_date,
CAST(r.vehicle_id AS CHAR) AS truck_id,
r.plate_number,
DATE_FORMAT(r.arrival_time, '%Y-%m-%d %H:%i:%s') AS event_time,
DATE_FORMAT(r.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
c.business_department_name AS department,
c.business_manager_name AS manager,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM return_vehicle_task r
LEFT JOIN delivery_task_subject dts
ON dts.id = r.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = r.contract_id
AND c.del_flag = '0'
WHERE r.del_flag = '0'
AND r.vehicle_id IS NOT NULL
AND r.create_time IS NOT NULL
AND r.status IN (2,3,5)
AND r.create_time >= ?
AND r.create_time < DATE_ADD(?, INTERVAL 1 DAY)
UNION ALL
SELECT
CONCAT('replaced-', vr.id) AS id,
'replaced' AS type,
'替换' AS type_label,
DATE_FORMAT(vr.create_time, '%Y-%m-%d') AS stat_date,
CAST(vr.new_vehicle_id AS CHAR) AS truck_id,
vr.new_vehicle_plate AS plate_number,
DATE_FORMAT(vr.replace_time, '%Y-%m-%d %H:%i:%s') AS event_time,
DATE_FORMAT(vr.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
c.business_department_name AS department,
c.business_manager_name AS manager,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM vehicle_replacement vr
LEFT JOIN delivery_task_subject dts
ON dts.id = vr.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.id = vr.contract_id
AND c.del_flag = '0'
WHERE vr.del_flag = '0'
AND vr.new_vehicle_id IS NOT NULL
AND vr.create_time IS NOT NULL
AND vr.status = 20
AND vr.create_time >= ?
AND vr.create_time < DATE_ADD(?, INTERVAL 1 DAY)
) flow
ORDER BY flow.submit_time DESC
`;
const [rows] = await pool.query<any[]>(sql, [start, end, start, end, start, end]);
const details = (rows as FlowDetailRow[])
.filter((row) => allowedTruckIds.has(String(row.truck_id)))
.map((row) => ({
id: row.id,
type: row.type,
typeLabel: row.type_label,
date: row.stat_date,
truckId: row.truck_id,
plateNumber: row.plate_number,
eventTime: row.event_time,
submitTime: row.submit_time,
department: row.department || '',
manager: row.manager || '',
customerName: maskCustomerName(row.customer_name),
}));
const dailyMap = new Map<string, { date: string; delivered: number; returned: number; replaced: number; total: number }>();
for (const date of listDateRange(start, end)) dailyMap.set(date, { date, delivered: 0, returned: 0, replaced: 0, total: 0 });
for (const item of details) {
const stat = dailyMap.get(item.date);
if (!stat) continue;
stat[item.type] += 1;
stat.total += 1;
}
const daily = Array.from(dailyMap.values());
const totals = daily.reduce(
(acc, item) => ({
delivered: acc.delivered + item.delivered,
returned: acc.returned + item.returned,
replaced: acc.replaced + item.replaced,
total: acc.total + item.total,
}),
{ delivered: 0, returned: 0, replaced: 0, total: 0 },
);
return c.json({ start, end, daily, totals, details });
});
// GET /api/vehicles/subjects — 归属公司列表(含台数预览),用于顶部筛选下拉
app.get('/subjects', async (c) => {
const all = await getVehicles();
@@ -1349,18 +1124,20 @@ app.get('/debug', async (c) => {
${WEEK_END_SQL} AS week_end,
CURDATE() AS today,
WEEKDAY(CURDATE()) AS weekday`);
const [[deliveredAll]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)`);
const [[deliveredRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}`);
const [[latestTake]] = await pool.query<any[]>(`SELECT MAX(delivery_time) AS latest FROM delivery_vehicle
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)`);
const [[returnedRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM return_vehicle_task
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}`);
const [[latestReturn]] = await pool.query<any[]>(`SELECT MAX(arrival_time) AS latest FROM return_vehicle_task
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)`);
const [[deliveredAll]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL AND task.task_type=1 AND task.task_status=1`);
const [[deliveredRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL AND task.task_type=1 AND task.task_status=1
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`);
const [[latestTake]] = await pool.query<any[]>(`SELECT MAX(take.handover_date) AS latest FROM tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL AND task.task_type=1 AND task.task_status=1`);
const [[returnedRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_return r
WHERE r.is_deleted=0 AND r.return_date IS NOT NULL
AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL}`);
const [[latestReturn]] = await pool.query<any[]>(`SELECT MAX(r.return_date) AS latest FROM tab_truck_rent_return r WHERE r.is_deleted=0 AND r.return_date IS NOT NULL`);
return c.json({
weekRange: dateRange,

View File

@@ -8,7 +8,6 @@ export interface VehicleRow {
租赁公司: string;
车辆归属状态Label: string | null;
车辆型号Label: string | null;
车辆类型参数: string | null;
库存区域: string | null;
车辆租赁状态: string | null;
车辆租赁状态Label: string | null;
@@ -41,7 +40,6 @@ export interface Vehicle {
province: string | null;
city: string | null;
status: 'Operating' | 'Inventory' | 'Pending' | 'Abnormal';
operationStatus: string | null;
ownership: string;
rentCompany: string;
contractNo: string | null;