Compare commits
37 Commits
dev-ljyang
...
b0c8d6a5b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0c8d6a5b8 | ||
|
|
d8773ff0a0 | ||
|
|
becacecc67 | ||
|
|
67f3ec7306 | ||
|
|
3f29bed5fa | ||
|
|
4d523cbce0 | ||
|
|
2b31f8efe8 | ||
|
|
ae4b5d30e7 | ||
|
|
392a36a0ec | ||
|
|
a558db5795 | ||
|
|
b0caa5afcb | ||
|
|
5377d2c225 | ||
|
|
654573ac4b | ||
|
|
43f94ed1b8 | ||
|
|
fb5789d705 | ||
|
|
94a6e0a75e | ||
|
|
67c5f9d281 | ||
|
|
91202bdf71 | ||
|
|
c13f341d5e | ||
|
|
6962c4ff1c | ||
|
|
6b7f0eedd9 | ||
|
|
5bb3ceb47a | ||
|
|
cc778f3701 | ||
|
|
74d6efe261 | ||
|
|
a124e31fab | ||
|
|
a3dfe7ab8c | ||
|
|
3f0edfaaf5 | ||
|
|
feb950dd59 | ||
|
|
5e1c12eba2 | ||
|
|
ae24bc7647 | ||
|
|
0a372e4290 | ||
|
|
1e08d1ea62 | ||
|
|
2d82918d73 | ||
|
|
482243e052 | ||
|
|
f1a69c8271 | ||
|
|
1d2c3a0cd5 | ||
|
|
e7ba5315e1 |
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.codex
|
||||||
|
outputs
|
||||||
|
reports
|
||||||
|
data/geo
|
||||||
|
data/vehicle-heatmap/source.csv
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
|
.worktrees
|
||||||
|
|||||||
@@ -5,14 +5,37 @@ services:
|
|||||||
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
|
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
|
||||||
network_mode: host
|
network_mode: host
|
||||||
environment:
|
environment:
|
||||||
DB_HOST: "47.101.148.99"
|
DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com"
|
||||||
DB_PORT: "3306"
|
DB_PORT: "3306"
|
||||||
DB_USER: "root"
|
DB_USER: "oneos_db_prod"
|
||||||
DB_PASSWORD: "LN#Passw0rd@2026"
|
DB_PASSWORD: "adASHJcviqwjkbn23ngt1"
|
||||||
DB_NAME: "lingniu_prod"
|
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}"
|
||||||
SERVER_PORT: "8111"
|
SERVER_PORT: "8111"
|
||||||
EXTERNAL_API_BASE: "https://lnh2e.com"
|
EXTERNAL_API_BASE: "https://lnh2e.com"
|
||||||
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
|
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:
|
deploy:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
restart_policy:
|
restart_policy:
|
||||||
|
|||||||
150
docs/oneos-mileage-api-requirements.md
Normal file
150
docs/oneos-mileage-api-requirements.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# OneOS 车辆里程接口需求
|
||||||
|
|
||||||
|
## 开发原则
|
||||||
|
|
||||||
|
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"]`;
|
||||||
|
- 仪表数据内部优先级固定为 `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. 禁用某类协议后,响应不得回退到该协议。
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<title>羚牛氢能车辆资产</title>
|
<title>羚牛氢能</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
162
package-lock.json
generated
162
package-lock.json
generated
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "ln-bi",
|
"name": "ln-bi",
|
||||||
"version": "1.1.5",
|
"version": "1.1.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ln-bi",
|
"name": "ln-bi",
|
||||||
"version": "1.1.5",
|
"version": "1.1.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||||
"@hono/node-server": "^1.13.0",
|
"@hono/node-server": "^1.13.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"ali-oss": "^6.23.0",
|
"ali-oss": "^6.23.0",
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"mysql2": "^3.11.0",
|
"mysql2": "^3.11.0",
|
||||||
|
"pg": "^8.22.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
@@ -27,6 +29,7 @@
|
|||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@types/ali-oss": "^6.23.3",
|
"@types/ali-oss": "^6.23.3",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
@@ -36,6 +39,12 @@
|
|||||||
"vite": "^6.2.0"
|
"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": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@@ -1620,6 +1629,18 @@
|
|||||||
"undici-types": "~6.21.0"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
@@ -3521,6 +3542,95 @@
|
|||||||
"through": "~2.3"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -3576,6 +3686,45 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/process-nextick-args": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
@@ -3969,6 +4118,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/sql-escaper": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ln-bi",
|
"name": "ln-bi",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.5",
|
"version": "1.1.12",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||||
"@hono/node-server": "^1.13.0",
|
"@hono/node-server": "^1.13.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"ali-oss": "^6.23.0",
|
"ali-oss": "^6.23.0",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"mysql2": "^3.11.0",
|
"mysql2": "^3.11.0",
|
||||||
|
"pg": "^8.22.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@types/ali-oss": "^6.23.3",
|
"@types/ali-oss": "^6.23.3",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
|||||||
88
src/App.tsx
88
src/App.tsx
@@ -1,18 +1,22 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||||
import { Truck, Route, Activity, Fuel, BatteryCharging, Receipt } from "lucide-react";
|
import { Truck, Route, Activity, Fuel, BatteryCharging, Receipt, MapPinned } from "lucide-react";
|
||||||
import { Shell, type ModuleConfig } from "./components/Shell";
|
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 HydrogenModule from "./modules/energy/HydrogenModule";
|
|
||||||
import ElectricModule from "./modules/energy/ElectricModule";
|
|
||||||
import EtcModule from "./modules/energy/EtcModule";
|
|
||||||
import EleImportPage from "./modules/ele/EleImportPage";
|
|
||||||
import FeedbackAdminPage from "./modules/admin/FeedbackAdminPage";
|
|
||||||
import AuthProvider from "./auth/AuthProvider";
|
import AuthProvider from "./auth/AuthProvider";
|
||||||
import { useAuth } from "./auth/useAuth";
|
import { useAuth } from "./auth/useAuth";
|
||||||
import UnauthorizedPage from "./auth/UnauthorizedPage";
|
import UnauthorizedPage from "./auth/UnauthorizedPage";
|
||||||
import { canAccessScheduling, canAccessEnergy } from "./shared/auth/roles";
|
import { canAccessScheduling, canAccessEnergy } from "./shared/auth/roles";
|
||||||
|
import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface";
|
||||||
|
|
||||||
|
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 ASSETS_MODULE: ModuleConfig = {
|
const ASSETS_MODULE: ModuleConfig = {
|
||||||
id: "assets",
|
id: "assets",
|
||||||
@@ -28,6 +32,13 @@ const MILEAGE_MODULE: ModuleConfig = {
|
|||||||
component: MileageModule,
|
component: MileageModule,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const VEHICLE_HEATMAP_MODULE: ModuleConfig = {
|
||||||
|
id: "vehicle-heatmap",
|
||||||
|
label: "车辆",
|
||||||
|
icon: MapPinned,
|
||||||
|
component: VehicleHeatmapModule,
|
||||||
|
};
|
||||||
|
|
||||||
const SCHEDULING_MODULE: ModuleConfig = {
|
const SCHEDULING_MODULE: ModuleConfig = {
|
||||||
id: "scheduling",
|
id: "scheduling",
|
||||||
label: "智能调度",
|
label: "智能调度",
|
||||||
@@ -42,6 +53,13 @@ const HYDROGEN_MODULE: ModuleConfig = {
|
|||||||
component: HydrogenModule,
|
component: HydrogenModule,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const HYDROGEN_HEATMAP_MODULE: ModuleConfig = {
|
||||||
|
id: "hydrogen-heatmap",
|
||||||
|
label: "加氢",
|
||||||
|
icon: MapPinned,
|
||||||
|
component: HydrogenHeatmapModule,
|
||||||
|
};
|
||||||
|
|
||||||
const ELECTRIC_MODULE: ModuleConfig = {
|
const ELECTRIC_MODULE: ModuleConfig = {
|
||||||
id: "electric",
|
id: "electric",
|
||||||
label: "电能",
|
label: "电能",
|
||||||
@@ -128,21 +146,45 @@ function AuthGate() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI";
|
||||||
|
}, [pathSet]);
|
||||||
|
|
||||||
const modules = useMemo<ModuleConfig[]>(() => {
|
const modules = useMemo<ModuleConfig[]>(() => {
|
||||||
if (pathSet === "energy") {
|
if (pathSet === "energy") {
|
||||||
return [HYDROGEN_MODULE, ELECTRIC_MODULE, ETC_MODULE];
|
return [HYDROGEN_MODULE, ELECTRIC_MODULE, ETC_MODULE];
|
||||||
}
|
}
|
||||||
const result: ModuleConfig[] = [ASSETS_MODULE, MILEAGE_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];
|
||||||
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
|
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
|
||||||
return result;
|
return result;
|
||||||
}, [pathSet, user?.roles]);
|
}, [pathSet, user?.roles]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
|
<div className="min-h-screen bg-[var(--app-bg)] p-4 text-slate-800 md:p-8">
|
||||||
<div className="text-center">
|
<div className="mx-auto flex max-w-5xl flex-col gap-4">
|
||||||
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
|
<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">
|
||||||
<p className="text-xs text-slate-400 font-bold">正在验证身份...</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -153,8 +195,20 @@ function AuthGate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
|
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
|
||||||
if (routeKey === "ele/import") return <EleImportPage />;
|
if (routeKey === "ele/import") {
|
||||||
if (routeKey === "admin/feedback") return <FeedbackAdminPage />;
|
return (
|
||||||
|
<Suspense fallback={<LoadingState label="正在加载导入工作台" />}>
|
||||||
|
<EleImportPage />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (routeKey === "admin/feedback") {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingState label="正在加载反馈后台" />}>
|
||||||
|
<FeedbackAdminPage />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// /energy 整组按能源权限控制
|
// /energy 整组按能源权限控制
|
||||||
if (pathSet === "energy" && !canAccessEnergy(user?.roles)) {
|
if (pathSet === "energy" && !canAccessEnergy(user?.roles)) {
|
||||||
|
|||||||
@@ -82,19 +82,7 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const jumpToken = params.get('jumpToken');
|
const jumpToken = params.get('jumpToken');
|
||||||
|
|
||||||
if (!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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
import { ShieldX, Monitor, Smartphone } from 'lucide-react';
|
import { ShieldX, Monitor, Smartphone } from 'lucide-react';
|
||||||
|
import { PageFrame, SurfaceCard } from '../components/ui/surface';
|
||||||
|
|
||||||
export default function UnauthorizedPage({ message }: { message?: string }) {
|
export default function UnauthorizedPage({ message }: { message?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center p-6">
|
<PageFrame
|
||||||
<div className="text-center max-w-sm">
|
title="未授权访问"
|
||||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-slate-100 flex items-center justify-center">
|
subtitle={message || '获取用户认证信息失败,可能是跳转令牌已过期或无效。'}
|
||||||
<ShieldX size={36} className="text-slate-400" />
|
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>
|
</div>
|
||||||
<h1 className="text-lg font-black text-slate-800 mb-2">未授权访问</h1>
|
<div className="space-y-3">
|
||||||
<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">
|
<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" />
|
<Monitor size={16} className="text-blue-500 flex-shrink-0 mt-0.5" />
|
||||||
@@ -31,7 +36,7 @@ export default function UnauthorizedPage({ message }: { message?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SurfaceCard>
|
||||||
</div>
|
</PageFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { useState, useEffect, useMemo, type ComponentType } from 'react';
|
import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { Building2, ChevronRight, ShieldCheck } from 'lucide-react';
|
||||||
import { useAuth } from '../auth/useAuth';
|
import { useAuth } from '../auth/useAuth';
|
||||||
import { DemoModeProvider } from './Blur';
|
import { DemoModeProvider } from './Blur';
|
||||||
import FeedbackFab from './FeedbackFab';
|
import FeedbackFab from './FeedbackFab';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
|
import { LoadingState } from './ui/surface';
|
||||||
|
|
||||||
export interface ModuleConfig {
|
export interface ModuleConfig {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: ComponentType<{ size?: number; className?: string }>;
|
icon: ComponentType<{ size?: number; className?: string }>;
|
||||||
component: ComponentType;
|
component?: ElementType;
|
||||||
|
children?: ModuleConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenModules(modules: ModuleConfig[]): ModuleConfig[] {
|
||||||
|
return modules.flatMap((module) => module.children ?? [module]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** hash 一级段(`#<id>` 或 `#<id>/<sub>` 都只取 id) */
|
/** hash 一级段(`#<id>` 或 `#<id>/<sub>` 都只取 id) */
|
||||||
@@ -27,16 +36,27 @@ function getHashModule(modules: ModuleConfig[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||||
const [activeModule, setActiveModule] = useState(() => getInitialModule(modules));
|
const flatModules = useMemo(() => flattenModules(modules), [modules]);
|
||||||
|
const [activeModule, setActiveModule] = useState(() => getInitialModule(flattenModules(modules)));
|
||||||
|
const [openGroupId, setOpenGroupId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onHashChange = () => {
|
const onHashChange = () => {
|
||||||
const h = getHashModule(modules);
|
const h = getHashModule(flatModules);
|
||||||
if (h) setActiveModule(h);
|
if (h) setActiveModule(h);
|
||||||
};
|
};
|
||||||
window.addEventListener('hashchange', onHashChange);
|
window.addEventListener('hashchange', onHashChange);
|
||||||
return () => window.removeEventListener('hashchange', onHashChange);
|
return () => window.removeEventListener('hashchange', onHashChange);
|
||||||
}, [modules]);
|
}, [flatModules]);
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
// 同步 hash 一段到当前模块:使用 replaceState 避免产生多余的 history 记录,
|
// 同步 hash 一段到当前模块:使用 replaceState 避免产生多余的 history 记录,
|
||||||
@@ -49,15 +69,20 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
|||||||
}, [activeModule]);
|
}, [activeModule]);
|
||||||
|
|
||||||
const switchModule = (id: string) => {
|
const switchModule = (id: string) => {
|
||||||
if (getHashHead() === id) return;
|
setOpenGroupId(null);
|
||||||
|
if (getHashHead() === id) {
|
||||||
|
setActiveModule(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { pathname, search } = window.location;
|
const { pathname, search } = window.location;
|
||||||
window.history.replaceState(null, '', `${pathname}${search}#${id}`);
|
window.history.replaceState(null, '', `${pathname}${search}#${id}`);
|
||||||
setActiveModule(id);
|
setActiveModule(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component;
|
const ActiveComponent = flatModules.find((module) => module.id === activeModule)?.component ?? flatModules[0]?.component;
|
||||||
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const activeLabel = flatModules.find((module) => module.id === activeModule)?.label ?? '业务看板';
|
||||||
const watermarkText = useMemo(() => {
|
const watermarkText = useMemo(() => {
|
||||||
const name = user?.userName || '未登录';
|
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, '-');
|
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, '-');
|
||||||
@@ -66,7 +91,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DemoModeProvider enabled={false}>
|
<DemoModeProvider enabled={false}>
|
||||||
<div className="flex min-h-screen">
|
<div className="enterprise-grid-bg flex min-h-screen">
|
||||||
{/* 全局水印 */}
|
{/* 全局水印 */}
|
||||||
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
|
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
|
||||||
<div className="absolute inset-0" style={{
|
<div className="absolute inset-0" style={{
|
||||||
@@ -75,49 +100,114 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
|||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
{/* Web 侧边栏 (md 及以上) */}
|
{/* Web 侧边栏 (md 及以上) */}
|
||||||
<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">
|
<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">
|
||||||
{modules.map((m) => {
|
<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">
|
||||||
const Icon = m.icon;
|
<Building2 size={22} />
|
||||||
const isActive = m.id === activeModule;
|
</div>
|
||||||
return (
|
<div className="flex w-full flex-1 flex-col items-center gap-2">
|
||||||
<button
|
{modules.map((m) => {
|
||||||
key={m.id}
|
const Icon = m.icon;
|
||||||
onClick={() => switchModule(m.id)}
|
const isGroup = Boolean(m.children?.length);
|
||||||
className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${
|
const isActive = m.id === activeModule || Boolean(m.children?.some((child) => child.id === activeModule));
|
||||||
isActive
|
const isOpen = openGroupId === m.id;
|
||||||
? 'bg-blue-50 text-blue-600'
|
return (
|
||||||
: 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
|
<div key={m.id} className="relative">
|
||||||
}`}
|
<button
|
||||||
>
|
type="button"
|
||||||
<Icon size={22} />
|
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)}
|
||||||
<span className="text-[10px] mt-1 leading-tight">{m.label}</span>
|
aria-expanded={isGroup ? isOpen : undefined}
|
||||||
</button>
|
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>
|
</nav>
|
||||||
|
|
||||||
{/* 内容区 */}
|
{/* 内容区 */}
|
||||||
<main className="flex-1 md:ml-16 pb-16 md:pb-0 min-w-0" style={{ overflowX: 'clip' }}>
|
<main className="min-w-0 flex-1 pb-16 md:ml-20 md:pb-0" style={{ overflowX: 'clip' }}>
|
||||||
{ActiveComponent && <ActiveComponent />}
|
<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>
|
||||||
<FeedbackFab module={activeModule} />
|
<FeedbackFab module={activeModule} />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* 移动端底部导航 (md 以下) */}
|
{/* 移动端底部导航 (md 以下) */}
|
||||||
<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">
|
<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))` }}>
|
||||||
{modules.map((m) => {
|
{modules.map((m) => {
|
||||||
const Icon = m.icon;
|
const Icon = m.icon;
|
||||||
const isActive = m.id === activeModule;
|
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 (
|
return (
|
||||||
<button
|
<div key={m.id} className="relative flex justify-center">
|
||||||
key={m.id}
|
<button
|
||||||
onClick={() => switchModule(m.id)}
|
type="button"
|
||||||
className={`flex flex-col items-center ${isActive ? 'text-blue-600' : 'text-gray-400'}`}
|
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)}
|
||||||
>
|
aria-expanded={isGroup ? isOpen : undefined}
|
||||||
<Icon size={20} />
|
aria-haspopup={isGroup ? 'menu' : undefined}
|
||||||
<span className="text-[10px] mt-1">{m.label}</span>
|
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')}
|
||||||
</button>
|
>
|
||||||
|
{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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</DemoModeProvider>
|
</DemoModeProvider>
|
||||||
|
|||||||
287
src/components/ui/surface.tsx
Normal file
287
src/components/ui/surface.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--app-bg: #f4f7fb;
|
||||||
|
--panel-bg: rgba(255, 255, 255, 0.9);
|
||||||
|
--hairline: rgba(148, 163, 184, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
background: var(--app-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
@@ -13,4 +20,79 @@ html {
|
|||||||
body {
|
body {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
height: 100%;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
3
src/lib/cn.ts
Normal file
3
src/lib/cn.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function cn(...parts: Array<string | false | null | undefined>) {
|
||||||
|
return parts.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react';
|
import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react';
|
||||||
import { fetchJson } from '../../auth/api-client';
|
import { fetchJson } from '../../auth/api-client';
|
||||||
|
import { EmptyState, LoadingState, PageFrame, SurfaceCard } from '../../components/ui/surface';
|
||||||
|
|
||||||
interface FeedbackItem {
|
interface FeedbackItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -20,10 +21,10 @@ interface FeedbackItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_LABEL: Record<string, string> = {
|
const TYPE_LABEL: Record<string, string> = {
|
||||||
dimension: '💡 新维度',
|
dimension: '新维度',
|
||||||
bug: '🐛 Bug',
|
bug: 'Bug',
|
||||||
ux: '🎨 体验',
|
ux: '体验',
|
||||||
other: '📝 其他',
|
other: '其他',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [
|
const STATUS_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [
|
||||||
@@ -126,36 +127,36 @@ export default function FeedbackAdminPage() {
|
|||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] p-4 md:p-8">
|
<PageFrame
|
||||||
<div className="max-w-5xl mx-auto space-y-4">
|
title="用户反馈管理"
|
||||||
<header className="flex items-center justify-between">
|
subtitle="查看、回复、跟进用户提交的建议,形成从问题发现到处理闭环的运营后台。"
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
icon={Inbox}
|
||||||
|
eyebrow="FEEDBACK OPS"
|
||||||
|
meta={`当前 ${items.length} 条反馈`}
|
||||||
|
actions={(
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// 优先 history.back(来自 SPA 内部跳转);否则回到主页
|
// 优先 history.back(来自 SPA 内部跳转);否则回到主页
|
||||||
if (window.history.length > 1) window.history.back();
|
if (window.history.length > 1) window.history.back();
|
||||||
else { window.location.hash = '#mileage'; }
|
else { window.location.hash = '#mileage'; }
|
||||||
}}
|
}}
|
||||||
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"
|
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"
|
||||||
title="返回"
|
title="返回"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
</button>
|
</button>
|
||||||
<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">
|
<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="刷新">
|
||||||
<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' : ''} />
|
<RotateCcw size={16} className={loading ? 'animate-spin' : ''} />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</div>
|
||||||
|
)}
|
||||||
|
maxWidth="max-w-5xl"
|
||||||
|
>
|
||||||
|
|
||||||
{/* 状态过滤 */}
|
{/* 状态过滤 */}
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-2 flex items-center gap-1 overflow-x-auto">
|
<SurfaceCard className="p-2">
|
||||||
|
<div className="flex items-center gap-1 overflow-x-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => setStatusFilter('')}
|
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'}`}
|
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'}`}
|
||||||
@@ -170,6 +171,7 @@ export default function FeedbackAdminPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</SurfaceCard>
|
||||||
|
|
||||||
{error && (
|
{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">
|
<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">
|
||||||
@@ -192,9 +194,9 @@ export default function FeedbackAdminPage() {
|
|||||||
{/* 列表 */}
|
{/* 列表 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{loading && items.length === 0 ? (
|
{loading && items.length === 0 ? (
|
||||||
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold">加载中…</div>
|
<LoadingState label="正在加载用户反馈" />
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold">还没有反馈</div>
|
<EmptyState title="还没有反馈" description="新的用户反馈会出现在这里" />
|
||||||
) : items.map(it => {
|
) : items.map(it => {
|
||||||
const shots = parseScreenshots(it.screenshots);
|
const shots = parseScreenshots(it.screenshots);
|
||||||
const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status);
|
const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status);
|
||||||
@@ -218,7 +220,7 @@ export default function FeedbackAdminPage() {
|
|||||||
{(shots.length > 0 || it.contact) && (
|
{(shots.length > 0 || it.contact) && (
|
||||||
<div className="flex items-center gap-3 mt-2 text-[10px] text-slate-400 font-bold">
|
<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>}
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{it.reply_content && (
|
{it.reply_content && (
|
||||||
@@ -236,8 +238,6 @@ export default function FeedbackAdminPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 详情 / 回复弹窗 */}
|
{/* 详情 / 回复弹窗 */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{active && (
|
{active && (
|
||||||
@@ -336,6 +336,6 @@ export default function FeedbackAdminPage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</PageFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,12 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
ArrowRightLeft,
|
ArrowRightLeft,
|
||||||
MapPin,
|
MapPin,
|
||||||
|
Download,
|
||||||
|
CalendarDays,
|
||||||
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
import {
|
import {
|
||||||
BarChart,
|
BarChart,
|
||||||
Bar,
|
Bar,
|
||||||
@@ -30,12 +34,13 @@ import {
|
|||||||
LabelList,
|
LabelList,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types';
|
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types';
|
||||||
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, type SubjectOption } from './api';
|
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, fetchFlowStats, type SubjectOption } from './api';
|
||||||
import type { WeeklyDetailItem } from './api';
|
import type { FlowDetailItem, FlowStatsResponse, FlowType, WeeklyDetailItem } from './api';
|
||||||
import { SearchSelect } from '../../components/SearchSelect';
|
import { SearchSelect } from '../../components/SearchSelect';
|
||||||
import { MultiSearchSelect } from '../../components/MultiSearchSelect';
|
import { MultiSearchSelect } from '../../components/MultiSearchSelect';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
import { ErrorState, LoadingState, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface';
|
||||||
|
|
||||||
|
|
||||||
// --- Constants ---
|
// --- Constants ---
|
||||||
@@ -46,6 +51,79 @@ const TABS = [
|
|||||||
{ id: 'customer', label: '按客户' },
|
{ 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() {
|
export default function AssetsModule() {
|
||||||
const [activeTab, setActiveTab] = useState<'overview' | 'department' | 'region' | 'customer'>('overview');
|
const [activeTab, setActiveTab] = useState<'overview' | 'department' | 'region' | 'customer'>('overview');
|
||||||
const [tabReady, setTabReady] = useState(true);
|
const [tabReady, setTabReady] = useState(true);
|
||||||
@@ -92,8 +170,13 @@ export default function AssetsModule() {
|
|||||||
const [modalWeeklyDetail, setModalWeeklyDetail] = useState<WeeklyDetailItem[]>([]);
|
const [modalWeeklyDetail, setModalWeeklyDetail] = useState<WeeklyDetailItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [lastUpdate, setLastUpdate] = useState<string>('');
|
const [lastUpdate, setLastUpdate] = useState<string>(() => formatLocalDateTime(new Date()));
|
||||||
const [modalLoading, setModalLoading] = useState(false);
|
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
|
// Dept/Region/Customer data
|
||||||
const [deptData, setDeptData] = useState<DeptGroup[]>([]);
|
const [deptData, setDeptData] = useState<DeptGroup[]>([]);
|
||||||
@@ -162,7 +245,7 @@ export default function AssetsModule() {
|
|||||||
setRegionData(region);
|
setRegionData(region);
|
||||||
setCustomerData(cust);
|
setCustomerData(cust);
|
||||||
setInventoryData(inv);
|
setInventoryData(inv);
|
||||||
setLastUpdate(new Date().toLocaleString('zh-CN'));
|
setLastUpdate(formatLocalDateTime(new Date()));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : '数据加载失败');
|
setError(e instanceof Error ? e.message : '数据加载失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -176,6 +259,24 @@ export default function AssetsModule() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [loadData]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
fetchSubjects().then(setSubjects).catch(() => setSubjects([]));
|
fetchSubjects().then(setSubjects).catch(() => setSubjects([]));
|
||||||
@@ -475,6 +576,40 @@ export default function AssetsModule() {
|
|||||||
return mp;
|
return mp;
|
||||||
}), [modalWeeklyDetail, modalFilters.plateNumber]);
|
}), [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 }[]>([]);
|
const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (customerChartView === 'province') {
|
if (customerChartView === 'province') {
|
||||||
@@ -495,38 +630,60 @@ export default function AssetsModule() {
|
|||||||
|
|
||||||
if (loading && !summary) {
|
if (loading && !summary) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
|
<PageFrame
|
||||||
<div className="flex flex-col items-center gap-3">
|
title="车辆资产中心"
|
||||||
<Loader2 className="animate-spin text-blue-500" size={32} />
|
subtitle="正在同步车辆、部门、区域与客户归属数据,加载完成后可继续穿透查看明细。"
|
||||||
<span className="text-sm text-gray-500">正在加载数据...</span>
|
icon={Truck}
|
||||||
</div>
|
eyebrow="ASSET INTELLIGENCE"
|
||||||
</div>
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error && !summary) {
|
if (error && !summary) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
|
<PageFrame
|
||||||
<div className="flex flex-col items-center gap-3 text-center">
|
title="车辆资产中心"
|
||||||
<div className="text-red-500 text-lg font-bold">加载失败</div>
|
subtitle="车辆资产数据暂时没有返回,请重试或稍后再看。"
|
||||||
<div className="text-sm text-gray-500">{error}</div>
|
icon={Truck}
|
||||||
<button onClick={loadData} className="mt-2 px-4 py-2 bg-blue-500 text-white rounded text-sm hover:bg-blue-600">
|
eyebrow="ASSET INTELLIGENCE"
|
||||||
重试
|
meta="加载失败"
|
||||||
</button>
|
>
|
||||||
</div>
|
<SurfaceCard className="min-h-[360px]">
|
||||||
</div>
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUMMARY = summary!;
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-6 relative">
|
<div className="min-h-screen bg-[var(--app-bg)] text-gray-800 font-sans p-3 md:p-6 relative">
|
||||||
{/* Compact Header Bar */}
|
{/* Compact Header Bar */}
|
||||||
<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">
|
<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">
|
||||||
{/* Title row */}
|
{/* Title row */}
|
||||||
<div className="relative flex items-center justify-center px-4 pt-3 pb-1">
|
<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">羚牛氢能车辆资产</h1>
|
<h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide">羚牛氢能-资产BI</h1>
|
||||||
{/* Right: status + theme */}
|
{/* Right: status + theme */}
|
||||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
<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">
|
<div className="hidden sm:flex items-center gap-1 text-[10px] text-gray-400">
|
||||||
@@ -689,13 +846,12 @@ export default function AssetsModule() {
|
|||||||
<span className="w-1 h-1 rounded-full bg-blue-400 inline-block" />
|
<span className="w-1 h-1 rounded-full bg-blue-400 inline-block" />
|
||||||
最后更新: {lastUpdate}
|
最后更新: {lastUpdate}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* OneOS 迁移提示滚动条 */}
|
||||||
|
<MarqueeBanner />
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
|
|
||||||
@@ -708,11 +864,11 @@ export default function AssetsModule() {
|
|||||||
{tabReady && activeTab === 'overview' && (
|
{tabReady && activeTab === 'overview' && (
|
||||||
<>
|
<>
|
||||||
{/* Header Summary - Ultra Compact */}
|
{/* Header Summary - Ultra Compact */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-2 mb-2">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-2">
|
||||||
{/* Total Assets */}
|
{/* Total Assets */}
|
||||||
<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"
|
<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"
|
||||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', source: 'asset', title: '资产概览' })}>
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', source: 'asset', title: '资产概览' })}>
|
||||||
<div className="w-7 h-7 rounded bg-gray-50 flex items-center justify-center text-gray-400">
|
<div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
|
||||||
<Truck size={14} />
|
<Truck size={14} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -722,9 +878,9 @@ export default function AssetsModule() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Operating */}
|
{/* Operating */}
|
||||||
<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"
|
<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"
|
||||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Operating', source: 'asset', title: '正在运营' })}>
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Operating', source: 'asset', title: '正在运营' })}>
|
||||||
<div className="w-7 h-7 rounded bg-blue-50 flex items-center justify-center text-blue-500">
|
<div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500">
|
||||||
<Activity size={14} />
|
<Activity size={14} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -737,9 +893,9 @@ export default function AssetsModule() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Inventory */}
|
{/* Inventory */}
|
||||||
<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"
|
<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"
|
||||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}>
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}>
|
||||||
<div className="w-7 h-7 rounded bg-gray-50 flex items-center justify-center text-gray-500">
|
<div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
|
||||||
<Warehouse size={14} />
|
<Warehouse size={14} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -752,9 +908,9 @@ export default function AssetsModule() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pending */}
|
{/* Pending */}
|
||||||
<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"
|
<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"
|
||||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset', title: '待交车' })}>
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset', title: '待交车' })}>
|
||||||
<div className="w-7 h-7 rounded bg-blue-50 flex items-center justify-center text-blue-500">
|
<div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500">
|
||||||
<PlusCircle size={14} />
|
<PlusCircle size={14} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -763,41 +919,163 @@ export default function AssetsModule() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dynamics */}
|
</div>
|
||||||
<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 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="text-[9px] text-gray-400 font-bold uppercase tracking-tight">本周动态</div>
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="text-[7px] text-gray-300 font-normal italic">上周六-本周五</div>
|
<div className="shrink-0 text-[11px] font-black text-slate-400">运营概览</div>
|
||||||
</div>
|
<div className="grid min-w-0 flex-1 grid-cols-3 divide-x divide-slate-100 text-center">
|
||||||
<div className="flex justify-between items-center gap-1">
|
<div className="px-2">
|
||||||
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-green-50 py-1 rounded transition-all group">
|
<div className="text-[10px] font-black text-slate-400">运营率</div>
|
||||||
<span className="text-xs font-bold text-gray-800 group-hover:text-green-600">{SUMMARY.weeklyNew}</span>
|
<div className="mt-0.5 text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div>
|
||||||
<span className="text-[8px] text-green-500/80 font-bold mt-0.5">新增</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[1px] h-3 bg-gray-100"></div>
|
<div className="px-2">
|
||||||
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-blue-50 py-1 rounded transition-all group"
|
<div className="text-[10px] font-black text-slate-400">库存率</div>
|
||||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Delivered', source: 'asset', title: '本周交车' })}>
|
<div className="mt-0.5 text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
|
||||||
<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>
|
||||||
<div className="w-[1px] h-3 bg-gray-100"></div>
|
<div className="px-2">
|
||||||
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-orange-50 py-1 rounded transition-all group"
|
<div className="text-[10px] font-black text-slate-400">待交率</div>
|
||||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Returned', source: 'asset', title: '本周还车' })}>
|
<div className="mt-0.5 text-base font-black text-amber-600">{pendingRate.toFixed(1)}%</div>
|
||||||
<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>
|
</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 */}
|
{/* Asset Summary Table */}
|
||||||
<div className="bg-white rounded-sm border border-gray-100 shadow-sm overflow-hidden mb-6">
|
<div className="bg-white rounded-2xl border border-slate-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="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">
|
<div className="flex flex-wrap items-center gap-4 sm:gap-6">
|
||||||
<h2 className="text-sm font-bold text-gray-700">资产数据实时汇总</h2>
|
<h2 className="text-sm font-bold text-gray-700">资产数据实时汇总</h2>
|
||||||
@@ -2602,6 +2880,123 @@ 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 */}
|
{/* Vehicle Detail Modal */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showPlateNumbers && (
|
{showPlateNumbers && (
|
||||||
|
|||||||
@@ -78,6 +78,43 @@ export interface WeeklyDetailItem {
|
|||||||
customer_name: string | null;
|
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[]> {
|
export async function fetchDeptStats(subject?: string | null): Promise<DeptGroup[]> {
|
||||||
return fetchJson<DeptGroup[]>(withSubject(`${BASE}/dept-stats`, subject));
|
return fetchJson<DeptGroup[]>(withSubject(`${BASE}/dept-stats`, subject));
|
||||||
}
|
}
|
||||||
@@ -125,3 +162,13 @@ export async function fetchWeeklyDetail(
|
|||||||
if (filters?.source) params.set('source', filters.source);
|
if (filters?.source) params.set('source', filters.source);
|
||||||
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?${params.toString()}`);
|
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()}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export interface VehicleListItem {
|
|||||||
city: string | null;
|
city: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
ownership: string;
|
ownership: string;
|
||||||
|
rentCompany?: string | null;
|
||||||
contractNo: string | null;
|
contractNo: string | null;
|
||||||
customerName: string | null;
|
customerName: string | null;
|
||||||
subjectOrg: string | null;
|
subjectOrg: string | null;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { fetchJson } from '../../auth/api-client';
|
|||||||
import { useAuth } from '../../auth/useAuth';
|
import { useAuth } from '../../auth/useAuth';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
import FeedbackFab from '../../components/FeedbackFab';
|
import FeedbackFab from '../../components/FeedbackFab';
|
||||||
|
import { PageFrame } from '../../components/ui/surface';
|
||||||
|
|
||||||
function getJwt(): string | null {
|
function getJwt(): string | null {
|
||||||
return sessionStorage.getItem('bi_jwt');
|
return sessionStorage.getItem('bi_jwt');
|
||||||
@@ -135,30 +136,34 @@ export default function EleImportPage() {
|
|||||||
const totalFee = overall.reduce((s, o) => s + Number(o.total_fee || 0), 0);
|
const totalFee = overall.reduce((s, o) => s + Number(o.total_fee || 0), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 p-4 md:p-8">
|
<PageFrame
|
||||||
<div className="max-w-6xl mx-auto space-y-4">
|
title="充电记录导入"
|
||||||
<header className="flex items-center justify-between">
|
subtitle="每日上传 xlsx,按订单编号去重,并自动匹配内部/外部车辆归属。"
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
icon={Zap}
|
||||||
|
eyebrow="ELECTRIC IMPORT"
|
||||||
|
meta={user?.userName || '导入工作台'}
|
||||||
|
actions={(
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (window.history.length > 1) window.history.back();
|
if (window.history.length > 1) window.history.back();
|
||||||
else { window.location.hash = '#mileage'; }
|
else { window.location.hash = '#mileage'; }
|
||||||
}}
|
}}
|
||||||
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"
|
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"
|
||||||
title="返回"
|
title="返回"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
</button>
|
</button>
|
||||||
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center flex-shrink-0">
|
<button
|
||||||
<Zap size={18} className="text-white" />
|
onClick={() => inputRef.current?.click()}
|
||||||
</div>
|
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"
|
||||||
<div className="min-w-0">
|
>
|
||||||
<h1 className="text-lg font-black text-slate-900 leading-tight">充电记录导入</h1>
|
<Upload size={14} />
|
||||||
<p className="text-[11px] font-bold text-slate-400">每日上传 xlsx · 订单编号去重 · 系统车辆自动匹配</p>
|
上传文件
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-bold text-slate-400 flex-shrink-0">{user?.userName || ''}</span>
|
)}
|
||||||
</header>
|
>
|
||||||
|
|
||||||
{/* 上传区 */}
|
{/* 上传区 */}
|
||||||
<section
|
<section
|
||||||
@@ -359,9 +364,8 @@ export default function EleImportPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<RotatingFooterHint className="pb-4" />
|
<RotatingFooterHint className="pb-4" />
|
||||||
</div>
|
|
||||||
<FeedbackFab module="ele" />
|
<FeedbackFab module="ele" />
|
||||||
</div>
|
</PageFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronRight, Plug } from 'lucide-react';
|
import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Truck, Wallet } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import TrendBadge from './TrendBadge';
|
import TrendBadge from './TrendBadge';
|
||||||
import { fetchElectricMonthly } from './api';
|
import { fetchElectricMonthly } from './api';
|
||||||
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
|
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||||
|
|
||||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||||
{ id: 'thisWeek', label: '本周' },
|
{ id: 'thisWeek', label: '本周' },
|
||||||
@@ -12,26 +13,61 @@ const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
|||||||
{ id: 'last15', label: '近 15 天' },
|
{ 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() {
|
export default function ElectricDaily() {
|
||||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
||||||
const [pick, setPick] = useState<DateQuickPick>('last15');
|
const [pick, setPick] = useState<RangeMode>('last15');
|
||||||
|
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
|
||||||
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
|
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
|
||||||
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
|
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setError(null);
|
setError(null);
|
||||||
fetchElectricMonthly(customer, pick)
|
const query = pick === 'custom'
|
||||||
|
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
||||||
|
: { range: pick };
|
||||||
|
fetchElectricMonthly(customer, query)
|
||||||
.then(m => {
|
.then(m => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setMonths(m);
|
setMonths(m);
|
||||||
// 默认展开最新一个月
|
// 默认展开最新一个月
|
||||||
if (m.length > 0) setOpenMonths(prev => prev.size > 0 ? prev : new Set([m[0].month]));
|
if (m.length > 0) setOpenMonths(new Set([m[0].month]));
|
||||||
})
|
})
|
||||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [customer, pick]);
|
}, [customer, pick, effectiveRange.start, effectiveRange.end]);
|
||||||
|
|
||||||
const toggleMonth = (m: string) => setOpenMonths(prev => {
|
const toggleMonth = (m: string) => setOpenMonths(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -40,40 +76,108 @@ export default function ElectricDaily() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const totalKwh = useMemo(() => (months ?? []).reduce((s, m) => s + (m.kwh || 0), 0), [months]);
|
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 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 (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* 日期速选 */}
|
<SurfaceCard className="p-2 md:p-3">
|
||||||
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
|
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||||
{QUICK_PICK_OPTIONS.map(opt => (
|
{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>
|
||||||
|
))}
|
||||||
<button
|
<button
|
||||||
key={opt.id}
|
onClick={() => setPick('custom')}
|
||||||
onClick={() => setPick(opt.id)}
|
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
||||||
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
|
pick === 'custom'
|
||||||
pick === opt.id
|
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||||
? 'bg-blue-50 text-blue-600 border-blue-200'
|
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
|
||||||
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{opt.label}
|
自定义
|
||||||
</button>
|
</button>
|
||||||
))}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 客户类型 */}
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
|
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
||||||
{(['lingniu', 'external'] as const).map(c => (
|
<span className="block text-[10px] font-black text-slate-400">开始日期</span>
|
||||||
<button
|
<input
|
||||||
key={c}
|
type="date"
|
||||||
onClick={() => setCustomer(c)}
|
value={dateRange.start}
|
||||||
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
|
onChange={e => updateDateRange('start', e.target.value)}
|
||||||
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
|
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"
|
||||||
>
|
/>
|
||||||
{c === 'external' ? '外部车辆' : '羚牛车辆'}
|
</label>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* 外部车辆 数据未就绪 */}
|
{/* 外部车辆 数据未就绪 */}
|
||||||
@@ -106,11 +210,11 @@ export default function ElectricDaily() {
|
|||||||
<span className="text-right">环比</span>
|
<span className="text-right">环比</span>
|
||||||
</div>
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">加载失败:{error}</div>
|
<div className="p-3"><ErrorState message={error} /></div>
|
||||||
) : months === null ? (
|
) : months === null ? (
|
||||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">加载中…</div>
|
<div className="p-3"><LoadingState label="正在加载充电明细" /></div>
|
||||||
) : months.length === 0 ? (
|
) : months.length === 0 ? (
|
||||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">暂无数据</div>
|
<div className="p-3"><EmptyState title="暂无充电数据" description="请切换时间范围或车辆归属" /></div>
|
||||||
) : months.map(m => {
|
) : months.map(m => {
|
||||||
const open = openMonths.has(m.month);
|
const open = openMonths.has(m.month);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { LayoutDashboard, CalendarDays } from 'lucide-react';
|
import { LayoutDashboard, CalendarDays } from 'lucide-react';
|
||||||
|
import { AnimatePresence } from 'motion/react';
|
||||||
import ElectricView, { type ElectricSubTab } from './ElectricView';
|
import ElectricView, { type ElectricSubTab } from './ElectricView';
|
||||||
import SubTabs from './SubTabs';
|
import SubTabs from './SubTabs';
|
||||||
import { useHashSubTab } from './useHashSubTab';
|
import { useHashSubTab } from './useHashSubTab';
|
||||||
|
import { FadeIn, PageFrame } from '../../components/ui/surface';
|
||||||
|
|
||||||
const SUB_TABS = [
|
const SUB_TABS = [
|
||||||
{ id: 'daily', label: '每日', icon: CalendarDays },
|
{ id: 'daily', label: '每日', icon: CalendarDays },
|
||||||
@@ -13,11 +15,19 @@ const SUB_IDS: readonly ElectricSubTab[] = ['daily', 'overview'];
|
|||||||
export default function ElectricModule() {
|
export default function ElectricModule() {
|
||||||
const [sub, setSub] = useHashSubTab<ElectricSubTab>('electric', SUB_IDS);
|
const [sub, setSub] = useHashSubTab<ElectricSubTab>('electric', SUB_IDS);
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
<PageFrame
|
||||||
<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">
|
title="电能成本看板"
|
||||||
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
|
subtitle="围绕充电量、费用、日趋势和车辆归属展示电能支出结构,辅助识别费用波动。"
|
||||||
<ElectricView sub={sub} />
|
icon={CalendarDays}
|
||||||
</div>
|
eyebrow="ELECTRIC BI"
|
||||||
</div>
|
meta="时间单位清晰标注 · 支持日/总览切换"
|
||||||
|
>
|
||||||
|
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<FadeIn key={sub}>
|
||||||
|
<ElectricView sub={sub} />
|
||||||
|
</FadeIn>
|
||||||
|
</AnimatePresence>
|
||||||
|
</PageFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Wallet, CalendarClock } from 'lucide-react';
|
import { BatteryCharging, Gauge, Wallet, CalendarClock } from 'lucide-react';
|
||||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
|
||||||
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
|
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||||
|
|
||||||
function fmtYuan(yuan: number) {
|
function fmtYuan(yuan: number) {
|
||||||
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
||||||
@@ -24,10 +25,10 @@ export default function ElectricOverview() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">加载失败:{error}</div>;
|
return <ErrorState message={error} />;
|
||||||
}
|
}
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6 text-center text-slate-400 text-sm">加载中…</div>;
|
return <LoadingState label="正在加载电能总览" />;
|
||||||
}
|
}
|
||||||
const k = data.kpi;
|
const k = data.kpi;
|
||||||
const trendData = data.trend;
|
const trendData = data.trend;
|
||||||
@@ -37,6 +38,12 @@ export default function ElectricOverview() {
|
|||||||
const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
|
const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
|
||||||
? `${trendMonthLabel} 每日充电`
|
? `${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 (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -44,28 +51,36 @@ export default function ElectricOverview() {
|
|||||||
龙王路停车场充电站,期初 2025-01-01,手工导入每日更新
|
龙王路停车场充电站,期初 2025-01-01,手工导入每日更新
|
||||||
</div>
|
</div>
|
||||||
{/* 横向 mini KPI 头 */}
|
{/* 横向 mini KPI 头 */}
|
||||||
<div className="grid grid-cols-2 gap-2 md:gap-3">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
<MetricTile icon={Wallet} label="累计充电费" value={fmtYuan(k.totalFee)} helper={fmtKwh(k.totalKwh)} />
|
||||||
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
|
<MetricTile icon={CalendarClock} label="本月充电费" value={fmtYuan(k.monthFee)} helper={fmtKwh(k.monthKwh)} tone="emerald" />
|
||||||
<Wallet size={11} className="text-blue-600" />累计
|
<MetricTile icon={Gauge} label="累计均价" value={avgPrice.toFixed(2)} unit="元/度" helper={`本月 ${monthPrice.toFixed(2)} 元/度`} tone="amber" />
|
||||||
</div>
|
<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 className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.totalFee)}</div>
|
</div>
|
||||||
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.totalKwh)}</div>
|
|
||||||
</div>
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
<SurfaceCard className="p-3">
|
||||||
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
|
<div className="text-[11px] font-black text-slate-400">有效充电日</div>
|
||||||
<CalendarClock size={11} className="text-blue-600" />本月
|
<div className="mt-1 text-xl font-black text-slate-900">{activeDays}<span className="ml-1 text-[11px] text-slate-400">天</span></div>
|
||||||
</div>
|
<div className="mt-1 text-[11px] font-bold text-slate-500">日均 {avgDailyKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度</div>
|
||||||
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.monthFee)}</div>
|
</SurfaceCard>
|
||||||
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.monthKwh)}</div>
|
<SurfaceCard className="p-3">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* 本月每日充电柱图 */}
|
{/* 本月每日充电柱图 */}
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
<SurfaceCard className="p-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-sm font-bold text-slate-700">{chartTitle}</span>
|
<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>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={160}>
|
<ResponsiveContainer width="100%" height={160}>
|
||||||
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||||
@@ -85,6 +100,14 @@ export default function ElectricOverview() {
|
|||||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||||
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
|
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]}>
|
<Bar dataKey="fee" radius={[4, 4, 0, 0]}>
|
||||||
{trendData.map((_, i) => (
|
{trendData.map((_, i) => (
|
||||||
<Cell key={i} fill="url(#electricBarGrad)" />
|
<Cell key={i} fill="url(#electricBarGrad)" />
|
||||||
@@ -98,7 +121,7 @@ export default function ElectricOverview() {
|
|||||||
</defs>
|
</defs>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</SurfaceCard>
|
||||||
<RotatingFooterHint />
|
<RotatingFooterHint />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
|
import { Receipt } from 'lucide-react';
|
||||||
import ETCView from './ETCView';
|
import ETCView from './ETCView';
|
||||||
|
import { PageFrame } from '../../components/ui/surface';
|
||||||
|
|
||||||
export default function EtcModule() {
|
export default function EtcModule() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
<PageFrame
|
||||||
<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">
|
title="ETC 通行费看板"
|
||||||
<ETCView />
|
subtitle="规划按车、按月、按线路拆分通行费,让车辆运营成本口径逐步完整。"
|
||||||
</div>
|
icon={Receipt}
|
||||||
</div>
|
eyebrow="ETC BI"
|
||||||
|
meta="数据对接中 · 页面能力预留"
|
||||||
|
>
|
||||||
|
<ETCView />
|
||||||
|
</PageFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronRight, Plug } from 'lucide-react';
|
import { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
|
||||||
import TrendBadge from './TrendBadge';
|
import TrendBadge from './TrendBadge';
|
||||||
import { fetchHydrogenDaily } from './api';
|
import { fetchHydrogenDaily } from './api';
|
||||||
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||||
|
|
||||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||||
{ id: 'thisWeek', label: '本周' },
|
{ id: 'thisWeek', label: '本周' },
|
||||||
@@ -13,25 +14,76 @@ const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
|||||||
{ id: 'last15', label: '近 15 天' },
|
{ 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() {
|
export default function HydrogenDaily() {
|
||||||
const [pick, setPick] = useState<DateQuickPick>('last15');
|
const [pick, setPick] = useState<RangeMode>('last15');
|
||||||
|
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
|
||||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setError(null);
|
setError(null);
|
||||||
fetchHydrogenDaily(pick, customer)
|
const query = pick === 'custom'
|
||||||
|
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
||||||
|
: { range: pick };
|
||||||
|
fetchHydrogenDaily(query, customer)
|
||||||
.then(r => { if (!cancelled) setRows(r); })
|
.then(r => { if (!cancelled) setRows(r); })
|
||||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [pick, customer]);
|
}, [pick, customer, effectiveRange.start, effectiveRange.end]);
|
||||||
|
|
||||||
// 柱图:按日期升序,用于"从左到右时间流"
|
// 柱图:按日期升序,用于"从左到右时间流"
|
||||||
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
|
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 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 toggle = (date: string) => setExpanded(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -39,38 +91,90 @@ export default function HydrogenDaily() {
|
|||||||
return next;
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* 日期速选 */}
|
<SurfaceCard className="p-2 md:p-3">
|
||||||
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
|
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||||
{QUICK_PICK_OPTIONS.map(opt => (
|
{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>
|
||||||
|
))}
|
||||||
<button
|
<button
|
||||||
key={opt.id}
|
onClick={() => setPick('custom')}
|
||||||
onClick={() => setPick(opt.id)}
|
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
||||||
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
|
pick === 'custom'
|
||||||
pick === opt.id
|
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||||
? 'bg-blue-50 text-blue-600 border-blue-200'
|
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
|
||||||
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{opt.label}
|
自定义
|
||||||
</button>
|
</button>
|
||||||
))}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 客户类型 segmented */}
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
|
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
||||||
{(['lingniu', 'external'] as const).map(c => (
|
<span className="block text-[10px] font-black text-slate-400">开始日期</span>
|
||||||
<button
|
<input
|
||||||
key={c}
|
type="date"
|
||||||
onClick={() => setCustomer(c)}
|
value={dateRange.start}
|
||||||
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
|
onChange={e => updateDateRange('start', e.target.value)}
|
||||||
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
|
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"
|
||||||
>
|
/>
|
||||||
{c === 'external' ? '外部车辆' : '羚牛车辆'}
|
</label>
|
||||||
</button>
|
<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" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 外部车辆:新系统数据还没准备好 */}
|
{/* 外部车辆:新系统数据还没准备好 */}
|
||||||
@@ -96,13 +200,34 @@ export default function HydrogenDaily() {
|
|||||||
|
|
||||||
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
|
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
|
||||||
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
|
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
<SurfaceCard>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between px-4 pt-4 mb-2">
|
||||||
<span className="text-sm font-bold text-slate-700">每日加氢量</span>
|
<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>
|
||||||
<ResponsiveContainer width="100%" height={160}>
|
<div className="mx-4 mb-2 grid grid-cols-3 gap-2 rounded-xl bg-slate-50 p-2">
|
||||||
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
<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 }}>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
tickFormatter={(v: string) => v.slice(5)}
|
tickFormatter={(v: string) => v.slice(5)}
|
||||||
@@ -112,13 +237,27 @@ export default function HydrogenDaily() {
|
|||||||
interval="preserveStartEnd"
|
interval="preserveStartEnd"
|
||||||
minTickGap={8}
|
minTickGap={8}
|
||||||
/>
|
/>
|
||||||
<YAxis hide />
|
<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)}`}
|
||||||
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']}
|
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']}
|
||||||
labelFormatter={(d) => `日期 ${d}`}
|
labelFormatter={(d) => `日期 ${d}`}
|
||||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||||
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
|
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]}>
|
<Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
|
||||||
{trendData.map((_, i) => (
|
{trendData.map((_, i) => (
|
||||||
<Cell key={i} fill="url(#hydrogenBarGrad)" />
|
<Cell key={i} fill="url(#hydrogenBarGrad)" />
|
||||||
@@ -132,7 +271,8 @@ export default function HydrogenDaily() {
|
|||||||
</defs>
|
</defs>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
</SurfaceCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
|
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
|
||||||
@@ -154,11 +294,11 @@ export default function HydrogenDaily() {
|
|||||||
</div>
|
</div>
|
||||||
{/* 主行 + 子行 */}
|
{/* 主行 + 子行 */}
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">加载失败:{error}</div>
|
<div className="p-3"><ErrorState message={error} /></div>
|
||||||
) : rows === null ? (
|
) : rows === null ? (
|
||||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">加载中…</div>
|
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">暂无数据</div>
|
<div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div>
|
||||||
) : rows.map(r => {
|
) : rows.map(r => {
|
||||||
const open = expanded.has(r.date);
|
const open = expanded.has(r.date);
|
||||||
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
|
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { LayoutDashboard, CalendarDays } from 'lucide-react';
|
import { LayoutDashboard, CalendarDays } from 'lucide-react';
|
||||||
|
import { AnimatePresence } from 'motion/react';
|
||||||
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
|
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
|
||||||
import SubTabs from './SubTabs';
|
import SubTabs from './SubTabs';
|
||||||
import { useHashSubTab } from './useHashSubTab';
|
import { useHashSubTab } from './useHashSubTab';
|
||||||
|
import { FadeIn, PageFrame } from '../../components/ui/surface';
|
||||||
|
|
||||||
const SUB_TABS = [
|
const SUB_TABS = [
|
||||||
{ id: 'daily', label: '每日', icon: CalendarDays },
|
{ id: 'daily', label: '每日', icon: CalendarDays },
|
||||||
@@ -13,11 +15,19 @@ const SUB_IDS: readonly HydrogenSubTab[] = ['daily', 'overview'];
|
|||||||
export default function HydrogenModule() {
|
export default function HydrogenModule() {
|
||||||
const [sub, setSub] = useHashSubTab<HydrogenSubTab>('hydrogen', SUB_IDS);
|
const [sub, setSub] = useHashSubTab<HydrogenSubTab>('hydrogen', SUB_IDS);
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
<PageFrame
|
||||||
<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">
|
title="氢能经营看板"
|
||||||
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
|
subtitle="按时间、车辆归属、加氢站和区域统一展示加氢量、费用、收入与异常波动。"
|
||||||
<HydrogenView sub={sub} />
|
icon={CalendarDays}
|
||||||
</div>
|
eyebrow="ENERGY BI"
|
||||||
</div>
|
meta="数据单位清晰标注 · 支持日/总览切换"
|
||||||
|
>
|
||||||
|
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<FadeIn key={sub}>
|
||||||
|
<HydrogenView sub={sub} />
|
||||||
|
</FadeIn>
|
||||||
|
</AnimatePresence>
|
||||||
|
</PageFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
|
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw } from 'lucide-react';
|
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw, Gauge, AlertTriangle, Building2 } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
@@ -147,6 +147,14 @@ export default function HydrogenOverview() {
|
|||||||
const yearRevenueFmt = fmtYuan(k.yearRevenue);
|
const yearRevenueFmt = fmtYuan(k.yearRevenue);
|
||||||
|
|
||||||
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
|
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 => ({
|
const monthlyDual = monthly.map(m => ({
|
||||||
@@ -158,7 +166,7 @@ export default function HydrogenOverview() {
|
|||||||
<div className="flex flex-col gap-3 relative">
|
<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">
|
<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 ? `更新于 ${formatRelative(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
|
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRefreshTime(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<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">
|
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
|
||||||
{availableYears.map(y => {
|
{availableYears.map(y => {
|
||||||
@@ -247,6 +255,59 @@ export default function HydrogenOverview() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 && (
|
{monthly.length > 0 && (
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||||
@@ -559,6 +620,18 @@ function formatRelative(ts: number): string {
|
|||||||
return new Date(ts).toLocaleString('zh-CN', { hour12: false });
|
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() {
|
function HydrogenOverviewSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 animate-pulse">
|
<div className="flex flex-col gap-3 animate-pulse">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ComponentType } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
|
import { SegmentedNav } from '../../components/ui/surface';
|
||||||
|
|
||||||
interface SubTab<T extends string> {
|
interface SubTab<T extends string> {
|
||||||
id: T;
|
id: T;
|
||||||
@@ -14,26 +15,8 @@ interface Props<T extends string> {
|
|||||||
|
|
||||||
export default function SubTabs<T extends string>({ tabs, active, onChange }: Props<T>) {
|
export default function SubTabs<T extends string>({ tabs, active, onChange }: Props<T>) {
|
||||||
return (
|
return (
|
||||||
<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="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">
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
<SegmentedNav tabs={tabs} active={active} onChange={onChange} />
|
||||||
<div className="p-1 flex gap-1">
|
|
||||||
{tabs.map(({ id, label, icon: Icon }) => {
|
|
||||||
const isActive = active === id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={id}
|
|
||||||
onClick={() => onChange(id)}
|
|
||||||
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
|
|
||||||
isActive ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon size={14} />
|
|
||||||
<span>{label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,17 @@ export function fetchHydrogenOverview(year?: number, force = false): Promise<Hyd
|
|||||||
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`);
|
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise<HydrogenDailyRow[]> {
|
export interface HydrogenDailyQuery {
|
||||||
const q = new URLSearchParams({ range, customer });
|
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);
|
||||||
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
|
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +50,10 @@ export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
|
|||||||
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
|
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchElectricMonthly(customer: CustomerType, range: DateQuickPick = 'last15'): Promise<ElectricMonthGroup[]> {
|
export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> {
|
||||||
const q = new URLSearchParams({ customer, range });
|
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);
|
||||||
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
|
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
|
||||||
}
|
}
|
||||||
|
|||||||
148
src/modules/hydrogen-heatmap/HydrogenAmapCanvas.tsx
Normal file
148
src/modules/hydrogen-heatmap/HydrogenAmapCanvas.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/modules/hydrogen-heatmap/HydrogenHeatmapDetailPanel.tsx
Normal file
85
src/modules/hydrogen-heatmap/HydrogenHeatmapDetailPanel.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/modules/hydrogen-heatmap/HydrogenHeatmapFilters.tsx
Normal file
85
src/modules/hydrogen-heatmap/HydrogenHeatmapFilters.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
222
src/modules/hydrogen-heatmap/HydrogenHeatmapModule.tsx
Normal file
222
src/modules/hydrogen-heatmap/HydrogenHeatmapModule.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/modules/hydrogen-heatmap/api.ts
Normal file
51
src/modules/hydrogen-heatmap/api.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
72
src/modules/hydrogen-heatmap/types.ts
Normal file
72
src/modules/hydrogen-heatmap/types.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
import { FileText } from 'lucide-react';
|
import { FileText } from 'lucide-react';
|
||||||
|
import { SurfaceCard } from '../../components/ui/surface';
|
||||||
|
|
||||||
export default function DailyReportView() {
|
export default function DailyReportView() {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-20">
|
<SurfaceCard className="min-h-[360px]">
|
||||||
<div className="text-center">
|
<div className="flex min-h-[320px] flex-col items-center justify-center px-6 py-10 text-center">
|
||||||
<FileText size={48} className="mx-auto text-gray-300 mb-4" />
|
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
|
||||||
<h2 className="text-lg font-semibold text-gray-500">每日汇报</h2>
|
<FileText size={26} />
|
||||||
<p className="text-sm text-gray-400 mt-2">开发中...</p>
|
</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>
|
</div>
|
||||||
</div>
|
</SurfaceCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,50 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { LayoutDashboard, BarChart3, FileText } from 'lucide-react';
|
import { LayoutDashboard, BarChart3, FileText } from 'lucide-react';
|
||||||
import { motion } from 'motion/react';
|
import { AnimatePresence } from 'motion/react';
|
||||||
import MonitoringView from './MonitoringView';
|
import MonitoringView from './MonitoringView';
|
||||||
import StatisticsView from './StatisticsView';
|
import StatisticsView from './StatisticsView';
|
||||||
import DailyReportView from './DailyReportView';
|
import DailyReportView from './DailyReportView';
|
||||||
|
import { useHashSubTab } from '../energy/useHashSubTab';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
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() {
|
export default function MileageModule() {
|
||||||
const [activeSubTab, setActiveSubTab] = useState<'monitoring' | 'statistics' | 'report'>('monitoring');
|
const [activeSubTab, setActiveSubTab] = useHashSubTab<MileageSubTab>('mileage', MILEAGE_SUB_IDS);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
<PageFrame
|
||||||
<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">
|
title="车辆里程中心"
|
||||||
{/* Sub-navigation — sticky */}
|
subtitle="统一监控车辆日里程、累计里程、考核进度与日报经营口径,突出异常车辆和任务压力。"
|
||||||
<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">
|
icon={LayoutDashboard}
|
||||||
<button
|
eyebrow="MILEAGE BI"
|
||||||
onClick={() => setActiveSubTab('monitoring')}
|
meta="实时监控 · 统计报表 · 每日汇报"
|
||||||
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'monitoring' ? 'text-blue-600' : 'text-slate-400'}`}
|
compactInfo
|
||||||
>
|
>
|
||||||
<LayoutDashboard size={14} />
|
<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">
|
||||||
<span className="text-[11px] font-bold">实时监控</span>
|
<SegmentedNav tabs={MILEAGE_TABS} active={activeSubTab} onChange={setActiveSubTab} />
|
||||||
{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>
|
|
||||||
|
|
||||||
{activeSubTab === 'monitoring' ? (
|
|
||||||
<MonitoringView />
|
|
||||||
) : activeSubTab === 'statistics' ? (
|
|
||||||
<StatisticsView />
|
|
||||||
) : (
|
|
||||||
<DailyReportView />
|
|
||||||
)}
|
|
||||||
<RotatingFooterHint />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<FadeIn key={activeSubTab}>
|
||||||
|
{activeSubTab === 'monitoring' ? (
|
||||||
|
<MonitoringView />
|
||||||
|
) : activeSubTab === 'statistics' ? (
|
||||||
|
<StatisticsView />
|
||||||
|
) : (
|
||||||
|
<DailyReportView />
|
||||||
|
)}
|
||||||
|
</FadeIn>
|
||||||
|
</AnimatePresence>
|
||||||
|
<RotatingFooterHint />
|
||||||
|
</PageFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ import {
|
|||||||
Truck, ChevronDown, Maximize2, Minimize2,
|
Truck, ChevronDown, Maximize2, Minimize2,
|
||||||
Search, ArrowUpDown, X, RotateCcw, Calendar,
|
Search, ArrowUpDown, X, RotateCcw, Calendar,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { TargetSummary, TargetVehicle, TrendPoint } from './types';
|
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from './types';
|
||||||
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
|
|
||||||
@@ -19,11 +19,31 @@ function getDefaultDate(): string {
|
|||||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
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 {
|
function fmtKm(value: number): string {
|
||||||
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
|
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
|
||||||
return value.toLocaleString();
|
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 {
|
function shortTargetName(name: string): string {
|
||||||
// Extract the number and a short description
|
// Extract the number and a short description
|
||||||
const match = name.match(/(\d+)[辆台](.+)/);
|
const match = name.match(/(\d+)[辆台](.+)/);
|
||||||
@@ -39,6 +59,7 @@ function shortTargetName(name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function StatisticsView() {
|
export default function StatisticsView() {
|
||||||
|
const currentDateLabel = getCurrentDateLabel();
|
||||||
const [targets, setTargets] = useState<TargetSummary[]>([]);
|
const [targets, setTargets] = useState<TargetSummary[]>([]);
|
||||||
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
||||||
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
|
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
|
||||||
@@ -46,7 +67,8 @@ export default function StatisticsView() {
|
|||||||
|
|
||||||
const [chartType, setChartType] = useState<'bar' | 'line' | 'area'>('bar');
|
const [chartType, setChartType] = useState<'bar' | 'line' | 'area'>('bar');
|
||||||
const [isTableFullscreen, setIsTableFullscreen] = useState(false);
|
const [isTableFullscreen, setIsTableFullscreen] = useState(false);
|
||||||
const [expandedModel, setExpandedModel] = useState<string | null>(null);
|
const [expandedTargetId, setExpandedTargetId] = useState<number | null>(null);
|
||||||
|
const [assessmentYearMap, setAssessmentYearMap] = useState<Record<number, number>>({});
|
||||||
const [viewAllTargetId, setViewAllTargetId] = useState<number | null>(null);
|
const [viewAllTargetId, setViewAllTargetId] = useState<number | null>(null);
|
||||||
const [viewAllTargetName, setViewAllTargetName] = useState<string>('');
|
const [viewAllTargetName, setViewAllTargetName] = useState<string>('');
|
||||||
const [viewAllSearch, setViewAllSearch] = useState('');
|
const [viewAllSearch, setViewAllSearch] = useState('');
|
||||||
@@ -54,12 +76,33 @@ export default function StatisticsView() {
|
|||||||
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
|
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
|
||||||
const [viewAllLoading, setViewAllLoading] = useState(false);
|
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
|
// Load targets on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTargets().then(data => {
|
fetchTargets().then(data => {
|
||||||
setTargets(data);
|
const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0];
|
||||||
if (data.length > 0 && !selectedTargetId) {
|
const ordered = focused
|
||||||
setSelectedTargetId(data[0].id);
|
? [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(() => {});
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -67,6 +110,12 @@ export default function StatisticsView() {
|
|||||||
// Load trend when selectedTargetId changes
|
// Load trend when selectedTargetId changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedTargetId === null) return;
|
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([]));
|
fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
|
||||||
}, [selectedTargetId]);
|
}, [selectedTargetId]);
|
||||||
|
|
||||||
@@ -80,13 +129,16 @@ export default function StatisticsView() {
|
|||||||
}, [viewAllTargetId, viewAllDate]);
|
}, [viewAllTargetId, viewAllDate]);
|
||||||
|
|
||||||
return (
|
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" 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 [overflow-anchor:none]" style={{ overflowX: 'clip' }}>
|
||||||
{/* Project Selector */}
|
{/* 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">
|
<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 => (
|
{targets.map(target => (
|
||||||
<button
|
<button
|
||||||
key={target.id}
|
key={target.id}
|
||||||
onClick={() => setSelectedTargetId(target.id)}
|
onClick={() => {
|
||||||
|
setSelectedTargetId(target.id);
|
||||||
|
setExpandedTargetId(target.id);
|
||||||
|
}}
|
||||||
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
|
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
|
||||||
selectedTargetId === target.id
|
selectedTargetId === target.id
|
||||||
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
|
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
|
||||||
@@ -98,12 +150,51 @@ export default function StatisticsView() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<div className="flex flex-col landscape:flex-row gap-4 flex-1 landscape:overflow-hidden">
|
||||||
{/* Left Side: Trend Chart / Dashboard Sidebar */}
|
{/* 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">
|
<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 */}
|
{/* KPI Cards in Landscape — linked to selected target */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const sel = targets.find(t => t.id === selectedTargetId);
|
const sel = selectedTarget;
|
||||||
return (
|
return (
|
||||||
<div className="hidden landscape:grid grid-cols-4 gap-3 flex-shrink-0">
|
<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">
|
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
||||||
@@ -130,7 +221,7 @@ export default function StatisticsView() {
|
|||||||
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
<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-[10px] font-bold text-slate-400 uppercase mb-1">完成率</div>
|
||||||
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
||||||
{(sel?.avgCompletion ?? 0).toFixed(1)}
|
{selectedCompletion.toFixed(1)}
|
||||||
<span className="text-blue-500 text-[10px] ml-1">%</span>
|
<span className="text-blue-500 text-[10px] ml-1">%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,9 +249,8 @@ export default function StatisticsView() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 w-full min-h-[250px] relative">
|
<div className="h-[280px] w-full min-w-0">
|
||||||
<div className="absolute inset-0">
|
<ResponsiveContainer width="100%" height={280} minWidth={0}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
{chartType === 'bar' ? (
|
{chartType === 'bar' ? (
|
||||||
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
|
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
|
||||||
@@ -202,7 +292,6 @@ export default function StatisticsView() {
|
|||||||
</AreaChart>
|
</AreaChart>
|
||||||
)}
|
)}
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,9 +299,12 @@ export default function StatisticsView() {
|
|||||||
{/* Right Side: Summary Section */}
|
{/* 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="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 items-center justify-between px-2 flex-shrink-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<div className="w-1 h-4 bg-blue-600 rounded-full" />
|
<div className="w-1 h-4 bg-blue-600 rounded-full" />
|
||||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest">车型考核里程汇总</h3>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsTableFullscreen(true)}
|
onClick={() => setIsTableFullscreen(true)}
|
||||||
@@ -223,13 +315,18 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-1.5 overflow-y-auto no-scrollbar pb-2">
|
<div className="grid grid-cols-1 gap-1.5 overflow-y-auto no-scrollbar pb-2">
|
||||||
{targets.map((target, idx) => (
|
{(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 (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
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"
|
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={() => {
|
onClick={() => {
|
||||||
const name = target.targetName;
|
setExpandedTargetId(target.id);
|
||||||
setExpandedModel(expandedModel === name ? null : name);
|
|
||||||
if (!targetVehiclesMap[target.id]) {
|
if (!targetVehiclesMap[target.id]) {
|
||||||
fetchTargetVehicles(target.id).then(data => {
|
fetchTargetVehicles(target.id).then(data => {
|
||||||
setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data }));
|
setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data }));
|
||||||
@@ -249,12 +346,12 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-[9px] text-slate-400">完成率:</span>
|
<span className="text-[9px] text-slate-400">{assessment ? `${assessment.label}完成:` : '完成率:'}</span>
|
||||||
<span className={`text-[9px] font-bold ${target.avgCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{target.avgCompletion.toFixed(1)}%</span>
|
<span className={`text-[9px] font-bold ${primaryCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{fmtPercent(primaryCompletion)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-[9px] text-slate-400">达标:</span>
|
<span className="text-[9px] text-slate-400">{primaryQualifiedLabel}</span>
|
||||||
<span className="text-[9px] font-bold text-slate-600">{target.yearQualifiedCount}台</span>
|
<span className="text-[9px] font-bold text-slate-600">{primaryQualified}台</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,7 +366,7 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ rotate: expandedModel === target.targetName ? 180 : 0 }}
|
animate={{ rotate: 180 }}
|
||||||
className="text-slate-300"
|
className="text-slate-300"
|
||||||
>
|
>
|
||||||
<ChevronDown size={14} />
|
<ChevronDown size={14} />
|
||||||
@@ -277,52 +374,103 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence initial={false} mode="wait">
|
||||||
{expandedModel === target.targetName && (
|
{expandedTargetId === target.id && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ height: 0, opacity: 0 }}
|
initial={{ height: 0, opacity: 0 }}
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
exit={{ height: 0, opacity: 0 }}
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||||
className="overflow-hidden"
|
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="pt-3 mt-2 border-t border-slate-50 grid grid-cols-2 gap-x-4 gap-y-3">
|
||||||
<div className="space-y-0.5">
|
<div className="col-span-2 flex items-center justify-between gap-3 bg-blue-50/70 p-2 rounded-lg">
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">考核区间</p>
|
<span className="text-[10px] font-black text-blue-700">考核年度</span>
|
||||||
{target.periods.map((p, i) => (
|
<select
|
||||||
<p key={i} className="text-[10px] font-black text-slate-700">{p}</p>
|
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>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="col-span-2 bg-slate-50/80 rounded-lg p-2 grid grid-cols-2 gap-3">
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核里程</p>
|
<div className="space-y-0.5">
|
||||||
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核区间</p>
|
||||||
</div>
|
{target.periods.map((p, i) => (
|
||||||
<div className="space-y-0.5">
|
<p key={i} className="text-[10px] font-black text-slate-700">{p}</p>
|
||||||
<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>
|
<div className="space-y-0.5">
|
||||||
<div className="space-y-0.5">
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核里程</p>
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">50%达标数</p>
|
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
|
||||||
<p className="text-[10px] font-black text-blue-600">{target.halfQualifiedCount} 台</p>
|
</div>
|
||||||
</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>
|
</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">
|
<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">剩余考核天数</span>
|
<span className="text-[9px] font-bold text-slate-500">{assessment ? `${assessment.label}剩余考核天数` : '剩余考核天数'}</span>
|
||||||
<span className="text-[10px] font-black text-slate-900">{target.daysLeft} 天</span>
|
<span className="text-[10px] font-black text-slate-900">{assessment?.daysLeft ?? target.daysLeft} 天</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vehicle List Detail */}
|
{/* Vehicle List Detail */}
|
||||||
@@ -363,6 +511,8 @@ export default function StatisticsView() {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -391,7 +541,7 @@ export default function StatisticsView() {
|
|||||||
<span className="text-slate-700">|</span>
|
<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-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-700">|</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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -417,12 +567,12 @@ export default function StatisticsView() {
|
|||||||
<tr className="border-b border-slate-800/60">
|
<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 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 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 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-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-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-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-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-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-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>
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-14">余天</th>
|
||||||
@@ -430,10 +580,22 @@ export default function StatisticsView() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/30">
|
<tbody className="divide-y divide-slate-800/30">
|
||||||
{targets.map((target, idx) => (
|
{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 (
|
||||||
<tr key={idx} className="hover:bg-slate-800/20 transition-colors">
|
<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">
|
<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-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>
|
<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>
|
||||||
<td className="px-3 py-3 text-xs font-bold text-slate-300 text-center">{target.vehicleCount}</td>
|
<td className="px-3 py-3 text-xs font-bold text-slate-300 text-center">{target.vehicleCount}</td>
|
||||||
@@ -441,28 +603,31 @@ export default function StatisticsView() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full ${target.avgCompletion >= 90 ? 'bg-emerald-500' : target.avgCompletion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
|
className={`h-full rounded-full ${completion >= 90 ? 'bg-emerald-500' : completion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
|
||||||
style={{ width: `${Math.min(target.avgCompletion, 100)}%` }}
|
style={{ width: `${Math.min(completion, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-black text-white w-10 text-right">{target.avgCompletion.toFixed(1)}%</span>
|
<span className="text-[10px] font-black text-white w-10 text-right">{completion.toFixed(1)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between mt-1 text-[9px] text-slate-500">
|
<div className="flex justify-between mt-1 text-[9px] text-slate-500">
|
||||||
<span>{fmtKm(target.cumulativeTotal)}</span>
|
<span>{fmtKm(completed)}</span>
|
||||||
<span>/ {fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</span>
|
<span>/ {fmtKm(goal)} km</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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 text-slate-300 text-right">{fmtKm(taskPerVehicle)} 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-emerald-400 text-center">{qualified}</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-blue-400 text-center">{halfQualified}</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 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(target.currentYearTarget)} 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(target.currentYearCompleted)} 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(target.remaining)} 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">{target.daysLeft}</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">{fmtKm(target.dailyTarget)} km</td>
|
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">
|
||||||
|
{assessment && days === 0 ? '考核已到期' : `${fmtKm(daily)} km`}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import { X, Truck } from 'lucide-react';
|
|||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
|
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import type { MonitoringVehicle } from './types';
|
import type { MileageSourceGroup, MonitoringVehicle } from './types';
|
||||||
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
|
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vehicle: MonitoringVehicle | null;
|
vehicle: MonitoringVehicle | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
sourcePriority: MileageSourceGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type RangeKey = 'last15' | 'month' | 'quarter';
|
type RangeKey = 'last15' | 'month' | 'quarter';
|
||||||
@@ -56,7 +57,13 @@ function formatLabel(date: string, key: RangeKey): string {
|
|||||||
return date.slice(5);
|
return date.slice(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VehicleDetailModal({ vehicle, onClose }: Props) {
|
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) {
|
||||||
const [days, setDays] = useState<VehicleRecentDay[]>([]);
|
const [days, setDays] = useState<VehicleRecentDay[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [range, setRange] = useState<RangeKey>('last15');
|
const [range, setRange] = useState<RangeKey>('last15');
|
||||||
@@ -74,12 +81,12 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setDays([]);
|
setDays([]);
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetchVehicleRecent(vehicle.plate, { start, end })
|
fetchVehicleRecent(vehicle.plate, { start, end, sourcePriority })
|
||||||
.then(d => { if (!cancelled) setDays(d.days); })
|
.then(d => { if (!cancelled) setDays(d.days); })
|
||||||
.catch(() => { if (!cancelled) setDays([]); })
|
.catch(() => { if (!cancelled) setDays([]); })
|
||||||
.finally(() => { if (!cancelled) setLoading(false); });
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [vehicle?.plate, range]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [vehicle?.plate, range, sourcePriority]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// 锁滚动
|
// 锁滚动
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -280,7 +287,21 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) {
|
|||||||
transition={{ delay: Math.min(i * 0.012, 0.4), duration: 0.18 }}
|
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"
|
className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-slate-50"
|
||||||
>
|
>
|
||||||
<span className="text-[11px] font-mono font-bold text-slate-600">{d.date}</span>
|
<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>
|
||||||
<div className="flex items-center gap-2 flex-1 ml-3">
|
<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">
|
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import type { MonitoringData, TargetSummary, TargetVehicle, TrendPoint } from './types';
|
import type {
|
||||||
|
MileageSourceCategory,
|
||||||
|
MileageSourceGroup,
|
||||||
|
MileageSourceProtocol,
|
||||||
|
MonitoringData,
|
||||||
|
TargetSummary,
|
||||||
|
TargetVehicle,
|
||||||
|
TrendPoint,
|
||||||
|
} from './types';
|
||||||
import { fetchJson } from '../../auth/api-client';
|
import { fetchJson } from '../../auth/api-client';
|
||||||
|
|
||||||
const BASE = '/api/mileage';
|
const BASE = '/api/mileage';
|
||||||
@@ -16,11 +24,16 @@ export async function fetchMonitoring(params?: {
|
|||||||
rentStatus?: string;
|
rentStatus?: string;
|
||||||
platePrefix?: string;
|
platePrefix?: string;
|
||||||
targetName?: string;
|
targetName?: string;
|
||||||
|
targetNames?: string[];
|
||||||
region?: string;
|
region?: string;
|
||||||
plate?: string;
|
plate?: string;
|
||||||
mileageMin?: string;
|
mileageMin?: string;
|
||||||
mileageMax?: string;
|
mileageMax?: string;
|
||||||
date?: string;
|
date?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
brands?: string[];
|
||||||
|
sourcePriority?: MileageSourceGroup[];
|
||||||
}): Promise<MonitoringData> {
|
}): Promise<MonitoringData> {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (params?.sortBy) query.set('sortBy', params.sortBy);
|
if (params?.sortBy) query.set('sortBy', params.sortBy);
|
||||||
@@ -35,11 +48,24 @@ export async function fetchMonitoring(params?: {
|
|||||||
if (params?.rentStatus) query.set('rentStatus', params.rentStatus);
|
if (params?.rentStatus) query.set('rentStatus', params.rentStatus);
|
||||||
if (params?.platePrefix) query.set('platePrefix', params.platePrefix);
|
if (params?.platePrefix) query.set('platePrefix', params.platePrefix);
|
||||||
if (params?.targetName) query.set('targetName', params.targetName);
|
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?.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?.plate) query.set('plate', params.plate);
|
||||||
if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
|
if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
|
||||||
if (params?.mileageMax) query.set('mileageMax', params.mileageMax);
|
if (params?.mileageMax) query.set('mileageMax', params.mileageMax);
|
||||||
if (params?.date) query.set('date', params.date);
|
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();
|
const qs = query.toString();
|
||||||
return fetchJson<MonitoringData>(`${BASE}/monitoring${qs ? `?${qs}` : ''}`);
|
return fetchJson<MonitoringData>(`${BASE}/monitoring${qs ? `?${qs}` : ''}`);
|
||||||
}
|
}
|
||||||
@@ -66,6 +92,8 @@ export interface VehicleRecentDay {
|
|||||||
date: string;
|
date: string;
|
||||||
dailyKm: number;
|
dailyKm: number;
|
||||||
isDataSynced: boolean;
|
isDataSynced: boolean;
|
||||||
|
sourceProtocol: MileageSourceProtocol | null;
|
||||||
|
sourceCategory: Exclude<MileageSourceCategory, 'MIXED'> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VehicleRecentResponse {
|
export interface VehicleRecentResponse {
|
||||||
@@ -73,17 +101,156 @@ export interface VehicleRecentResponse {
|
|||||||
start?: string;
|
start?: string;
|
||||||
end?: string;
|
end?: string;
|
||||||
days: VehicleRecentDay[];
|
days: VehicleRecentDay[];
|
||||||
|
sourcePriority?: MileageSourceGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchVehicleRecent(
|
export async function fetchVehicleRecent(
|
||||||
plate: string,
|
plate: string,
|
||||||
range: { days?: number; start?: string; end?: string } = { days: 15 },
|
range: {
|
||||||
|
days?: number;
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
sourcePriority?: MileageSourceGroup[];
|
||||||
|
} = { days: 15 },
|
||||||
): Promise<VehicleRecentResponse> {
|
): Promise<VehicleRecentResponse> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (range.start) params.set('start', range.start);
|
if (range.start) params.set('start', range.start);
|
||||||
if (range.end) params.set('end', range.end);
|
if (range.end) params.set('end', range.end);
|
||||||
if (range.days != null) params.set('days', String(range.days));
|
if (range.days != null) params.set('days', String(range.days));
|
||||||
|
if (range.sourcePriority?.length) params.set('sourcePriority', range.sourcePriority.join(','));
|
||||||
return fetchJson<VehicleRecentResponse>(
|
return fetchJson<VehicleRecentResponse>(
|
||||||
`${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}`
|
`${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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
|
export type MileageSourceGroup = 'instrument' | 'gps';
|
||||||
|
export type MileageSourceCategory = 'INSTRUMENT' | 'GPS' | 'MIXED';
|
||||||
|
export type MileageSourceProtocol = 'GB32960' | 'MQTT' | 'JT808';
|
||||||
|
|
||||||
export interface MonitoringVehicle {
|
export interface MonitoringVehicle {
|
||||||
plate: string;
|
plate: string;
|
||||||
vin: string;
|
vin: string;
|
||||||
dailyKm: number;
|
dailyKm: number;
|
||||||
|
dailyMileage?: Record<string, number>;
|
||||||
totalKm: number | null;
|
totalKm: number | null;
|
||||||
source: string;
|
source: string;
|
||||||
|
sourceProtocol: MileageSourceProtocol | null;
|
||||||
|
sourceCategory: MileageSourceCategory | null;
|
||||||
|
dailySourceProtocols?: Record<string, MileageSourceProtocol | null>;
|
||||||
|
dataTime: string | null;
|
||||||
|
calculatedAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
isOnline: boolean;
|
isOnline: boolean;
|
||||||
isDataSynced: boolean;
|
isDataSynced: boolean;
|
||||||
customer: string | null;
|
customer: string | null;
|
||||||
@@ -13,6 +24,8 @@ export interface MonitoringVehicle {
|
|||||||
entity: string | null;
|
entity: string | null;
|
||||||
project: string | null;
|
project: string | null;
|
||||||
region: string | null;
|
region: string | null;
|
||||||
|
brand: string | null;
|
||||||
|
targetNames: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MonitoringStats {
|
export interface MonitoringStats {
|
||||||
@@ -32,16 +45,20 @@ export interface MonitoringFilters {
|
|||||||
platePrefixes: { prefix: string; count: number }[];
|
platePrefixes: { prefix: string; count: number }[];
|
||||||
targetNames: string[];
|
targetNames: string[];
|
||||||
regions: string[];
|
regions: string[];
|
||||||
|
brands: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MonitoringData {
|
export interface MonitoringData {
|
||||||
vehicles: MonitoringVehicle[];
|
vehicles: MonitoringVehicle[];
|
||||||
stats: MonitoringStats;
|
stats: MonitoringStats;
|
||||||
filters: MonitoringFilters;
|
filters: MonitoringFilters;
|
||||||
|
rangeDailyTotals?: { date: string; totalKm: number }[];
|
||||||
|
dateRange?: { start: string; end: string };
|
||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
sourcePriority: MileageSourceGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TargetSummary {
|
export interface TargetSummary {
|
||||||
@@ -63,6 +80,37 @@ export interface TargetSummary {
|
|||||||
remaining: number;
|
remaining: number;
|
||||||
daysLeft: number;
|
daysLeft: number;
|
||||||
dailyTarget: 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 {
|
export interface TargetVehicle {
|
||||||
|
|||||||
@@ -2,15 +2,24 @@ import * as XLSX from 'xlsx';
|
|||||||
import type { MonitoringVehicle } from './types';
|
import type { MonitoringVehicle } from './types';
|
||||||
|
|
||||||
interface ExportContext {
|
interface ExportContext {
|
||||||
date: string;
|
date?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
sortBy: 'today' | 'total';
|
sortBy: 'today' | 'total';
|
||||||
}
|
}
|
||||||
|
|
||||||
const HEADERS = [
|
const BASE_HEADERS = [
|
||||||
'状态', '车牌号', '客户', '业务部门', '项目', '租赁状态',
|
'状态', '数据来源', '车牌号', '客户', '业务部门', '项目', '租赁状态',
|
||||||
'运营区域', '今日里程(km)', '累计里程(km)',
|
'运营区域',
|
||||||
] as const;
|
] 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 {
|
function statusLabel(v: MonitoringVehicle): string {
|
||||||
if (!v.isDataSynced) return '未对接';
|
if (!v.isDataSynced) return '未对接';
|
||||||
return v.isOnline ? '在线' : '离线';
|
return v.isOnline ? '在线' : '离线';
|
||||||
@@ -18,47 +27,83 @@ function statusLabel(v: MonitoringVehicle): string {
|
|||||||
|
|
||||||
function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | number {
|
function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | number {
|
||||||
if (kind === 'today') {
|
if (kind === 'today') {
|
||||||
// 当日未对接但有历史累计,视作今日 0;只有完全无数据才标「未对接」
|
|
||||||
if (!v.isDataSynced && v.totalKm == null) return '未对接';
|
if (!v.isDataSynced && v.totalKm == null) return '未对接';
|
||||||
return Math.max(0, Math.round(v.dailyKm || 0));
|
return Math.max(0, v.dailyKm || 0);
|
||||||
}
|
}
|
||||||
return v.totalKm != null ? Math.round(v.totalKm) : '未对接';
|
return v.totalKm != null ? v.totalKm : '未对接';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void {
|
export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void {
|
||||||
const data: (string | number)[][] = [
|
const start = ctx.startDate || ctx.date || '';
|
||||||
[...HEADERS],
|
const end = ctx.endDate || ctx.date || '';
|
||||||
...vehicles.map(v => [
|
const isRange = !!start && !!end && start !== end;
|
||||||
statusLabel(v),
|
|
||||||
v.plate,
|
const dayKeys = Array.from(
|
||||||
v.customer || '',
|
new Set(vehicles.flatMap(v => Object.keys(v.dailyMileage || {})))
|
||||||
v.department || '',
|
).sort();
|
||||||
v.project || '',
|
|
||||||
v.rentStatus || '',
|
// 车辆汇总 sheet:基础列 + 每日里程列 + 区间合计 + 累计里程
|
||||||
v.region || '',
|
const dailyHeaders = dayKeys.map(day => `${day.slice(5)}里程(km)`);
|
||||||
mileageCell(v, 'today'),
|
const summaryHeaders = [
|
||||||
mileageCell(v, 'total'),
|
...BASE_HEADERS,
|
||||||
]),
|
...(dayKeys.length > 0 ? dailyHeaders : []),
|
||||||
|
isRange ? '区间合计(km)' : '当日里程(km)',
|
||||||
|
'累计里程(km)',
|
||||||
];
|
];
|
||||||
|
|
||||||
const ws = XLSX.utils.aoa_to_sheet(data);
|
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'),
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
ws['!cols'] = [
|
const ws = XLSX.utils.aoa_to_sheet(summaryData);
|
||||||
|
|
||||||
|
const numFixedCols = BASE_HEADERS.length;
|
||||||
|
const wsCols: { wch: number }[] = [
|
||||||
{ wch: 8 }, // 状态
|
{ wch: 8 }, // 状态
|
||||||
|
{ wch: 16 }, // 数据来源
|
||||||
{ wch: 12 }, // 车牌号
|
{ wch: 12 }, // 车牌号
|
||||||
{ wch: 28 }, // 客户
|
{ wch: 28 }, // 客户
|
||||||
{ wch: 14 }, // 业务部门
|
{ wch: 14 }, // 业务部门
|
||||||
{ wch: 16 }, // 项目
|
{ wch: 16 }, // 项目
|
||||||
{ wch: 10 }, // 租赁状态
|
{ wch: 10 }, // 租赁状态
|
||||||
{ wch: 12 }, // 运营区域
|
{ 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: 0, ySplit: 1 } as never;
|
// 冻结首行 + 固定列
|
||||||
|
ws['!freeze'] = { xSplit: numFixedCols, ySplit: 1 } as never;
|
||||||
|
|
||||||
// 表头样式(在客户端 SheetJS 社区版仅基本样式生效)
|
for (let r = 1; r < summaryData.length; r++) {
|
||||||
for (let c = 0; c < HEADERS.length; c++) {
|
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++) {
|
||||||
const ref = XLSX.utils.encode_cell({ r: 0, c });
|
const ref = XLSX.utils.encode_cell({ r: 0, c });
|
||||||
if (ws[ref]) {
|
if (ws[ref]) {
|
||||||
(ws[ref] as { s?: unknown }).s = {
|
(ws[ref] as { s?: unknown }).s = {
|
||||||
@@ -70,7 +115,53 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
|||||||
}
|
}
|
||||||
|
|
||||||
const wb = XLSX.utils.book_new();
|
const wb = XLSX.utils.book_new();
|
||||||
XLSX.utils.book_append_sheet(wb, ws, '里程明细');
|
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, '每日明细');
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const y = now.getFullYear();
|
const y = now.getFullYear();
|
||||||
@@ -78,7 +169,9 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
|||||||
const d = String(now.getDate()).padStart(2, '0');
|
const d = String(now.getDate()).padStart(2, '0');
|
||||||
const hh = String(now.getHours()).padStart(2, '0');
|
const hh = String(now.getHours()).padStart(2, '0');
|
||||||
const mm = String(now.getMinutes()).padStart(2, '0');
|
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||||
const dateTag = ctx.date ? ctx.date.replace(/-/g, '') : `${y}${m}${d}`;
|
const dateTag = start && end
|
||||||
const filename = `里程看板_${dateTag}_${hh}${mm}_${ctx.sortBy === 'today' ? '今日' : '累计'}.xlsx`;
|
? `${start.replace(/-/g, '')}-${end.replace(/-/g, '')}`
|
||||||
|
: `${y}${m}${d}`;
|
||||||
|
const filename = `里程看板_${dateTag}_${hh}${mm}_${ctx.sortBy === 'today' ? (isRange ? '区间' : '今日') : '累计'}.xlsx`;
|
||||||
XLSX.writeFile(wb, filename);
|
XLSX.writeFile(wb, filename);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { Filter, RotateCcw, X, Search, ChevronDown, CheckSquare, Send, Clock, Download } from 'lucide-react';
|
import { Activity, AlertTriangle, CheckCircle2, Filter, RotateCcw, X, Search, ChevronDown, CheckSquare, Send, Clock, Download, SendHorizonal } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { fetchSuggestions, sendNotifyBatch } from './api';
|
import { fetchSuggestions, sendNotifyBatch } from './api';
|
||||||
import type { SchedulingResponse, SchedulingSuggestion, CandidateVehicle } from './types';
|
import type { SchedulingResponse, SchedulingSuggestion, CandidateVehicle } from './types';
|
||||||
@@ -9,6 +9,7 @@ import NotificationHistory from './NotificationHistory';
|
|||||||
import { exportSuggestionsCsv } from './csv-export';
|
import { exportSuggestionsCsv } from './csv-export';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
import { MetricTile, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface';
|
||||||
|
|
||||||
type TypeFilter = 'all' | 'qualified' | 'hopeless';
|
type TypeFilter = 'all' | 'qualified' | 'hopeless';
|
||||||
|
|
||||||
@@ -87,63 +88,43 @@ function FilterSelect({ label, options, value, onChange, placeholder }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Skeleton pulse block */
|
|
||||||
function Sk({ className }: { className?: string }) {
|
function Sk({ className }: { className?: string }) {
|
||||||
return <div className={`animate-pulse bg-slate-200/70 rounded ${className ?? ''}`} />;
|
return <div className={`animate-pulse bg-slate-200/70 rounded ${className ?? ''}`} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SkeletonPage() {
|
function SkeletonPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F0F4F8] font-sans p-3 md:p-6">
|
<PageFrame
|
||||||
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">
|
title="智能调度工作台"
|
||||||
{/* Cards skeleton */}
|
subtitle="自动识别高里程可释放车辆与低里程待救援车辆,形成可登记、可追踪的运营干预建议。"
|
||||||
<div className="grid grid-cols-3 gap-2.5">
|
icon={Activity}
|
||||||
{[0, 1, 2].map(i => (
|
eyebrow="SCHEDULING OPS"
|
||||||
<div key={i} className="p-4 rounded-2xl bg-white border border-slate-100 space-y-2.5">
|
meta="建议生成中 · 正在计算候选车辆"
|
||||||
<Sk className="h-3 w-16" />
|
>
|
||||||
<Sk className="h-7 w-12" />
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<Sk className="h-2.5 w-24" />
|
{Array.from({ length: 4 }).map((_, i) => <SkeletonBlock key={i} className="h-28" />)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
<SurfaceCard>
|
||||||
</div>
|
<div className="space-y-3 p-4">
|
||||||
|
<SkeletonBlock className="h-5 w-40" />
|
||||||
{/* List card skeleton */}
|
<div className="flex gap-2">
|
||||||
<div className="bg-white rounded-2xl border border-slate-200/60 overflow-hidden">
|
{[0, 1, 2, 3].map(i => <SkeletonBlock key={i} className="h-8 w-24 rounded-full" />)}
|
||||||
{/* 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>
|
</div>
|
||||||
|
|
||||||
{/* Rows */}
|
|
||||||
<div className="divide-y divide-slate-50">
|
<div className="divide-y divide-slate-50">
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
<div key={i} className="px-4 py-3 flex items-center gap-3">
|
<div key={i} className="flex items-center gap-3 py-3">
|
||||||
<Sk className="w-1 h-10 rounded-full" />
|
<SkeletonBlock className="h-10 w-1 rounded-full" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<SkeletonBlock className="h-3.5 w-48" />
|
||||||
<Sk className="h-3.5 w-20" />
|
<SkeletonBlock className="h-2.5 w-72 max-w-full" />
|
||||||
<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>
|
</div>
|
||||||
<Sk className="h-4 w-8" />
|
<SkeletonBlock className="h-6 w-16" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SurfaceCard>
|
||||||
</div>
|
</PageFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,89 +256,33 @@ export default function SchedulingModule() {
|
|||||||
if (loading && !data) return <SkeletonPage />;
|
if (loading && !data) return <SkeletonPage />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F0F4F8] text-slate-800 font-sans p-3 md:p-6" style={{ overflowX: 'clip' }}>
|
<PageFrame
|
||||||
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">
|
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>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
|
||||||
{/* ===== Summary Cards ===== */}
|
{/* ===== Summary Cards ===== */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2.5">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
{/* 里程高·换下 — warm orange */}
|
<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'}>
|
||||||
<button
|
<MetricTile icon={CheckCircle2} label="已完成考核目标" value={summary?.qualifiedCount ?? 0} unit="台" helper="换下,腾位给待达标车" tone="amber" />
|
||||||
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>
|
||||||
|
<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'}>
|
||||||
{/* 里程低·换走 — cool blue */}
|
<MetricTile icon={AlertTriangle} label="预估无法达标" value={summary?.hopelessCount ?? 0} unit="台" helper="换走,换上快达标的车" tone="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>
|
||||||
|
<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'}>
|
||||||
{/* 替换建议 — neutral dark */}
|
<MetricTile icon={Activity} label="替换建议" value={summary?.suggestionCount ?? 0} unit="条" helper={`执行后预计 +${summary?.estimatedGain ?? 0} 台达标`} tone="slate" />
|
||||||
<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>
|
||||||
|
<button type="button" onClick={() => { setShowHistory(true); setHistoryRecentOnly(true); }} className="rounded-2xl">
|
||||||
{/* 近期已干预 — emerald */}
|
<MetricTile icon={SendHorizonal} label="近期已干预" value={summary?.recentInterventionCount ?? 0} unit="条" helper="最近 7 天 · 点击查看" tone="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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -632,8 +557,7 @@ export default function SchedulingModule() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
<RotatingFooterHint className="pb-4" />
|
<RotatingFooterHint className="pb-4" />
|
||||||
</div>
|
</PageFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
158
src/modules/vehicle-heatmap/AmapHeatmapCanvas.tsx
Normal file
158
src/modules/vehicle-heatmap/AmapHeatmapCanvas.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/modules/vehicle-heatmap/HeatmapDetailPanel.tsx
Normal file
86
src/modules/vehicle-heatmap/HeatmapDetailPanel.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/modules/vehicle-heatmap/HeatmapFilters.tsx
Normal file
115
src/modules/vehicle-heatmap/HeatmapFilters.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
249
src/modules/vehicle-heatmap/VehicleHeatmapModule.tsx
Normal file
249
src/modules/vehicle-heatmap/VehicleHeatmapModule.tsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/modules/vehicle-heatmap/api.ts
Normal file
34
src/modules/vehicle-heatmap/api.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
59
src/modules/vehicle-heatmap/types.ts
Normal file
59
src/modules/vehicle-heatmap/types.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
@@ -52,11 +52,15 @@ app.get('/exchange', async (c) => {
|
|||||||
// 查询 depCode 对应的部门名称
|
// 查询 depCode 对应的部门名称
|
||||||
let depName = '';
|
let depName = '';
|
||||||
if (userInfo.depCode) {
|
if (userInfo.depCode) {
|
||||||
const [rows] = await pool.execute(
|
try {
|
||||||
'SELECT dep_name FROM tab_department WHERE dep_code = ? AND is_deleted = 0 LIMIT 1',
|
const [rows] = await pool.execute(
|
||||||
[userInfo.depCode]
|
'SELECT dep_name FROM tab_department WHERE dep_code = ? AND is_deleted = 0 LIMIT 1',
|
||||||
) as [{ dep_name: string }[], unknown];
|
[userInfo.depCode]
|
||||||
depName = rows[0]?.dep_name || '';
|
) as [{ dep_name: string }[], unknown];
|
||||||
|
depName = rows[0]?.dep_name || '';
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.code !== 'ER_NO_SUCH_TABLE') throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: JwtPayload = {
|
const payload: JwtPayload = {
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import type { JwtPayload, AuthUser } from './types.js';
|
|||||||
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
|
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) {
|
export async function authMiddleware(c: Context, next: Next) {
|
||||||
const path = c.req.path;
|
const path = c.req.path;
|
||||||
|
|||||||
29
src/server/heatmap-db.ts
Normal file
29
src/server/heatmap-db.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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;
|
||||||
17
src/server/hydrogen-db.ts
Normal file
17
src/server/hydrogen-db.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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;
|
||||||
@@ -9,6 +9,8 @@ import schedulingRouter from './routes/scheduling/index.js';
|
|||||||
import energyRouter from './routes/energy/index.js';
|
import energyRouter from './routes/energy/index.js';
|
||||||
import eleRouter from './routes/ele/index.js';
|
import eleRouter from './routes/ele/index.js';
|
||||||
import feedbackRouter from './routes/feedback/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 { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
|
||||||
import authRouter from './auth/login.js';
|
import authRouter from './auth/login.js';
|
||||||
import { authMiddleware } from './auth/middleware.js';
|
import { authMiddleware } from './auth/middleware.js';
|
||||||
@@ -31,6 +33,8 @@ app.route('/api/scheduling', schedulingRouter);
|
|||||||
app.route('/api/energy', energyRouter);
|
app.route('/api/energy', energyRouter);
|
||||||
app.route('/api/ele', eleRouter);
|
app.route('/api/ele', eleRouter);
|
||||||
app.route('/api/feedback', feedbackRouter);
|
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() }));
|
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import mysql from 'mysql2/promise';
|
import mysql from 'mysql2/promise';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
const mileagePool = mysql.createPool({
|
const mileagePool = mysql.createPool({
|
||||||
host: '101.133.130.65',
|
host: process.env.MILEAGE_DB_HOST || '101.133.130.65',
|
||||||
port: 3306,
|
port: Number(process.env.MILEAGE_DB_PORT) || 3306,
|
||||||
user: 'bi_reader_02',
|
user: process.env.MILEAGE_DB_USER || 'bi_reader_02',
|
||||||
password: 'bi_reader_02_Pass',
|
password: process.env.MILEAGE_DB_PASSWORD || 'bi_reader_02_Pass',
|
||||||
database: 'hydrogen_energy',
|
database: process.env.MILEAGE_DB_NAME || 'hydrogen_energy',
|
||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
connectionLimit: 5,
|
connectionLimit: 5,
|
||||||
queueLimit: 0,
|
queueLimit: 0,
|
||||||
|
|||||||
@@ -149,8 +149,8 @@ async function buildPlateLookup(plates: Set<string>): Promise<Map<string, string
|
|||||||
const placeholders = arr.map(() => '?').join(',');
|
const placeholders = arr.map(() => '?').join(',');
|
||||||
const [rows] = await pool.query<RowDataPacket[]>(
|
const [rows] = await pool.query<RowDataPacket[]>(
|
||||||
`SELECT plate_number, CAST(id AS CHAR) AS truck_id
|
`SELECT plate_number, CAST(id AS CHAR) AS truck_id
|
||||||
FROM tab_truck
|
FROM vehicle_info
|
||||||
WHERE is_deleted = 0 AND plate_number IN (${placeholders})`,
|
WHERE del_flag = '0' AND plate_number IN (${placeholders})`,
|
||||||
arr,
|
arr,
|
||||||
);
|
);
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { RowDataPacket } from 'mysql2';
|
import type { RowDataPacket } from 'mysql2';
|
||||||
import pool from '../../db.js';
|
import pool from '../../db.js';
|
||||||
|
import hydrogenPool from '../../hydrogen-db.js';
|
||||||
import { cached } from './cache.js';
|
import { cached } from './cache.js';
|
||||||
import type { AuthUser } from '../../auth/types.js';
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
import { canAccessEnergy } from '../../auth/types.js';
|
import { canAccessEnergy } from '../../auth/types.js';
|
||||||
@@ -19,20 +20,70 @@ app.use('*', async (c, next) => {
|
|||||||
|
|
||||||
const HYDROGEN_MIN_DATE = '2024-01-01';
|
const HYDROGEN_MIN_DATE = '2024-01-01';
|
||||||
|
|
||||||
// hydrogen_time 已是 CST 字面值,直接使用即可(不再 +8 小时)
|
// hydrogen_fuel_ledger.refuel_time 已是业务本地时间字面值,直接使用即可(不再 +8 小时)
|
||||||
const HYDROGEN_LOCAL = `hydrogen_time`;
|
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'`;
|
||||||
const ELECTRIC_LOCAL = `charging_start_time`;
|
const ELECTRIC_LOCAL = `charging_start_time`;
|
||||||
|
|
||||||
type CustomerKind = 'external' | 'lingniu' | 'all';
|
type CustomerKind = 'external' | 'lingniu' | 'all';
|
||||||
|
|
||||||
// 外部/我司判定:truck_id 为空 = 外部;truck_id 非空 = 我司(羚牛车辆)
|
// 新账本 hydrogen_fuel_ledger 当前只承载羚牛车辆订单;外部车辆数据源待接入。
|
||||||
function customerClause(field: string, customer: CustomerKind): string {
|
function customerClause(customer: CustomerKind): string {
|
||||||
if (customer === 'external') return `${field} IS NULL`;
|
if (customer === 'external') return '1=0';
|
||||||
if (customer === 'lingniu') return `${field} IS NOT NULL`;
|
if (customer === 'lingniu') return '1=1';
|
||||||
return '1=1';
|
return '1=1';
|
||||||
}
|
}
|
||||||
|
|
||||||
type Range = 'thisWeek' | 'thisMonth' | 'last15';
|
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 {
|
function rangeClause(localExpr: string, range: Range): string {
|
||||||
switch (range) {
|
switch (range) {
|
||||||
@@ -44,25 +95,16 @@ function rangeClause(localExpr: string, range: Range): string {
|
|||||||
|
|
||||||
/** 列出某 range 在当前时点下的全部日期(YYYY-MM-DD),用于补零 */
|
/** 列出某 range 在当前时点下的全部日期(YYYY-MM-DD),用于补零 */
|
||||||
function enumerateDates(range: Range): string[] {
|
function enumerateDates(range: Range): string[] {
|
||||||
const today = new Date();
|
const { start, end } = resolveDateRange(range);
|
||||||
today.setHours(0, 0, 0, 0);
|
return enumerateDateRange(start, end);
|
||||||
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') {
|
function enumerateDateRange(startYmd: string, endYmd: string): string[] {
|
||||||
// 周一为一周开始(与 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 result: string[] = [];
|
||||||
const cur = new Date(start);
|
const cur = new Date(`${startYmd}T00:00:00`);
|
||||||
while (cur <= today) {
|
const end = new Date(`${endYmd}T00:00:00`);
|
||||||
result.push(fmt(cur));
|
while (cur <= end) {
|
||||||
|
result.push(fmtYmd(cur));
|
||||||
cur.setDate(cur.getDate() + 1);
|
cur.setDate(cur.getDate() + 1);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -80,10 +122,10 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
|
|
||||||
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
|
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
|
||||||
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
|
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
|
||||||
const [yearListRows] = await pool.query<RowDataPacket[]>(
|
const [yearListRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
|
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
|
||||||
FROM tab_energy_hydrogen_bill
|
FROM ${HYDROGEN_TABLE}
|
||||||
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?
|
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?
|
||||||
ORDER BY y DESC`,
|
ORDER BY y DESC`,
|
||||||
[HYDROGEN_MIN_DATE],
|
[HYDROGEN_MIN_DATE],
|
||||||
);
|
);
|
||||||
@@ -92,44 +134,46 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
const isCurrentYear = year === todayYear;
|
const isCurrentYear = year === todayYear;
|
||||||
|
|
||||||
// KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
|
// KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
|
||||||
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
const [kpiRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT
|
`SELECT
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS yearKg,
|
THEN amount_kg ELSE 0 END) AS yearKg,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||||
THEN cost_expense ELSE 0 END) AS yearFee,
|
THEN cost_total ELSE 0 END) AS yearFee,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
||||||
THEN cost_expense ELSE 0 END) AS yearCustomerCost,
|
THEN cost_total ELSE 0 END) AS yearCustomerCost,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||||
THEN customer_expense ELSE 0 END) AS yearRevenue,
|
THEN fee_total ELSE 0 END) AS yearRevenue,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS ourYearKg,
|
THEN amount_kg ELSE 0 END) AS ourYearKg,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
|
||||||
THEN cost_expense ELSE 0 END) AS ourYearFee,
|
THEN cost_total ELSE 0 END) AS ourYearFee,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS customerYearKg,
|
THEN amount_kg ELSE 0 END) AS customerYearKg,
|
||||||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS monthKg,
|
THEN amount_kg ELSE 0 END) AS monthKg,
|
||||||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||||
THEN cost_expense ELSE 0 END) AS monthFee,
|
THEN cost_total 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')
|
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||||
THEN customer_expense ELSE 0 END) AS monthRevenue,
|
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,
|
||||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS todayKg,
|
THEN amount_kg ELSE 0 END) AS todayKg,
|
||||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||||
THEN cost_expense ELSE 0 END) AS todayFee,
|
THEN cost_total 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()
|
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||||
THEN customer_expense ELSE 0 END) AS todayRevenue,
|
AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
||||||
SUM(CASE WHEN truck_id IS NOT NULL
|
THEN cost_total ELSE 0 END) AS todayCustomerCost,
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS lingniuBornKg,
|
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||||
SUM(CASE WHEN truck_id IS NOT NULL
|
THEN fee_total ELSE 0 END) AS todayRevenue,
|
||||||
THEN cost_expense ELSE 0 END) AS lingniuBornFee
|
SUM(CASE WHEN vehicle_id IS NOT NULL
|
||||||
FROM tab_energy_hydrogen_bill
|
THEN amount_kg ELSE 0 END) AS lingniuBornKg,
|
||||||
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?`,
|
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} >= ?`,
|
||||||
[year, year, year, year, year, year, year,
|
[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,
|
||||||
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
|
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
|
||||||
@@ -166,23 +210,19 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Top5 加氢站(指定年份)
|
// Top5 加氢站(指定年份)
|
||||||
const [top5Rows] = await pool.query<RowDataPacket[]>(
|
const [top5Rows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT b.hydrogen_station_id AS id,
|
`SELECT b.station_id AS id,
|
||||||
COALESCE(MAX(s.short_name), MAX(s.name),
|
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
||||||
MAX(os.fixed_station_name), MAX(os.station_name),
|
CASE WHEN b.station_id IS NULL THEN '未关联站点'
|
||||||
MAX(i.hydrogen_station_name),
|
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
|
||||||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
SUM(b.amount_kg) AS kg,
|
||||||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS name,
|
SUM(b.cost_total) AS fee
|
||||||
SUM(b.hydrogen_quantity) AS kg,
|
FROM ${HYDROGEN_TABLE} b
|
||||||
SUM(b.cost_expense) AS fee
|
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||||
FROM tab_energy_hydrogen_bill b
|
WHERE ${HYDROGEN_BASE_WHERE_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 b.${HYDROGEN_LOCAL} >= ?
|
||||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||||
GROUP BY b.hydrogen_station_id
|
GROUP BY b.station_id
|
||||||
ORDER BY kg DESC
|
ORDER BY kg DESC
|
||||||
LIMIT 5`,
|
LIMIT 5`,
|
||||||
[HYDROGEN_MIN_DATE, year],
|
[HYDROGEN_MIN_DATE, year],
|
||||||
@@ -197,23 +237,19 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// 加氢站全量汇总(同年所有站,按加氢量降序)
|
// 加氢站全量汇总(同年所有站,按加氢量降序)
|
||||||
const [stationFullRows] = await pool.query<RowDataPacket[]>(
|
const [stationFullRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT b.hydrogen_station_id AS id,
|
`SELECT b.station_id AS id,
|
||||||
COALESCE(MAX(s.short_name), MAX(s.name),
|
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
||||||
MAX(os.fixed_station_name), MAX(os.station_name),
|
CASE WHEN b.station_id IS NULL THEN '未关联站点'
|
||||||
MAX(i.hydrogen_station_name),
|
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
|
||||||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
SUM(b.amount_kg) AS kg,
|
||||||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS name,
|
SUM(b.fee_total) AS revenue
|
||||||
SUM(b.hydrogen_quantity) AS kg,
|
FROM ${HYDROGEN_TABLE} b
|
||||||
SUM(b.customer_expense) AS revenue
|
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||||
FROM tab_energy_hydrogen_bill b
|
WHERE ${HYDROGEN_BASE_WHERE_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 b.${HYDROGEN_LOCAL} >= ?
|
||||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||||
GROUP BY b.hydrogen_station_id
|
GROUP BY b.station_id
|
||||||
ORDER BY kg DESC`,
|
ORDER BY kg DESC`,
|
||||||
[HYDROGEN_MIN_DATE, year],
|
[HYDROGEN_MIN_DATE, year],
|
||||||
);
|
);
|
||||||
@@ -228,14 +264,22 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// 区域占比(按城市,指定年份)— 取前 8,其余合并为"其他"
|
// 区域占比(按城市,指定年份)— 取前 8,其余合并为"其他"
|
||||||
const [regionRows] = await pool.query<RowDataPacket[]>(
|
const [regionRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT region, SUM(kg) AS kg FROM (
|
`SELECT region, SUM(kg) AS kg FROM (
|
||||||
SELECT REPLACE(REPLACE(SUBSTRING_INDEX(COALESCE(s.city, os.city, '未知'), '-', -1), '市', ''), '省', '') AS region,
|
SELECT CASE
|
||||||
b.hydrogen_quantity AS kg
|
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%嘉兴%' OR COALESCE(s.station_name, b.station_name, '') LIKE '%平湖%' THEN '嘉兴'
|
||||||
FROM tab_energy_hydrogen_bill b
|
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%广州%' THEN '广州'
|
||||||
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%佛山%' THEN '佛山'
|
||||||
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%成都%' THEN '成都'
|
||||||
WHERE b.is_deleted = 0
|
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}
|
||||||
AND b.${HYDROGEN_LOCAL} >= ?
|
AND b.${HYDROGEN_LOCAL} >= ?
|
||||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||||
) r
|
) r
|
||||||
@@ -257,15 +301,15 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// 月度趋势(指定年份内 12 个月,缺失月补 0)含成本/收入/利润
|
// 月度趋势(指定年份内 12 个月,缺失月补 0)含成本/收入/利润
|
||||||
// 利润 = 客户单收入 - 客户单成本(仅 cost_type = 2)
|
// 利润 = 客户单收入 - 客户单成本(按 customer_price/fee_total 判断客户承担)
|
||||||
const [monthRows] = await pool.query<RowDataPacket[]>(
|
const [monthRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
|
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
|
||||||
ROUND(SUM(hydrogen_quantity), 2) AS kg,
|
ROUND(SUM(amount_kg), 2) AS kg,
|
||||||
ROUND(SUM(cost_expense), 2) AS fee,
|
ROUND(SUM(cost_total), 2) AS fee,
|
||||||
ROUND(SUM(CASE WHEN cost_type = 2 THEN cost_expense ELSE 0 END), 2) AS customerCost,
|
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(customer_expense), 2) AS revenue
|
ROUND(SUM(fee_total), 2) AS revenue
|
||||||
FROM tab_energy_hydrogen_bill
|
FROM ${HYDROGEN_TABLE}
|
||||||
WHERE is_deleted = 0
|
WHERE ${HYDROGEN_BASE_WHERE}
|
||||||
AND ${HYDROGEN_LOCAL} >= ?
|
AND ${HYDROGEN_LOCAL} >= ?
|
||||||
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
||||||
GROUP BY m
|
GROUP BY m
|
||||||
@@ -290,16 +334,16 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 客户账单 Top(指定年份;按加氢量降序,前 30)
|
// 客户账单 Top(指定年份;按加氢量降序,前 30)
|
||||||
// payer:cost_type=2 → 客户承担;cost_type=3 → 羚牛承担;其他 → 客户(默认)
|
// payer:有客户单价/收入 → 客户承担;否则 → 羚牛承担
|
||||||
const [customerRows] = await pool.query<RowDataPacket[]>(
|
const [customerRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name,
|
`SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name,
|
||||||
CASE WHEN MAX(cost_type) = 3 AND MIN(cost_type) = 3 THEN 'lingniu'
|
CASE WHEN MAX(COALESCE(customer_price, 0)) <= 0 AND MAX(COALESCE(fee_total, 0)) <= 0 THEN 'lingniu'
|
||||||
ELSE 'customer' END AS payer,
|
ELSE 'customer' END AS payer,
|
||||||
SUM(hydrogen_quantity) AS kg,
|
SUM(amount_kg) AS kg,
|
||||||
SUM(cost_expense) AS cost,
|
SUM(cost_total) AS cost,
|
||||||
SUM(customer_expense) AS revenue
|
SUM(fee_total) AS revenue
|
||||||
FROM tab_energy_hydrogen_bill
|
FROM ${HYDROGEN_TABLE}
|
||||||
WHERE is_deleted = 0
|
WHERE ${HYDROGEN_BASE_WHERE}
|
||||||
AND ${HYDROGEN_LOCAL} >= ?
|
AND ${HYDROGEN_LOCAL} >= ?
|
||||||
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
||||||
GROUP BY name
|
GROUP BY name
|
||||||
@@ -325,39 +369,37 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
// =========================================================
|
// =========================================================
|
||||||
app.get('/hydrogen/daily', async (c) => {
|
app.get('/hydrogen/daily', async (c) => {
|
||||||
const range = (c.req.query('range') || 'last15') as Range;
|
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 customer = (c.req.query('customer') || 'external') as CustomerKind;
|
||||||
const force = c.req.query('force') === '1';
|
const force = c.req.query('force') === '1';
|
||||||
|
|
||||||
const data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => {
|
const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}`, async () => {
|
||||||
|
|
||||||
const where = [
|
const where = [
|
||||||
'b.is_deleted = 0',
|
HYDROGEN_BASE_WHERE_B,
|
||||||
`b.hydrogen_time >= '${HYDROGEN_MIN_DATE}'`,
|
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
|
||||||
rangeClause(`b.hydrogen_time`, range),
|
dateRangeClause(`b.${HYDROGEN_LOCAL}`),
|
||||||
customerClause('b.truck_id', customer),
|
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
|
||||||
].join(' AND ');
|
].join(' AND ');
|
||||||
|
|
||||||
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
||||||
// 站点名 fallback:内部站表 → 外部站表 → 导入订单表(tab_import_hydrogen_order,按 bill_code 关联)
|
// 站点名 fallback:站点主数据 → 账本冗余站点名 → 未关联站点
|
||||||
// 单价不重算:同价组显示原价,混合价组返回 NULL,前端显示「—」
|
// 单价不重算:直接取账本成本价。
|
||||||
const [stationRows] = await pool.query<RowDataPacket[]>(
|
const [stationRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT DATE_FORMAT(b.hydrogen_time, '%Y-%m-%d') AS d,
|
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS d,
|
||||||
b.hydrogen_station_id AS stationId,
|
COALESCE(b.station_id, 0) AS stationId,
|
||||||
COALESCE(MAX(s.short_name), MAX(s.name),
|
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
||||||
MAX(os.fixed_station_name), MAX(os.station_name),
|
CASE WHEN MAX(b.station_id) IS NULL THEN '未关联站点'
|
||||||
MAX(i.hydrogen_station_name),
|
ELSE CONCAT('未知站点 #', MAX(b.station_id)) END) AS stationName,
|
||||||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
ROUND(SUM(b.amount_kg), 2) AS kg,
|
||||||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS stationName,
|
|
||||||
ROUND(SUM(b.hydrogen_quantity), 2) AS kg,
|
|
||||||
-- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单
|
-- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单
|
||||||
MAX(b.cost_price) AS pricePerKg
|
MAX(b.cost_price) AS pricePerKg
|
||||||
FROM tab_energy_hydrogen_bill b
|
FROM ${HYDROGEN_TABLE} b
|
||||||
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||||
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}
|
WHERE ${where}
|
||||||
GROUP BY d, b.hydrogen_station_id
|
GROUP BY d, COALESCE(b.station_id, 0)
|
||||||
ORDER BY d DESC, kg DESC`,
|
ORDER BY d DESC, kg DESC`,
|
||||||
|
[dateRange.start, dateRange.end],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 站点环比:同站点上一条记录的 kg
|
// 站点环比:同站点上一条记录的 kg
|
||||||
@@ -407,14 +449,14 @@ app.get('/hydrogen/daily', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 补零:列出 range 内全部日期,缺失日期返回 totalKg=0、stations=[]
|
// 补零:列出 range 内全部日期,缺失日期返回 totalKg=0、stations=[]
|
||||||
const allDates = enumerateDates(range);
|
const allDates = enumerateDateRange(dateRange.start, dateRange.end);
|
||||||
const fullDays = allDates.map(date => {
|
const fullDays = allDates.map(date => {
|
||||||
const info = dayMap.get(date);
|
const info = dayMap.get(date);
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0,
|
totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0,
|
||||||
chainPct: dayChainPct.get(date) ?? 0,
|
chainPct: dayChainPct.get(date) ?? 0,
|
||||||
customerType: customer === 'lingniu' ? 'lingniu' : 'external',
|
customerType: customer,
|
||||||
stations: info
|
stations: info
|
||||||
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
|
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
|
||||||
name: s.name,
|
name: s.name,
|
||||||
@@ -531,9 +573,10 @@ app.get('/electric/overview', async (c) => {
|
|||||||
app.get('/electric/monthly', async (c) => {
|
app.get('/electric/monthly', async (c) => {
|
||||||
const customer = (c.req.query('customer') || 'lingniu') as CustomerKind;
|
const customer = (c.req.query('customer') || 'lingniu') as CustomerKind;
|
||||||
const range = (c.req.query('range') || 'last15') as Range;
|
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 force = c.req.query('force') === '1';
|
||||||
|
|
||||||
const data = await cached(`electric/monthly?customer=${customer}&range=${range}`, async () => {
|
const data = await cached(`electric/monthly?customer=${customer}&start=${dateRange.start}&end=${dateRange.end}`, async () => {
|
||||||
|
|
||||||
// bi_ele_charge_record 用 vehicle_kind 区分:internal=我司,external=外部
|
// bi_ele_charge_record 用 vehicle_kind 区分:internal=我司,external=外部
|
||||||
let kindClause = '1=1';
|
let kindClause = '1=1';
|
||||||
@@ -546,8 +589,9 @@ app.get('/electric/monthly', async (c) => {
|
|||||||
SUM(fee) AS fee
|
SUM(fee) AS fee
|
||||||
FROM bi_ele_charge_record
|
FROM bi_ele_charge_record
|
||||||
WHERE ${kindClause}
|
WHERE ${kindClause}
|
||||||
AND ${rangeClause('start_time', range)}
|
AND ${dateRangeClause('start_time')}
|
||||||
GROUP BY date`,
|
GROUP BY date`,
|
||||||
|
[dateRange.start, dateRange.end],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 实际数据 map
|
// 实际数据 map
|
||||||
@@ -560,7 +604,7 @@ app.get('/electric/monthly', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 补零:枚举 range 全部日期
|
// 补零:枚举 range 全部日期
|
||||||
const allDates = enumerateDates(range);
|
const allDates = enumerateDateRange(dateRange.start, dateRange.end);
|
||||||
const fullDays = allDates.map(date => {
|
const fullDays = allDates.map(date => {
|
||||||
const d = dataMap.get(date);
|
const d = dataMap.get(date);
|
||||||
return {
|
return {
|
||||||
|
|||||||
290
src/server/routes/hydrogen-heatmap.ts
Normal file
290
src/server/routes/hydrogen-heatmap.ts
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
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;
|
||||||
@@ -2,8 +2,13 @@ import { readFileSync } from 'node:fs';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import pool from '../../db.js';
|
import pool from '../../db.js';
|
||||||
import mileagePool from '../../mileage-db.js';
|
|
||||||
import { fetchVehicleInfoMap } from './vehicle-info.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';
|
import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix, VehicleInfoRow } from './types.js';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -37,6 +42,7 @@ 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 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 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 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>();
|
const prefixCount = new Map<string, number>();
|
||||||
for (const v of vehicles) {
|
for (const v of vehicles) {
|
||||||
@@ -54,7 +60,7 @@ function buildFilters(vehicles: CachedVehicle[], targetNames: string[]): Monitor
|
|||||||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||||||
});
|
});
|
||||||
|
|
||||||
return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames, regions };
|
return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames, regions, brands };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MileageRow {
|
interface MileageRow {
|
||||||
@@ -63,48 +69,103 @@ interface MileageRow {
|
|||||||
daily_km: string;
|
daily_km: string;
|
||||||
total_km: string | null;
|
total_km: string | null;
|
||||||
source: string;
|
source: string;
|
||||||
|
source_protocol: OneOsProtocol | null;
|
||||||
|
data_time: string | null;
|
||||||
|
calculated_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchBizTotalMileageMap(): Promise<Map<string, number>> {
|
interface DailyMileageRow {
|
||||||
// v_vehicle_daily_stats.total_km 对 G7S 数据源常为 NULL(G7 只回传日增量),
|
plate: string;
|
||||||
// 业务库 tab_mileage_assessment_vehicle.vehicle_total_mileage 是累加后的权威累计值,
|
vin: string | null;
|
||||||
// 用它兜底保证 totalKm 汇总完整。
|
date: string;
|
||||||
const [rows] = await pool.execute(
|
daily_km: string | number | null;
|
||||||
'SELECT plate_number, vehicle_total_mileage FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0'
|
source: string | null;
|
||||||
) as [{ plate_number: string; vehicle_total_mileage: string | number | null }[], unknown];
|
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> {
|
||||||
const map = new Map<string, number>();
|
const map = new Map<string, number>();
|
||||||
for (const r of rows) {
|
for (const row of rows) {
|
||||||
const km = Number(r.vehicle_total_mileage);
|
if (row.status !== 'NORMAL') continue;
|
||||||
if (Number.isFinite(km) && km > 0) map.set(r.plate_number, km);
|
const km = Math.max(0, Number(row.dailyMileageKm) || 0);
|
||||||
|
const existing = map.get(row.plateNumber) || 0;
|
||||||
|
if (km > existing) map.set(row.plateNumber, km);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLatestPgTotalMileageMap(asOf?: string): Promise<Map<string, number>> {
|
export interface RangeMileageResult {
|
||||||
// 当日 ln_vehicle_day_total_pg 无记录或 total_mileage 为 NULL 时,
|
vehicles: CachedVehicle[];
|
||||||
// 回填该车 dates <= asOf 的最近一条非空 total_mileage(÷1000 转 km),
|
dailyTotals: { date: string; totalKm: number }[];
|
||||||
// 让视图 total_km 为 NULL 的车也能显示历史累计。
|
start: string;
|
||||||
// MySQL 5.7 无窗口函数,用 GROUP BY MAX(dates) + JOIN 取每车最近一条。
|
end: string;
|
||||||
const sql = `
|
}
|
||||||
SELECT t.plate_number, t.total_mileage
|
|
||||||
FROM ln_vehicle_day_total_pg t
|
interface TargetRow {
|
||||||
INNER JOIN (
|
id: number;
|
||||||
SELECT plate_number, MAX(dates) AS max_dates
|
target_name: string;
|
||||||
FROM ln_vehicle_day_total_pg
|
plate_number: string;
|
||||||
WHERE total_mileage IS NOT NULL
|
}
|
||||||
${asOf ? 'AND dates <= ?' : ''}
|
|
||||||
GROUP BY plate_number
|
async function fetchTargetRows(): Promise<TargetRow[]> {
|
||||||
) m ON m.plate_number = t.plate_number AND m.max_dates = t.dates
|
return pool.execute(
|
||||||
WHERE t.total_mileage IS NOT NULL`;
|
`SELECT t.id, t.target_name, v.plate_number
|
||||||
const params = asOf ? [asOf] : [];
|
FROM lingniu_prod.tab_mileage_assessment_target t
|
||||||
const [rows] = await mileagePool.execute(sql, params) as [
|
JOIN lingniu_prod.tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
|
||||||
{ plate_number: string; total_mileage: string | number | null }[],
|
WHERE t.is_deleted = 0`
|
||||||
unknown,
|
).then(([rows]) => rows as TargetRow[]);
|
||||||
];
|
}
|
||||||
const map = new Map<string, number>();
|
|
||||||
for (const r of rows) {
|
function buildTargetPlatesMap(targetRows: TargetRow[]): Map<string, Set<string>> {
|
||||||
const km = Number(r.total_mileage) / 1000;
|
const targetPlatesMap = new Map<string, Set<string>>();
|
||||||
if (Number.isFinite(km) && km > 0) map.set(r.plate_number, km);
|
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);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
@@ -113,8 +174,7 @@ function mergeVehicles(
|
|||||||
mileageRows: MileageRow[],
|
mileageRows: MileageRow[],
|
||||||
infoMap: Map<string, VehicleInfoRow>,
|
infoMap: Map<string, VehicleInfoRow>,
|
||||||
yesterdayMap: Map<string, number>,
|
yesterdayMap: Map<string, number>,
|
||||||
bizTotalMap: Map<string, number>,
|
targetNamesByPlate: Map<string, string[]>,
|
||||||
latestPgTotalMap: Map<string, number>,
|
|
||||||
): CachedVehicle[] {
|
): CachedVehicle[] {
|
||||||
const mileageMap = new Map<string, MileageRow>();
|
const mileageMap = new Map<string, MileageRow>();
|
||||||
for (const row of mileageRows) {
|
for (const row of mileageRows) {
|
||||||
@@ -124,30 +184,38 @@ function mergeVehicles(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(mileageMap.values()).map(m => {
|
return Array.from(infoMap.values()).map(info => {
|
||||||
const info = infoMap.get(m.plate);
|
const m = mileageMap.get(info.plate);
|
||||||
const dailyKm = Number(m.daily_km) || 0;
|
const plate = info.plate;
|
||||||
const source = m.source || 'NONE';
|
const dailyKm = Number(m?.daily_km) || 0;
|
||||||
const gpsTotal = m.total_km !== null ? Number(m.total_km) : null;
|
const source = m?.source || 'NONE';
|
||||||
const latestPgTotal = latestPgTotalMap.get(m.plate);
|
const gpsTotal = m?.total_km != null ? Number(m.total_km) : null;
|
||||||
const bizTotal = bizTotalMap.get(m.plate);
|
|
||||||
return {
|
return {
|
||||||
plate: m.plate,
|
plate,
|
||||||
vin: m.vin,
|
vin: m?.vin || info.vin || '',
|
||||||
dailyKm,
|
dailyKm,
|
||||||
totalKm: gpsTotal !== null ? gpsTotal : (latestPgTotal ?? bizTotal ?? null),
|
// The OneOS daily mileage API does not currently return cumulative mileage.
|
||||||
|
// Never backfill it from another mileage source outside the assessment page.
|
||||||
|
totalKm: gpsTotal,
|
||||||
source,
|
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,
|
isOnline: source !== 'NONE' && dailyKm > 0,
|
||||||
isDataSynced: source !== 'NONE',
|
isDataSynced: source !== 'NONE',
|
||||||
customer: info?.customer || null,
|
customer: info.customer || null,
|
||||||
department: info?.department || null,
|
department: info.department || null,
|
||||||
manager: info?.manager || null,
|
manager: info.manager || null,
|
||||||
managerId: info?.manager_id || null,
|
managerId: info.manager_id || null,
|
||||||
rentStatus: info?.rent_status || null,
|
rentStatus: info.rent_status || null,
|
||||||
entity: info?.entity || null,
|
entity: info.entity || null,
|
||||||
project: info?.project || null,
|
project: info.project || null,
|
||||||
region: regionMap[m.plate] || null,
|
region: regionMap[plate] || null,
|
||||||
yesterdayKm: yesterdayMap.get(m.plate) || 0,
|
brand: info.brand_label || null,
|
||||||
|
targetNames: targetNamesByPlate.get(plate) || [],
|
||||||
|
yesterdayKm: yesterdayMap.get(plate) || 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -157,53 +225,23 @@ export async function refreshMonitoringCache(): Promise<void> {
|
|||||||
console.log('[mileage] refreshing monitoring cache...');
|
console.log('[mileage] refreshing monitoring cache...');
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
||||||
const [mileageRows, yesterdayMap, infoMap, targetRows, bizTotalMap, latestPgTotalMap] = await Promise.all([
|
const date = shanghaiDate();
|
||||||
(async () => {
|
const [apiRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
|
||||||
const [dateRows] = await mileagePool.execute(
|
fetchOneOsDailyMileage(date),
|
||||||
'SELECT MAX(stat_date) as latest FROM v_vehicle_daily_stats'
|
fetchOneOsDailyMileage(previousDate(date)),
|
||||||
) 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(),
|
fetchVehicleInfoMap(),
|
||||||
pool.execute(
|
fetchTargetRows(),
|
||||||
`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 targetPlatesMap = new Map<string, Set<string>>();
|
const mileageRows = toMileageRows(apiRows);
|
||||||
for (const r of targetRows) {
|
const yesterdayMap = dailyMileageMap(yesterdayRows);
|
||||||
const set = targetPlatesMap.get(r.target_name) || new Set();
|
|
||||||
set.add(r.plate_number);
|
const targetPlatesMap = buildTargetPlatesMap(targetRows);
|
||||||
targetPlatesMap.set(r.target_name, set);
|
const targetNamesByPlate = buildPlateTargetNamesMap(targetRows);
|
||||||
}
|
|
||||||
const targetNames = Array.from(targetPlatesMap.keys());
|
const targetNames = Array.from(targetPlatesMap.keys());
|
||||||
|
|
||||||
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap, latestPgTotalMap);
|
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, targetNamesByPlate);
|
||||||
const totalToday = vehicles.reduce((sum, v) => sum + v.dailyKm, 0);
|
const totalToday = Math.round(vehicles.reduce((sum, v) => sum + v.dailyKm, 0));
|
||||||
const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
|
const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
|
||||||
|
|
||||||
monitoringCache = {
|
monitoringCache = {
|
||||||
@@ -220,29 +258,196 @@ export async function refreshMonitoringCache(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]> {
|
export async function queryDateMileage(
|
||||||
const [mileageRows, yesterdayRows, infoMap, bizTotalMap, latestPgTotalMap] = await Promise.all([
|
dateStr: string,
|
||||||
mileagePool.execute(
|
protocolPriority?: OneOsProtocol[],
|
||||||
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
|
): Promise<CachedVehicle[]> {
|
||||||
[dateStr]
|
const [apiRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
|
||||||
).then(([r]) => r as MileageRow[]),
|
fetchOneOsDailyMileage(dateStr, undefined, protocolPriority),
|
||||||
mileagePool.execute(
|
fetchOneOsDailyMileage(previousDate(dateStr), undefined, protocolPriority),
|
||||||
'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(),
|
fetchVehicleInfoMap(),
|
||||||
fetchBizTotalMileageMap(),
|
fetchTargetRows(),
|
||||||
fetchLatestPgTotalMileageMap(dateStr),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const yesterdayMap = new Map<string, number>();
|
const mileageRows = toMileageRows(apiRows);
|
||||||
for (const r of yesterdayRows) {
|
const yesterdayMap = dailyMileageMap(yesterdayRows);
|
||||||
const km = Number(r.daily_km) || 0;
|
|
||||||
const existing = yesterdayMap.get(r.plate) || 0;
|
return mergeVehicles(
|
||||||
if (km > existing) yesterdayMap.set(r.plate, km);
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap, latestPgTotalMap);
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
|
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
|
||||||
|
|||||||
@@ -1,25 +1,32 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { getCache, queryDateMileage, buildDateFilters } from './cache.js';
|
import { getCache, queryDateMileage, queryRangeMileage, buildDateFilters } from './cache.js';
|
||||||
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
||||||
import type { AuthUser } from '../../auth/types.js';
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
import type { CachedVehicle, MonitoringFilters, MonitoringResponse } from './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 app = new Hono();
|
||||||
|
|
||||||
const EMPTY_RESPONSE: MonitoringResponse = {
|
const EMPTY_RESPONSE: MonitoringResponse = {
|
||||||
vehicles: [],
|
vehicles: [],
|
||||||
stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 },
|
stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 },
|
||||||
filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] },
|
filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [], brands: [] },
|
||||||
total: 0,
|
total: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
sourcePriority: [...DEFAULT_MILEAGE_SOURCE_PRIORITY],
|
||||||
};
|
};
|
||||||
|
|
||||||
function applyFilters(vehicles: CachedVehicle[], params: {
|
function applyFilters(vehicles: CachedVehicle[], params: {
|
||||||
search: string; dept: string; customer: string; project: string;
|
search: string; dept: string; customer: string; project: string;
|
||||||
entity: string; rentStatus: string; plate: string; platePrefix: string;
|
entity: string; rentStatus: string; plate: string; platePrefix: string;
|
||||||
targetName: string; region: string; mileageMin: string; mileageMax: string;
|
targetNames: string[]; region: string; mileageMin: string; mileageMax: string;
|
||||||
|
brands: string[];
|
||||||
}): CachedVehicle[] {
|
}): CachedVehicle[] {
|
||||||
let result = vehicles;
|
let result = vehicles;
|
||||||
|
|
||||||
@@ -27,12 +34,13 @@ function applyFilters(vehicles: CachedVehicle[], params: {
|
|||||||
const q = params.search.toLowerCase();
|
const q = params.search.toLowerCase();
|
||||||
result = result.filter(v =>
|
result = result.filter(v =>
|
||||||
v.plate.toLowerCase().includes(q) ||
|
v.plate.toLowerCase().includes(q) ||
|
||||||
|
v.vin.toLowerCase().includes(q) ||
|
||||||
(v.customer || '').toLowerCase().includes(q) ||
|
(v.customer || '').toLowerCase().includes(q) ||
|
||||||
(v.project || '').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.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 === params.customer);
|
if (params.customer) result = result.filter(v => params.customer === '__EMPTY__' ? !v.customer : (v.customer || '').toLowerCase().includes(params.customer.toLowerCase()));
|
||||||
if (params.project) result = result.filter(v => v.project === params.project);
|
if (params.project) result = result.filter(v => v.project === params.project);
|
||||||
if (params.entity) result = result.filter(v => v.entity === params.entity);
|
if (params.entity) result = result.filter(v => v.entity === params.entity);
|
||||||
if (params.rentStatus) result = result.filter(v => v.rentStatus === params.rentStatus);
|
if (params.rentStatus) result = result.filter(v => v.rentStatus === params.rentStatus);
|
||||||
@@ -42,10 +50,13 @@ function applyFilters(vehicles: CachedVehicle[], params: {
|
|||||||
}
|
}
|
||||||
if (params.platePrefix) result = result.filter(v => v.plate.startsWith(params.platePrefix));
|
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.region) result = result.filter(v => v.region === params.region);
|
||||||
if (params.targetName) {
|
if (params.targetNames.length > 0) {
|
||||||
const cache = getCache();
|
const selectedTargets = new Set(params.targetNames);
|
||||||
const tPlates = cache?.targetPlatesMap.get(params.targetName);
|
result = result.filter(v => v.targetNames.some(targetName => selectedTargets.has(targetName)));
|
||||||
result = tPlates ? result.filter(v => tPlates.has(v.plate)) : [];
|
}
|
||||||
|
if (params.brands.length > 0) {
|
||||||
|
const selectedBrands = new Set(params.brands);
|
||||||
|
result = result.filter(v => v.brand != null && selectedBrands.has(v.brand));
|
||||||
}
|
}
|
||||||
if (params.mileageMin) result = result.filter(v => v.dailyKm >= Number(params.mileageMin));
|
if (params.mileageMin) result = result.filter(v => v.dailyKm >= Number(params.mileageMin));
|
||||||
if (params.mileageMax) result = result.filter(v => v.dailyKm <= Number(params.mileageMax));
|
if (params.mileageMax) result = result.filter(v => v.dailyKm <= Number(params.mileageMax));
|
||||||
@@ -53,12 +64,54 @@ function applyFilters(vehicles: CachedVehicle[], params: {
|
|||||||
return result;
|
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) => {
|
app.get('/', async (c) => {
|
||||||
const sortBy = c.req.query('sortBy') || 'today';
|
const sortBy = c.req.query('sortBy') || 'today';
|
||||||
const sortOrder = c.req.query('sortOrder') || 'desc';
|
const sortOrder = c.req.query('sortOrder') || 'desc';
|
||||||
const limit = Number(c.req.query('limit')) || 50;
|
const limit = Number(c.req.query('limit')) || 50;
|
||||||
const page = Number(c.req.query('page')) || 1;
|
const page = Number(c.req.query('page')) || 1;
|
||||||
const date = c.req.query('date') || '';
|
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 = {
|
const filterParams = {
|
||||||
search: c.req.query('search') || '',
|
search: c.req.query('search') || '',
|
||||||
@@ -69,18 +122,32 @@ app.get('/', async (c) => {
|
|||||||
rentStatus: c.req.query('rentStatus') || '',
|
rentStatus: c.req.query('rentStatus') || '',
|
||||||
plate: c.req.query('plate') || '',
|
plate: c.req.query('plate') || '',
|
||||||
platePrefix: c.req.query('platePrefix') || '',
|
platePrefix: c.req.query('platePrefix') || '',
|
||||||
targetName: c.req.query('targetName') || '',
|
targetNames: parseTargetNames(c.req.url),
|
||||||
region: c.req.query('region') || '',
|
region: c.req.query('region') || '',
|
||||||
mileageMin: c.req.query('mileageMin') || '',
|
mileageMin: c.req.query('mileageMin') || '',
|
||||||
mileageMax: c.req.query('mileageMax') || '',
|
mileageMax: c.req.query('mileageMax') || '',
|
||||||
|
brands: c.req.queries('brand') || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
let allVehicles: CachedVehicle[];
|
let allVehicles: CachedVehicle[];
|
||||||
let filters: MonitoringFilters;
|
let filters: MonitoringFilters;
|
||||||
|
let rangeDailyTotals: { date: string; totalKm: number }[] | undefined;
|
||||||
|
let dateRange: { start: string; end: string } | undefined;
|
||||||
|
|
||||||
if (date) {
|
if (range) {
|
||||||
try {
|
try {
|
||||||
allVehicles = await queryDateMileage(date);
|
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);
|
||||||
filters = buildDateFilters(allVehicles);
|
filters = buildDateFilters(allVehicles);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error('monitoring date query error:', e);
|
console.error('monitoring date query error:', e);
|
||||||
@@ -107,9 +174,15 @@ app.get('/', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filtered = applyFilters(allVehicles, filterParams);
|
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 = {
|
const stats = {
|
||||||
totalToday: filtered.reduce((sum, v) => sum + v.dailyKm, 0),
|
totalToday: Math.round(filtered.reduce((sum, v) => sum + v.dailyKm, 0)),
|
||||||
totalAll: filtered.reduce((sum, v) => sum + (v.totalKm || 0), 0),
|
totalAll: filtered.reduce((sum, v) => sum + (v.totalKm || 0), 0),
|
||||||
vehicleCount: filtered.length,
|
vehicleCount: filtered.length,
|
||||||
yesterdayTotal: filtered.reduce((sum, v) => sum + v.yesterdayKm, 0),
|
yesterdayTotal: filtered.reduce((sum, v) => sum + v.yesterdayKm, 0),
|
||||||
@@ -129,10 +202,13 @@ app.get('/', async (c) => {
|
|||||||
vehicles: maskCustomerNames(paged),
|
vehicles: maskCustomerNames(paged),
|
||||||
stats,
|
stats,
|
||||||
filters,
|
filters,
|
||||||
|
rangeDailyTotals,
|
||||||
|
dateRange,
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
totalPages: Math.ceil(total / limit),
|
totalPages: Math.ceil(total / limit),
|
||||||
updatedAt: date || getCache()?.updatedAt || new Date().toISOString(),
|
updatedAt: dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(),
|
||||||
|
sourcePriority,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
392
src/server/routes/mileage/oneos-api.ts
Normal file
392
src/server/routes/mileage/oneos-api.ts
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
38
src/server/routes/mileage/source-policy.ts
Normal file
38
src/server/routes/mileage/source-policy.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export type MileageSourceGroup = 'instrument' | 'gps';
|
||||||
|
export type OneOsProtocol = 'GB32960' | 'MQTT' | 'JT808';
|
||||||
|
export type MileageSourceCategory = 'INSTRUMENT' | 'GPS';
|
||||||
|
|
||||||
|
export const DEFAULT_MILEAGE_SOURCE_PRIORITY: MileageSourceGroup[] = ['instrument', 'gps'];
|
||||||
|
export const DEFAULT_ONEOS_PROTOCOL_PRIORITY: OneOsProtocol[] = ['GB32960', 'MQTT', 'JT808'];
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import pool from '../../db.js';
|
import pool from '../../db.js';
|
||||||
import mileagePool from '../../mileage-db.js';
|
|
||||||
import { getCache } from './cache.js';
|
import { getCache } from './cache.js';
|
||||||
|
import { fetchOneOsDailyMileage } from './oneos-api.js';
|
||||||
import { fetchVehicleInfoByPlates } from './vehicle-info.js';
|
import { fetchVehicleInfoByPlates } from './vehicle-info.js';
|
||||||
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ const app = new Hono();
|
|||||||
app.get('/', async (c) => {
|
app.get('/', async (c) => {
|
||||||
try {
|
try {
|
||||||
const [targets] = await pool.execute(
|
const [targets] = await pool.execute(
|
||||||
'SELECT * FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id'
|
'SELECT * FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id'
|
||||||
) as [any[], unknown];
|
) as [any[], unknown];
|
||||||
|
|
||||||
const [vehicleStats] = await pool.execute(`
|
const [vehicleStats] = await pool.execute(`
|
||||||
@@ -25,19 +25,96 @@ app.get('/', async (c) => {
|
|||||||
SUM(current_year_mileage_task) as current_year_target,
|
SUM(current_year_mileage_task) as current_year_target,
|
||||||
SUM(current_year_mileage) as current_year_completed,
|
SUM(current_year_mileage) as current_year_completed,
|
||||||
MAX(current_year_assessment_end_date) as year_end_date
|
MAX(current_year_assessment_end_date) as year_end_date
|
||||||
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
|
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
|
||||||
GROUP BY target_id
|
GROUP BY target_id
|
||||||
`) as [any[], unknown];
|
`) as [any[], unknown];
|
||||||
|
|
||||||
const statsMap = new Map<number, any>();
|
const statsMap = new Map<number, any>();
|
||||||
for (const s of vehicleStats) statsMap.set(s.target_id, s);
|
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(`
|
const [periodRows] = await pool.execute(`
|
||||||
SELECT target_id,
|
SELECT target_id,
|
||||||
DATE_FORMAT(assessment_start_date, '%Y-%m-%d') as start_date,
|
DATE_FORMAT(assessment_start_date, '%Y-%m-%d') as start_date,
|
||||||
DATE_FORMAT(assessment_end_date, '%Y-%m-%d') as end_date,
|
DATE_FORMAT(assessment_end_date, '%Y-%m-%d') as end_date,
|
||||||
COUNT(*) as cnt
|
COUNT(*) as cnt
|
||||||
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
|
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
|
||||||
GROUP BY target_id, assessment_start_date, assessment_end_date
|
GROUP BY target_id, assessment_start_date, assessment_end_date
|
||||||
ORDER BY target_id, assessment_start_date
|
ORDER BY target_id, assessment_start_date
|
||||||
`) as [any[], unknown];
|
`) as [any[], unknown];
|
||||||
@@ -58,7 +135,7 @@ app.get('/', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [targetVehicleRows] = await pool.execute(
|
const [targetVehicleRows] = await pool.execute(
|
||||||
'SELECT target_id, plate_number FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0'
|
'SELECT target_id, plate_number FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0'
|
||||||
) as [{ target_id: number; plate_number: string }[], unknown];
|
) as [{ target_id: number; plate_number: string }[], unknown];
|
||||||
|
|
||||||
const targetIdPlatesMap = new Map<number, string[]>();
|
const targetIdPlatesMap = new Map<number, string[]>();
|
||||||
@@ -71,12 +148,44 @@ app.get('/', async (c) => {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const result = targets.map((t: any) => {
|
const result = targets.map((t: any) => {
|
||||||
const s = statsMap.get(t.id) || {};
|
const s = statsMap.get(t.id) || {};
|
||||||
|
const fy = firstYearMap.get(t.id) || {};
|
||||||
const currentYearTarget = Number(s.current_year_target) || 0;
|
const currentYearTarget = Number(s.current_year_target) || 0;
|
||||||
const currentYearCompleted = Number(s.current_year_completed) || 0;
|
const currentYearCompleted = Number(s.current_year_completed) || 0;
|
||||||
const remaining = Math.max(0, currentYearTarget - currentYearCompleted);
|
const remaining = Math.max(0, currentYearTarget - currentYearCompleted);
|
||||||
const yearEnd = s.year_end_date ? new Date(s.year_end_date) : now;
|
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 daysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000));
|
||||||
const dailyTarget = remaining / daysLeft;
|
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) || [];
|
const periods = periodsMap.get(t.id) || [];
|
||||||
if (periods.length === 0) {
|
if (periods.length === 0) {
|
||||||
@@ -93,7 +202,7 @@ app.get('/', async (c) => {
|
|||||||
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
|
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
|
||||||
assessmentYears: t.assessment_years,
|
assessmentYears: t.assessment_years,
|
||||||
periods,
|
periods,
|
||||||
todayTotal: (targetIdPlatesMap.get(t.id) || []).reduce((sum, plate) => sum + (cacheVehicleMap.get(plate) || 0), 0),
|
todayTotal: Math.round((targetIdPlatesMap.get(t.id) || []).reduce((sum, plate) => sum + (cacheVehicleMap.get(plate) || 0), 0)),
|
||||||
cumulativeTotal: Number(s.cumulative_total) || 0,
|
cumulativeTotal: Number(s.cumulative_total) || 0,
|
||||||
avgCompletion: (Number(s.avg_completion) || 0) * 100,
|
avgCompletion: (Number(s.avg_completion) || 0) * 100,
|
||||||
qualifiedCount: Number(s.qualified_count) || 0,
|
qualifiedCount: Number(s.qualified_count) || 0,
|
||||||
@@ -104,6 +213,19 @@ app.get('/', async (c) => {
|
|||||||
remaining,
|
remaining,
|
||||||
daysLeft,
|
daysLeft,
|
||||||
dailyTarget: Math.round(dailyTarget * 10) / 10,
|
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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,7 +245,7 @@ app.get('/:id/vehicles', async (c) => {
|
|||||||
`SELECT plate_number, today_mileage, vehicle_total_mileage,
|
`SELECT plate_number, today_mileage, vehicle_total_mileage,
|
||||||
completion_rate, is_qualified, current_year_is_qualified,
|
completion_rate, is_qualified, current_year_is_qualified,
|
||||||
daily_required_mileage
|
daily_required_mileage
|
||||||
FROM tab_mileage_assessment_vehicle
|
FROM lingniu_prod.tab_mileage_assessment_vehicle
|
||||||
WHERE target_id = ? AND is_deleted = 0
|
WHERE target_id = ? AND is_deleted = 0
|
||||||
ORDER BY today_mileage DESC`,
|
ORDER BY today_mileage DESC`,
|
||||||
[targetId]
|
[targetId]
|
||||||
@@ -134,20 +256,15 @@ app.get('/:id/vehicles', async (c) => {
|
|||||||
|
|
||||||
const dateMileageMap = new Map<string, { dailyKm: number; totalKm: number | null; isOnline: boolean }>();
|
const dateMileageMap = new Map<string, { dailyKm: number; totalKm: number | null; isOnline: boolean }>();
|
||||||
if (date && plates.length > 0) {
|
if (date && plates.length > 0) {
|
||||||
const [mileageRows] = await mileagePool.execute(
|
const mileageRows = await fetchOneOsDailyMileage(date, plates);
|
||||||
`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) {
|
for (const m of mileageRows) {
|
||||||
const existing = dateMileageMap.get(m.plate);
|
const existing = dateMileageMap.get(m.plateNumber);
|
||||||
const dailyKm = Number(m.daily_km) || 0;
|
const dailyKm = m.status === 'NORMAL' ? (Number(m.dailyMileageKm) || 0) : 0;
|
||||||
if (!existing || dailyKm > existing.dailyKm) {
|
if (!existing || dailyKm > existing.dailyKm) {
|
||||||
const source = m.source || 'NONE';
|
dateMileageMap.set(m.plateNumber, {
|
||||||
dateMileageMap.set(m.plate, {
|
|
||||||
dailyKm,
|
dailyKm,
|
||||||
totalKm: m.total_km !== null ? Number(m.total_km) : null,
|
totalKm: m.totalMileageKm,
|
||||||
isOnline: source !== 'NONE' && dailyKm > 0,
|
isOnline: m.status === 'NORMAL' && dailyKm > 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,41 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import pool from '../../db.js';
|
import pool from '../../db.js';
|
||||||
import mileagePool from '../../mileage-db.js';
|
import { fetchOneOsMileageDates } from './oneos-api.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
app.get('/', async (c) => {
|
app.get('/', async (c) => {
|
||||||
const targetId = c.req.query('targetId');
|
const targetId = c.req.query('targetId');
|
||||||
const days = Number(c.req.query('days')) || 7;
|
const days = Math.min(Math.max(Number(c.req.query('days')) || 7, 1), 366);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let plates: string[] = [];
|
let plates: string[] = [];
|
||||||
if (targetId) {
|
if (targetId) {
|
||||||
const [vehicleRows] = await pool.execute(
|
const [vehicleRows] = await pool.execute(
|
||||||
'SELECT plate_number FROM tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0',
|
'SELECT plate_number FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0',
|
||||||
[targetId]
|
[targetId]
|
||||||
) as [{ plate_number: string }[], unknown];
|
) as [{ plate_number: string }[], unknown];
|
||||||
plates = vehicleRows.map(r => r.plate_number);
|
plates = vehicleRows.map(r => r.plate_number);
|
||||||
if (plates.length === 0) return c.json([]);
|
if (plates.length === 0) return c.json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 单车日里程负值视为脏数据(里程表回滚 / 换 GPS 设备),不纳入统计
|
const today = new Date(new Date().toLocaleString('en-US', { timeZone: 'Asia/Shanghai' }));
|
||||||
let sql = `
|
today.setHours(0, 0, 0, 0);
|
||||||
SELECT DATE_FORMAT(stat_date, '%m-%d') as date,
|
const dates: string[] = [];
|
||||||
SUM(IF(daily_km < 0, 0, daily_km)) as mileage
|
for (let offset = days; offset >= 1; offset -= 1) {
|
||||||
FROM v_vehicle_daily_stats
|
const date = new Date(today);
|
||||||
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) AND stat_date < CURDATE()
|
date.setDate(today.getDate() - offset);
|
||||||
`;
|
dates.push(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`);
|
||||||
const params: (string | number)[] = [days];
|
|
||||||
|
|
||||||
if (plates.length > 0) {
|
|
||||||
sql += ` AND plate IN (${plates.map(() => '?').join(',')})`;
|
|
||||||
params.push(...plates);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sql += ' GROUP BY stat_date ORDER BY stat_date';
|
const rowsByDate = await fetchOneOsMileageDates(dates);
|
||||||
|
const selectedPlates = plates.length > 0 ? new Set(plates) : null;
|
||||||
const [rows] = await mileagePool.execute(sql, params) as [any[], unknown];
|
return c.json(dates.map(date => ({
|
||||||
|
date: date.slice(5),
|
||||||
return c.json(rows.map((r: any) => ({
|
mileage: Math.round((rowsByDate.get(date) || []).reduce((sum, row) => {
|
||||||
date: r.date,
|
if (selectedPlates && !selectedPlates.has(row.plateNumber)) return sum;
|
||||||
mileage: Math.round(Number(r.mileage) || 0),
|
return sum + (row.status === 'NORMAL' ? (row.dailyMileageKm || 0) : 0);
|
||||||
|
}, 0)),
|
||||||
})));
|
})));
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error('trend error:', e);
|
console.error('trend error:', e);
|
||||||
|
|||||||
@@ -3,8 +3,15 @@ export interface CachedVehicle {
|
|||||||
plate: string;
|
plate: string;
|
||||||
vin: string;
|
vin: string;
|
||||||
dailyKm: number;
|
dailyKm: number;
|
||||||
|
dailyMileage?: Record<string, number>;
|
||||||
totalKm: number | null;
|
totalKm: number | null;
|
||||||
source: string;
|
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;
|
isOnline: boolean;
|
||||||
isDataSynced: boolean;
|
isDataSynced: boolean;
|
||||||
customer: string | null;
|
customer: string | null;
|
||||||
@@ -15,6 +22,8 @@ export interface CachedVehicle {
|
|||||||
entity: string | null;
|
entity: string | null;
|
||||||
project: string | null;
|
project: string | null;
|
||||||
region: string | null;
|
region: string | null;
|
||||||
|
brand: string | null;
|
||||||
|
targetNames: string[];
|
||||||
yesterdayKm: number;
|
yesterdayKm: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +44,7 @@ export interface MonitoringFilters {
|
|||||||
platePrefixes: PlatePrefix[];
|
platePrefixes: PlatePrefix[];
|
||||||
targetNames: string[];
|
targetNames: string[];
|
||||||
regions: string[];
|
regions: string[];
|
||||||
|
brands: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 监控缓存 */
|
/** 监控缓存 */
|
||||||
@@ -59,15 +69,19 @@ export interface MonitoringResponse {
|
|||||||
vehicles: CachedVehicle[];
|
vehicles: CachedVehicle[];
|
||||||
stats: MonitoringStats;
|
stats: MonitoringStats;
|
||||||
filters: MonitoringFilters;
|
filters: MonitoringFilters;
|
||||||
|
rangeDailyTotals?: { date: string; totalKm: number }[];
|
||||||
|
dateRange?: { start: string; end: string };
|
||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
sourcePriority: ('instrument' | 'gps')[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 车辆关联信息(从 lingniu_prod 查出的原始行) */
|
/** 车辆关联信息(从 lingniu_prod 查出的原始行) */
|
||||||
export interface VehicleInfoRow {
|
export interface VehicleInfoRow {
|
||||||
plate: string;
|
plate: string;
|
||||||
|
vin: string | null;
|
||||||
customer: string | null;
|
customer: string | null;
|
||||||
department: string | null;
|
department: string | null;
|
||||||
manager: string | null;
|
manager: string | null;
|
||||||
@@ -75,4 +89,5 @@ export interface VehicleInfoRow {
|
|||||||
rent_status: string | null;
|
rent_status: string | null;
|
||||||
entity: string | null;
|
entity: string | null;
|
||||||
project: string | null;
|
project: string | null;
|
||||||
|
brand_label: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,68 @@
|
|||||||
import pool from '../../db.js';
|
import pool from '../../db.js';
|
||||||
import type { VehicleInfoRow } from './types.js';
|
import type { VehicleInfoRow } from './types.js';
|
||||||
|
|
||||||
/** 车辆关联信息 SQL(客户名、部门、经理、租赁状态、主体、项目) */
|
/** 车辆关联信息 SQL(客户名、部门、经理、租赁状态、主体、项目、品牌) */
|
||||||
export const VEHICLE_INFO_SQL = `SELECT
|
export const VEHICLE_INFO_SQL = `SELECT
|
||||||
truck.plate_number AS plate,
|
vi.plate_number AS plate,
|
||||||
cus.customer_name AS customer,
|
vi.vin AS vin,
|
||||||
dep.dep_name AS department,
|
COALESCE(c.customer_name, vor.customer_name, ci.customer_name) AS customer,
|
||||||
u.user_name AS manager,
|
COALESCE(c.business_department_name, vor.business_dept) AS department,
|
||||||
CAST(c.bd AS CHAR) AS manager_id,
|
COALESCE(c.business_manager_name, vor.business_manager) AS manager,
|
||||||
dic_status.dic_name AS rent_status,
|
CAST(COALESCE(c.business_manager_id, vi.business_id) AS CHAR) AS manager_id,
|
||||||
org_truck.org_name AS entity,
|
CASE vs.operation_status
|
||||||
c.project_name AS project
|
WHEN '1' THEN '租赁'
|
||||||
FROM tab_truck truck
|
WHEN '2' THEN '自营'
|
||||||
LEFT JOIN tab_truck_status_info si ON si.truck_id = truck.id AND si.is_deleted = 0
|
WHEN '3' THEN '可运营'
|
||||||
LEFT JOIN tab_contract c ON c.id = si.contract_id AND c.is_deleted = 0
|
WHEN '4' THEN '待运营'
|
||||||
LEFT JOIN tab_customer cus ON cus.id = c.customer_id AND cus.is_deleted = 0
|
WHEN '5' THEN '退出运营'
|
||||||
LEFT JOIN tab_user u ON u.id = c.bd AND u.is_deleted = 0
|
ELSE vs.operation_status
|
||||||
LEFT JOIN tab_department dep ON dep.id = u.dep_id AND dep.is_deleted = 0
|
END AS rent_status,
|
||||||
LEFT JOIN tab_dic dic_status ON dic_status.parent_code = 'dic_truck_rent_status'
|
NULLIF(vi.registered_ownership, '') AS entity,
|
||||||
AND dic_status.dic_code = truck.truck_rent_status AND dic_status.is_deleted = 0
|
COALESCE(c.project_name, vor.project_name) AS project,
|
||||||
LEFT JOIN tab_org org_truck ON org_truck.id = truck.org_id AND org_truck.is_deleted = 0
|
CASE vm.brand
|
||||||
WHERE truck.is_deleted = 0 AND truck.is_operation = 1`;
|
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'`;
|
||||||
|
|
||||||
/** 查询所有车辆关联信息,返回 plate→info 的 Map */
|
/** 查询所有车辆关联信息,返回 plate→info 的 Map */
|
||||||
export async function fetchVehicleInfoMap(): Promise<Map<string, VehicleInfoRow>> {
|
export async function fetchVehicleInfoMap(): Promise<Map<string, VehicleInfoRow>> {
|
||||||
@@ -36,7 +78,7 @@ export async function fetchVehicleInfoMap(): Promise<Map<string, VehicleInfoRow>
|
|||||||
export async function fetchVehicleInfoByPlates(plates: string[]): Promise<Map<string, VehicleInfoRow>> {
|
export async function fetchVehicleInfoByPlates(plates: string[]): Promise<Map<string, VehicleInfoRow>> {
|
||||||
if (plates.length === 0) return new Map();
|
if (plates.length === 0) return new Map();
|
||||||
const [rows] = await pool.execute(
|
const [rows] = await pool.execute(
|
||||||
`${VEHICLE_INFO_SQL} AND truck.plate_number IN (${plates.map(() => '?').join(',')})`,
|
`${VEHICLE_INFO_SQL} AND vi.plate_number IN (${plates.map(() => '?').join(',')})`,
|
||||||
plates
|
plates
|
||||||
) as [VehicleInfoRow[], unknown];
|
) as [VehicleInfoRow[], unknown];
|
||||||
const map = new Map<string, VehicleInfoRow>();
|
const map = new Map<string, VehicleInfoRow>();
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import mileagePool from '../../mileage-db.js';
|
import { fetchOneOsMileageDates } from './oneos-api.js';
|
||||||
|
import {
|
||||||
|
parseMileageSourcePriority,
|
||||||
|
protocolsForSourcePriority,
|
||||||
|
sourceCategoryFromProtocol,
|
||||||
|
} from './source-policy.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
interface DayRow {
|
|
||||||
date: string;
|
|
||||||
daily_km: string | number | null;
|
|
||||||
source: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmt(d: Date): string {
|
function fmt(d: Date): string {
|
||||||
const y = d.getFullYear();
|
const y = d.getFullYear();
|
||||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
@@ -29,6 +28,8 @@ const MAX_DAYS = 366;
|
|||||||
app.get('/:plate/recent', async (c) => {
|
app.get('/:plate/recent', async (c) => {
|
||||||
const plate = c.req.param('plate');
|
const plate = c.req.param('plate');
|
||||||
if (!plate) return c.json({ plate: '', days: [] }, 400);
|
if (!plate) return c.json({ plate: '', days: [] }, 400);
|
||||||
|
const sourcePriority = parseMileageSourcePriority(c.req.query('sourcePriority'));
|
||||||
|
const protocolPriority = protocolsForSourcePriority(sourcePriority);
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
@@ -58,40 +59,43 @@ app.get('/:plate/recent', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [rows] = await mileagePool.execute(
|
const dates: string[] = [];
|
||||||
`SELECT DATE_FORMAT(stat_date, '%Y-%m-%d') AS date, daily_km, source
|
const dateCursor = new Date(start);
|
||||||
FROM v_vehicle_daily_stats
|
while (dateCursor <= end) {
|
||||||
WHERE plate = ? AND stat_date >= ? AND stat_date <= ?
|
dates.push(fmt(dateCursor));
|
||||||
ORDER BY stat_date`,
|
dateCursor.setDate(dateCursor.getDate() + 1);
|
||||||
[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 每天一条
|
// 补全:从 start 到 end 每天一条
|
||||||
const result: { date: string; dailyKm: number; isDataSynced: boolean }[] = [];
|
const result: {
|
||||||
|
date: string;
|
||||||
|
dailyKm: number;
|
||||||
|
isDataSynced: boolean;
|
||||||
|
sourceProtocol: 'GB32960' | 'MQTT' | 'JT808' | null;
|
||||||
|
sourceCategory: 'INSTRUMENT' | 'GPS' | null;
|
||||||
|
}[] = [];
|
||||||
const cursor = new Date(start);
|
const cursor = new Date(start);
|
||||||
while (cursor <= end) {
|
while (cursor <= end) {
|
||||||
const key = fmt(cursor);
|
const key = fmt(cursor);
|
||||||
const hit = map.get(key);
|
const hit = (rowsByDate.get(key) || []).find(row => row.plateNumber === plate);
|
||||||
result.push({
|
result.push({
|
||||||
date: key,
|
date: key,
|
||||||
dailyKm: hit?.dailyKm ?? 0,
|
dailyKm: hit?.status === 'NORMAL' ? (hit.dailyMileageKm || 0) : 0,
|
||||||
isDataSynced: !!hit && hit.source !== 'NONE',
|
isDataSynced: hit?.status === 'NORMAL',
|
||||||
|
sourceProtocol: hit?.sourceProtocol || null,
|
||||||
|
sourceCategory: sourceCategoryFromProtocol(hit?.sourceProtocol || null),
|
||||||
});
|
});
|
||||||
cursor.setDate(cursor.getDate() + 1);
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ plate, start: fmt(start), end: fmt(end), days: result });
|
return c.json({
|
||||||
|
plate,
|
||||||
|
start: fmt(start),
|
||||||
|
end: fmt(end),
|
||||||
|
sourcePriority,
|
||||||
|
days: result,
|
||||||
|
});
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error('vehicle recent error:', e);
|
console.error('vehicle recent error:', e);
|
||||||
return c.json({ plate, days: [] }, 500);
|
return c.json({ plate, days: [] }, 500);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import pool from '../../db.js';
|
import pool from '../../db.js';
|
||||||
import mileagePool from '../../mileage-db.js';
|
|
||||||
import { fetchVehicleInfoMap } from '../mileage/vehicle-info.js';
|
import { fetchVehicleInfoMap } from '../mileage/vehicle-info.js';
|
||||||
|
import { fetchOneOsMileageDates } from '../mileage/oneos-api.js';
|
||||||
import { mapRegion } from '../vehicles.js';
|
import { mapRegion } from '../vehicles.js';
|
||||||
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
||||||
import { classifyVehicle, generateSuggestions } from './algorithm.js';
|
import { classifyVehicle, generateSuggestions } from './algorithm.js';
|
||||||
@@ -28,19 +28,38 @@ function inferTypeFromTargetName(targetName: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Classify vehicle type from dic_type.dic_name (e.g. "4.5吨冷链车", "4.5吨货车", "18吨双飞翼货车").
|
* Classify vehicle type from ln_asset_management.vehicle_model.
|
||||||
* The typeName is the full label from the dictionary, modelRaw is the numeric dic_code.
|
* modelRaw is vehicle_model.vehicle_type, which is not the old dic_truck_type code.
|
||||||
*/
|
*/
|
||||||
function classifyVehicleType(typeName: string, _modelRaw: string): string {
|
function classifyVehicleType(typeName: string, _modelRaw: string): string {
|
||||||
const t = (typeName || '').trim();
|
const t = (typeName || '').trim();
|
||||||
if (t.includes('4.5') && t.includes('冷链')) return '4.5T冷链';
|
if (t.includes('4.5') && t.includes('冷链')) return '4.5T冷链';
|
||||||
if (t.includes('4.5')) return '4.5T普货';
|
if (t.includes('4.5')) return '4.5T普货';
|
||||||
if (t.includes('18')) return '18T';
|
if (t.includes('18')) return '18T';
|
||||||
if (t.includes('49') || t.includes('牵引')) return '49T';
|
|
||||||
if (t.includes('挂车')) return '挂车';
|
if (t.includes('挂车')) return '挂车';
|
||||||
|
if (t.includes('49')) return '49T';
|
||||||
|
if (t.includes('35')) return '35T';
|
||||||
return t || '其他';
|
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
|
// Route
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -54,7 +73,7 @@ app.get('/', async (c) => {
|
|||||||
|
|
||||||
// ---- Query 1: Assessment targets ----
|
// ---- Query 1: Assessment targets ----
|
||||||
const [targets] = await pool.execute(
|
const [targets] = await pool.execute(
|
||||||
'SELECT id, target_name, annual_mileage_per_vehicle FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id',
|
'SELECT id, target_name, annual_mileage_per_vehicle FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id',
|
||||||
) as [any[], unknown];
|
) as [any[], unknown];
|
||||||
|
|
||||||
const targetMap = new Map<number, { targetName: string; annualMileage: number }>();
|
const targetMap = new Map<number, { targetName: string; annualMileage: number }>();
|
||||||
@@ -71,21 +90,20 @@ app.get('/', async (c) => {
|
|||||||
current_mileage, current_year_mileage, current_year_mileage_task,
|
current_mileage, current_year_mileage, current_year_mileage_task,
|
||||||
completion_rate, is_qualified, current_year_is_qualified,
|
completion_rate, is_qualified, current_year_is_qualified,
|
||||||
daily_required_mileage, current_year_assessment_end_date
|
daily_required_mileage, current_year_assessment_end_date
|
||||||
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
|
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
|
||||||
`) as [any[], unknown];
|
`) as [any[], unknown];
|
||||||
|
|
||||||
// ---- Query 3: Vehicle info (customer, dept, manager) ----
|
// ---- Query 3: Vehicle info (customer, dept, manager) ----
|
||||||
const vehicleInfoMap = await fetchVehicleInfoMap();
|
const vehicleInfoMap = await fetchVehicleInfoMap();
|
||||||
|
|
||||||
// ---- Query 4: Vehicle types from tab_truck ----
|
// ---- Query 4: Vehicle types from vehicle_info ----
|
||||||
// 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(`
|
const [truckTypeRows] = await pool.execute(`
|
||||||
SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw
|
SELECT vi.plate_number, vm.model AS type_name, vm.vehicle_type AS model_raw
|
||||||
FROM tab_truck truck
|
FROM vehicle_info vi
|
||||||
LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type'
|
LEFT JOIN vehicle_status vs ON vs.vehicle_id = vi.id AND vs.del_flag = 0
|
||||||
AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0
|
LEFT JOIN vehicle_model vm ON vm.id = vi.vehicle_model_id AND vm.del_flag = '0'
|
||||||
WHERE truck.is_operation = 1
|
WHERE vi.del_flag = '0'
|
||||||
|
AND COALESCE(vs.operation_status, '') <> '5'
|
||||||
`) as [any[], unknown];
|
`) as [any[], unknown];
|
||||||
|
|
||||||
const truckTypeMap = new Map<string, { typeName: string; modelRaw: string }>();
|
const truckTypeMap = new Map<string, { typeName: string; modelRaw: string }>();
|
||||||
@@ -114,29 +132,33 @@ app.get('/', async (c) => {
|
|||||||
// ---- Collect all plates for Query 6 ----
|
// ---- Collect all plates for Query 6 ----
|
||||||
const allPlates = assessmentRows.map((r: any) => r.plate_number as string);
|
const allPlates = assessmentRows.map((r: any) => r.plate_number as string);
|
||||||
|
|
||||||
// ---- Query 6: Customer daily avg (from mileage DB) — 30d baseline + 7d recent ----
|
// ---- Query 6: Customer daily avg (OneOS only) — 30d baseline + 7d recent ----
|
||||||
const customerAvgDailyMap = new Map<string, number>();
|
const customerAvgDailyMap = new Map<string, number>();
|
||||||
const customerAvgDaily7dMap = new Map<string, number>();
|
const customerAvgDaily7dMap = new Map<string, number>();
|
||||||
if (allPlates.length > 0) {
|
if (allPlates.length > 0) {
|
||||||
const placeholders = allPlates.map(() => '?').join(',');
|
const dates = recentCompletedDates(30);
|
||||||
// Single query returning both windows per plate.
|
const sevenDayDates = new Set(dates.slice(-7));
|
||||||
const [dailyRows] = await mileagePool.execute(
|
const allowedPlates = new Set(allPlates);
|
||||||
`SELECT plate,
|
const rowsByDate = await fetchOneOsMileageDates(dates);
|
||||||
AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) THEN daily_km END) AS avg_30d,
|
const aggregates = new Map<string, { sum30: number; count30: number; sum7: number; count7: number }>();
|
||||||
AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN daily_km END) AS avg_7d
|
for (const [date, rows] of rowsByDate) {
|
||||||
FROM v_vehicle_daily_stats
|
for (const row of rows) {
|
||||||
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
if (!allowedPlates.has(row.plateNumber) || row.status !== 'NORMAL' || row.dailyMileageKm === null) continue;
|
||||||
AND stat_date < CURDATE()
|
const current = aggregates.get(row.plateNumber) || { sum30: 0, count30: 0, sum7: 0, count7: 0 };
|
||||||
AND plate IN (${placeholders})
|
current.sum30 += row.dailyMileageKm;
|
||||||
GROUP BY plate`,
|
current.count30 += 1;
|
||||||
allPlates,
|
if (sevenDayDates.has(date)) {
|
||||||
) as [any[], unknown];
|
current.sum7 += row.dailyMileageKm;
|
||||||
|
current.count7 += 1;
|
||||||
|
}
|
||||||
|
aggregates.set(row.plateNumber, current);
|
||||||
|
}
|
||||||
|
}
|
||||||
const plateAvg30Map = new Map<string, number>();
|
const plateAvg30Map = new Map<string, number>();
|
||||||
const plateAvg7Map = new Map<string, number>();
|
const plateAvg7Map = new Map<string, number>();
|
||||||
for (const row of dailyRows) {
|
for (const [plate, values] of aggregates) {
|
||||||
if (row.avg_30d !== null) plateAvg30Map.set(row.plate, Number(row.avg_30d));
|
if (values.count30 > 0) plateAvg30Map.set(plate, values.sum30 / values.count30);
|
||||||
if (row.avg_7d !== null) plateAvg7Map.set(row.plate, Number(row.avg_7d));
|
if (values.count7 > 0) plateAvg7Map.set(plate, values.sum7 / values.count7);
|
||||||
}
|
}
|
||||||
|
|
||||||
const customerPlates30 = new Map<string, number[]>();
|
const customerPlates30 = new Map<string, number[]>();
|
||||||
@@ -161,12 +183,13 @@ app.get('/', async (c) => {
|
|||||||
|
|
||||||
// ---- Query 7: Inventory vehicles (rent_status = 0) ----
|
// ---- Query 7: Inventory vehicles (rent_status = 0) ----
|
||||||
const [inventoryTruckRows] = await pool.execute(`
|
const [inventoryTruckRows] = await pool.execute(`
|
||||||
SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw
|
SELECT vi.plate_number, vm.model AS type_name, vm.vehicle_type AS model_raw
|
||||||
FROM tab_truck truck
|
FROM vehicle_info vi
|
||||||
LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type'
|
LEFT JOIN vehicle_status vs ON vs.vehicle_id = vi.id AND vs.del_flag = 0
|
||||||
AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0
|
LEFT JOIN vehicle_model vm ON vm.id = vi.vehicle_model_id AND vm.del_flag = '0'
|
||||||
WHERE truck.is_deleted = 0 AND truck.is_operation = 1
|
WHERE vi.del_flag = '0'
|
||||||
AND truck.truck_rent_status = 0
|
AND COALESCE(vs.operation_status, '') IN ('3','4')
|
||||||
|
AND COALESCE(vs.vehicle_status, '') <> '4'
|
||||||
`) as [any[], unknown];
|
`) as [any[], unknown];
|
||||||
|
|
||||||
// ---- Build assessment vehicle lookup for inventory cross-reference ----
|
// ---- Build assessment vehicle lookup for inventory cross-reference ----
|
||||||
|
|||||||
299
src/server/routes/vehicle-heatmap.ts
Normal file
299
src/server/routes/vehicle-heatmap.ts
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
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;
|
||||||
@@ -17,75 +17,101 @@ import type { Context } from 'hono';
|
|||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
const MAIN_SQL = `SELECT
|
const MAIN_SQL = `SELECT
|
||||||
CAST(truck.id AS CHAR) AS id,
|
CAST(vi.id AS CHAR) AS id,
|
||||||
truck.plate_number AS 车牌号,
|
vi.plate_number AS 车牌号,
|
||||||
truck.vin AS vin,
|
vi.vin AS vin,
|
||||||
truck.brand AS 车辆品牌,
|
vm.brand AS 车辆品牌,
|
||||||
truck.model AS 车辆型号,
|
vm.model AS 车辆型号,
|
||||||
truck.color AS 车辆颜色,
|
vi.body_color AS 车辆颜色,
|
||||||
truck.rent_from_company AS 租赁公司,
|
vi.rental_company AS 租赁公司,
|
||||||
dic_ascription_status.dic_name AS 车辆归属状态Label,
|
CASE vi.vehicle_source
|
||||||
dic_type.dic_name AS 车辆型号Label,
|
WHEN '0' THEN '自有'
|
||||||
truck.stock_area AS 库存区域,
|
WHEN '1' THEN '挂靠'
|
||||||
truck.truck_rent_status AS 车辆租赁状态,
|
WHEN '2' THEN '外租'
|
||||||
dic_status.dic_name AS 车辆租赁状态Label,
|
WHEN '3' THEN '自有'
|
||||||
truck.is_operation AS 是否营运,
|
ELSE vi.actual_ownership
|
||||||
info.province AS 省,
|
END AS 车辆归属状态Label,
|
||||||
info.city AS 市,
|
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 市,
|
||||||
info.lat AS 纬度,
|
info.lat AS 纬度,
|
||||||
info.lng AS 经度,
|
info.lng AS 经度,
|
||||||
dic_brand.dic_name AS 车辆品牌Label,
|
CASE vm.brand
|
||||||
si.contract_id AS 合同ID,
|
WHEN 'hyundai' THEN CASE WHEN vm.model LIKE '%帕力安%' OR vm.model LIKE '%冷链%' OR vm.model LIKE '%双飞翼%' THEN '帕力安牌' ELSE '现代' END
|
||||||
COALESCE(c.contract_no, si.contract_no) AS 合同编码,
|
WHEN 'yuejin' THEN '跃进'
|
||||||
cus.customer_name AS 客户名称,
|
WHEN 'feichi' THEN '飞驰'
|
||||||
org.org_name AS 合同归属公司,
|
WHEN 'sulong' THEN '苏龙'
|
||||||
dep.dep_name AS 合同归属部门,
|
WHEN 'higer' THEN '海格'
|
||||||
org_truck.org_name AS 主体,
|
WHEN 'dongfeng' THEN '东风'
|
||||||
c.project_name AS 项目名称,
|
WHEN 'yutong' THEN '宇通'
|
||||||
u.user_name AS 客户经理,
|
WHEN 'chufeng' THEN '楚风'
|
||||||
CAST(c.bd AS CHAR) AS 经理ID
|
WHEN 'tonghua' THEN '通华'
|
||||||
FROM tab_truck truck
|
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'
|
||||||
LEFT JOIN tab_truck_remote_sync_realtime_info info
|
LEFT JOIN tab_truck_remote_sync_realtime_info info
|
||||||
ON info.id = truck.id
|
ON info.plate_number = vi.plate_number
|
||||||
LEFT JOIN tab_dic dic_type
|
AND info.is_deleted = 0
|
||||||
ON dic_type.parent_code = 'dic_truck_type'
|
LEFT JOIN common_district info_province
|
||||||
AND dic_type.dic_code = truck.model
|
ON info_province.CODE = info.province COLLATE utf8mb4_unicode_ci
|
||||||
AND dic_type.is_deleted = 0
|
AND info_province.STATUS = 'VALID'
|
||||||
LEFT JOIN tab_dic dic_status
|
LEFT JOIN common_district info_city
|
||||||
ON dic_status.parent_code = 'dic_truck_rent_status'
|
ON info_city.CODE = info.city COLLATE utf8mb4_unicode_ci
|
||||||
AND dic_status.dic_code = truck.truck_rent_status
|
AND info_city.STATUS = 'VALID'
|
||||||
AND dic_status.is_deleted = 0
|
LEFT JOIN common_district vi_province
|
||||||
LEFT JOIN tab_dic dic_brand
|
ON vi_province.CODE = vi.province COLLATE utf8mb4_unicode_ci
|
||||||
ON dic_brand.parent_code = 'dic_vehicle_brand'
|
AND vi_province.STATUS = 'VALID'
|
||||||
AND dic_brand.dic_code = truck.brand
|
LEFT JOIN common_district vi_city
|
||||||
AND dic_brand.is_deleted = 0
|
ON vi_city.CODE = vi.city COLLATE utf8mb4_unicode_ci
|
||||||
LEFT JOIN tab_truck_status_info si
|
AND vi_city.STATUS = 'VALID'
|
||||||
ON si.truck_id = truck.id
|
LEFT JOIN common_district vi_operation_city
|
||||||
AND si.is_deleted = 0
|
ON vi_operation_city.CODE = vi.operation_city COLLATE utf8mb4_unicode_ci
|
||||||
LEFT JOIN tab_contract c
|
AND vi_operation_city.STATUS = 'VALID'
|
||||||
ON c.id = si.contract_id
|
LEFT JOIN vehicle_lease_order_record vor
|
||||||
AND c.is_deleted = 0
|
ON vor.vehicle_id = vi.id
|
||||||
LEFT JOIN tab_customer cus
|
AND vor.del_flag = '0'
|
||||||
ON cus.id = c.customer_id
|
AND vor.id = (
|
||||||
AND cus.is_deleted = 0
|
SELECT MAX(vor2.id)
|
||||||
LEFT JOIN tab_org org
|
FROM vehicle_lease_order_record vor2
|
||||||
ON org.id = c.org_id
|
WHERE vor2.vehicle_id = vi.id
|
||||||
AND org.is_deleted = 0
|
AND vor2.del_flag = '0'
|
||||||
LEFT JOIN tab_org org_truck
|
)
|
||||||
ON org_truck.id = truck.org_id
|
LEFT JOIN vehicle_lease_contract_info c
|
||||||
AND org_truck.is_deleted = 0
|
ON c.order_id = vor.contract_id
|
||||||
LEFT JOIN tab_dic dic_ascription_status
|
AND c.del_flag = '0'
|
||||||
ON dic_ascription_status.parent_code = 'dic_truck_ascription_status'
|
LEFT JOIN customer_info ci
|
||||||
AND dic_ascription_status.dic_code = truck.ascription_status
|
ON ci.id = vi.customer_id
|
||||||
AND dic_ascription_status.is_deleted = 0
|
AND ci.del_flag = '0'
|
||||||
LEFT JOIN tab_user u
|
WHERE vi.del_flag = '0'
|
||||||
ON u.id = c.bd
|
AND COALESCE(vs.operation_status, '') <> '5'`;
|
||||||
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
|
// Region mapping: province/city -> display region
|
||||||
const REGIONS = ['嘉兴', '广东', '北京', '新疆', '其他'] as const;
|
const REGIONS = ['嘉兴', '广东', '北京', '新疆', '其他'] as const;
|
||||||
@@ -148,23 +174,33 @@ function countByType(vehicles: Vehicle[]): VehicleTypeCounts {
|
|||||||
return counts;
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map rental status to frontend status
|
// Map operation status to frontend status.
|
||||||
// Actual DB values: 在库(0), 自营(1), 租赁(2), 待交车(7), 挂靠(8), 异动(12)
|
// ln_asset_management.vehicle_status.operation_status:
|
||||||
function mapStatus(rentStatus: string | null): 'Operating' | 'Inventory' | 'Pending' | 'Abnormal' {
|
// 1=租赁, 2=自营, 3=可运营, 4=待运营, 5=退出运营.
|
||||||
if (!rentStatus) return 'Inventory';
|
function mapStatus(operationStatus: string | null, vehicleStatus: string | null): 'Operating' | 'Inventory' | 'Pending' | 'Abnormal' {
|
||||||
const s = rentStatus.trim();
|
const op = (operationStatus || '').trim();
|
||||||
if (s === '租赁' || s === '自营' || s === '挂靠') return 'Operating';
|
const vehicle = (vehicleStatus || '').trim();
|
||||||
if (s === '在库') return 'Inventory';
|
if (vehicle === '4') return 'Pending';
|
||||||
if (s === '待交车') return 'Pending';
|
if (vehicle === '14') return 'Abnormal';
|
||||||
if (s === '异动') 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';
|
||||||
return 'Inventory';
|
return 'Inventory';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map ownership from truck_rent_status (rentStatusLabel)
|
// Map ownership from vehicle_info.vehicle_source.
|
||||||
// DB values: 自营(1), 租赁(2), 挂靠(8) → these are the operating subtypes
|
// ln_asset_management vehicle_source values: 1=挂靠, 2=外租, 3=自有. Keep 0=自有 for legacy rows.
|
||||||
function mapOwnership(rentStatusLabel: string | null): string {
|
function mapOwnership(rentStatusLabel: string | null): string {
|
||||||
if (!rentStatusLabel) return 'Unknown';
|
if (!rentStatusLabel) return 'Unknown';
|
||||||
const s = rentStatusLabel.trim();
|
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 'Self';
|
||||||
if (s === '租赁') return 'Leased';
|
if (s === '租赁') return 'Leased';
|
||||||
if (s === '挂靠') return 'Hanging';
|
if (s === '挂靠') return 'Hanging';
|
||||||
@@ -179,27 +215,41 @@ function resolveCity(city: string | null, province: string | null): string {
|
|||||||
return p || '其他';
|
return p || '其他';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive vehicle type category from model label
|
// Derive page category from ln_asset_management.vehicle_model.
|
||||||
// Actual DB values: 4.5吨冷链车, 4.5吨货车, 18吨双飞翼货车, 18吨厢式货车, 49吨牵引车头, 35吨牵引车头,
|
// vehicle_type is a new-system category code, not the old lingniu_prod.dic_truck_type code:
|
||||||
// 重型集装箱半挂车, 重型平板半挂车, 氢能叉车, SJ型蓄电池观光车, 公务用车/小客车, 挂靠油车
|
// 1 = 4.5T, 2 = 18T / other truck-like models, 3 = tractor head, 5/6 = trailers.
|
||||||
function deriveType(modelLabel: string | null, brandLabel: string | null): string {
|
function deriveType(modelLabel: string | null, vehicleTypeCode: string | null): string {
|
||||||
const label = (modelLabel || '').trim();
|
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('4.5吨')) return '4.5T';
|
||||||
if (label.includes('18吨')) return '18T';
|
if (label.includes('18吨')) return '18T';
|
||||||
if (label.includes('49吨')) return '49T';
|
if (label.includes('49吨')) return '49T';
|
||||||
if (label.includes('35吨')) return '35T';
|
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 '叉车';
|
||||||
if (label.includes('半挂车')) return '挂车';
|
|
||||||
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 → alias mapping with sort order
|
||||||
// tag is generated as: brand-modelLabel-color[+rentCompany if 外租]
|
// tag is generated as: brand-modelLabel-color[+rentCompany if 外租]
|
||||||
// Some tags are merged (e.g. 嘉氢 red + 嘉氢 blue/green → one alias)
|
// Some tags are merged (e.g. 嘉氢 red + 嘉氢 blue/green → one alias)
|
||||||
const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = {
|
const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = {
|
||||||
// 4.5T 普货
|
// 4.5T 普货
|
||||||
'现代-4.5吨货车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T普货(交投)', order: 101 },
|
'现代-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: 102 },
|
||||||
|
'现代-4.5吨货车-白色': { alias: '现代4.5T普货', order: 101 },
|
||||||
|
'现代-4.5吨货车-白': { alias: '现代4.5T普货', order: 101 },
|
||||||
// 4.5T 冷链
|
// 4.5T 冷链
|
||||||
'帕力安牌-4.5吨冷链车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T冷链(交投)', order: 201 },
|
'帕力安牌-4.5吨冷链车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T冷链(交投)', order: 201 },
|
||||||
'帕力安牌-4.5吨冷链车-白色': { alias: '现代4.5T冷链(羚牛)', order: 202 },
|
'帕力安牌-4.5吨冷链车-白色': { alias: '现代4.5T冷链(羚牛)', order: 202 },
|
||||||
@@ -212,13 +262,16 @@ const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = {
|
|||||||
'苏龙-18吨双飞翼货车-白色': { alias: '苏龙18T飞翼', order: 304 }, // dirty data, merge
|
'苏龙-18吨双飞翼货车-白色': { alias: '苏龙18T飞翼', order: 304 }, // dirty data, merge
|
||||||
'苏龙-18吨双飞翼货车-白安吉天地物流科技有限公司': { alias: '苏龙18T飞翼(安吉)', order: 305 },
|
'苏龙-18吨双飞翼货车-白安吉天地物流科技有限公司': { alias: '苏龙18T飞翼(安吉)', order: 305 },
|
||||||
'帕力安牌-18吨双飞翼货车-白': { alias: '现代18T双飞翼(羚牛)', order: 306 },
|
'帕力安牌-18吨双飞翼货车-白': { alias: '现代18T双飞翼(羚牛)', order: 306 },
|
||||||
|
'帕力安牌-18吨双飞翼货车-白/绿': { alias: '现代18T双飞翼(羚牛)', order: 306 },
|
||||||
// 49T
|
// 49T
|
||||||
'宇通-49吨牵引车头-白': { alias: '49T宇通', order: 401 },
|
'宇通-49吨牵引车头-白': { alias: '49T宇通', order: 401 },
|
||||||
'飞驰-49吨牵引车头-白/蓝/绿': { alias: '49T飞驰', order: 402 },
|
'飞驰-49吨牵引车头-白/蓝/绿': { alias: '49T飞驰', order: 402 },
|
||||||
'飞驰-49吨牵引车头-白/蓝/绿嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰(嘉氢)', order: 403 },
|
'飞驰-49吨牵引车头-白/蓝/绿嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰(嘉氢)', order: 403 },
|
||||||
|
'飞驰-49吨牵引车头-红': { alias: '49T飞驰(红)', order: 402 },
|
||||||
'飞驰-49吨牵引车头-红嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰(嘉氢)', order: 403 }, // merge with above
|
'飞驰-49吨牵引车头-红嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰(嘉氢)', order: 403 }, // merge with above
|
||||||
'飞驰-49吨牵引车头-红浙江氢能产业发展有限公司': { alias: '49T飞驰(浙氢-红)', order: 404 },
|
'飞驰-49吨牵引车头-红浙江氢能产业发展有限公司': { alias: '49T飞驰(浙氢-红)', order: 404 },
|
||||||
'飞驰-49吨牵引车头-白/蓝/绿浙江氢能产业发展有限公司': { alias: '49T飞驰(浙氢-蓝白绿)', order: 405 },
|
'飞驰-49吨牵引车头-白/蓝/绿浙江氢能产业发展有限公司': { alias: '49T飞驰(浙氢-蓝白绿)', order: 405 },
|
||||||
|
'楚风-49吨牵引车头-蓝/黑': { alias: '49T楚风', order: 406 },
|
||||||
'楚风-49吨牵引车头-蓝/黑海珀特科技(北京)有限公司': { alias: '49T楚风(海珀特)', order: 406 },
|
'楚风-49吨牵引车头-蓝/黑海珀特科技(北京)有限公司': { alias: '49T楚风(海珀特)', order: 406 },
|
||||||
// 其他
|
// 其他
|
||||||
'红岩-35吨牵引车头-红色': { alias: '35T油车', order: 501 },
|
'红岩-35吨牵引车头-红色': { alias: '35T油车', order: 501 },
|
||||||
@@ -232,9 +285,11 @@ const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = {
|
|||||||
'万风-重型平板半挂车-红': { alias: '挂车', order: 503 },
|
'万风-重型平板半挂车-红': { alias: '挂车', order: 503 },
|
||||||
'舒捷-SJ型蓄电池观光车-蓝白': { alias: '观光车', order: 504 },
|
'舒捷-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 },
|
'其他-公务用车/小客车-蓝色': { alias: '公务车/挂靠车', order: 505 },
|
||||||
|
'其他-公务用车/小客车-灰': { alias: '公务车/挂靠车', order: 505 },
|
||||||
'远程牌-公务用车/小客车-白': { alias: '公务车/挂靠车', order: 505 },
|
'远程牌-公务用车/小客车-白': { alias: '公务车/挂靠车', order: 505 },
|
||||||
'大通-公务用车/小客车-灰': { alias: '公务车/挂靠车', order: 505 },
|
'大通-公务用车/小客车-灰': { alias: '公务车/挂靠车', order: 505 },
|
||||||
};
|
};
|
||||||
@@ -247,8 +302,9 @@ function deriveModelTag(
|
|||||||
rentCompany: string | null,
|
rentCompany: string | null,
|
||||||
): string {
|
): string {
|
||||||
const brand = (brandLabel || '').trim();
|
const brand = (brandLabel || '').trim();
|
||||||
const model = (modelLabel || '').trim();
|
const model = (normalizeModelLabel(modelLabel) || '').trim();
|
||||||
const c = (color || '').trim();
|
const c = (color || '').trim();
|
||||||
|
if (model === '公务用车/小客车') return '公务车/挂靠车';
|
||||||
const isRented = ownershipLabel?.trim() === '外租';
|
const isRented = ownershipLabel?.trim() === '外租';
|
||||||
const company = isRented ? (rentCompany || '').trim() : '';
|
const company = isRented ? (rentCompany || '').trim() : '';
|
||||||
|
|
||||||
@@ -272,15 +328,16 @@ function transformRow(row: VehicleRow): Vehicle {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
plateNumber: row.车牌号 || '',
|
plateNumber: row.车牌号 || '',
|
||||||
vin: row.vin || '',
|
vin: row.vin || '',
|
||||||
type: deriveType(row.车辆型号Label, row.车辆品牌Label),
|
type: deriveType(row.车辆型号Label, row.车辆类型参数),
|
||||||
model: deriveModelTag(row.车辆品牌Label, row.车辆型号Label, row.车辆颜色, row.车辆归属状态Label, row.租赁公司),
|
model: deriveModelTag(row.车辆品牌Label, row.车辆型号Label, row.车辆颜色, row.车辆归属状态Label, row.租赁公司),
|
||||||
color: row.车辆颜色 || '',
|
color: row.车辆颜色 || '',
|
||||||
location: region,
|
location: region,
|
||||||
region,
|
region,
|
||||||
province: row.省,
|
province: row.省,
|
||||||
city: row.市,
|
city: row.市,
|
||||||
status: mapStatus(row.车辆租赁状态Label),
|
status: mapStatus(row.车辆租赁状态Label, row.车辆租赁状态),
|
||||||
ownership: mapOwnership(row.车辆租赁状态Label),
|
operationStatus: row.车辆租赁状态Label,
|
||||||
|
ownership: mapOwnership(row.车辆归属状态Label),
|
||||||
rentCompany: row.租赁公司 || '',
|
rentCompany: row.租赁公司 || '',
|
||||||
contractNo: row.合同编码,
|
contractNo: row.合同编码,
|
||||||
customerName: row.客户名称,
|
customerName: row.客户名称,
|
||||||
@@ -318,7 +375,7 @@ async function getVehiclesForUser(c: Context): Promise<Vehicle[]> {
|
|||||||
return maskCustomerNames(list);
|
return maskCustomerNames(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 归属公司筛选(所属公司 = tab_truck.org_id → org_name, 即 Vehicle.subjectOrg)
|
// 归属公司筛选(所属公司 = vehicle_info.registered_ownership, 即 Vehicle.subjectOrg)
|
||||||
function getSubjectParam(c: Context): string | null {
|
function getSubjectParam(c: Context): string | null {
|
||||||
const raw = (c.req.query('subject') || '').trim();
|
const raw = (c.req.query('subject') || '').trim();
|
||||||
return raw ? raw : null;
|
return raw ? raw : null;
|
||||||
@@ -354,24 +411,27 @@ async function getWeeklyTruckIds(): Promise<WeeklyTruckIds> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [[pendingRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([
|
const [[pendingRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([
|
||||||
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(vehicle_id AS CHAR) AS truck_id
|
||||||
pool.query<any[]>(`SELECT CAST(rent_truck.truck_id AS CHAR) AS truck_id FROM tab_truck_rent_take take
|
FROM vehicle_status
|
||||||
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
WHERE del_flag=0 AND vehicle_status='4' AND COALESCE(operation_status, '') <> '5'`),
|
||||||
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
|
pool.query<any[]>(`SELECT CAST(vehicle_id AS CHAR) AS truck_id
|
||||||
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL
|
FROM delivery_vehicle
|
||||||
AND task.task_type=1 AND task.task_status=1 AND take.update_time IS NOT NULL
|
WHERE del_flag='0'
|
||||||
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`),
|
AND vehicle_id IS NOT NULL
|
||||||
pool.query<any[]>(`SELECT CAST(rent_truck.truck_id AS CHAR) AS truck_id FROM tab_truck_rent_return r
|
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}
|
||||||
LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id
|
AND delivery_status IN (2,3,5)`),
|
||||||
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
|
pool.query<any[]>(`SELECT CAST(vehicle_id AS CHAR) AS truck_id
|
||||||
WHERE r.is_deleted=0 AND r.return_date IS NOT NULL
|
FROM return_vehicle_task
|
||||||
AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL}`),
|
WHERE del_flag='0'
|
||||||
pool.query<any[]>(`SELECT CAST(rent_truck.truck_id AS CHAR) AS truck_id FROM tab_truck_rent_take take
|
AND vehicle_id IS NOT NULL
|
||||||
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}
|
||||||
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
|
AND status IN (2,3,5)`),
|
||||||
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL
|
pool.query<any[]>(`SELECT CAST(new_vehicle_id AS CHAR) AS truck_id
|
||||||
AND task.task_type=3 AND task.task_status=1 AND take.update_time IS NOT NULL
|
FROM vehicle_replacement
|
||||||
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`),
|
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`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const toSet = (rows: any[]) => new Set((rows as any[]).map((r) => String(r.truck_id)).filter((s) => s && s !== 'null'));
|
const toSet = (rows: any[]) => new Set((rows as any[]).map((r) => String(r.truck_id)).filter((s) => s && s !== 'null'));
|
||||||
@@ -408,6 +468,59 @@ function getStats(list: Vehicle[], weeklyIds?: WeeklyTruckIds) {
|
|||||||
const WEEK_START_SQL = `DATE_SUB(CURDATE(), INTERVAL (WEEKDAY(CURDATE()) + 2) % 7 DAY)`;
|
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)`;
|
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 {
|
interface WeeklyStats {
|
||||||
pendingDelivery: number;
|
pendingDelivery: number;
|
||||||
weeklyNew: number;
|
weeklyNew: number;
|
||||||
@@ -419,59 +532,54 @@ interface WeeklyStats {
|
|||||||
|
|
||||||
// 交车单 SQL
|
// 交车单 SQL
|
||||||
const DELIVERED_SQL = `SELECT
|
const DELIVERED_SQL = `SELECT
|
||||||
take.id, DATE(take.handover_date) AS handover_date,
|
dv.id, DATE(dv.delivery_time) AS handover_date,
|
||||||
CAST(truck.id AS CHAR) AS truck_id, truck.plate_number,
|
CAST(dv.vehicle_id AS CHAR) AS truck_id, dv.plate_number,
|
||||||
dic_contract_type.dic_name AS contract_type,
|
c.contract_type,
|
||||||
customer.customer_name
|
COALESCE(dts.customer_name, c.customer_name) AS customer_name
|
||||||
FROM tab_truck_rent_take take
|
FROM delivery_vehicle dv
|
||||||
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
LEFT JOIN delivery_task_subject dts
|
||||||
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
|
ON dts.id = dv.delivery_task_subject_id
|
||||||
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
|
AND dts.del_flag = '0'
|
||||||
LEFT JOIN tab_contract contract ON task.contract_id = contract.id
|
LEFT JOIN vehicle_lease_contract_info c
|
||||||
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
|
ON c.order_id = dv.contract_id
|
||||||
LEFT JOIN tab_dic dic_contract_type
|
AND c.del_flag = '0'
|
||||||
ON dic_contract_type.parent_code = 'dic_contract_type'
|
WHERE dv.del_flag = '0'
|
||||||
AND dic_contract_type.dic_code = contract.contract_type
|
AND dv.vehicle_id IS NOT NULL
|
||||||
AND dic_contract_type.is_deleted = 0
|
AND dv.delivery_time IS NOT NULL
|
||||||
WHERE take.is_deleted = 0 AND take.take_name IS NOT NULL
|
AND dv.delivery_status IN (2,3,5)`;
|
||||||
AND task.task_type = 1 AND task.task_status = 1 AND take.update_time IS NOT NULL`;
|
|
||||||
|
|
||||||
// 还车单 SQL
|
// 还车单 SQL
|
||||||
const RETURNED_SQL = `SELECT
|
const RETURNED_SQL = `SELECT
|
||||||
r.id, DATE(r.return_date) AS handover_date,
|
r.id, DATE(r.arrival_time) AS handover_date,
|
||||||
CAST(truck.id AS CHAR) AS truck_id, truck.plate_number,
|
CAST(r.vehicle_id AS CHAR) AS truck_id, r.plate_number,
|
||||||
dic_contract_type.dic_name AS contract_type,
|
c.contract_type,
|
||||||
customer.customer_name
|
COALESCE(dts.customer_name, c.customer_name) AS customer_name
|
||||||
FROM tab_truck_rent_return r
|
FROM return_vehicle_task r
|
||||||
LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id
|
LEFT JOIN delivery_task_subject dts
|
||||||
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
|
ON dts.id = r.delivery_task_subject_id
|
||||||
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
|
AND dts.del_flag = '0'
|
||||||
LEFT JOIN tab_contract contract ON task.contract_id = contract.id
|
LEFT JOIN vehicle_lease_contract_info c
|
||||||
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
|
ON c.order_id = r.contract_id
|
||||||
LEFT JOIN tab_dic dic_contract_type
|
AND c.del_flag = '0'
|
||||||
ON dic_contract_type.parent_code = 'dic_contract_type'
|
WHERE r.del_flag = '0'
|
||||||
AND dic_contract_type.dic_code = contract.contract_type
|
AND r.vehicle_id IS NOT NULL
|
||||||
AND dic_contract_type.is_deleted = 0
|
AND r.arrival_time IS NOT NULL
|
||||||
WHERE r.is_deleted = 0 AND r.return_date IS NOT NULL`;
|
AND r.status IN (2,3,5)`;
|
||||||
|
|
||||||
// 替换车单 SQL
|
// 替换车单 SQL
|
||||||
const REPLACED_SQL = `SELECT
|
const REPLACED_SQL = `SELECT
|
||||||
take.id, DATE(take.handover_date) AS handover_date,
|
vr.id, DATE(vr.replace_time) AS handover_date,
|
||||||
CAST(truck.id AS CHAR) AS truck_id, truck.plate_number,
|
CAST(vr.new_vehicle_id AS CHAR) AS truck_id, vr.new_vehicle_plate AS plate_number,
|
||||||
dic_contract_type.dic_name AS contract_type,
|
c.contract_type,
|
||||||
customer.customer_name
|
c.customer_name
|
||||||
FROM tab_truck_rent_take take
|
FROM vehicle_replacement vr
|
||||||
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
LEFT JOIN vehicle_lease_contract_info c
|
||||||
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
|
ON c.id = vr.contract_id
|
||||||
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
|
AND c.del_flag = '0'
|
||||||
LEFT JOIN tab_contract contract ON task.contract_id = contract.id
|
WHERE vr.del_flag = '0'
|
||||||
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
|
AND vr.new_vehicle_id IS NOT NULL
|
||||||
LEFT JOIN tab_dic dic_contract_type
|
AND vr.replace_time IS NOT NULL
|
||||||
ON dic_contract_type.parent_code = 'dic_contract_type'
|
AND vr.status = 20`;
|
||||||
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 cachedWeeklyStats: WeeklyStats | null = null;
|
||||||
let weeklyStatsLastFetch = 0;
|
let weeklyStatsLastFetch = 0;
|
||||||
@@ -483,23 +591,18 @@ async function getWeeklyStats(): Promise<WeeklyStats> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [[pendingRows], [newRows], [removedRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([
|
const [[pendingRows], [newRows], [removedRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([
|
||||||
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 vehicle_status WHERE del_flag=0 AND vehicle_status='4' AND COALESCE(operation_status, '') <> '5'`),
|
||||||
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 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 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 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 tab_truck_rent_take take
|
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle
|
||||||
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)
|
||||||
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL
|
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}`),
|
||||||
AND task.task_type=1 AND task.task_status=1 AND take.update_time IS NOT NULL
|
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM return_vehicle_task
|
||||||
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`),
|
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)
|
||||||
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_return r
|
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}`),
|
||||||
LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id
|
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_replacement
|
||||||
WHERE r.is_deleted=0 AND r.return_date IS NOT NULL
|
WHERE del_flag='0' AND replace_time IS NOT NULL AND status=20
|
||||||
AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL}`),
|
AND replace_time >= ${WEEK_START_SQL} AND replace_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=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 = {
|
cachedWeeklyStats = {
|
||||||
@@ -520,13 +623,13 @@ app.get('/summary', async (c) => {
|
|||||||
const vehicleIds = new Set(vehicles.map(v => String(v.id)));
|
const vehicleIds = new Set(vehicles.map(v => String(v.id)));
|
||||||
const summary: SummaryData = {
|
const summary: SummaryData = {
|
||||||
totalAssets: vehicles.length,
|
totalAssets: vehicles.length,
|
||||||
operating: {
|
operating: {
|
||||||
total: vehicles.filter((v) => v.status === 'Operating').length,
|
total: vehicles.filter((v) => v.status === 'Operating' && (v.operationStatus === '1' || v.operationStatus === '2')).length,
|
||||||
self: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Self').length,
|
self: vehicles.filter((v) => v.status === 'Operating' && v.operationStatus === '2').length,
|
||||||
leased: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Leased').length,
|
leased: vehicles.filter((v) => v.status === 'Operating' && v.operationStatus === '1').length,
|
||||||
public: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Public').length,
|
public: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Public').length,
|
||||||
hanging: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Hanging').length,
|
hanging: 0,
|
||||||
},
|
},
|
||||||
inventory: {
|
inventory: {
|
||||||
total: vehicles.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal').length,
|
total: vehicles.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal').length,
|
||||||
inStock: vehicles.filter((v) => v.status === 'Inventory').length,
|
inStock: vehicles.filter((v) => v.status === 'Inventory').length,
|
||||||
@@ -695,7 +798,8 @@ app.get('/dept-stats', async (c) => {
|
|||||||
|
|
||||||
const deptMap = new Map<string, Map<string, Vehicle[]>>();
|
const deptMap = new Map<string, Map<string, Vehicle[]>>();
|
||||||
for (const v of withManager) {
|
for (const v of withManager) {
|
||||||
const dept = v.departmentName || '公务车';
|
const isPublicServiceVehicle = v.model === '公务车/挂靠车';
|
||||||
|
const dept = isPublicServiceVehicle ? '公务车' : (v.departmentName || '未分配部门');
|
||||||
const mgr = v.customerManager || '未分配';
|
const mgr = v.customerManager || '未分配';
|
||||||
if (EXCLUDED_MANAGERS.has(mgr)) continue;
|
if (EXCLUDED_MANAGERS.has(mgr)) continue;
|
||||||
if (!deptMap.has(dept)) deptMap.set(dept, new Map());
|
if (!deptMap.has(dept)) deptMap.set(dept, new Map());
|
||||||
@@ -704,29 +808,6 @@ app.get('/dept-stats', async (c) => {
|
|||||||
mgrMap.get(mgr)!.push(v);
|
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
|
// Compute attendance & avg mileage from realtime data
|
||||||
const getMileageStats = (vList: Vehicle[]) => {
|
const getMileageStats = (vList: Vehicle[]) => {
|
||||||
const todayActive = vList.filter((v) => (todayMileageMap.get(v.plateNumber) || 0) > 0).length;
|
const todayActive = vList.filter((v) => (todayMileageMap.get(v.plateNumber) || 0) > 0).length;
|
||||||
@@ -971,7 +1052,11 @@ app.get('/list', async (c) => {
|
|||||||
filtered = filtered.filter((v) => customer === '未分配客户' ? !v.customerName : v.customerName === customer);
|
filtered = filtered.filter((v) => customer === '未分配客户' ? !v.customerName : v.customerName === customer);
|
||||||
}
|
}
|
||||||
if (department) {
|
if (department) {
|
||||||
filtered = filtered.filter((v) => department === '公务车' ? !v.departmentName : v.departmentName === department);
|
filtered = filtered.filter((v) => {
|
||||||
|
if (department === '公务车') return v.model === '公务车/挂靠车';
|
||||||
|
if (department === '未分配部门') return v.model !== '公务车/挂靠车' && !v.departmentName;
|
||||||
|
return v.departmentName === department;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (isColdChain !== undefined) {
|
if (isColdChain !== undefined) {
|
||||||
const wantCold = isColdChain === 'true';
|
const wantCold = isColdChain === 'true';
|
||||||
@@ -992,9 +1077,10 @@ app.get('/list', async (c) => {
|
|||||||
location: v.location,
|
location: v.location,
|
||||||
province: v.province,
|
province: v.province,
|
||||||
city: v.city,
|
city: v.city,
|
||||||
status: v.status,
|
status: v.status,
|
||||||
ownership: v.ownership,
|
ownership: v.ownership,
|
||||||
contractNo: v.contractNo,
|
rentCompany: v.rentCompany,
|
||||||
|
contractNo: v.contractNo,
|
||||||
customerName: v.customerName,
|
customerName: v.customerName,
|
||||||
subjectOrg: v.subjectOrg,
|
subjectOrg: v.subjectOrg,
|
||||||
departmentName: v.departmentName,
|
departmentName: v.departmentName,
|
||||||
@@ -1049,18 +1135,22 @@ app.get('/weekly-detail', async (c) => {
|
|||||||
const source = c.req.query('source');
|
const source = c.req.query('source');
|
||||||
let sql: string;
|
let sql: string;
|
||||||
if (type === 'delivered') {
|
if (type === 'delivered') {
|
||||||
sql = `${DELIVERED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`;
|
sql = `${DELIVERED_SQL} AND dv.delivery_time >= ${WEEK_START_SQL} AND dv.delivery_time < ${WEEK_END_SQL} ORDER BY dv.delivery_time DESC`;
|
||||||
} else if (type === 'returned') {
|
} else if (type === 'returned') {
|
||||||
sql = `${RETURNED_SQL} AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL} ORDER BY r.return_date DESC`;
|
sql = `${RETURNED_SQL} AND r.arrival_time >= ${WEEK_START_SQL} AND r.arrival_time < ${WEEK_END_SQL} ORDER BY r.arrival_time DESC`;
|
||||||
} else if (type === 'replaced') {
|
} else if (type === 'replaced') {
|
||||||
sql = `${REPLACED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`;
|
sql = `${REPLACED_SQL} AND vr.replace_time >= ${WEEK_START_SQL} AND vr.replace_time < ${WEEK_END_SQL} ORDER BY vr.replace_time DESC`;
|
||||||
} else if (type === 'pending') {
|
} else if (type === 'pending') {
|
||||||
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
|
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 tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1 AND truck.truck_rent_status=7`;
|
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'`;
|
||||||
} else if (type === 'new') {
|
} else if (type === 'new') {
|
||||||
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
|
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 tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1
|
FROM vehicle_info vi
|
||||||
AND truck.create_time >= ${WEEK_START_SQL} AND truck.create_time < ${WEEK_END_SQL} ORDER BY truck.create_time DESC`;
|
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`;
|
||||||
} else {
|
} else {
|
||||||
return c.json([]);
|
return c.json([]);
|
||||||
}
|
}
|
||||||
@@ -1085,6 +1175,141 @@ app.get('/weekly-detail', async (c) => {
|
|||||||
return c.json(masked);
|
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 — 归属公司列表(含台数预览),用于顶部筛选下拉
|
// GET /api/vehicles/subjects — 归属公司列表(含台数预览),用于顶部筛选下拉
|
||||||
app.get('/subjects', async (c) => {
|
app.get('/subjects', async (c) => {
|
||||||
const all = await getVehicles();
|
const all = await getVehicles();
|
||||||
@@ -1124,20 +1349,18 @@ app.get('/debug', async (c) => {
|
|||||||
${WEEK_END_SQL} AS week_end,
|
${WEEK_END_SQL} AS week_end,
|
||||||
CURDATE() AS today,
|
CURDATE() AS today,
|
||||||
WEEKDAY(CURDATE()) AS weekday`);
|
WEEKDAY(CURDATE()) AS weekday`);
|
||||||
const [[deliveredAll]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_take take
|
const [[deliveredAll]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle
|
||||||
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)`);
|
||||||
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 delivery_vehicle
|
||||||
const [[deliveredRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_take take
|
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)
|
||||||
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}`);
|
||||||
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL AND task.task_type=1 AND task.task_status=1
|
const [[latestTake]] = await pool.query<any[]>(`SELECT MAX(delivery_time) AS latest FROM delivery_vehicle
|
||||||
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`);
|
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)`);
|
||||||
const [[latestTake]] = await pool.query<any[]>(`SELECT MAX(take.handover_date) AS latest FROM tab_truck_rent_take take
|
const [[returnedRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM return_vehicle_task
|
||||||
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)
|
||||||
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL AND task.task_type=1 AND task.task_status=1`);
|
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}`);
|
||||||
const [[returnedRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_return r
|
const [[latestReturn]] = await pool.query<any[]>(`SELECT MAX(arrival_time) AS latest FROM return_vehicle_task
|
||||||
WHERE r.is_deleted=0 AND r.return_date IS NOT NULL
|
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)`);
|
||||||
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({
|
return c.json({
|
||||||
weekRange: dateRange,
|
weekRange: dateRange,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface VehicleRow {
|
|||||||
租赁公司: string;
|
租赁公司: string;
|
||||||
车辆归属状态Label: string | null;
|
车辆归属状态Label: string | null;
|
||||||
车辆型号Label: string | null;
|
车辆型号Label: string | null;
|
||||||
|
车辆类型参数: string | null;
|
||||||
库存区域: string | null;
|
库存区域: string | null;
|
||||||
车辆租赁状态: string | null;
|
车辆租赁状态: string | null;
|
||||||
车辆租赁状态Label: string | null;
|
车辆租赁状态Label: string | null;
|
||||||
@@ -40,6 +41,7 @@ export interface Vehicle {
|
|||||||
province: string | null;
|
province: string | null;
|
||||||
city: string | null;
|
city: string | null;
|
||||||
status: 'Operating' | 'Inventory' | 'Pending' | 'Abnormal';
|
status: 'Operating' | 'Inventory' | 'Pending' | 'Abnormal';
|
||||||
|
operationStatus: string | null;
|
||||||
ownership: string;
|
ownership: string;
|
||||||
rentCompany: string;
|
rentCompany: string;
|
||||||
contractNo: string | null;
|
contractNo: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user