Compare commits
5 Commits
codex/big-
...
3f29bed5fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f29bed5fa | ||
|
|
4d523cbce0 | ||
|
|
2b31f8efe8 | ||
|
|
ae4b5d30e7 | ||
|
|
392a36a0ec |
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
|
||||
@@ -20,9 +20,22 @@ services:
|
||||
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"
|
||||
EXTERNAL_API_BASE: "https://lnh2e.com"
|
||||
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
|
||||
AMAP_WEB_KEY: "${AMAP_WEB_KEY}"
|
||||
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}"
|
||||
HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}"
|
||||
HEATMAP_DB_PORT: "${HEATMAP_DB_PORT:-36453}"
|
||||
HEATMAP_DB_USER: "${HEATMAP_DB_USER}"
|
||||
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}"
|
||||
HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}"
|
||||
HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}"
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
|
||||
129
docs/oneos-mileage-api-requirements.md
Normal file
129
docs/oneos-mileage-api-requirements.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# 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"],
|
||||
"cursor": null,
|
||||
"pageSize": 5000
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `startDate`、`endDate` 必填,最长支持 366 天;
|
||||
- `plateNumbers` 可省略,省略时返回授权范围内全部车辆;
|
||||
- 数据量过大时使用 `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,
|
||||
"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"]
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `date` 和 `plateNumbers` 沿用现有接口规则;
|
||||
- `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,
|
||||
"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 格式;
|
||||
- 每个响应返回 `traceId`;
|
||||
- 接口返回值保留原始精度;
|
||||
- 负里程不得作为正常数据返回;
|
||||
- 公网和 ECS 内网使用同一 AppKey 时,授权范围和查询结果保持一致;
|
||||
- 区间接口及现有接口的汇总扩展不得影响现有调用方和默认行为。
|
||||
|
||||
## 验收
|
||||
|
||||
1. 现有 `/api/v1/vehicles/mileage/query` 请求方式保持不变,回归测试全部通过。
|
||||
2. 区间接口不传车牌可完整查询全部授权车辆,分页无重复、无漏行。
|
||||
3. 现有接口不传车牌时,默认返回全部授权车辆的日里程、累计里程和数据时间。
|
||||
4. 正确区分无数据与真实零里程。
|
||||
5. `dataTime` 能反映每辆车实际数据的新鲜度。
|
||||
162
package-lock.json
generated
162
package-lock.json
generated
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "ln-bi",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ln-bi",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.10",
|
||||
"dependencies": {
|
||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"ali-oss": "^6.23.0",
|
||||
@@ -17,6 +18,7 @@
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"mysql2": "^3.11.0",
|
||||
"pg": "^8.22.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^3.8.1",
|
||||
@@ -27,6 +29,7 @@
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/ali-oss": "^6.23.3",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
@@ -36,6 +39,12 @@
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@amap/amap-jsapi-loader": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@amap/amap-jsapi-loader/-/amap-jsapi-loader-1.0.1.tgz",
|
||||
"integrity": "sha512-nPyLKt7Ow/ThHLkSvn2etQlUzqxmTVgK7bIgwdBRTg2HK5668oN7xVxkaiRe3YZEzGzfV2XgH5Jmu2T73ljejw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
@@ -1620,6 +1629,18 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
@@ -3521,6 +3542,95 @@
|
||||
"through": "~2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.15.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -3576,6 +3686,45 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
@@ -3969,6 +4118,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/sql-escaper": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ln-bi",
|
||||
"private": true,
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.10",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
||||
@@ -12,6 +12,7 @@
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"ali-oss": "^6.23.0",
|
||||
@@ -21,6 +22,7 @@
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"mysql2": "^3.11.0",
|
||||
"pg": "^8.22.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^3.8.1",
|
||||
@@ -31,6 +33,7 @@
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/ali-oss": "^6.23.3",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
|
||||
29
src/App.tsx
29
src/App.tsx
@@ -1,5 +1,5 @@
|
||||
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 AuthProvider from "./auth/AuthProvider";
|
||||
import { useAuth } from "./auth/useAuth";
|
||||
@@ -15,6 +15,8 @@ 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 = {
|
||||
id: "assets",
|
||||
@@ -30,6 +32,13 @@ const MILEAGE_MODULE: ModuleConfig = {
|
||||
component: MileageModule,
|
||||
};
|
||||
|
||||
const VEHICLE_HEATMAP_MODULE: ModuleConfig = {
|
||||
id: "vehicle-heatmap",
|
||||
label: "车辆",
|
||||
icon: MapPinned,
|
||||
component: VehicleHeatmapModule,
|
||||
};
|
||||
|
||||
const SCHEDULING_MODULE: ModuleConfig = {
|
||||
id: "scheduling",
|
||||
label: "智能调度",
|
||||
@@ -44,6 +53,13 @@ const HYDROGEN_MODULE: ModuleConfig = {
|
||||
component: HydrogenModule,
|
||||
};
|
||||
|
||||
const HYDROGEN_HEATMAP_MODULE: ModuleConfig = {
|
||||
id: "hydrogen-heatmap",
|
||||
label: "加氢",
|
||||
icon: MapPinned,
|
||||
component: HydrogenHeatmapModule,
|
||||
};
|
||||
|
||||
const ELECTRIC_MODULE: ModuleConfig = {
|
||||
id: "electric",
|
||||
label: "电能",
|
||||
@@ -138,7 +154,16 @@ function AuthGate() {
|
||||
if (pathSet === "energy") {
|
||||
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);
|
||||
return result;
|
||||
}, [pathSet, user?.roles]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Building2, ShieldCheck } from 'lucide-react';
|
||||
import { Building2, ChevronRight, ShieldCheck } from 'lucide-react';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { DemoModeProvider } from './Blur';
|
||||
import FeedbackFab from './FeedbackFab';
|
||||
@@ -11,7 +11,12 @@ export interface ModuleConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ComponentType<{ size?: number; className?: string }>;
|
||||
component: ElementType;
|
||||
component?: ElementType;
|
||||
children?: ModuleConfig[];
|
||||
}
|
||||
|
||||
function flattenModules(modules: ModuleConfig[]): ModuleConfig[] {
|
||||
return modules.flatMap((module) => module.children ?? [module]);
|
||||
}
|
||||
|
||||
/** hash 一级段(`#<id>` 或 `#<id>/<sub>` 都只取 id) */
|
||||
@@ -31,16 +36,27 @@ function getHashModule(modules: ModuleConfig[]): string {
|
||||
}
|
||||
|
||||
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(() => {
|
||||
const onHashChange = () => {
|
||||
const h = getHashModule(modules);
|
||||
const h = getHashModule(flatModules);
|
||||
if (h) setActiveModule(h);
|
||||
};
|
||||
window.addEventListener('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(() => {
|
||||
// 同步 hash 一段到当前模块:使用 replaceState 避免产生多余的 history 记录,
|
||||
@@ -53,16 +69,20 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
}, [activeModule]);
|
||||
|
||||
const switchModule = (id: string) => {
|
||||
if (getHashHead() === id) return;
|
||||
setOpenGroupId(null);
|
||||
if (getHashHead() === id) {
|
||||
setActiveModule(id);
|
||||
return;
|
||||
}
|
||||
const { pathname, search } = window.location;
|
||||
window.history.replaceState(null, '', `${pathname}${search}#${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 activeLabel = modules.find((m) => m.id === activeModule)?.label ?? '业务看板';
|
||||
const activeLabel = flatModules.find((module) => module.id === activeModule)?.label ?? '业务看板';
|
||||
const watermarkText = useMemo(() => {
|
||||
const name = user?.userName || '未登录';
|
||||
const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-');
|
||||
@@ -87,29 +107,43 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
<div className="flex w-full flex-1 flex-col items-center gap-2">
|
||||
{modules.map((m) => {
|
||||
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 (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => switchModule(m.id)}
|
||||
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 }}
|
||||
/>
|
||||
<div key={m.id} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)}
|
||||
aria-expanded={isGroup ? isOpen : undefined}
|
||||
aria-haspopup={isGroup ? 'menu' : undefined}
|
||||
className={cn(
|
||||
'group relative flex h-16 w-16 flex-col items-center justify-center rounded-2xl text-[10px] font-black transition-all',
|
||||
isActive ? 'text-white' : 'text-slate-400 hover:bg-white/8 hover:text-white',
|
||||
)}
|
||||
title={m.label}
|
||||
>
|
||||
{isActive ? (
|
||||
<motion.span layoutId="desktop-shell-active" className="absolute inset-0 rounded-2xl bg-blue-600 shadow-lg shadow-blue-950/30" transition={{ type: 'spring', stiffness: 430, damping: 34 }} />
|
||||
) : null}
|
||||
<Icon size={21} className="relative mb-1" />
|
||||
<span className="relative leading-tight">{m.label}</span>
|
||||
{isGroup ? <ChevronRight size={11} className={cn('absolute right-1 top-1/2 -translate-y-1/2 transition-transform', isOpen && 'rotate-180')} /> : null}
|
||||
</button>
|
||||
{isGroup && isOpen ? (
|
||||
<div role="menu" aria-label={`${m.label}二级菜单`} className="absolute left-[calc(100%+12px)] top-1/2 w-36 -translate-y-1/2 rounded-2xl border border-slate-700 bg-slate-900 p-2 shadow-2xl shadow-slate-950/30">
|
||||
{m.children!.map((child) => {
|
||||
const ChildIcon = child.icon;
|
||||
const childActive = child.id === activeModule;
|
||||
return (
|
||||
<button key={child.id} type="button" role="menuitem" onClick={() => switchModule(child.id)} className={cn('flex h-11 w-full items-center gap-2 rounded-xl px-3 text-xs font-black transition-colors', childActive ? 'bg-blue-600 text-white' : 'text-slate-300 hover:bg-white/8 hover:text-white')}>
|
||||
<ChildIcon size={16} />{child.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<Icon size={21} className="relative mb-1" />
|
||||
<span className="relative leading-tight">{m.label}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -139,26 +173,38 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${modules.length}, minmax(0, 1fr))` }}>
|
||||
{modules.map((m) => {
|
||||
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 (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => switchModule(m.id)}
|
||||
className={cn(
|
||||
'relative flex min-h-12 flex-col items-center justify-center gap-0.5 rounded-2xl text-[10px] font-black transition-colors',
|
||||
isActive ? 'text-blue-700' : 'text-slate-400',
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="mobile-shell-active"
|
||||
className="absolute inset-0 rounded-2xl bg-blue-50 ring-1 ring-blue-100"
|
||||
transition={{ type: 'spring', stiffness: 430, damping: 34 }}
|
||||
/>
|
||||
<div key={m.id} className="relative flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)}
|
||||
aria-expanded={isGroup ? isOpen : undefined}
|
||||
aria-haspopup={isGroup ? 'menu' : undefined}
|
||||
className={cn('relative flex min-h-12 w-full flex-col items-center justify-center gap-0.5 rounded-2xl text-[10px] font-medium transition-colors', isActive ? 'text-blue-700' : 'text-slate-400')}
|
||||
>
|
||||
{isActive ? (
|
||||
<motion.span layoutId="mobile-shell-active" className="absolute inset-0 rounded-2xl bg-blue-50 ring-1 ring-blue-100" transition={{ type: 'spring', stiffness: 430, damping: 34 }} />
|
||||
) : null}
|
||||
<Icon size={20} className="relative" />
|
||||
<span className="relative">{m.label}</span>
|
||||
</button>
|
||||
{isGroup && isOpen ? (
|
||||
<div role="menu" aria-label={`${m.label}二级菜单`} className="absolute bottom-[calc(100%+14px)] left-1/2 w-36 -translate-x-1/2 rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl shadow-slate-900/15">
|
||||
{m.children!.map((child) => {
|
||||
const ChildIcon = child.icon;
|
||||
const childActive = child.id === activeModule;
|
||||
return (
|
||||
<button key={child.id} type="button" role="menuitem" onClick={() => switchModule(child.id)} className={cn('flex h-11 w-full items-center gap-2 rounded-xl px-3 text-xs font-medium transition-colors', childActive ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-50')}>
|
||||
<ChildIcon size={16} />{child.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<Icon size={20} className="relative" />
|
||||
<span className="relative">{m.label}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,32 @@ html {
|
||||
body {
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
color: #0f172a;
|
||||
background: #fff;
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
button, input, select {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.amap-logo,
|
||||
.amap-copyright {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.amap-toolbar {
|
||||
border: 1px solid #e2e8f0 !important;
|
||||
border-radius: 10px !important;
|
||||
box-shadow: 0 8px 24px rgb(15 23 42 / 10%) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.amap-logo,
|
||||
.amap-copyright {
|
||||
bottom: 60px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Truck, Filter, ChevronDown,
|
||||
Maximize2, Minimize2, RotateCcw,
|
||||
ArrowUp, ArrowDown, ChevronsUp, Download, Check, CalendarDays,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { BarChart, Bar, ResponsiveContainer, Tooltip, ReferenceLine, XAxis } from 'recharts';
|
||||
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
|
||||
@@ -25,6 +26,72 @@ function defaultMileageDate(): string {
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function parseOneOsTime(value: string | null | undefined): number | null {
|
||||
if (!value) return null;
|
||||
let normalized = value.trim().replace(' ', 'T');
|
||||
if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized)) normalized += '+08:00';
|
||||
const timestamp = Date.parse(normalized);
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
}
|
||||
|
||||
function formatAbsoluteTime(value: string | null | undefined): string | null {
|
||||
const timestamp = parseOneOsTime(value);
|
||||
if (timestamp === null) return null;
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).format(new Date(timestamp));
|
||||
}
|
||||
|
||||
function vehicleStatisticTime(vehicle: MonitoringVehicle, nowMs: number): {
|
||||
label: string;
|
||||
title: string;
|
||||
color: string;
|
||||
} {
|
||||
const primaryTime = vehicle.dataTime || vehicle.updatedAt || vehicle.calculatedAt;
|
||||
const timestamp = parseOneOsTime(primaryTime);
|
||||
if (timestamp === null) {
|
||||
return {
|
||||
label: vehicle.isDataSynced ? '统计时间待接口' : '无有效数据时间',
|
||||
title: 'OneOS 当前未返回车辆级数据时间',
|
||||
color: vehicle.isDataSynced ? 'text-amber-500' : 'text-slate-500',
|
||||
};
|
||||
}
|
||||
|
||||
const deltaSeconds = Math.floor((nowMs - timestamp) / 1000);
|
||||
let label: string;
|
||||
if (deltaSeconds < -60) label = '时间异常';
|
||||
else if (deltaSeconds < 5) label = '刚刚';
|
||||
else if (deltaSeconds < 60) label = `${deltaSeconds}秒前`;
|
||||
else if (deltaSeconds < 3600) label = `${Math.floor(deltaSeconds / 60)}分钟前`;
|
||||
else if (deltaSeconds < 86400) label = `${Math.floor(deltaSeconds / 3600)}小时前`;
|
||||
else label = `${Math.floor(deltaSeconds / 86400)}天前`;
|
||||
|
||||
const absoluteDataTime = formatAbsoluteTime(vehicle.dataTime);
|
||||
const absoluteCalculatedAt = formatAbsoluteTime(vehicle.calculatedAt);
|
||||
const absoluteUpdatedAt = formatAbsoluteTime(vehicle.updatedAt);
|
||||
const title = [
|
||||
absoluteDataTime ? `数据时间:${absoluteDataTime}` : null,
|
||||
absoluteCalculatedAt ? `计算时间:${absoluteCalculatedAt}` : null,
|
||||
absoluteUpdatedAt ? `更新时间:${absoluteUpdatedAt}` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
const ageSeconds = Math.max(0, deltaSeconds);
|
||||
const color = ageSeconds <= 300
|
||||
? 'text-emerald-500'
|
||||
: ageSeconds <= 3600
|
||||
? 'text-blue-500'
|
||||
: ageSeconds <= 86400
|
||||
? 'text-amber-500'
|
||||
: 'text-rose-500';
|
||||
return { label, title, color };
|
||||
}
|
||||
|
||||
function normalizeRangeLabel(start: string, end: string): string {
|
||||
if (!start && !end) return '最新数据';
|
||||
if (start && end && start === end) return start;
|
||||
@@ -35,12 +102,15 @@ function fmtDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
type RangePreset = 'today' | 'thisWeek' | 'thisMonth' | 'last7' | 'last15';
|
||||
type RangePreset = 'today' | 'yesterday' | 'thisWeek' | 'thisMonth' | 'last7' | 'last15';
|
||||
|
||||
function getRangePreset(preset: RangePreset): { start: string; end: string } {
|
||||
const end = new Date(`${defaultMileageDate()}T00:00:00`);
|
||||
const start = new Date(end);
|
||||
if (preset === 'thisWeek') {
|
||||
if (preset === 'yesterday') {
|
||||
start.setDate(start.getDate() - 1);
|
||||
end.setDate(end.getDate() - 1);
|
||||
} else if (preset === 'thisWeek') {
|
||||
const day = start.getDay() || 7;
|
||||
start.setDate(start.getDate() - day + 1);
|
||||
} else if (preset === 'thisMonth') {
|
||||
@@ -277,6 +347,7 @@ export default function MonitoringView() {
|
||||
const [filterPlatePrefix, setFilterPlatePrefix] = useState('All');
|
||||
const [filterTargetNames, setFilterTargetNames] = useState<string[]>([]);
|
||||
const [filterRegion, setFilterRegion] = useState('All');
|
||||
const [filterBrands, setFilterBrands] = useState<string[]>([]);
|
||||
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
||||
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
|
||||
const [exporting, setExporting] = useState(false);
|
||||
@@ -286,7 +357,7 @@ export default function MonitoringView() {
|
||||
|
||||
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
|
||||
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
||||
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] });
|
||||
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [], brands: [] });
|
||||
const [rangeDailyTotals, setRangeDailyTotals] = useState<{ date: string; totalKm: number }[]>([]);
|
||||
const [effectiveRange, setEffectiveRange] = useState<{ start: string; end: string }>(() => ({ start: defaultMileageDate(), end: defaultMileageDate() }));
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -294,9 +365,17 @@ export default function MonitoringView() {
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
const [manualRefreshing, setManualRefreshing] = useState(false);
|
||||
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null);
|
||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
const [relativeNow, setRelativeNow] = useState(() => Date.now());
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setRelativeNow(Date.now()), 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const departments = filterOptions.departments;
|
||||
const plateNumbers = filterOptions.plates;
|
||||
const rangeLabel = normalizeRangeLabel(effectiveRange.start, effectiveRange.end);
|
||||
@@ -304,10 +383,6 @@ export default function MonitoringView() {
|
||||
const averageDailyKm = rangeDailyTotals.length > 0
|
||||
? rangeDailyTotals.reduce((sum, item) => sum + item.totalKm, 0) / rangeDailyTotals.length
|
||||
: 0;
|
||||
const topLoadedVehicle = useMemo(
|
||||
() => vehicles.reduce<MonitoringVehicle | null>((best, vehicle) => (!best || vehicle.dailyKm > best.dailyKm ? vehicle : best), null),
|
||||
[vehicles],
|
||||
);
|
||||
const applyRangePreset = useCallback((preset: RangePreset) => {
|
||||
const range = getRangePreset(preset);
|
||||
setRangeStart(range.start);
|
||||
@@ -321,9 +396,9 @@ export default function MonitoringView() {
|
||||
}, [filterTargetNames]);
|
||||
|
||||
// 加载首页数据
|
||||
const loadFirstPage = useCallback(() => {
|
||||
setPageLoading(true);
|
||||
fetchMonitoring({
|
||||
const loadFirstPage = useCallback((showPageLoading = true) => {
|
||||
if (showPageLoading) setPageLoading(true);
|
||||
return fetchMonitoring({
|
||||
sortBy,
|
||||
sortOrder,
|
||||
limit: PAGE_SIZE,
|
||||
@@ -342,6 +417,7 @@ export default function MonitoringView() {
|
||||
mileageMax: appliedMileageRange.max || undefined,
|
||||
startDate: rangeStart || undefined,
|
||||
endDate: rangeEnd || undefined,
|
||||
brands: filterBrands.length > 0 ? filterBrands : undefined,
|
||||
}).then(d => {
|
||||
setVehicles(d.vehicles);
|
||||
setStats(d.stats);
|
||||
@@ -351,8 +427,21 @@ export default function MonitoringView() {
|
||||
setTotal(d.total);
|
||||
setPage(1);
|
||||
setHasMore(d.page < d.totalPages);
|
||||
}).catch(() => {}).finally(() => setPageLoading(false));
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd]);
|
||||
setLastRefreshedAt(new Date());
|
||||
}).catch(() => {}).finally(() => {
|
||||
if (showPageLoading) setPageLoading(false);
|
||||
});
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands]);
|
||||
|
||||
const handleManualRefresh = useCallback(async () => {
|
||||
if (manualRefreshing) return;
|
||||
setManualRefreshing(true);
|
||||
try {
|
||||
await loadFirstPage(false);
|
||||
} finally {
|
||||
setManualRefreshing(false);
|
||||
}
|
||||
}, [loadFirstPage, manualRefreshing]);
|
||||
|
||||
// 加载更多
|
||||
const loadMore = useCallback(() => {
|
||||
@@ -378,12 +467,13 @@ export default function MonitoringView() {
|
||||
mileageMax: appliedMileageRange.max || undefined,
|
||||
startDate: rangeStart || undefined,
|
||||
endDate: rangeEnd || undefined,
|
||||
brands: filterBrands.length > 0 ? filterBrands : undefined,
|
||||
}).then(d => {
|
||||
setVehicles(prev => [...prev, ...d.vehicles]);
|
||||
setPage(nextPage);
|
||||
setHasMore(nextPage < d.totalPages);
|
||||
}).catch(() => {}).finally(() => setLoadingMore(false));
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, page, loadingMore, hasMore]);
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, page, loadingMore, hasMore, filterBrands]);
|
||||
|
||||
// 筛选/排序变化时重新加载
|
||||
useEffect(() => {
|
||||
@@ -422,6 +512,7 @@ export default function MonitoringView() {
|
||||
mileageMax: appliedMileageRange.max || undefined,
|
||||
startDate: rangeStart || undefined,
|
||||
endDate: rangeEnd || undefined,
|
||||
brands: filterBrands.length > 0 ? filterBrands : undefined,
|
||||
});
|
||||
exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy });
|
||||
} catch (err) {
|
||||
@@ -429,11 +520,11 @@ export default function MonitoringView() {
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd]);
|
||||
}, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands]);
|
||||
|
||||
// 每分钟自动刷新
|
||||
useEffect(() => {
|
||||
const timer = setInterval(loadFirstPage, 60 * 1000);
|
||||
const timer = setInterval(() => { void loadFirstPage(false); }, 60 * 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [loadFirstPage]);
|
||||
|
||||
@@ -500,12 +591,13 @@ export default function MonitoringView() {
|
||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||||
startDate: rangeStart || undefined,
|
||||
endDate: rangeEnd || undefined,
|
||||
brands: filterBrands.length > 0 ? filterBrands : undefined,
|
||||
}).then(d => {
|
||||
setFullscreenVehicles(d.vehicles);
|
||||
setFullscreenStats(d.stats);
|
||||
setFilterOptions(d.filters);
|
||||
}).catch(() => {}).finally(() => setFullscreenLoading(false));
|
||||
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, fullscreenRefresh]);
|
||||
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, fullscreenRefresh, filterBrands]);
|
||||
|
||||
// 全屏时禁止背景滚动
|
||||
useEffect(() => {
|
||||
@@ -637,15 +729,51 @@ export default function MonitoringView() {
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>客户</span>
|
||||
<select
|
||||
className="bg-slate-800 border-none rounded px-2 py-0.5 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30"
|
||||
value={filterCustomer}
|
||||
onChange={(e) => setFilterCustomer(e.target.value)}
|
||||
>
|
||||
<option value="All">全部客户</option>
|
||||
<option value="__EMPTY__">无值</option>
|
||||
{filterOptions.customers.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
className="bg-slate-800 border-none rounded px-2 py-0.5 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30 w-20"
|
||||
placeholder={filterCustomer === 'All' ? '全部' : filterCustomer}
|
||||
defaultValue=""
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim();
|
||||
setFilterCustomer(val || 'All');
|
||||
e.target.value = '';
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const val = (e.target as HTMLInputElement).value.trim();
|
||||
setFilterCustomer(val || 'All');
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>品牌</span>
|
||||
<input
|
||||
type="text"
|
||||
className="bg-slate-800 border-none rounded px-2 py-0.5 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30 w-16"
|
||||
placeholder={filterBrands.length === 0 ? '全部' : `已选${filterBrands.length}`}
|
||||
defaultValue=""
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim();
|
||||
if (val) {
|
||||
setFilterBrands(filterBrands.includes(val) ? filterBrands.filter(b => b !== val) : [...filterBrands, val]);
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const val = (e.target as HTMLInputElement).value.trim();
|
||||
if (val) {
|
||||
setFilterBrands(filterBrands.includes(val) ? filterBrands.filter(b => b !== val) : [...filterBrands, val]);
|
||||
}
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
||||
@@ -716,13 +844,20 @@ export default function MonitoringView() {
|
||||
<tbody className="divide-y divide-slate-800/30">
|
||||
{fullscreenVehicles.map((v) => {
|
||||
const highMileageAlert = isHighMileageAlert(v);
|
||||
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
||||
return (
|
||||
<tr key={v.plate} className="hover:bg-slate-800/20 transition-colors">
|
||||
<td className="px-3 py-2 text-center">
|
||||
<div className={`w-2 h-2 rounded-full mx-auto ${v.isOnline ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.4)]' : (v.isDataSynced || v.totalKm != null) ? 'bg-slate-600' : 'bg-amber-400 animate-pulse'}`}></div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs font-bold text-white"><Blur>{v.plate}</Blur></td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="text-xs font-bold text-white"><Blur>{v.plate}</Blur></div>
|
||||
<div className={`mt-0.5 text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
|
||||
{statisticTime.label}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[11px] text-slate-400"><Blur>{v.customer || '-'}</Blur></td>
|
||||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.brand || '-'}</td>
|
||||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.rentStatus || '-'}</td>
|
||||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
@@ -746,89 +881,61 @@ export default function MonitoringView() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Ultra Compact Header - Two Rows */}
|
||||
<div className="bg-white p-3 rounded-2xl border border-gray-100 shadow-sm flex flex-col gap-3">
|
||||
{/* Top Row: Title & Sort */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-8 bg-blue-600 rounded-full"></div>
|
||||
<div>
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-gray-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="h-8 w-1 flex-shrink-0 rounded-full bg-blue-600" />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-black text-slate-900 leading-none">里程看板</h1>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-1 text-slate-300 hover:text-blue-600 transition-colors"
|
||||
title="全屏视图"
|
||||
>
|
||||
<h1 className="truncate text-lg font-black leading-none text-slate-900">里程看板</h1>
|
||||
<button onClick={toggleFullscreen} className="p-1 text-slate-300 transition-colors hover:text-blue-600" title="全屏视图">
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={exporting}
|
||||
className="p-1 text-slate-300 hover:text-blue-600 transition-colors disabled:text-slate-200"
|
||||
title="下载当前筛选结果"
|
||||
>
|
||||
<button onClick={handleDownload} disabled={exporting} className="p-1 text-slate-300 transition-colors hover:text-blue-600 disabled:text-slate-200" title="下载当前筛选结果">
|
||||
{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<span className="flex h-1.5 w-1.5 rounded-full bg-blue-500 animate-pulse"></span>
|
||||
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-tight">数据监控 • 每15分钟更新</span>
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
|
||||
<span className="text-[9px] font-bold uppercase tracking-tight text-slate-400">数据监控 • OneOS 实时查询</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 bg-slate-100 p-0.5 rounded-lg">
|
||||
<div className="flex gap-0.5">
|
||||
<button
|
||||
onClick={() => setSortBy('today')}
|
||||
className={`px-2 py-1 text-[9px] font-bold rounded-md transition-all ${sortBy === 'today' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}
|
||||
>
|
||||
区间
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSortBy('total')}
|
||||
className={`px-2 py-1 text-[9px] font-bold rounded-md transition-all ${sortBy === 'total' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}
|
||||
>
|
||||
累计
|
||||
<div className="flex flex-shrink-0 items-center gap-1.5">
|
||||
<span className="hidden text-[9px] font-bold tabular-nums text-slate-400 sm:inline">
|
||||
{lastRefreshedAt ? lastRefreshedAt.toLocaleTimeString('zh-CN', { hour12: false }) : '--:--:--'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleManualRefresh}
|
||||
disabled={manualRefreshing}
|
||||
className="flex h-7 items-center gap-1 rounded-lg border border-blue-100 bg-blue-50 px-2 text-[9px] font-bold text-blue-600 disabled:cursor-wait disabled:opacity-60"
|
||||
title="仅刷新当前筛选结果"
|
||||
>
|
||||
<RefreshCw size={11} className={manualRefreshing ? 'animate-spin' : ''} />
|
||||
<span className="hidden sm:inline">{manualRefreshing ? '刷新中' : '刷新'}</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-1 rounded-lg bg-slate-100 p-0.5">
|
||||
<button onClick={() => setSortBy('today')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'today' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}>区间</button>
|
||||
<button onClick={() => setSortBy('total')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'total' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}>累计</button>
|
||||
<button onClick={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')} className="rounded-md p-1 text-blue-600 transition-all hover:bg-white">
|
||||
{sortOrder === 'desc' ? <ArrowDown size={12} /> : <ArrowUp size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-[1px] h-3 bg-slate-200 mx-0.5"></div>
|
||||
<button
|
||||
onClick={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')}
|
||||
className="p-1 text-blue-600 hover:bg-white rounded-md transition-all"
|
||||
>
|
||||
{sortOrder === 'desc' ? <ArrowDown size={12} /> : <ArrowUp size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Row: 外部三选 (批次型号 / 运营区域 / 车牌多选) + 详情筛选 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 grid grid-cols-3 gap-1.5">
|
||||
<BatchMultiSelect
|
||||
options={filterOptions.targetNames}
|
||||
selected={filterTargetNames}
|
||||
onChange={setFilterTargetNames}
|
||||
placeholder="批次型号"
|
||||
/>
|
||||
<SearchableSelect
|
||||
options={filterOptions.regions}
|
||||
value={filterRegion}
|
||||
onChange={setFilterRegion}
|
||||
placeholder="运营区域"
|
||||
/>
|
||||
<PlateMultiSelect
|
||||
allPlates={plateNumbers}
|
||||
selected={filterPlates}
|
||||
onChange={setFilterPlates}
|
||||
placeholder="按车牌"
|
||||
/>
|
||||
<div className="grid flex-1 grid-cols-3 gap-1.5">
|
||||
<BatchMultiSelect options={filterOptions.targetNames} selected={filterTargetNames} onChange={setFilterTargetNames} placeholder="批次型号" />
|
||||
<SearchableSelect options={filterOptions.regions} value={filterRegion} onChange={setFilterRegion} placeholder="运营区域" />
|
||||
<PlateMultiSelect allPlates={plateNumbers} selected={filterPlates} onChange={setFilterPlates} placeholder="按车牌" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsFilterOpen(!isFilterOpen)}
|
||||
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlates.length > 0 || filterProject !== 'All' || filterTargetNames.length > 0 ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-slate-50 text-slate-400 border border-transparent'}`}
|
||||
className={`flex-shrink-0 rounded-lg border p-1.5 transition-all ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlates.length > 0 || filterProject !== 'All' || filterTargetNames.length > 0 || filterBrands.length > 0 ? 'border-blue-100 bg-blue-50 text-blue-600' : 'border-transparent bg-slate-50 text-slate-400'}`}
|
||||
title="高级筛选"
|
||||
>
|
||||
<Filter size={16} />
|
||||
</button>
|
||||
@@ -837,6 +944,7 @@ export default function MonitoringView() {
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar">
|
||||
{([
|
||||
['today', '今天'],
|
||||
['yesterday', '昨天'],
|
||||
['thisWeek', '本周'],
|
||||
['thisMonth', '本月'],
|
||||
['last15', '近15天'],
|
||||
@@ -848,19 +956,13 @@ export default function MonitoringView() {
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => applyRangePreset(preset)}
|
||||
className={`shrink-0 rounded-lg border px-2.5 py-1.5 text-[10px] font-black transition-all ${
|
||||
active
|
||||
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'border-slate-100 bg-slate-50 text-slate-500 hover:border-blue-100 hover:bg-blue-50 hover:text-blue-600'
|
||||
}`}
|
||||
className={`shrink-0 rounded-lg border px-2.5 py-1.5 text-[10px] font-black transition-all ${active ? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm' : 'border-slate-100 bg-slate-50 text-slate-500 hover:border-blue-100 hover:bg-blue-50 hover:text-blue-600'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<span className="ml-auto shrink-0 rounded-lg bg-slate-50 px-2 py-1 text-[10px] font-bold text-slate-400">
|
||||
{rangeLabel}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 rounded-lg bg-slate-50 px-2 py-1 text-[10px] font-bold text-slate-400">{rangeLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -895,6 +997,7 @@ export default function MonitoringView() {
|
||||
<div className="flex items-center gap-1.5 pt-1">
|
||||
{([
|
||||
['today', '今天'],
|
||||
['yesterday', '昨天'],
|
||||
['thisWeek', '本周'],
|
||||
['thisMonth', '本月'],
|
||||
['last7', '近7天'],
|
||||
@@ -930,18 +1033,26 @@ export default function MonitoringView() {
|
||||
{/* Customer */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">按客户</label>
|
||||
<select
|
||||
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
||||
<SearchableSelect
|
||||
options={filterOptions.customers}
|
||||
value={filterCustomer}
|
||||
onChange={(e) => setFilterCustomer(e.target.value)}
|
||||
>
|
||||
<option value="All">无限制</option>
|
||||
<option value="__EMPTY__">无值</option>
|
||||
{filterOptions.customers.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
onChange={setFilterCustomer}
|
||||
placeholder="搜索客户"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Brand multi-select */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">按品牌</label>
|
||||
<BatchMultiSelect
|
||||
options={filterOptions.brands}
|
||||
selected={filterBrands}
|
||||
onChange={setFilterBrands}
|
||||
placeholder="选择品牌"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Project */}
|
||||
<div className="space-y-1.5">
|
||||
@@ -1036,6 +1147,7 @@ export default function MonitoringView() {
|
||||
setFilterRegion('All');
|
||||
setFilterMileageRange({ min: '', max: '' });
|
||||
setAppliedMileageRange({ min: '', max: '' });
|
||||
setFilterBrands([]);
|
||||
const today = defaultMileageDate();
|
||||
setRangeStart(today);
|
||||
setRangeEnd(today);
|
||||
@@ -1071,6 +1183,10 @@ export default function MonitoringView() {
|
||||
if (filterRentStatus !== 'All') tags.push({ label: `状态: ${filterRentStatus}`, onClear: () => setFilterRentStatus('All') });
|
||||
if (filterDept !== 'All') tags.push({ label: `部门: ${filterDept === '__EMPTY__' ? '无值' : filterDept}`, onClear: () => setFilterDept('All') });
|
||||
if (filterCustomer !== 'All') tags.push({ label: `客户: ${filterCustomer === '__EMPTY__' ? '无值' : filterCustomer}`, onClear: () => setFilterCustomer('All') });
|
||||
if (filterBrands.length > 0) tags.push({
|
||||
label: `品牌: ${filterBrands.length === 1 ? filterBrands[0] : `${filterBrands[0]} 等${filterBrands.length}`}`,
|
||||
onClear: () => setFilterBrands([])
|
||||
});
|
||||
if (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => setFilterProject('All') });
|
||||
if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => setFilterEntity('All') });
|
||||
if (searchTerm) tags.push({ label: `搜索: ${searchTerm}`, onClear: () => setSearchTerm('') });
|
||||
@@ -1090,19 +1206,21 @@ export default function MonitoringView() {
|
||||
setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterProject('All'); setFilterEntity('All');
|
||||
setFilterPlates([]); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetNames([]); setFilterRegion('All');
|
||||
setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' });
|
||||
setFilterBrands([]);
|
||||
const today = defaultMileageDate();
|
||||
setRangeStart(today); setRangeEnd(today);
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex flex-wrap items-center gap-1.5 rounded-xl border border-slate-200 bg-white px-3 py-2 shadow-sm">
|
||||
<span className="mr-1 text-[10px] font-black text-slate-500">已选条件</span>
|
||||
{tags.map((tag, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-2 py-1 bg-blue-50 text-blue-600 rounded-lg text-[10px] font-bold">
|
||||
<span key={i} className="inline-flex items-center gap-1 rounded-md bg-blue-50 px-2 py-1 text-[10px] font-bold text-blue-600">
|
||||
{tag.label}
|
||||
<button onClick={tag.onClear} className="hover:text-blue-800 ml-0.5">×</button>
|
||||
</span>
|
||||
))}
|
||||
<button onClick={clearAll} className="text-[10px] font-bold text-rose-500 hover:text-rose-600 ml-auto">
|
||||
清除
|
||||
清除全部
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -1111,19 +1229,19 @@ export default function MonitoringView() {
|
||||
{/* Sticky header: KPI + 清单标题 */}
|
||||
<div className="sticky top-[44px] z-20 bg-[var(--app-bg)] pt-1 pb-1 space-y-2">
|
||||
<div className={`grid grid-cols-4 gap-2 transition-opacity ${pageLoading ? 'opacity-60' : ''}`}>
|
||||
<div className="col-span-2 bg-slate-900 p-2.5 rounded-xl text-white relative overflow-hidden">
|
||||
<div className="relative col-span-2 flex min-h-[68px] flex-col justify-center overflow-hidden rounded-xl bg-slate-900 p-2.5 text-white">
|
||||
<div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'today' ? (isRangeMode ? '区间' : '当日') : '累计'}总里程</div>
|
||||
<div className="text-lg font-black tracking-tighter leading-tight flex items-baseline gap-1">
|
||||
{pageLoading ? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div> : <>{Math.round(sortBy === 'today' ? stats.totalToday : stats.totalAll).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></>}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div>
|
||||
</div>
|
||||
<div className="bg-white p-2.5 rounded-xl border border-gray-100 shadow-sm">
|
||||
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm">
|
||||
<div className="text-[7px] font-bold text-slate-400 uppercase">平均单车</div>
|
||||
<div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : (stats.vehicleCount > 0 ? (sortBy === 'today' ? stats.totalToday : stats.totalAll) / stats.vehicleCount : 0).toFixed(0)}</div>
|
||||
<div className="text-[7px] text-slate-400">km/台</div>
|
||||
</div>
|
||||
<div className="bg-white p-2.5 rounded-xl border border-gray-100 shadow-sm">
|
||||
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm">
|
||||
<div className="text-[7px] font-bold text-slate-400 uppercase">监控台数</div>
|
||||
<div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : stats.vehicleCount}</div>
|
||||
<div className="text-[7px] text-slate-400">台</div>
|
||||
@@ -1167,42 +1285,35 @@ export default function MonitoringView() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_76px_76px] items-center gap-2 px-3 py-2 md:grid-cols-[minmax(0,1fr)_96px_96px]">
|
||||
<div className="min-w-0">
|
||||
<div className="grid min-h-[64px] grid-cols-[minmax(0,1fr)_86px_86px] items-stretch gap-2 px-3 py-2 md:grid-cols-[minmax(0,1fr)_110px_110px]">
|
||||
<div className="flex min-w-0 flex-col justify-center">
|
||||
<div className="flex items-center gap-1 text-[10px] font-black text-slate-700">
|
||||
<CalendarDays size={12} className="text-blue-500" />
|
||||
单日概览
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[9px] font-bold text-slate-400">{rangeLabel} · 单位 km</div>
|
||||
<div className="mt-1.5 rounded-lg bg-slate-50 px-2 py-1 ring-1 ring-slate-100">
|
||||
<div className="text-[8px] font-black text-slate-400">当前列表最高</div>
|
||||
{topLoadedVehicle ? (
|
||||
<div className="mt-0.5 flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5">
|
||||
<span className="whitespace-nowrap text-[10px] font-black text-slate-700">{topLoadedVehicle.plate}</span>
|
||||
<span className="whitespace-nowrap text-[10px] font-black text-blue-600 tabular-nums">
|
||||
{topLoadedVehicle.dailyKm.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} km
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-0.5 text-[10px] font-black text-slate-300">-</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-blue-50 px-2 py-1.5 text-right">
|
||||
<div className="flex flex-col justify-center rounded-lg bg-blue-50 px-2 py-1.5 text-right">
|
||||
<div className="text-[9px] font-black text-blue-400">当日总计</div>
|
||||
<div className="text-xs font-black text-blue-700">{Math.round(stats.totalToday).toLocaleString()}</div>
|
||||
<div className="mt-1 text-xs font-black tabular-nums text-blue-700">{Math.round(stats.totalToday).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-slate-50 px-2 py-1.5 text-right">
|
||||
<div className="flex flex-col justify-center rounded-lg bg-slate-50 px-2 py-1.5 text-right">
|
||||
<div className="text-[9px] font-black text-slate-400">日均单车</div>
|
||||
<div className="text-xs font-black text-slate-800">{stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}</div>
|
||||
<div className="mt-1 text-xs font-black tabular-nums text-slate-800">{stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">车辆详情清单</span>
|
||||
<span className="text-[9px] font-bold text-slate-300">{total} 条</span>
|
||||
</div>
|
||||
<div className="hidden grid-cols-[minmax(190px,1.1fr)_minmax(160px,1fr)_130px_130px] items-center gap-3 rounded-lg border border-slate-200 bg-white px-4 py-2 text-[10px] font-bold text-slate-400 md:grid">
|
||||
<span>车牌 / 数据状态</span>
|
||||
<span>客户 / 归属</span>
|
||||
<span className="text-right">{isRangeMode ? '区间里程' : '当日里程'}</span>
|
||||
<span className="text-right">累计里程</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vehicle List */}
|
||||
@@ -1231,15 +1342,16 @@ export default function MonitoringView() {
|
||||
<div className="grid grid-cols-1 gap-1.5">
|
||||
{filteredVehicles.map((v) => {
|
||||
const highMileageAlert = isHighMileageAlert(v);
|
||||
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
key={v.plate}
|
||||
className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between active:bg-slate-50 cursor-pointer transition-all"
|
||||
className="grid cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2.5 shadow-sm transition-all hover:border-blue-200 hover:shadow-md active:bg-slate-50 md:grid-cols-[minmax(190px,1.1fr)_minmax(160px,1fr)_130px_130px] md:px-4"
|
||||
onClick={() => setDetailVehicle(v)}
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden flex-1">
|
||||
<div className="flex min-w-0 items-center gap-3 overflow-hidden">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
|
||||
<Truck size={14} className="text-slate-400" />
|
||||
@@ -1253,13 +1365,34 @@ export default function MonitoringView() {
|
||||
{v.isOnline ? '在线' : '离线'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
|
||||
{statisticTime.label}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 md:hidden">
|
||||
<span className="text-[8px] text-slate-300 font-bold">{v.rentStatus || ''}{v.department ? ` · ${v.department.replace('业务', '')}` : ''}</span>
|
||||
<span className="text-[9px] font-bold text-slate-600 truncate"><Blur>{v.customer || '-'}</Blur></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 ml-2 flex flex-col items-end">
|
||||
<div className="hidden min-w-0 md:block">
|
||||
<div className="truncate text-xs font-bold text-slate-700"><Blur>{v.customer || '-'}</Blur></div>
|
||||
<div className="mt-1 truncate text-[9px] font-medium text-slate-400">
|
||||
{[v.rentStatus, v.department?.replace('业务', ''), v.project].filter(Boolean).join(' · ') || '暂无归属信息'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden text-right md:block">
|
||||
<div className={`text-sm font-black tabular-nums ${highMileageAlert ? 'text-red-600' : 'text-blue-600'}`}>
|
||||
{Math.max(0, v.dailyKm || 0).toLocaleString()}
|
||||
<span className="ml-1 text-[9px] font-bold text-slate-400">km</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden text-right md:block">
|
||||
<div className="text-xs font-black tabular-nums text-slate-700">
|
||||
{v.totalKm != null ? v.totalKm.toLocaleString() : '-'}
|
||||
<span className="ml-1 text-[9px] font-bold text-slate-400">km</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 flex-col items-end text-right md:hidden">
|
||||
<div className="flex items-center gap-1 mb-0.5">
|
||||
{!v.isDataSynced && v.totalKm == null && (
|
||||
<div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" title="未对接车机数据"></div>
|
||||
|
||||
@@ -24,6 +24,7 @@ export async function fetchMonitoring(params?: {
|
||||
date?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
brands?: string[];
|
||||
}): Promise<MonitoringData> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.sortBy) query.set('sortBy', params.sortBy);
|
||||
@@ -44,6 +45,11 @@ export async function fetchMonitoring(params?: {
|
||||
});
|
||||
}
|
||||
if (params?.region) query.set('region', params.region);
|
||||
if (params?.brands) {
|
||||
params.brands.forEach(brand => {
|
||||
if (brand) query.append('brand', brand);
|
||||
});
|
||||
}
|
||||
if (params?.plate) query.set('plate', params.plate);
|
||||
if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
|
||||
if (params?.mileageMax) query.set('mileageMax', params.mileageMax);
|
||||
|
||||
@@ -5,6 +5,9 @@ export interface MonitoringVehicle {
|
||||
dailyMileage?: Record<string, number>;
|
||||
totalKm: number | null;
|
||||
source: string;
|
||||
dataTime: string | null;
|
||||
calculatedAt: string | null;
|
||||
updatedAt: string | null;
|
||||
isOnline: boolean;
|
||||
isDataSynced: boolean;
|
||||
customer: string | null;
|
||||
@@ -14,6 +17,7 @@ export interface MonitoringVehicle {
|
||||
entity: string | null;
|
||||
project: string | null;
|
||||
region: string | null;
|
||||
brand: string | null;
|
||||
targetNames: string[];
|
||||
}
|
||||
|
||||
@@ -34,6 +38,7 @@ export interface MonitoringFilters {
|
||||
platePrefixes: { prefix: string; count: number }[];
|
||||
targetNames: string[];
|
||||
regions: string[];
|
||||
brands: string[];
|
||||
}
|
||||
|
||||
export interface MonitoringData {
|
||||
|
||||
@@ -8,9 +8,9 @@ interface ExportContext {
|
||||
sortBy: 'today' | 'total';
|
||||
}
|
||||
|
||||
const HEADERS = [
|
||||
const BASE_HEADERS = [
|
||||
'状态', '车牌号', '客户', '业务部门', '项目', '租赁状态',
|
||||
'运营区域', '区间里程(km)', '累计里程(km)',
|
||||
'运营区域',
|
||||
] as const;
|
||||
|
||||
function statusLabel(v: MonitoringVehicle): string {
|
||||
@@ -20,7 +20,6 @@ function statusLabel(v: MonitoringVehicle): string {
|
||||
|
||||
function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | number {
|
||||
if (kind === 'today') {
|
||||
// 区间内未对接但有历史累计,视作区间 0;只有完全无数据才标「未对接」。
|
||||
if (!v.isDataSynced && v.totalKm == null) return '未对接';
|
||||
return Math.max(0, v.dailyKm || 0);
|
||||
}
|
||||
@@ -31,24 +30,46 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
const start = ctx.startDate || ctx.date || '';
|
||||
const end = ctx.endDate || ctx.date || '';
|
||||
const isRange = !!start && !!end && start !== end;
|
||||
|
||||
const dayKeys = Array.from(
|
||||
new Set(vehicles.flatMap(v => Object.keys(v.dailyMileage || {})))
|
||||
).sort();
|
||||
|
||||
// 车辆汇总 sheet:基础列 + 每日里程列 + 区间合计 + 累计里程
|
||||
const dailyHeaders = dayKeys.map(day => `${day.slice(5)}里程(km)`);
|
||||
const summaryHeaders = [
|
||||
...BASE_HEADERS,
|
||||
...(dayKeys.length > 0 ? dailyHeaders : []),
|
||||
isRange ? '区间合计(km)' : '当日里程(km)',
|
||||
'累计里程(km)',
|
||||
];
|
||||
|
||||
const summaryData: (string | number)[][] = [
|
||||
[...HEADERS],
|
||||
...vehicles.map(v => [
|
||||
statusLabel(v),
|
||||
v.plate,
|
||||
v.customer || '',
|
||||
v.department || '',
|
||||
v.project || '',
|
||||
v.rentStatus || '',
|
||||
v.region || '',
|
||||
mileageCell(v, 'today'),
|
||||
mileageCell(v, 'total'),
|
||||
]),
|
||||
summaryHeaders,
|
||||
...vehicles.map(v => {
|
||||
const baseRow = [
|
||||
statusLabel(v),
|
||||
v.plate,
|
||||
v.customer || '',
|
||||
v.department || '',
|
||||
v.project || '',
|
||||
v.rentStatus || '',
|
||||
v.region || '',
|
||||
];
|
||||
const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? 0);
|
||||
return [
|
||||
...baseRow,
|
||||
...dailyCols,
|
||||
mileageCell(v, 'today'),
|
||||
mileageCell(v, 'total'),
|
||||
];
|
||||
}),
|
||||
];
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet(summaryData);
|
||||
|
||||
ws['!cols'] = [
|
||||
const numFixedCols = BASE_HEADERS.length;
|
||||
const wsCols: { wch: number }[] = [
|
||||
{ wch: 8 }, // 状态
|
||||
{ wch: 12 }, // 车牌号
|
||||
{ wch: 28 }, // 客户
|
||||
@@ -56,21 +77,24 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
{ wch: 16 }, // 项目
|
||||
{ wch: 10 }, // 租赁状态
|
||||
{ wch: 12 }, // 运营区域
|
||||
{ wch: 14 }, // 区间里程
|
||||
{ wch: 14 }, // 累计里程
|
||||
];
|
||||
for (let i = 0; i < dayKeys.length; i++) wsCols.push({ wch: 12 });
|
||||
wsCols.push({ wch: 14 }); // 区间合计
|
||||
wsCols.push({ wch: 14 }); // 累计里程
|
||||
ws['!cols'] = wsCols;
|
||||
|
||||
ws['!freeze'] = { xSplit: 0, ySplit: 1 } as never;
|
||||
// 冻结首行 + 固定列
|
||||
ws['!freeze'] = { xSplit: numFixedCols, ySplit: 1 } as never;
|
||||
|
||||
for (let r = 1; r < summaryData.length; r++) {
|
||||
for (const c of [7, 8]) {
|
||||
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.##########';
|
||||
}
|
||||
}
|
||||
|
||||
// 表头样式(在客户端 SheetJS 社区版仅基本样式生效)
|
||||
for (let c = 0; c < HEADERS.length; c++) {
|
||||
// 表头样式
|
||||
for (let c = 0; c < summaryHeaders.length; c++) {
|
||||
const ref = XLSX.utils.encode_cell({ r: 0, c });
|
||||
if (ws[ref]) {
|
||||
(ws[ref] as { s?: unknown }).s = {
|
||||
@@ -84,9 +108,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, '车辆汇总');
|
||||
|
||||
const dayKeys = Array.from(
|
||||
new Set(vehicles.flatMap(v => Object.keys(v.dailyMileage || {})))
|
||||
).sort();
|
||||
// 每日明细 sheet:保留原有格式
|
||||
if (dayKeys.length > 0) {
|
||||
const detailHeaders = [
|
||||
'车牌号', '客户', '业务部门', '项目', '租赁状态', '运营区域',
|
||||
|
||||
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;
|
||||
};
|
||||
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;
|
||||
@@ -9,6 +9,8 @@ import schedulingRouter from './routes/scheduling/index.js';
|
||||
import energyRouter from './routes/energy/index.js';
|
||||
import eleRouter from './routes/ele/index.js';
|
||||
import feedbackRouter from './routes/feedback/index.js';
|
||||
import vehicleHeatmapRouter from './routes/vehicle-heatmap.js';
|
||||
import hydrogenHeatmapRouter from './routes/hydrogen-heatmap.js';
|
||||
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
|
||||
import authRouter from './auth/login.js';
|
||||
import { authMiddleware } from './auth/middleware.js';
|
||||
@@ -31,6 +33,8 @@ app.route('/api/scheduling', schedulingRouter);
|
||||
app.route('/api/energy', energyRouter);
|
||||
app.route('/api/ele', eleRouter);
|
||||
app.route('/api/feedback', feedbackRouter);
|
||||
app.route('/api/vehicle-heatmap', vehicleHeatmapRouter);
|
||||
app.route('/api/hydrogen-heatmap', hydrogenHeatmapRouter);
|
||||
|
||||
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));
|
||||
|
||||
|
||||
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,8 @@ import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import pool from '../../db.js';
|
||||
import mileagePool from '../../mileage-db.js';
|
||||
import { fetchVehicleInfoMap } from './vehicle-info.js';
|
||||
import { fetchOneOsDailyMileage, fetchOneOsMileageDates, type OneOsDailyMileage } from './oneos-api.js';
|
||||
import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix, VehicleInfoRow } from './types.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -37,6 +37,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 entities = Array.from(new Set(vehicles.map(v => v.entity).filter((e): e is string => e !== null)));
|
||||
const rentStatuses = Array.from(new Set(vehicles.map(v => v.rentStatus).filter((r): r is string => r !== null)));
|
||||
const brands = Array.from(new Set(vehicles.map(v => v.brand).filter((b): b is string => b !== null))).sort();
|
||||
|
||||
const prefixCount = new Map<string, number>();
|
||||
for (const v of vehicles) {
|
||||
@@ -54,7 +55,7 @@ function buildFilters(vehicles: CachedVehicle[], targetNames: string[]): Monitor
|
||||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||||
});
|
||||
|
||||
return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames, regions };
|
||||
return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames, regions, brands };
|
||||
}
|
||||
|
||||
interface MileageRow {
|
||||
@@ -63,6 +64,9 @@ interface MileageRow {
|
||||
daily_km: string;
|
||||
total_km: string | null;
|
||||
source: string;
|
||||
data_time: string | null;
|
||||
calculated_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
interface DailyMileageRow {
|
||||
@@ -71,6 +75,49 @@ interface DailyMileageRow {
|
||||
date: string;
|
||||
daily_km: string | number | null;
|
||||
source: string | 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',
|
||||
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>();
|
||||
for (const row of rows) {
|
||||
if (row.status !== 'NORMAL') continue;
|
||||
const km = Math.max(0, Number(row.dailyMileageKm) || 0);
|
||||
const existing = map.get(row.plateNumber) || 0;
|
||||
if (km > existing) map.set(row.plateNumber, km);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export interface RangeMileageResult {
|
||||
@@ -115,56 +162,10 @@ function buildPlateTargetNamesMap(targetRows: TargetRow[]): Map<string, string[]
|
||||
return map;
|
||||
}
|
||||
|
||||
async function fetchBizTotalMileageMap(): Promise<Map<string, number>> {
|
||||
// v_vehicle_daily_stats.total_km 对 G7S 数据源常为 NULL(G7 只回传日增量),
|
||||
// 业务库 lingniu_prod.tab_mileage_assessment_vehicle.vehicle_total_mileage 是累加后的权威累计值,
|
||||
// 用它兜底保证 totalKm 汇总完整。
|
||||
const [rows] = await pool.execute(
|
||||
'SELECT plate_number, vehicle_total_mileage FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0'
|
||||
) as [{ plate_number: string; vehicle_total_mileage: string | number | null }[], unknown];
|
||||
const map = new Map<string, number>();
|
||||
for (const r of rows) {
|
||||
const km = Number(r.vehicle_total_mileage);
|
||||
if (Number.isFinite(km) && km > 0) map.set(r.plate_number, km);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function fetchLatestPgTotalMileageMap(asOf?: string): Promise<Map<string, number>> {
|
||||
// 当日 ln_vehicle_day_total_pg 无记录或 total_mileage 为 NULL 时,
|
||||
// 回填该车 dates <= asOf 的最近一条非空 total_mileage(÷1000 转 km),
|
||||
// 让视图 total_km 为 NULL 的车也能显示历史累计。
|
||||
// MySQL 5.7 无窗口函数,用 GROUP BY MAX(dates) + JOIN 取每车最近一条。
|
||||
const sql = `
|
||||
SELECT t.plate_number, t.total_mileage
|
||||
FROM ln_vehicle_day_total_pg t
|
||||
INNER JOIN (
|
||||
SELECT plate_number, MAX(dates) AS max_dates
|
||||
FROM ln_vehicle_day_total_pg
|
||||
WHERE total_mileage IS NOT NULL
|
||||
${asOf ? 'AND dates <= ?' : ''}
|
||||
GROUP BY plate_number
|
||||
) m ON m.plate_number = t.plate_number AND m.max_dates = t.dates
|
||||
WHERE t.total_mileage IS NOT NULL`;
|
||||
const params = asOf ? [asOf] : [];
|
||||
const [rows] = await mileagePool.execute(sql, params) as [
|
||||
{ plate_number: string; total_mileage: string | number | null }[],
|
||||
unknown,
|
||||
];
|
||||
const map = new Map<string, number>();
|
||||
for (const r of rows) {
|
||||
const km = Number(r.total_mileage) / 1000;
|
||||
if (Number.isFinite(km) && km > 0) map.set(r.plate_number, km);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function mergeVehicles(
|
||||
mileageRows: MileageRow[],
|
||||
infoMap: Map<string, VehicleInfoRow>,
|
||||
yesterdayMap: Map<string, number>,
|
||||
bizTotalMap: Map<string, number>,
|
||||
latestPgTotalMap: Map<string, number>,
|
||||
targetNamesByPlate: Map<string, string[]>,
|
||||
): CachedVehicle[] {
|
||||
const mileageMap = new Map<string, MileageRow>();
|
||||
@@ -181,14 +182,17 @@ function mergeVehicles(
|
||||
const dailyKm = Number(m?.daily_km) || 0;
|
||||
const source = m?.source || 'NONE';
|
||||
const gpsTotal = m?.total_km != null ? Number(m.total_km) : null;
|
||||
const latestPgTotal = latestPgTotalMap.get(plate);
|
||||
const bizTotal = bizTotalMap.get(plate);
|
||||
return {
|
||||
plate,
|
||||
vin: m?.vin || info.vin || '',
|
||||
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,
|
||||
dataTime: m?.data_time || null,
|
||||
calculatedAt: m?.calculated_at || null,
|
||||
updatedAt: m?.updated_at || null,
|
||||
isOnline: source !== 'NONE' && dailyKm > 0,
|
||||
isDataSynced: source !== 'NONE',
|
||||
customer: info.customer || null,
|
||||
@@ -199,6 +203,7 @@ function mergeVehicles(
|
||||
entity: info.entity || null,
|
||||
project: info.project || null,
|
||||
region: regionMap[plate] || null,
|
||||
brand: info.brand_label || null,
|
||||
targetNames: targetNamesByPlate.get(plate) || [],
|
||||
yesterdayKm: yesterdayMap.get(plate) || 0,
|
||||
};
|
||||
@@ -210,44 +215,23 @@ export async function refreshMonitoringCache(): Promise<void> {
|
||||
console.log('[mileage] refreshing monitoring cache...');
|
||||
const start = Date.now();
|
||||
|
||||
const [mileageRows, yesterdayMap, infoMap, targetRows, bizTotalMap, latestPgTotalMap] = await Promise.all([
|
||||
(async () => {
|
||||
const [dateRows] = await mileagePool.execute(
|
||||
'SELECT MAX(stat_date) as latest FROM v_vehicle_daily_stats'
|
||||
) as [{ latest: string | null }[], unknown];
|
||||
const latestDate = dateRows[0]?.latest;
|
||||
if (!latestDate) return [];
|
||||
const [rows] = await mileagePool.execute(
|
||||
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
|
||||
[latestDate]
|
||||
) as [MileageRow[], unknown];
|
||||
return rows;
|
||||
})(),
|
||||
(async () => {
|
||||
const [rows] = await mileagePool.execute(
|
||||
`SELECT plate, daily_km FROM v_vehicle_daily_stats
|
||||
WHERE stat_date = DATE_SUB((SELECT MAX(stat_date) FROM v_vehicle_daily_stats), INTERVAL 1 DAY)`
|
||||
) as [{ plate: string; daily_km: string }[], unknown];
|
||||
const map = new Map<string, number>();
|
||||
for (const r of rows) {
|
||||
const km = Number(r.daily_km) || 0;
|
||||
const existing = map.get(r.plate) || 0;
|
||||
if (km > existing) map.set(r.plate, km);
|
||||
}
|
||||
return map;
|
||||
})(),
|
||||
const date = shanghaiDate();
|
||||
const [apiRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
|
||||
fetchOneOsDailyMileage(date),
|
||||
fetchOneOsDailyMileage(previousDate(date)),
|
||||
fetchVehicleInfoMap(),
|
||||
fetchTargetRows(),
|
||||
fetchBizTotalMileageMap(),
|
||||
fetchLatestPgTotalMileageMap(),
|
||||
]);
|
||||
|
||||
const mileageRows = toMileageRows(apiRows);
|
||||
const yesterdayMap = dailyMileageMap(yesterdayRows);
|
||||
|
||||
const targetPlatesMap = buildTargetPlatesMap(targetRows);
|
||||
const targetNamesByPlate = buildPlateTargetNamesMap(targetRows);
|
||||
const targetNames = Array.from(targetPlatesMap.keys());
|
||||
|
||||
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap, latestPgTotalMap, targetNamesByPlate);
|
||||
const totalToday = vehicles.reduce((sum, v) => sum + v.dailyKm, 0);
|
||||
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, targetNamesByPlate);
|
||||
const totalToday = Math.round(vehicles.reduce((sum, v) => sum + v.dailyKm, 0));
|
||||
const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
|
||||
|
||||
monitoringCache = {
|
||||
@@ -265,34 +249,20 @@ export async function refreshMonitoringCache(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]> {
|
||||
const [mileageRows, yesterdayRows, infoMap, targetRows, bizTotalMap, latestPgTotalMap] = await Promise.all([
|
||||
mileagePool.execute(
|
||||
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
|
||||
[dateStr]
|
||||
).then(([r]) => r as MileageRow[]),
|
||||
mileagePool.execute(
|
||||
'SELECT plate, daily_km FROM v_vehicle_daily_stats WHERE stat_date = DATE_SUB(?, INTERVAL 1 DAY)',
|
||||
[dateStr]
|
||||
).then(([r]) => r as { plate: string; daily_km: string }[]),
|
||||
const [apiRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
|
||||
fetchOneOsDailyMileage(dateStr),
|
||||
fetchOneOsDailyMileage(previousDate(dateStr)),
|
||||
fetchVehicleInfoMap(),
|
||||
fetchTargetRows(),
|
||||
fetchBizTotalMileageMap(),
|
||||
fetchLatestPgTotalMileageMap(dateStr),
|
||||
]);
|
||||
|
||||
const yesterdayMap = new Map<string, number>();
|
||||
for (const r of yesterdayRows) {
|
||||
const km = Number(r.daily_km) || 0;
|
||||
const existing = yesterdayMap.get(r.plate) || 0;
|
||||
if (km > existing) yesterdayMap.set(r.plate, km);
|
||||
}
|
||||
const mileageRows = toMileageRows(apiRows);
|
||||
const yesterdayMap = dailyMileageMap(yesterdayRows);
|
||||
|
||||
return mergeVehicles(
|
||||
mileageRows,
|
||||
infoMap,
|
||||
yesterdayMap,
|
||||
bizTotalMap,
|
||||
latestPgTotalMap,
|
||||
buildPlateTargetNamesMap(targetRows),
|
||||
);
|
||||
}
|
||||
@@ -313,31 +283,57 @@ function datesBetween(start: string, end: string): string[] {
|
||||
}
|
||||
|
||||
export async function queryRangeMileage(startDate: string, endDate: string): Promise<RangeMileageResult> {
|
||||
if (startDate === endDate) {
|
||||
const vehicles = (await queryDateMileage(startDate)).map(vehicle => ({
|
||||
...vehicle,
|
||||
dailyMileage: { [startDate]: vehicle.dailyKm },
|
||||
}));
|
||||
return {
|
||||
vehicles,
|
||||
dailyTotals: [{
|
||||
date: startDate,
|
||||
totalKm: Math.round(vehicles.reduce((sum, vehicle) => sum + vehicle.dailyKm, 0)),
|
||||
}],
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
};
|
||||
}
|
||||
|
||||
const days = datesBetween(startDate, endDate);
|
||||
const [dailyRows, yesterdayRows, infoMap, targetRows, bizTotalMap, latestPgTotalMap] = await Promise.all([
|
||||
mileagePool.execute(
|
||||
`SELECT plate,
|
||||
DATE_FORMAT(stat_date, '%Y-%m-%d') AS date,
|
||||
vin,
|
||||
daily_km,
|
||||
source
|
||||
FROM v_vehicle_daily_stats
|
||||
WHERE stat_date >= ? AND stat_date <= ?
|
||||
ORDER BY stat_date, plate`,
|
||||
[startDate, endDate]
|
||||
).then(([r]) => r as DailyMileageRow[]),
|
||||
mileagePool.execute(
|
||||
'SELECT plate, daily_km FROM v_vehicle_daily_stats WHERE stat_date = DATE_SUB(?, INTERVAL 1 DAY)',
|
||||
[startDate]
|
||||
).then(([r]) => r as { plate: string; daily_km: string }[]),
|
||||
const [apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
|
||||
fetchOneOsMileageDates(days),
|
||||
fetchOneOsDailyMileage(endDate),
|
||||
fetchOneOsDailyMileage(previousDate(startDate)),
|
||||
fetchVehicleInfoMap(),
|
||||
fetchTargetRows(),
|
||||
fetchBizTotalMileageMap(),
|
||||
fetchLatestPgTotalMileageMap(endDate),
|
||||
]);
|
||||
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',
|
||||
data_time: row.dataTime,
|
||||
calculated_at: row.calculatedAt,
|
||||
updated_at: row.updatedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const perVehicleDaily = new Map<string, Record<string, number>>();
|
||||
const perVehicleSum = new Map<string, { plate: string; vin: string; daily_km: string; total_km: null; source: string }>();
|
||||
const perVehicleSum = new Map<string, {
|
||||
plate: string;
|
||||
vin: string;
|
||||
daily_km: string;
|
||||
total_km: string | null;
|
||||
source: string;
|
||||
data_time: string | null;
|
||||
calculated_at: string | null;
|
||||
updated_at: string | null;
|
||||
}>();
|
||||
const dailyTotals = new Map<string, number>();
|
||||
const bestDailyRows = new Map<string, DailyMileageRow>();
|
||||
|
||||
@@ -369,22 +365,31 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
|
||||
daily_km: String((Number(existing?.daily_km) || 0) + km),
|
||||
total_km: null,
|
||||
source: existing?.source !== 'NONE' && existing?.source ? existing.source : (row.source || 'NONE'),
|
||||
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 yesterdayMap = new Map<string, number>();
|
||||
for (const r of yesterdayRows) {
|
||||
const km = Number(r.daily_km) || 0;
|
||||
const existing = yesterdayMap.get(r.plate) || 0;
|
||||
if (km > existing) yesterdayMap.set(r.plate, km);
|
||||
const 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),
|
||||
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,
|
||||
bizTotalMap,
|
||||
latestPgTotalMap,
|
||||
buildPlateTargetNamesMap(targetRows),
|
||||
).map(vehicle => {
|
||||
const dailyMileage = perVehicleDaily.get(vehicle.plate) || {};
|
||||
@@ -395,7 +400,7 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
|
||||
|
||||
return {
|
||||
vehicles,
|
||||
dailyTotals: days.map(date => ({ date, totalKm: dailyTotals.get(date) || 0 })),
|
||||
dailyTotals: days.map(date => ({ date, totalKm: Math.round(dailyTotals.get(date) || 0) })),
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ const app = new Hono();
|
||||
const EMPTY_RESPONSE: MonitoringResponse = {
|
||||
vehicles: [],
|
||||
stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 },
|
||||
filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] },
|
||||
filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [], brands: [] },
|
||||
total: 0,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
@@ -20,6 +20,7 @@ function applyFilters(vehicles: CachedVehicle[], params: {
|
||||
search: string; dept: string; customer: string; project: string;
|
||||
entity: string; rentStatus: string; plate: string; platePrefix: string;
|
||||
targetNames: string[]; region: string; mileageMin: string; mileageMax: string;
|
||||
brands: string[];
|
||||
}): CachedVehicle[] {
|
||||
let result = vehicles;
|
||||
|
||||
@@ -27,12 +28,13 @@ function applyFilters(vehicles: CachedVehicle[], params: {
|
||||
const q = params.search.toLowerCase();
|
||||
result = result.filter(v =>
|
||||
v.plate.toLowerCase().includes(q) ||
|
||||
v.vin.toLowerCase().includes(q) ||
|
||||
(v.customer || '').toLowerCase().includes(q) ||
|
||||
(v.project || '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
if (params.dept) result = result.filter(v => params.dept === '__EMPTY__' ? !v.department : v.department === params.dept);
|
||||
if (params.customer) result = result.filter(v => params.customer === '__EMPTY__' ? !v.customer : v.customer === 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.entity) result = result.filter(v => v.entity === params.entity);
|
||||
if (params.rentStatus) result = result.filter(v => v.rentStatus === params.rentStatus);
|
||||
@@ -46,6 +48,10 @@ function applyFilters(vehicles: CachedVehicle[], params: {
|
||||
const selectedTargets = new Set(params.targetNames);
|
||||
result = result.filter(v => v.targetNames.some(targetName => selectedTargets.has(targetName)));
|
||||
}
|
||||
if (params.brands.length > 0) {
|
||||
const selectedBrands = new Set(params.brands);
|
||||
result = result.filter(v => v.brand != null && selectedBrands.has(v.brand));
|
||||
}
|
||||
if (params.mileageMin) result = result.filter(v => v.dailyKm >= Number(params.mileageMin));
|
||||
if (params.mileageMax) result = result.filter(v => v.dailyKm <= Number(params.mileageMax));
|
||||
|
||||
@@ -112,6 +118,7 @@ app.get('/', async (c) => {
|
||||
region: c.req.query('region') || '',
|
||||
mileageMin: c.req.query('mileageMin') || '',
|
||||
mileageMax: c.req.query('mileageMax') || '',
|
||||
brands: c.req.queries('brand') || [],
|
||||
};
|
||||
|
||||
let allVehicles: CachedVehicle[];
|
||||
@@ -162,12 +169,12 @@ app.get('/', async (c) => {
|
||||
if (rangeDailyTotals && filtered.length !== allVehicles.length) {
|
||||
rangeDailyTotals = rangeDailyTotals.map(item => ({
|
||||
...item,
|
||||
totalKm: filtered.reduce((sum, vehicle) => sum + (vehicle.dailyMileage?.[item.date] || 0), 0),
|
||||
totalKm: Math.round(filtered.reduce((sum, vehicle) => sum + (vehicle.dailyMileage?.[item.date] || 0), 0)),
|
||||
}));
|
||||
}
|
||||
|
||||
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),
|
||||
vehicleCount: filtered.length,
|
||||
yesterdayTotal: filtered.reduce((sum, v) => sum + v.yesterdayKm, 0),
|
||||
|
||||
321
src/server/routes/mileage/oneos-api.ts
Normal file
321
src/server/routes/mileage/oneos-api.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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[]>>>();
|
||||
|
||||
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');
|
||||
if (!plateNumber || date !== requestedDate) continue;
|
||||
|
||||
const normalized: OneOsDailyMileage = {
|
||||
vin,
|
||||
plateNumber,
|
||||
date,
|
||||
dailyMileageKm,
|
||||
totalMileageKm,
|
||||
status,
|
||||
dataTime,
|
||||
calculatedAt,
|
||||
updatedAt,
|
||||
};
|
||||
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): Promise<OneOsDailyMileage[]> {
|
||||
const { baseUrl, apiKey, timeoutMs } = apiConfig();
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
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({ date }),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
const payload = await response.json().catch(() => null) as OneOsMileageResponse | null;
|
||||
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 {
|
||||
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): 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,
|
||||
};
|
||||
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 (!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 {
|
||||
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[],
|
||||
): Promise<OneOsDailyMileage[]> {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error(`Invalid mileage date: ${date}`);
|
||||
|
||||
const hit = cache.get(date);
|
||||
let rows: OneOsDailyMileage[];
|
||||
if (hit && hit.expiresAt > Date.now()) {
|
||||
rows = hit.rows;
|
||||
} else {
|
||||
let pending = inflight.get(date);
|
||||
if (!pending) {
|
||||
pending = requestDate(date).then(result => {
|
||||
const ttl = date === shanghaiDate() ? CURRENT_DAY_TTL_MS : HISTORICAL_TTL_MS;
|
||||
cache.delete(date);
|
||||
if (ttl > 0) {
|
||||
cache.set(date, { rows: result, expiresAt: Date.now() + ttl });
|
||||
trimCache();
|
||||
}
|
||||
return result;
|
||||
}).finally(() => inflight.delete(date));
|
||||
inflight.set(date, 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[],
|
||||
): Promise<Map<string, OneOsDailyMileage[]>> {
|
||||
const result = new Map<string, OneOsDailyMileage[]>();
|
||||
const uniqueDates = Array.from(new Set(dates)).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}`;
|
||||
let pending = rangeInflight.get(key);
|
||||
if (!pending) {
|
||||
pending = requestRange(startDate, endDate).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();
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../../db.js';
|
||||
import mileagePool from '../../mileage-db.js';
|
||||
import { getCache } from './cache.js';
|
||||
import { fetchOneOsDailyMileage } from './oneos-api.js';
|
||||
import { fetchVehicleInfoByPlates } from './vehicle-info.js';
|
||||
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
||||
|
||||
@@ -202,7 +202,7 @@ app.get('/', async (c) => {
|
||||
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
|
||||
assessmentYears: t.assessment_years,
|
||||
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,
|
||||
avgCompletion: (Number(s.avg_completion) || 0) * 100,
|
||||
qualifiedCount: Number(s.qualified_count) || 0,
|
||||
@@ -256,20 +256,15 @@ app.get('/:id/vehicles', async (c) => {
|
||||
|
||||
const dateMileageMap = new Map<string, { dailyKm: number; totalKm: number | null; isOnline: boolean }>();
|
||||
if (date && plates.length > 0) {
|
||||
const [mileageRows] = await mileagePool.execute(
|
||||
`SELECT plate, daily_km, total_km, source FROM v_vehicle_daily_stats
|
||||
WHERE stat_date = ? AND plate IN (${plates.map(() => '?').join(',')})`,
|
||||
[date, ...plates]
|
||||
) as [any[], unknown];
|
||||
const mileageRows = await fetchOneOsDailyMileage(date, plates);
|
||||
for (const m of mileageRows) {
|
||||
const existing = dateMileageMap.get(m.plate);
|
||||
const dailyKm = Number(m.daily_km) || 0;
|
||||
const existing = dateMileageMap.get(m.plateNumber);
|
||||
const dailyKm = m.status === 'NORMAL' ? (Number(m.dailyMileageKm) || 0) : 0;
|
||||
if (!existing || dailyKm > existing.dailyKm) {
|
||||
const source = m.source || 'NONE';
|
||||
dateMileageMap.set(m.plate, {
|
||||
dateMileageMap.set(m.plateNumber, {
|
||||
dailyKm,
|
||||
totalKm: m.total_km !== null ? Number(m.total_km) : null,
|
||||
isOnline: source !== 'NONE' && dailyKm > 0,
|
||||
totalKm: m.totalMileageKm,
|
||||
isOnline: m.status === 'NORMAL' && dailyKm > 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../../db.js';
|
||||
import mileagePool from '../../mileage-db.js';
|
||||
import { fetchOneOsMileageDates } from './oneos-api.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get('/', async (c) => {
|
||||
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 {
|
||||
let plates: string[] = [];
|
||||
@@ -19,27 +19,23 @@ app.get('/', async (c) => {
|
||||
if (plates.length === 0) return c.json([]);
|
||||
}
|
||||
|
||||
// 单车日里程负值视为脏数据(里程表回滚 / 换 GPS 设备),不纳入统计
|
||||
let sql = `
|
||||
SELECT DATE_FORMAT(stat_date, '%m-%d') as date,
|
||||
SUM(IF(daily_km < 0, 0, daily_km)) as mileage
|
||||
FROM v_vehicle_daily_stats
|
||||
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) AND stat_date < CURDATE()
|
||||
`;
|
||||
const params: (string | number)[] = [days];
|
||||
|
||||
if (plates.length > 0) {
|
||||
sql += ` AND plate IN (${plates.map(() => '?').join(',')})`;
|
||||
params.push(...plates);
|
||||
const today = new Date(new Date().toLocaleString('en-US', { timeZone: 'Asia/Shanghai' }));
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const dates: string[] = [];
|
||||
for (let offset = days; offset >= 1; offset -= 1) {
|
||||
const date = new Date(today);
|
||||
date.setDate(today.getDate() - offset);
|
||||
dates.push(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`);
|
||||
}
|
||||
|
||||
sql += ' GROUP BY stat_date ORDER BY stat_date';
|
||||
|
||||
const [rows] = await mileagePool.execute(sql, params) as [any[], unknown];
|
||||
|
||||
return c.json(rows.map((r: any) => ({
|
||||
date: r.date,
|
||||
mileage: Math.round(Number(r.mileage) || 0),
|
||||
const rowsByDate = await fetchOneOsMileageDates(dates);
|
||||
const selectedPlates = plates.length > 0 ? new Set(plates) : null;
|
||||
return c.json(dates.map(date => ({
|
||||
date: date.slice(5),
|
||||
mileage: Math.round((rowsByDate.get(date) || []).reduce((sum, row) => {
|
||||
if (selectedPlates && !selectedPlates.has(row.plateNumber)) return sum;
|
||||
return sum + (row.status === 'NORMAL' ? (row.dailyMileageKm || 0) : 0);
|
||||
}, 0)),
|
||||
})));
|
||||
} catch (e: unknown) {
|
||||
console.error('trend error:', e);
|
||||
|
||||
@@ -6,6 +6,9 @@ export interface CachedVehicle {
|
||||
dailyMileage?: Record<string, number>;
|
||||
totalKm: number | null;
|
||||
source: string;
|
||||
dataTime: string | null;
|
||||
calculatedAt: string | null;
|
||||
updatedAt: string | null;
|
||||
isOnline: boolean;
|
||||
isDataSynced: boolean;
|
||||
customer: string | null;
|
||||
@@ -16,6 +19,7 @@ export interface CachedVehicle {
|
||||
entity: string | null;
|
||||
project: string | null;
|
||||
region: string | null;
|
||||
brand: string | null;
|
||||
targetNames: string[];
|
||||
yesterdayKm: number;
|
||||
}
|
||||
@@ -37,6 +41,7 @@ export interface MonitoringFilters {
|
||||
platePrefixes: PlatePrefix[];
|
||||
targetNames: string[];
|
||||
regions: string[];
|
||||
brands: string[];
|
||||
}
|
||||
|
||||
/** 监控缓存 */
|
||||
@@ -80,4 +85,5 @@ export interface VehicleInfoRow {
|
||||
rent_status: string | null;
|
||||
entity: string | null;
|
||||
project: string | null;
|
||||
brand_label: string | null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import pool from '../../db.js';
|
||||
import type { VehicleInfoRow } from './types.js';
|
||||
|
||||
/** 车辆关联信息 SQL(客户名、部门、经理、租赁状态、主体、项目) */
|
||||
/** 车辆关联信息 SQL(客户名、部门、经理、租赁状态、主体、项目、品牌) */
|
||||
export const VEHICLE_INFO_SQL = `SELECT
|
||||
vi.plate_number AS plate,
|
||||
vi.vin AS vin,
|
||||
@@ -18,11 +18,34 @@ export const VEHICLE_INFO_SQL = `SELECT
|
||||
ELSE vs.operation_status
|
||||
END AS rent_status,
|
||||
NULLIF(vi.registered_ownership, '') AS entity,
|
||||
COALESCE(c.project_name, vor.project_name) AS project
|
||||
COALESCE(c.project_name, vor.project_name) AS project,
|
||||
CASE vm.brand
|
||||
WHEN 'hyundai' THEN CASE WHEN vm.model LIKE '%帕力安%' OR vm.model LIKE '%冷链%' OR vm.model LIKE '%双飞翼%' THEN '帕力安牌' ELSE '现代' END
|
||||
WHEN 'yuejin' THEN '跃进'
|
||||
WHEN 'feichi' THEN '飞驰'
|
||||
WHEN 'sulong' THEN '苏龙'
|
||||
WHEN 'higer' THEN '海格'
|
||||
WHEN 'dongfeng' THEN '东风'
|
||||
WHEN 'yutong' THEN '宇通'
|
||||
WHEN 'chufeng' THEN '楚风'
|
||||
WHEN 'tonghua' THEN '通华'
|
||||
WHEN 'maxus' THEN '大通'
|
||||
WHEN 'mingwei' THEN '明威'
|
||||
WHEN 'wanfeng' THEN '万风'
|
||||
WHEN 'shujie' THEN '舒捷'
|
||||
WHEN 'denza' THEN '腾势'
|
||||
WHEN 'hongyan' THEN '红岩'
|
||||
WHEN 'yuanchang brand' THEN '远程牌'
|
||||
WHEN 'others' THEN '其他'
|
||||
ELSE vm.brand
|
||||
END AS brand_label
|
||||
FROM vehicle_info vi
|
||||
LEFT JOIN vehicle_status vs
|
||||
ON vs.vehicle_id = vi.id
|
||||
AND vs.del_flag = 0
|
||||
LEFT JOIN vehicle_model vm
|
||||
ON vm.id = vi.vehicle_model_id
|
||||
AND vm.del_flag = '0'
|
||||
LEFT JOIN vehicle_lease_order_record vor
|
||||
ON vor.vehicle_id = vi.id
|
||||
AND vor.del_flag = '0'
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { Hono } from 'hono';
|
||||
import mileagePool from '../../mileage-db.js';
|
||||
import { fetchOneOsMileageDates } from './oneos-api.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
interface DayRow {
|
||||
date: string;
|
||||
daily_km: string | number | null;
|
||||
source: string | null;
|
||||
}
|
||||
|
||||
function fmt(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
@@ -58,35 +52,24 @@ app.get('/:plate/recent', async (c) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await mileagePool.execute(
|
||||
`SELECT DATE_FORMAT(stat_date, '%Y-%m-%d') AS date, daily_km, source
|
||||
FROM v_vehicle_daily_stats
|
||||
WHERE plate = ? AND stat_date >= ? AND stat_date <= ?
|
||||
ORDER BY stat_date`,
|
||||
[plate, fmt(start), fmt(end)]
|
||||
) as [DayRow[], unknown];
|
||||
|
||||
// 同一 plate 同一天可能有多个数据源,取最大 daily_km
|
||||
const map = new Map<string, { dailyKm: number; source: string }>();
|
||||
for (const r of rows) {
|
||||
const km = Number(r.daily_km) || 0;
|
||||
const src = r.source || 'NONE';
|
||||
const existing = map.get(r.date);
|
||||
if (!existing || km > existing.dailyKm) {
|
||||
map.set(r.date, { dailyKm: km, source: src });
|
||||
}
|
||||
const dates: string[] = [];
|
||||
const dateCursor = new Date(start);
|
||||
while (dateCursor <= end) {
|
||||
dates.push(fmt(dateCursor));
|
||||
dateCursor.setDate(dateCursor.getDate() + 1);
|
||||
}
|
||||
const rowsByDate = await fetchOneOsMileageDates(dates);
|
||||
|
||||
// 补全:从 start 到 end 每天一条
|
||||
const result: { date: string; dailyKm: number; isDataSynced: boolean }[] = [];
|
||||
const cursor = new Date(start);
|
||||
while (cursor <= end) {
|
||||
const key = fmt(cursor);
|
||||
const hit = map.get(key);
|
||||
const hit = (rowsByDate.get(key) || []).find(row => row.plateNumber === plate);
|
||||
result.push({
|
||||
date: key,
|
||||
dailyKm: hit?.dailyKm ?? 0,
|
||||
isDataSynced: !!hit && hit.source !== 'NONE',
|
||||
dailyKm: hit?.status === 'NORMAL' ? (hit.dailyMileageKm || 0) : 0,
|
||||
isDataSynced: hit?.status === 'NORMAL',
|
||||
});
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../../db.js';
|
||||
import mileagePool from '../../mileage-db.js';
|
||||
import { fetchVehicleInfoMap } from '../mileage/vehicle-info.js';
|
||||
import { fetchOneOsMileageDates } from '../mileage/oneos-api.js';
|
||||
import { mapRegion } from '../vehicles.js';
|
||||
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
||||
import { classifyVehicle, generateSuggestions } from './algorithm.js';
|
||||
@@ -42,6 +42,24 @@ function classifyVehicleType(typeName: string, _modelRaw: string): string {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -114,29 +132,33 @@ app.get('/', async (c) => {
|
||||
// ---- Collect all plates for Query 6 ----
|
||||
const allPlates = assessmentRows.map((r: any) => r.plate_number as string);
|
||||
|
||||
// ---- Query 6: Customer daily avg (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 customerAvgDaily7dMap = new Map<string, number>();
|
||||
if (allPlates.length > 0) {
|
||||
const placeholders = allPlates.map(() => '?').join(',');
|
||||
// Single query returning both windows per plate.
|
||||
const [dailyRows] = await mileagePool.execute(
|
||||
`SELECT plate,
|
||||
AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) THEN daily_km END) AS avg_30d,
|
||||
AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN daily_km END) AS avg_7d
|
||||
FROM v_vehicle_daily_stats
|
||||
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
AND stat_date < CURDATE()
|
||||
AND plate IN (${placeholders})
|
||||
GROUP BY plate`,
|
||||
allPlates,
|
||||
) as [any[], unknown];
|
||||
|
||||
const dates = recentCompletedDates(30);
|
||||
const sevenDayDates = new Set(dates.slice(-7));
|
||||
const allowedPlates = new Set(allPlates);
|
||||
const rowsByDate = await fetchOneOsMileageDates(dates);
|
||||
const aggregates = new Map<string, { sum30: number; count30: number; sum7: number; count7: number }>();
|
||||
for (const [date, rows] of rowsByDate) {
|
||||
for (const row of rows) {
|
||||
if (!allowedPlates.has(row.plateNumber) || row.status !== 'NORMAL' || row.dailyMileageKm === null) continue;
|
||||
const current = aggregates.get(row.plateNumber) || { sum30: 0, count30: 0, sum7: 0, count7: 0 };
|
||||
current.sum30 += row.dailyMileageKm;
|
||||
current.count30 += 1;
|
||||
if (sevenDayDates.has(date)) {
|
||||
current.sum7 += row.dailyMileageKm;
|
||||
current.count7 += 1;
|
||||
}
|
||||
aggregates.set(row.plateNumber, current);
|
||||
}
|
||||
}
|
||||
const plateAvg30Map = new Map<string, number>();
|
||||
const plateAvg7Map = new Map<string, number>();
|
||||
for (const row of dailyRows) {
|
||||
if (row.avg_30d !== null) plateAvg30Map.set(row.plate, Number(row.avg_30d));
|
||||
if (row.avg_7d !== null) plateAvg7Map.set(row.plate, Number(row.avg_7d));
|
||||
for (const [plate, values] of aggregates) {
|
||||
if (values.count30 > 0) plateAvg30Map.set(plate, values.sum30 / values.count30);
|
||||
if (values.count7 > 0) plateAvg7Map.set(plate, values.sum7 / values.count7);
|
||||
}
|
||||
|
||||
const customerPlates30 = new Map<string, number[]>();
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user