Compare commits
84
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c13ab6d3b | ||
|
|
1e4afacac7 | ||
|
|
0c26b973ef | ||
|
|
6ea1b3d5dd | ||
|
|
c49a02e3d9 | ||
|
|
d7538ed6eb | ||
|
|
6822eafd09 | ||
|
|
7c8e886663 | ||
|
|
c4e2c03348 | ||
|
|
f82867b159 | ||
|
|
25e53b8f66 | ||
|
|
9750d35ba4 | ||
|
|
7802e5b0eb | ||
|
|
e4c8195ede | ||
|
|
5196db5df7 | ||
|
|
523f7b312d | ||
|
|
c199f031c5 | ||
|
|
795fb207cb | ||
|
|
954d626afc | ||
|
|
507bc90ed2 | ||
|
|
5d14a28b0f | ||
|
|
b28ca49491 | ||
|
|
9a9c9d08e1 | ||
|
|
680c7e0662 | ||
|
|
7556dbcebc | ||
|
|
3daa9808ce | ||
|
|
89dca5c5a4 | ||
|
|
7d792933dd | ||
|
|
0dc7e89c1d | ||
|
|
4430fb75f1 | ||
|
|
df259fc12b | ||
|
|
df6b8201e8 | ||
|
|
a177781fe7 | ||
|
|
637a72c1e9 | ||
|
|
98efde3f75 | ||
|
|
6c91a6694a | ||
|
|
5eb38a4b05 | ||
|
|
2518d9ee54 | ||
|
|
62efef0ab9 | ||
|
|
37a9f303ec | ||
|
|
06fe75b4c7 | ||
|
|
d610e4b841 | ||
|
|
7fce6b8474 | ||
|
|
bd0627d00f | ||
|
|
17c839a35e | ||
|
|
99b4efda6d | ||
|
|
6b72f0ce3c | ||
|
|
b0c8d6a5b8 | ||
|
|
d8773ff0a0 | ||
|
|
becacecc67 | ||
|
|
67f3ec7306 | ||
|
|
3f29bed5fa | ||
|
|
4d523cbce0 | ||
|
|
2b31f8efe8 | ||
|
|
ae4b5d30e7 | ||
|
|
392a36a0ec | ||
|
|
a558db5795 | ||
|
|
b0caa5afcb | ||
|
|
5377d2c225 | ||
|
|
654573ac4b | ||
|
|
43f94ed1b8 | ||
|
|
fb5789d705 | ||
|
|
94a6e0a75e | ||
|
|
67c5f9d281 | ||
|
|
91202bdf71 | ||
|
|
c13f341d5e | ||
|
|
6962c4ff1c | ||
|
|
6b7f0eedd9 | ||
|
|
5bb3ceb47a | ||
|
|
cc778f3701 | ||
|
|
74d6efe261 | ||
|
|
a124e31fab | ||
|
|
a3dfe7ab8c | ||
|
|
3f0edfaaf5 | ||
|
|
feb950dd59 | ||
|
|
5e1c12eba2 | ||
|
|
ae24bc7647 | ||
|
|
0a372e4290 | ||
|
|
1e08d1ea62 | ||
|
|
2d82918d73 | ||
|
|
482243e052 | ||
|
|
f1a69c8271 | ||
|
|
1d2c3a0cd5 | ||
|
|
e7ba5315e1 |
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.codex
|
||||||
|
outputs
|
||||||
|
reports
|
||||||
|
data/geo
|
||||||
|
data/vehicle-heatmap/source.csv
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
|
.worktrees
|
||||||
|
|||||||
+27
-4
@@ -5,14 +5,37 @@ services:
|
|||||||
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
|
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
|
||||||
network_mode: host
|
network_mode: host
|
||||||
environment:
|
environment:
|
||||||
DB_HOST: "47.101.148.99"
|
DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com"
|
||||||
DB_PORT: "3306"
|
DB_PORT: "3306"
|
||||||
DB_USER: "root"
|
DB_USER: "oneos_db_prod"
|
||||||
DB_PASSWORD: "LN#Passw0rd@2026"
|
DB_PASSWORD: "adASHJcviqwjkbn23ngt1"
|
||||||
DB_NAME: "lingniu_prod"
|
DB_NAME: "ln_asset_management"
|
||||||
|
HYDROGEN_DB_HOST: "47.99.185.173"
|
||||||
|
HYDROGEN_DB_PORT: "3306"
|
||||||
|
HYDROGEN_DB_USER: "root"
|
||||||
|
HYDROGEN_DB_PASSWORD: "lnMysql."
|
||||||
|
HYDROGEN_DB_NAME: "ln_asset_management"
|
||||||
|
MILEAGE_DB_HOST: "101.133.130.65"
|
||||||
|
MILEAGE_DB_PORT: "3306"
|
||||||
|
MILEAGE_DB_USER: "bi_reader_02"
|
||||||
|
MILEAGE_DB_PASSWORD: "bi_reader_02_Pass"
|
||||||
|
MILEAGE_DB_NAME: "hydrogen_energy"
|
||||||
|
# ECS production accesses OneOS over the private network. Configure the key
|
||||||
|
# as a Portainer stack environment variable; never commit it to this file.
|
||||||
|
ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}"
|
||||||
|
ONEOS_MILEAGE_API_KEY: "${ONEOS_MILEAGE_API_KEY}"
|
||||||
|
ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}"
|
||||||
SERVER_PORT: "8111"
|
SERVER_PORT: "8111"
|
||||||
EXTERNAL_API_BASE: "https://lnh2e.com"
|
EXTERNAL_API_BASE: "https://lnh2e.com"
|
||||||
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
|
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
|
||||||
|
AMAP_WEB_KEY: "${AMAP_WEB_KEY}"
|
||||||
|
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}"
|
||||||
|
HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}"
|
||||||
|
HEATMAP_DB_PORT: "${HEATMAP_DB_PORT:-36453}"
|
||||||
|
HEATMAP_DB_USER: "${HEATMAP_DB_USER}"
|
||||||
|
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}"
|
||||||
|
HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}"
|
||||||
|
HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}"
|
||||||
deploy:
|
deploy:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
restart_policy:
|
restart_policy:
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# OneOS 车辆里程接口需求
|
||||||
|
|
||||||
|
当前协议选源联调阻塞项和 OneOS 修改清单见:
|
||||||
|
[OneOS 车辆里程数据源协议改造清单](./oneos-mileage-source-protocol-gaps.md)。
|
||||||
|
|
||||||
|
## 开发原则
|
||||||
|
|
||||||
|
1. 新增区间查询接口;车辆里程汇总能力在现有
|
||||||
|
`POST /api/v1/vehicles/mileage/query` 上兼容扩展。
|
||||||
|
2. 现有接口的路径、鉴权、已有请求参数、已有响应字段、字段语义和默认行为必须完全兼容。
|
||||||
|
3. 公网 `https://open.d.lnoneos.com` 与 ECS 内网 `http://172.17.111.55:20310` 使用相同路径、鉴权和数据口径。
|
||||||
|
4. 鉴权继续使用 `Authorization: Bearer APP_KEY`。
|
||||||
|
5. 日期采用 `YYYY-MM-DD`,时区统一为 `Asia/Shanghai`,里程单位统一为 km。
|
||||||
|
|
||||||
|
## 1. 车辆区间日里程批量查询
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/vehicles/mileage/range/query
|
||||||
|
```
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"startDate": "2026-07-01",
|
||||||
|
"endDate": "2026-07-23",
|
||||||
|
"plateNumbers": ["沪A00001", "沪A00002"],
|
||||||
|
"protocolPriority": ["GB32960", "MQTT", "JT808"],
|
||||||
|
"cursor": null,
|
||||||
|
"pageSize": 5000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- `startDate`、`endDate` 必填,最长支持 366 天;
|
||||||
|
- `plateNumbers` 可省略,省略时返回授权范围内全部车辆;
|
||||||
|
- `protocolPriority` 可省略;提供时只允许 `GB32960`、`MQTT`、`JT808`,
|
||||||
|
数组顺序表示逐车逐日的数据源优先级,未列出的协议视为禁用;
|
||||||
|
- BI 只会使用以下四种组合:
|
||||||
|
- 仪表数据优先:`["GB32960", "MQTT", "JT808"]`;
|
||||||
|
- GPS 数据优先:`["JT808", "GB32960", "MQTT"]`;
|
||||||
|
- 仅仪表数据:`["GB32960", "MQTT"]`;
|
||||||
|
- 仅 GPS 数据:`["JT808"]`;
|
||||||
|
- BI 页面默认只启用“仪表数据”,即默认发送
|
||||||
|
`["GB32960", "MQTT"]`;用户主动启用 GPS 后才发送包含 `JT808` 的组合;
|
||||||
|
- 仪表数据内部优先级固定为 `GB32960 > MQTT`,不提供反向切换;
|
||||||
|
- 数据量过大时使用 `cursor` 分页,同一次查询使用相同 `snapshotId`;
|
||||||
|
- 每辆车每天返回一条记录;
|
||||||
|
- 无数据返回 `status: "NO_DATA"` 且 `dailyMileageKm: null`,真实零里程返回 `status: "NORMAL"` 且 `dailyMileageKm: 0`。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"message": "success",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"vin": "LMRK...",
|
||||||
|
"plateNumber": "沪A00001",
|
||||||
|
"date": "2026-07-01",
|
||||||
|
"dailyMileageKm": 182.437,
|
||||||
|
"sourceProtocol": "GB32960",
|
||||||
|
"dataTime": "2026-07-01T23:58:45+08:00",
|
||||||
|
"updatedAt": "2026-07-02T05:10:00+08:00",
|
||||||
|
"status": "NORMAL"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"snapshotId": "mileage-20260723-001",
|
||||||
|
"nextCursor": null,
|
||||||
|
"traceId": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 兼容扩展现有单日车辆里程接口
|
||||||
|
|
||||||
|
用于一次获取指定日期每辆车的日里程、累计里程和数据时间。
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/vehicles/mileage/query
|
||||||
|
```
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"date": "2026-07-23",
|
||||||
|
"plateNumbers": ["沪A00001", "沪A00002"],
|
||||||
|
"protocolPriority": ["GB32960", "MQTT", "JT808"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- `date` 和 `plateNumbers` 沿用现有接口规则;
|
||||||
|
- `protocolPriority` 的枚举、顺序和禁用规则与区间接口完全一致;
|
||||||
|
- `plateNumbers` 省略时返回授权范围内全部车辆;
|
||||||
|
- 接口默认在现有车辆结果中返回 `totalMileageKm`、`dataTime` 和 `updatedAt`;
|
||||||
|
- 只增加响应字段,不删除或修改任何现有字段及其语义;
|
||||||
|
- 历史日期返回该自然日最终有效结果;
|
||||||
|
- 查询当天返回当前最新结果;
|
||||||
|
- `dataTime` 必须是本次统计实际采用的最后一条车辆源数据时间,不能使用接口请求时间或响应时间代替;
|
||||||
|
- 无数据时相关里程字段返回 `null`,不能使用 0 代替;
|
||||||
|
- 接口应支持当前应用授权范围内的全部车辆。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"message": "success",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"vin": "LMRK...",
|
||||||
|
"plateNumber": "沪A00001",
|
||||||
|
"date": "2026-07-23",
|
||||||
|
"dailyMileageKm": 182.437,
|
||||||
|
"totalMileageKm": 12345.678,
|
||||||
|
"sourceProtocol": "GB32960",
|
||||||
|
"dataTime": "2026-07-23T10:35:42+08:00",
|
||||||
|
"updatedAt": "2026-07-23T10:35:46+08:00",
|
||||||
|
"status": "NORMAL"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"traceId": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
现有响应外层结构及已有字段保持不变,调用方无需增加任何请求参数。
|
||||||
|
|
||||||
|
## 通用要求
|
||||||
|
|
||||||
|
- `dataTime`、`updatedAt` 使用带 `+08:00` 的 ISO 8601 格式;
|
||||||
|
- `sourceProtocol` 必须返回本条结果实际采用的协议:
|
||||||
|
`GB32960`、`MQTT` 或 `JT808`;无数据时返回 `null`;
|
||||||
|
- OneOS 必须按 `protocolPriority` 选择第一份有效数据,不得返回已禁用协议的数据;
|
||||||
|
- `sourceProtocol=GB32960/MQTT` 在 BI 显示为“仪表数据”,
|
||||||
|
`sourceProtocol=JT808` 显示为“GPS数据”;
|
||||||
|
- 每个响应返回 `traceId`;
|
||||||
|
- 接口返回值保留原始精度;
|
||||||
|
- 负里程不得作为正常数据返回;
|
||||||
|
- 公网和 ECS 内网使用同一 AppKey 时,授权范围和查询结果保持一致;
|
||||||
|
- 区间接口及现有接口的汇总扩展不得影响现有调用方和默认行为。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
1. 现有 `/api/v1/vehicles/mileage/query` 请求方式保持不变,回归测试全部通过。
|
||||||
|
2. 区间接口不传车牌可完整查询全部授权车辆,分页无重复、无漏行。
|
||||||
|
3. 现有接口不传车牌时,默认返回全部授权车辆的日里程、累计里程和数据时间。
|
||||||
|
4. 正确区分无数据与真实零里程。
|
||||||
|
5. `dataTime` 能反映每辆车实际数据的新鲜度。
|
||||||
|
6. 四种 `protocolPriority` 组合均能按顺序选源,且响应中的
|
||||||
|
`sourceProtocol` 与实际采用协议一致。
|
||||||
|
7. 禁用某类协议后,响应不得回退到该协议。
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# OneOS 日里程与累计里程差额排查报告
|
||||||
|
|
||||||
|
- 排查日期:2026-09-16(Asia/Shanghai)
|
||||||
|
- 涉及数据日期:2026-09-11 至 2026-09-15
|
||||||
|
- 接收方:OneOS 开放平台及里程计算服务维护人员
|
||||||
|
- 状态:已复现;待上游核查计算基准、跨日缺报及协议选源逻辑
|
||||||
|
|
||||||
|
## 1. 问题概述
|
||||||
|
|
||||||
|
BI 用户发现:昨日累计总里程减去前日累计总里程,不等于昨日的区间里程;继续向前检查,多日均存在差额。
|
||||||
|
|
||||||
|
本次在相同车辆范围、相同协议优先级下复现,并绕过 BI 直接请求 OneOS 单日接口核对异常车辆。上游返回本身就存在相邻日期累计差与日里程不一致的情况,不能仅用 BI 显示取整解释。
|
||||||
|
|
||||||
|
已确认的现象:
|
||||||
|
|
||||||
|
1. 某查询日返回的累计里程对应更早日期的报文;后续更新后的累计差跨越多天,与当日日里程不一致。
|
||||||
|
2. 同一车辆相邻日期切换 MQTT / GB32960,累计读数发生跳变,且切换后可能选中很早的读数。
|
||||||
|
|
||||||
|
尚未确认:日里程的内部计算公式、有效数据判定、跨日补算规则及异常过滤规则。本文不将差额直接认定为漏算里程,也不将累计差直接认定为真实日行驶里程。
|
||||||
|
|
||||||
|
## 2. 查询范围与 BI 计算口径
|
||||||
|
|
||||||
|
本次 BI 查询未附加客户、部门、车牌或里程筛选条件,每天返回 916 台车辆。
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/mileage/monitoring
|
||||||
|
?startDate=2026-09-15
|
||||||
|
&endDate=2026-09-15
|
||||||
|
&sourcePriority=instrument
|
||||||
|
&limit=2000
|
||||||
|
```
|
||||||
|
|
||||||
|
`instrument` 对应上游协议优先级:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["GB32960", "MQTT"]
|
||||||
|
```
|
||||||
|
|
||||||
|
BI 当前计算方式:
|
||||||
|
|
||||||
|
| 指标 | 计算方式 |
|
||||||
|
| --- | --- |
|
||||||
|
| 单车累计里程 | OneOS `totalMileageKm`,不以日里程反推 |
|
||||||
|
| 单车单日里程 | OneOS `dailyMileageKm` |
|
||||||
|
| 单车区间里程 | 区间内每日 `dailyMileageKm` 求和 |
|
||||||
|
| 累计总里程 | 查询范围内车辆的累计里程求和;空值按 0 参与汇总 |
|
||||||
|
| 当日总里程 | 查询范围内车辆的日里程求和;汇总接口对结果取整 |
|
||||||
|
|
||||||
|
以下对账表使用逐车日里程原始数值求和,保留三位小数,避免整数显示带来的误差。
|
||||||
|
|
||||||
|
## 3. 汇总对账结果
|
||||||
|
|
||||||
|
单位:km。定义 `差额 = 当天累计总里程 - 前一天累计总里程 - 当天日里程合计`。
|
||||||
|
|
||||||
|
| 日期 | 累计总里程 | 相邻日累计差 | 当日日里程合计 | 差额 |
|
||||||
|
| --- | ---: | ---: | ---: | ---: |
|
||||||
|
| 2026-09-11 | 48,448,486.800 | — | 98,182.208 | — |
|
||||||
|
| 2026-09-12 | 48,546,128.300 | 97,641.500 | 97,321.774 | +319.726 |
|
||||||
|
| 2026-09-13 | 48,619,784.800 | 73,656.500 | 82,322.847 | −8,666.347 |
|
||||||
|
| 2026-09-14 | 48,721,190.600 | 101,405.800 | 94,242.687 | +7,163.113 |
|
||||||
|
| 2026-09-15 | 48,824,917.800 | 103,727.200 | 103,554.604 | +172.596 |
|
||||||
|
|
||||||
|
按车牌匹配相邻日期,差额来源分解如下:
|
||||||
|
|
||||||
|
| 日期 | 两日累计非空且协议相同的车辆数 / 净差额 | 两日累计非空且协议变化的车辆数 / 净差额 | 至少一日累计为空的车辆数 / 净差额 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 09-12 | 759 / +319.726 | 0 / 0 | 157 / 0 |
|
||||||
|
| 09-13 | 758 / +674.653 | 1 / −9,341.000 | 157 / 0 |
|
||||||
|
| 09-14 | 759 / +7,163.113 | 0 / 0 | 157 / 0 |
|
||||||
|
| 09-15 | 759 / +172.596 | 0 / 0 | 157 / 0 |
|
||||||
|
|
||||||
|
本次样本中,空累计值组对差额净贡献为 0;同协议数据也存在明显不一致,不能只修复协议切换问题。
|
||||||
|
|
||||||
|
## 4. 异常样本一:累计读数跨日,日里程未与累计差对齐
|
||||||
|
|
||||||
|
以下记录已通过 OneOS 单日接口直接复核,响应均为 HTTP 200、`code=SUCCESS`,记录状态均为 `NORMAL`。
|
||||||
|
|
||||||
|
### 4.1 粤AGQ8377:9 月 15 日差额 +70.2 km
|
||||||
|
|
||||||
|
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) |
|
||||||
|
| --- | --- | ---: | ---: | --- |
|
||||||
|
| 2026-09-13 | GB32960 | 14,366.0 | 6.1 | 2026-09-13 07:17:27 |
|
||||||
|
| 2026-09-14 | GB32960 | 14,366.0 | 0 | 2026-09-13 07:17:27 |
|
||||||
|
| 2026-09-15 | GB32960 | 14,467.4 | 31.2 | 2026-09-15 20:57:04 |
|
||||||
|
|
||||||
|
```text
|
||||||
|
累计差 = 14,467.4 - 14,366.0 = 101.4
|
||||||
|
差额 = 101.4 - 31.2 = 70.2 km
|
||||||
|
```
|
||||||
|
|
||||||
|
查询 9 月 14 日时,累计读数仍来自 9 月 13 日。请核查 9 月 15 日日里程的起点读数及其时间,并解释 70.2 km 在日统计中的归属或过滤原因。
|
||||||
|
|
||||||
|
### 4.2 粤AGR6839:9 月 15 日差额 +38.0 km
|
||||||
|
|
||||||
|
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) |
|
||||||
|
| --- | --- | ---: | ---: | --- |
|
||||||
|
| 2026-09-14 | GB32960 | 45,122.2 | 0 | 2026-09-11 11:10:53 |
|
||||||
|
| 2026-09-15 | GB32960 | 45,162.8 | 2.6 | 2026-09-15 10:34:02 |
|
||||||
|
|
||||||
|
累计差为 40.6 km,日里程为 2.6 km,差额为 38.0 km。请核查跨多日无新读数后的统计基准及补算策略。
|
||||||
|
|
||||||
|
### 4.3 粤AGP3609:9 月 14 日差额 +808.7 km
|
||||||
|
|
||||||
|
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) |
|
||||||
|
| --- | --- | ---: | ---: | --- |
|
||||||
|
| 2026-09-12 | GB32960 | 48,102.9 | 1,293.6 | 2026-09-12 15:08:47 |
|
||||||
|
| 2026-09-13 | GB32960 | 48,102.9 | 0 | 2026-09-12 15:08:47 |
|
||||||
|
| 2026-09-14 | GB32960 | 48,981.7 | 70.1 | 2026-09-14 23:59:51 |
|
||||||
|
| 2026-09-15 | GB32960 | 50,349.1 | 1,367.4 | 2026-09-15 23:59:50 |
|
||||||
|
|
||||||
|
9 月 14 日累计差为 878.8 km,日里程为 70.1 km,差额为 808.7 km;9 月 15 日累计差与日里程则同为 1,367.4 km。该车适合对比正常日期与跨日缺报日期的计算路径。
|
||||||
|
|
||||||
|
## 5. 异常样本二:协议切换导致累计值倒退
|
||||||
|
|
||||||
|
车辆:沪A60591F。以下记录也已直接请求上游复核,均返回 `NORMAL`。
|
||||||
|
|
||||||
|
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) |
|
||||||
|
| --- | --- | ---: | ---: | --- |
|
||||||
|
| 2026-09-12 | MQTT | 51,416 | 0 | 2026-09-12 04:53:49 |
|
||||||
|
| 2026-09-13 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 |
|
||||||
|
| 2026-09-14 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 |
|
||||||
|
| 2026-09-15 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 |
|
||||||
|
|
||||||
|
```text
|
||||||
|
9 月 13 日累计差 = 42,075 - 51,416 = -9,341 km
|
||||||
|
9 月 13 日日里程 = 0 km
|
||||||
|
```
|
||||||
|
|
||||||
|
该车单独贡献 −9,341 km,叠加其他同协议车辆 +674.653 km 的差额后,形成当日全量净差额 −8,666.347 km。
|
||||||
|
|
||||||
|
请重点核查:
|
||||||
|
|
||||||
|
1. 同一请求优先级下,9 月 12 日采用 MQTT,9 月 13 日为何改用 7 月 16 日的 GB32960 读数?
|
||||||
|
2. 协议“有效数据”的定义是否包含数据时间、新鲜度和查询日期边界?
|
||||||
|
3. `NORMAL` 是否仅表示读数数值有效,而不表示查询当日存在有效报文?
|
||||||
|
4. 两协议累计读数是否具有同一基准?切换时是否应提供不连续标识?
|
||||||
|
5. 请核查该车协议记录对应的 VIN、设备及绑定历史,排除历史绑定变化造成的读数混用。
|
||||||
|
|
||||||
|
## 6. 上游直接复现方法
|
||||||
|
|
||||||
|
接口:`POST https://open.d.lnoneos.com/api/v1/vehicles/mileage/query`。
|
||||||
|
|
||||||
|
使用已授权的 API Key,通过环境变量注入,不将真实凭证写入报告或日志。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --fail-with-body --silent --show-error \
|
||||||
|
'https://open.d.lnoneos.com/api/v1/vehicles/mileage/query' \
|
||||||
|
-H "Authorization: Bearer ${ONEOS_MILEAGE_API_KEY}" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data '{
|
||||||
|
"date": "2026-09-15",
|
||||||
|
"plateNumbers": ["粤AGQ8377", "粤AGR6839", "粤AGP3609", "沪A60591F"],
|
||||||
|
"protocolPriority": ["GB32960", "MQTT"]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
依次将 `date` 替换为 `2026-09-12`、`2026-09-13`、`2026-09-14`、`2026-09-15`。
|
||||||
|
|
||||||
|
逐车对比字段:`date`、`plateNumber`、`vin`、`status`、`dailyMileageKm`、`totalMileageKm`、`sourceProtocol`、`dataTime`、`calculatedAt`、`updatedAt`。
|
||||||
|
|
||||||
|
本次直接复核保留了报告中的关键字段,未记录响应 `traceId`;上游重放时请保存完整响应及 `traceId`。历史数据如已重算或补报,当前返回值可能与本报告不同,请同时提供重算时间和修改前后结果。
|
||||||
|
|
||||||
|
## 7. 请上游提供的定位结果
|
||||||
|
|
||||||
|
### 7.1 明确字段契约
|
||||||
|
|
||||||
|
- `dailyMileageKm`:是当天首尾读数差、前日末值到当日末值的差,还是逐报文有效增量之和?是否丢弃跨日增量、异常跳变或负增量?
|
||||||
|
- `totalMileageKm`:是查询日最后读数、截至查询日最近读数,还是其他快照?无当日报文时是否延用历史值?
|
||||||
|
- `dataTime`:具体对应累计读数、日里程终点还是其他时间?
|
||||||
|
- 两个里程字段是否保证来自同一协议、同一设备、同一计算快照?
|
||||||
|
- 自然日边界是否统一为 Asia/Shanghai 的 00:00:00 至次日 00:00:00?
|
||||||
|
- 是否承诺相邻日累计差等于日里程;若不承诺,请明确哪些场景不成立及差额如何解释。
|
||||||
|
|
||||||
|
### 7.2 提供样本计算明细
|
||||||
|
|
||||||
|
对上述四辆车,请返回:
|
||||||
|
|
||||||
|
1. 各协议参与选源的候选记录、时间及未选中原因。
|
||||||
|
2. 日里程计算起止报文的时间、累计读数、设备/VIN 标识。
|
||||||
|
3. 有效增量、被过滤增量及过滤原因。
|
||||||
|
4. 缺报、补报、跨日增量归属,以及历史重算记录。
|
||||||
|
5. 对每个差额给出可核对的数值分解,不能只解释为“统计口径不同”。
|
||||||
|
|
||||||
|
## 8. 修复或口径确认后的验收建议
|
||||||
|
|
||||||
|
1. 使用同一车辆集合和协议配置,重放 9 月 11 至 15 日数据,逐车核对后再核对汇总。
|
||||||
|
2. 对连续上报且同协议的正常车辆,若契约约定可对账,应满足累计差与日里程一致;容差按上游实际数值精度明确。
|
||||||
|
3. 对跨日缺报车辆,明确跨日增量是否补算、归属哪天,并能解释全部差额。
|
||||||
|
4. 对协议切换车辆,避免将不同基准的累计值视为连续可减的序列;提供明确的协议切换和不连续信息。
|
||||||
|
5. 对陈旧读数,区分“历史累计值仍有效”和“当日有有效报文”,避免用单一 `NORMAL` 混淆两者。
|
||||||
|
6. 验证单日接口与区间接口对同一车、同一天的日里程、累计值和选源结果一致。
|
||||||
|
7. 如修复涉及历史重算,提供重算范围及完成时间;BI 历史接口缓存最长 6 小时,应在刷新后复核。
|
||||||
|
|
||||||
|
## 9. 本次排查边界
|
||||||
|
|
||||||
|
- 已检查 BI 里程映射、日/区间聚合和汇总逻辑,并对异常样本直接调用上游单日接口复核。
|
||||||
|
- 尚未访问 OneOS 原始报文、计算服务源码或内部存储,不能确定其内部算法根因。
|
||||||
|
- 本次未修改业务代码、未变更里程口径、未触发历史数据重算。
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# OneOS 车辆里程数据源协议改造清单
|
||||||
|
|
||||||
|
## 0. 发布后复测结论
|
||||||
|
|
||||||
|
复测日期:2026-07-23
|
||||||
|
|
||||||
|
开放平台发布后,BI 已重新连接 `https://open.d.lnoneos.com` 并完成复测:
|
||||||
|
|
||||||
|
- 单日接口和区间接口均接受 `protocolPriority`,不再返回
|
||||||
|
`INVALID_REQUEST`;
|
||||||
|
- 成功响应已返回实际使用的 `sourceProtocol`;
|
||||||
|
- 仪表优先、GPS 优先、仅仪表、仅 GPS 四种模式均返回 HTTP 200;
|
||||||
|
- “仅仪表数据”结果未出现 `JT808`;
|
||||||
|
- “仅 GPS 数据”结果未出现 `GB32960` 或 `MQTT`;
|
||||||
|
- 同一车辆四天区间查询能够随优先级在 `GB32960` 和 `JT808`
|
||||||
|
之间正确切换;
|
||||||
|
- BI 页面已显示真实“仪表数据”或“GPS数据”,不再显示“来源待接口”。
|
||||||
|
|
||||||
|
复测时的单日协议分布如下,数据量会随当天车辆上报变化:
|
||||||
|
|
||||||
|
| BI 模式 | GB32960 | MQTT | JT808 | 无数据 | 禁用协议混入 |
|
||||||
|
| --- | ---: | ---: | ---: | ---: | --- |
|
||||||
|
| 仪表数据优先 | 387 | 49 | 134 | 454 | 无 |
|
||||||
|
| GPS 数据优先 | 69 | 30 | 471 | 454 | 无 |
|
||||||
|
| 仅仪表数据 | 387 | 49 | 0 | 588 | 无 |
|
||||||
|
| 仅 GPS 数据 | 0 | 0 | 471 | 553 | 无 |
|
||||||
|
|
||||||
|
结论:本清单中的协议选源阻塞项已关闭,当前接口满足 BI 对接要求。
|
||||||
|
以下内容保留为接口契约和后续回归验收依据。
|
||||||
|
|
||||||
|
## 1. 历史联调结论(发布前)
|
||||||
|
|
||||||
|
联调地址:`https://open.d.lnoneos.com`
|
||||||
|
|
||||||
|
涉及接口:
|
||||||
|
|
||||||
|
- `POST /api/v1/vehicles/mileage/query`
|
||||||
|
- `POST /api/v1/vehicles/mileage/range/query`
|
||||||
|
|
||||||
|
发布前接口不满足 BI 对车辆里程数据源切换和来源标注的要求,阻塞项如下:
|
||||||
|
|
||||||
|
| 序号 | 当前表现 | 期望表现 | 影响 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | 请求增加 `protocolPriority` 后返回 HTTP 400,业务码为 `INVALID_REQUEST` | 两个里程接口均接受可选字段 `protocolPriority` | BI 无法切换仪表数据和 GPS 数据的优先级,也无法单独禁用某类数据 |
|
||||||
|
| 2 | 成功响应中没有 `sourceProtocol` | 每辆车、每天的结果返回实际采用的协议 | BI 无法准确标注“仪表数据”或“GPS数据” |
|
||||||
|
| 3 | 无法验证接口是否排除了已禁用协议 | 未列入 `protocolPriority` 的协议不得参与选源 | “仅仪表数据”和“仅 GPS 数据”无法生效 |
|
||||||
|
| 4 | 无法验证仪表数据内部的降级顺序 | 仪表数据固定按 `GB32960 > MQTT` 选源 | 32960 缺失时无法确认是否正确降级到 MQTT |
|
||||||
|
|
||||||
|
BI 保留旧请求格式的兼容回退,用于开放平台异常或版本回滚时保证现有里程查询可用。回退结果不会伪造数据来源,页面会显示“来源待接口”。正常情况下新接口已不触发该回退。
|
||||||
|
|
||||||
|
## 2. OneOS 必须修改的请求字段
|
||||||
|
|
||||||
|
两个接口都需要增加以下可选字段:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"protocolPriority": ["GB32960", "MQTT", "JT808"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段规则:
|
||||||
|
|
||||||
|
1. 类型为非空字符串数组。
|
||||||
|
2. 仅允许 `GB32960`、`MQTT`、`JT808`,协议名区分以此处定义为准。
|
||||||
|
3. 数组顺序表示逐车、逐自然日的数据源优先级。
|
||||||
|
4. 未出现在数组中的协议视为禁用,不得参与选源或作为兜底数据返回。
|
||||||
|
5. `protocolPriority` 省略时保持现有接口的默认行为,确保旧调用方兼容。
|
||||||
|
6. 非法枚举、空数组或重复协议应返回 HTTP 400,并在响应中给出明确的错误信息和 `traceId`。
|
||||||
|
|
||||||
|
BI 只会发送以下四种组合:
|
||||||
|
|
||||||
|
| BI 模式 | `protocolPriority` | 选源规则 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 仪表数据优先 | `["GB32960", "MQTT", "JT808"]` | 先查 32960,再查 MQTT,最后查 JT808 |
|
||||||
|
| GPS 数据优先 | `["JT808", "GB32960", "MQTT"]` | 先查 JT808,再查 32960,最后查 MQTT |
|
||||||
|
| 仅仪表数据 | `["GB32960", "MQTT"]` | 只允许 32960 和 MQTT,禁止使用 JT808 |
|
||||||
|
| 仅 GPS 数据 | `["JT808"]` | 只允许 JT808,禁止使用 32960 和 MQTT |
|
||||||
|
|
||||||
|
BI 页面默认只启用“仪表数据”,即默认使用
|
||||||
|
`["GB32960", "MQTT"]`;GPS 数据需要用户主动启用。
|
||||||
|
|
||||||
|
仪表数据内部优先级固定为 `GB32960 > MQTT`,BI 不会发送 `MQTT > GB32960`。
|
||||||
|
|
||||||
|
## 3. OneOS 必须增加的响应字段
|
||||||
|
|
||||||
|
两个接口的每条车辆日里程结果都需要增加:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sourceProtocol": "GB32960"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段规则:
|
||||||
|
|
||||||
|
1. 有有效里程数据时,只能返回 `GB32960`、`MQTT` 或 `JT808`。
|
||||||
|
2. 返回值必须是本条结果实际采用的协议,不能返回车辆支持的协议列表,也不能返回请求中的第一项作为固定值。
|
||||||
|
3. 无有效数据、`status="NO_DATA"` 时返回 `null`。
|
||||||
|
4. `sourceProtocol` 对应关系:
|
||||||
|
- `GB32960`、`MQTT`:BI 显示为“仪表数据”;
|
||||||
|
- `JT808`:BI 显示为“GPS数据”。
|
||||||
|
5. 如果 `sourceProtocol` 不在本次请求的 `protocolPriority` 中,应视为接口错误。
|
||||||
|
|
||||||
|
响应示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"message": "success",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"vin": "LMRK...",
|
||||||
|
"plateNumber": "粤A00001F",
|
||||||
|
"date": "2026-07-23",
|
||||||
|
"dailyMileageKm": 182.437,
|
||||||
|
"totalMileageKm": 12345.678,
|
||||||
|
"sourceProtocol": "GB32960",
|
||||||
|
"dataTime": "2026-07-23T10:35:42+08:00",
|
||||||
|
"updatedAt": "2026-07-23T10:35:46+08:00",
|
||||||
|
"status": "NORMAL"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"traceId": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 逐车逐日选源规则
|
||||||
|
|
||||||
|
OneOS 需要针对每辆车、每个自然日独立执行以下逻辑:
|
||||||
|
|
||||||
|
1. 按 `protocolPriority` 从前到后检查数据源。
|
||||||
|
2. 找到第一份有效数据后立即使用,不再混用低优先级协议的数据。
|
||||||
|
3. 有效数据必须满足现有里程质量规则,包括里程非空、非负且数据状态有效。
|
||||||
|
4. 高优先级协议无有效数据时,才能降级到下一个已启用协议。
|
||||||
|
5. 所有已启用协议均无有效数据时,返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dailyMileageKm": null,
|
||||||
|
"sourceProtocol": null,
|
||||||
|
"status": "NO_DATA"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
6. 真实零里程必须返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dailyMileageKm": 0,
|
||||||
|
"sourceProtocol": "实际采用的协议",
|
||||||
|
"status": "NORMAL"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
区间查询必须逐日选源,不能先按一个协议汇总整个区间后再切换协议。
|
||||||
|
|
||||||
|
## 5. 验收用例
|
||||||
|
|
||||||
|
OneOS 修改完成后,至少提供一辆在同一天同时存在 32960、MQTT、JT808 数据的测试车辆,以及一辆只有 MQTT 数据的测试车辆。
|
||||||
|
|
||||||
|
| 用例 | 请求优先级 | 必须满足的结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 仪表优先 | `GB32960, MQTT, JT808` | 同时有三种数据时采用 GB32960,`sourceProtocol="GB32960"` |
|
||||||
|
| GPS 优先 | `JT808, GB32960, MQTT` | 同时有三种数据时采用 JT808,`sourceProtocol="JT808"` |
|
||||||
|
| 仅仪表 | `GB32960, MQTT` | 不得返回 JT808;32960 缺失时允许降级到 MQTT |
|
||||||
|
| 仅 GPS | `JT808` | 不得返回 GB32960 或 MQTT |
|
||||||
|
| 32960 降级 | `GB32960, MQTT` | 只有 MQTT 有效时返回 MQTT,`sourceProtocol="MQTT"` |
|
||||||
|
| 禁用验证 | `JT808` | 即使仪表数据存在,也不能回退到仪表数据 |
|
||||||
|
| 无数据 | 任一合法组合 | 返回 `dailyMileageKm=null`、`sourceProtocol=null`、`status="NO_DATA"` |
|
||||||
|
| 真实零里程 | 任一合法组合 | 返回 `dailyMileageKm=0`、实际 `sourceProtocol`、`status="NORMAL"` |
|
||||||
|
| 单日接口 | 四种组合分别测试 | 请求成功且选源、来源字段符合规则 |
|
||||||
|
| 区间接口 | 四种组合分别测试 | 每辆车每天独立选源,分页无重复、无漏行 |
|
||||||
|
| 兼容性 | 不传 `protocolPriority` | 原有调用方式和响应语义保持不变 |
|
||||||
|
|
||||||
|
## 6. 交付和联调要求
|
||||||
|
|
||||||
|
1. 公网 `https://open.d.lnoneos.com` 和 ECS 内网地址同时发布相同能力。
|
||||||
|
2. 发布后提供接口版本或发布日期、测试车辆、测试日期及对应协议数据情况。
|
||||||
|
3. 提供四种合法组合的请求和响应样例。
|
||||||
|
4. 提供一组禁用协议的验证结果,证明未启用协议不会被回退使用。
|
||||||
|
5. 每个成功和失败响应均返回 `traceId`,便于双方定位问题。
|
||||||
|
|
||||||
|
完成标准:两个接口均支持 `protocolPriority`,每条结果准确返回 `sourceProtocol`,四种 BI 模式全部通过上述验收用例。
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# LN-BI 渐进式重构方案
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
本轮只调整代码结构,不改变业务功能。以下内容视为冻结契约:
|
||||||
|
|
||||||
|
- 页面文案、布局、交互和 URL 行为
|
||||||
|
- API 路径、请求参数、状态码和响应字段
|
||||||
|
- SQL、数据源优先级、权限与脱敏顺序
|
||||||
|
- 里程、资产、能源和调度的统计口径
|
||||||
|
- 缓存刷新频率与日报归档时间
|
||||||
|
|
||||||
|
## 当前结构
|
||||||
|
|
||||||
|
项目已经按业务模块组织,但部分文件仍承担了过多职责:
|
||||||
|
|
||||||
|
| 热点 | 规模 | 主要职责混合 |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| `modules/assets/AssetsModule.tsx` | 3240 行 | 请求、53 组状态、筛选、图表、弹窗、导出和四个视图 |
|
||||||
|
| `modules/mileage/MonitoringView.tsx` | 1611 行 | 查询参数、44 组状态、列表、全屏、导出和统计 |
|
||||||
|
| `server/routes/vehicles.ts` | 1426 行 | SQL、缓存、权限、领域映射、聚合和 15 个接口 |
|
||||||
|
| `modules/mileage/DailyReportView.tsx` | 1192 行 | 日期、领域计算、图表、表格和页面编排 |
|
||||||
|
|
||||||
|
目标结构遵循同一方向:
|
||||||
|
|
||||||
|
```text
|
||||||
|
route / page
|
||||||
|
-> controller / hook # 流程与状态
|
||||||
|
-> service / model # 业务规则与纯计算
|
||||||
|
-> repository / api # 数据访问
|
||||||
|
```
|
||||||
|
|
||||||
|
React 组件负责展示和交互编排;纯函数负责排序、筛选、分组和口径;服务端路由只解析请求并组织响应;数据库查询集中在 repository 层。
|
||||||
|
|
||||||
|
## 实施顺序
|
||||||
|
|
||||||
|
1. **建立回归门禁**:类型检查、领域测试和生产构建必须同时通过。
|
||||||
|
2. **提取纯模型**:先移动无副作用的日期、映射、筛选、排序和聚合逻辑,并补特征测试。
|
||||||
|
3. **拆分前端组件**:日期选择器、表格、展开详情等独立,页面保留状态和组合。
|
||||||
|
4. **拆分后端边界**:服务器应用创建与进程启动分离;大型路由按 model、service、repository、routes 拆分。
|
||||||
|
5. **清理重复与废弃代码**:只有在引用和行为测试都证明无影响后执行。
|
||||||
|
|
||||||
|
每一批只处理一个边界,不把现存 Bug 修复、新功能或视觉调整混入重构提交。
|
||||||
|
|
||||||
|
## 模块迭代路线
|
||||||
|
|
||||||
|
| 批次 | 模块 | 主要边界 | 风险控制 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 第一轮 | 应用入口、服务启动、日报、资产模型、监控查询、车辆模型 | 路由装配、启动副作用、纯计算 | 特征测试锁定路由、日期、口径和请求参数 |
|
||||||
|
| 第二轮 | 资产页、里程监控、车辆接口、统计报表 | 展示组件、数据访问、弹层和图表 | 保持 DOM、SQL、缓存覆盖和请求时机不变 |
|
||||||
|
| 第三轮 | 氢能展示、能源查询路由 | 图表展示、日期模型、数据库查询 | 固定金额/重量单位、客户范围和日期边界 |
|
||||||
|
| 第四轮 | 智能调度 | 筛选模型、建议详情、通知历史 | 写操作与只读派生分开,通知接口单独回归 |
|
||||||
|
| 第五轮 | OneOS 接入、里程缓存 | 外部 API、协议降级、缓存与合并 | 使用响应夹具验证降级顺序、TTL 和数据源优先级 |
|
||||||
|
|
||||||
|
高风险模块不会与普通页面组件放在同一批:能源路由依赖两套数据库,调度包含通知写入,OneOS 层还包含协议兼容、超时和缓存淘汰。拆分这些模块前必须先建立可重复的输入夹具。
|
||||||
|
|
||||||
|
## 当前进度
|
||||||
|
|
||||||
|
截至 2026-08-13,前五轮可独立验证的边界已经完成:
|
||||||
|
|
||||||
|
- 应用路由配置与服务器启动副作用已经从入口文件分离。
|
||||||
|
- 资产、里程监控、每日汇报、统计报表和智能调度已改为“页面协调层 + 展示组件 + 纯模型”。
|
||||||
|
- 车辆接口已改为“路由 + repository + model”,原 SQL、缓存范围和接口顺序由特征测试锁定。
|
||||||
|
- 能源接口已按氢能总览、氢能每日、电能总览和电能每日拆分,入口只保留权限守卫与装配。
|
||||||
|
- 氢能与电能每日页共用自然日范围模型和筛选控件;金额、重量、环比及 30% 波动边界已有测试。
|
||||||
|
- 反馈入口和电能导入页已收敛为“副作用协调层 + 无状态展示组件”;上传、筛选和请求时机仍由主组件负责。
|
||||||
|
- OneOS 响应归一化、连续日期分组和请求键已提取为纯模型;协议别名、去重和区间边界由固定样例锁定。
|
||||||
|
- 里程缓存中的车辆合并、筛选项、考核映射和自然日工具已与刷新流程分离;数据源分类和累计里程空值规则保持不变。
|
||||||
|
- 调度通知的数据库行映射、请求校验和更新字段生成已有契约测试;数据库写入顺序和单车有效干预约束仍留在路由层。
|
||||||
|
- 车辆与加氢热力图已把参数解析、距离计算、网格/排名和序列化提取为纯模型;原 SQL 及参数顺序由契约测试锁定。
|
||||||
|
- CI 已把类型检查、全量测试和生产构建设为同一发布门禁。
|
||||||
|
|
||||||
|
下一批继续只处理能够独立回归的边界:
|
||||||
|
|
||||||
|
1. 继续缩小 `AssetsModule.tsx` 的状态协调职责,但不改请求、筛选、导出和弹窗时序。
|
||||||
|
2. 拆分车辆详情、调度建议详情和通知历史中的纯展示区块,主组件继续持有写操作。
|
||||||
|
3. 为电能导入服务端路由补 SQL 与去重契约后,再拆解析、repository 和路由装配。
|
||||||
|
4. OneOS 网络重试、协议降级和缓存 TTL 暂不移动;只有在可注入请求层覆盖 400 降级和重试后再处理。
|
||||||
|
|
||||||
|
## 注释约定
|
||||||
|
|
||||||
|
注释解释“为什么”,不复述语句本身。优先说明:
|
||||||
|
|
||||||
|
- 统计边界和历史口径
|
||||||
|
- 时区、日期范围和特殊状态映射
|
||||||
|
- 外部 API 的兼容处理
|
||||||
|
- 看似可合并但必须保持差异的流程
|
||||||
|
|
||||||
|
命名和类型能够表达清楚的代码不额外添加注释。
|
||||||
|
|
||||||
|
## 已识别但不在本轮修复的问题
|
||||||
|
|
||||||
|
- 统计页实时车辆与历史日期车辆共用同一个缓存槽,存在状态覆盖风险。
|
||||||
|
- 监控页不同入口的请求参数目前不完全一致。
|
||||||
|
- 数据库连接配置与部署凭据需要独立安全治理。
|
||||||
|
|
||||||
|
这些问题需要单独建缺陷、明确期望行为后再修复,避免借重构改变线上结果。
|
||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<title>羚牛氢能车辆资产</title>
|
<title>羚牛氢能</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+160
-2
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "ln-bi",
|
"name": "ln-bi",
|
||||||
"version": "1.1.5",
|
"version": "1.1.14",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ln-bi",
|
"name": "ln-bi",
|
||||||
"version": "1.1.5",
|
"version": "1.1.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||||
"@hono/node-server": "^1.13.0",
|
"@hono/node-server": "^1.13.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"ali-oss": "^6.23.0",
|
"ali-oss": "^6.23.0",
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"mysql2": "^3.11.0",
|
"mysql2": "^3.11.0",
|
||||||
|
"pg": "^8.22.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
@@ -27,6 +29,7 @@
|
|||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@types/ali-oss": "^6.23.3",
|
"@types/ali-oss": "^6.23.3",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
@@ -36,6 +39,12 @@
|
|||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@amap/amap-jsapi-loader": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@amap/amap-jsapi-loader/-/amap-jsapi-loader-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-nPyLKt7Ow/ThHLkSvn2etQlUzqxmTVgK7bIgwdBRTg2HK5668oN7xVxkaiRe3YZEzGzfV2XgH5Jmu2T73ljejw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@@ -1620,6 +1629,18 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pg": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"pg-protocol": "*",
|
||||||
|
"pg-types": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
@@ -3521,6 +3542,95 @@
|
|||||||
"through": "~2.3"
|
"through": "~2.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||||
|
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.14.0",
|
||||||
|
"pg-pool": "^3.14.0",
|
||||||
|
"pg-protocol": "^1.15.0",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||||
|
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||||
|
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -3576,6 +3686,45 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/process-nextick-args": {
|
"node_modules/process-nextick-args": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
@@ -3969,6 +4118,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sql-escaper": {
|
"node_modules/sql-escaper": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
|
||||||
|
|||||||
+7
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ln-bi",
|
"name": "ln-bi",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.5",
|
"version": "1.1.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
||||||
@@ -9,9 +9,12 @@
|
|||||||
"dev:client": "vite --port=3000 --host=0.0.0.0",
|
"dev:client": "vite --port=3000 --host=0.0.0.0",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"start": "node --import tsx src/server/index.ts",
|
"start": "node --import tsx src/server/index.ts",
|
||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit",
|
||||||
|
"test": "node --import tsx --test $(find src -type f -name '*.test.ts' -print | sort)",
|
||||||
|
"test:mileage-report": "node --import tsx --test src/server/routes/mileage/daily-report-model.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||||
"@hono/node-server": "^1.13.0",
|
"@hono/node-server": "^1.13.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"ali-oss": "^6.23.0",
|
"ali-oss": "^6.23.0",
|
||||||
@@ -21,6 +24,7 @@
|
|||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"mysql2": "^3.11.0",
|
"mysql2": "^3.11.0",
|
||||||
|
"pg": "^8.22.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
@@ -31,6 +35,7 @@
|
|||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@types/ali-oss": "^6.23.3",
|
"@types/ali-oss": "^6.23.3",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { RowDataPacket } from 'mysql2';
|
||||||
|
import pool from '../src/server/db.js';
|
||||||
|
import type {
|
||||||
|
DailyMileageReport,
|
||||||
|
MileageReportTrendPoint,
|
||||||
|
} from '../src/shared/mileage/daily-report.js';
|
||||||
|
import { fetchOneOsMileageDates } from '../src/server/routes/mileage/oneos-api.js';
|
||||||
|
import { DEFAULT_ONEOS_PROTOCOL_PRIORITY } from '../src/server/routes/mileage/source-policy.js';
|
||||||
|
import { roundMileage } from '../src/server/routes/mileage/daily-report-model.js';
|
||||||
|
import { updateDailyReportSnapshotPayload } from '../src/server/routes/mileage/daily-report-store.js';
|
||||||
|
|
||||||
|
interface SnapshotRow extends RowDataPacket {
|
||||||
|
report_date: string;
|
||||||
|
source: string;
|
||||||
|
report_payload: DailyMileageReport | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportDates(reportDate: string): string[] {
|
||||||
|
const end = new Date(`${reportDate}T00:00:00Z`);
|
||||||
|
return Array.from({ length: 7 }, (_, index) => {
|
||||||
|
const date = new Date(end);
|
||||||
|
date.setUTCDate(end.getUTCDate() - (6 - index));
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseReport(value: DailyMileageReport | string): DailyMileageReport {
|
||||||
|
return typeof value === 'string' ? JSON.parse(value) as DailyMileageReport : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trendByPlate(report: DailyMileageReport): Promise<Map<string, MileageReportTrendPoint[]>> {
|
||||||
|
const dates = reportDates(report.reportDate);
|
||||||
|
const plates = report.groups.flatMap(group => group.vehicles.map(vehicle => vehicle.plate));
|
||||||
|
const rowsByDate = await fetchOneOsMileageDates(dates, plates, DEFAULT_ONEOS_PROTOCOL_PRIORITY);
|
||||||
|
const result = new Map<string, MileageReportTrendPoint[]>();
|
||||||
|
for (const plate of plates) {
|
||||||
|
result.set(plate, dates.map(date => {
|
||||||
|
const mileage = (rowsByDate.get(date) || [])
|
||||||
|
.filter(row => row.status === 'NORMAL' && row.plateNumber === plate)
|
||||||
|
.reduce((maximum, row) => Math.max(maximum, Number(row.dailyMileageKm) || 0), 0);
|
||||||
|
return { date, totalMileage: roundMileage(Math.max(0, mileage)) };
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apply = process.argv.includes('--apply');
|
||||||
|
const [rows] = await pool.query<SnapshotRow[]>(`
|
||||||
|
SELECT DATE_FORMAT(report_date, '%Y-%m-%d') AS report_date, source, report_payload
|
||||||
|
FROM lingniu_prod.tab_mileage_daily_report
|
||||||
|
WHERE source <> 'XLSX_IMPORT'
|
||||||
|
ORDER BY report_date
|
||||||
|
`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const row of rows) {
|
||||||
|
const report = parseReport(row.report_payload);
|
||||||
|
if (report.groups.every(group => group.vehicles.every(vehicle => vehicle.trend?.length === 7))) {
|
||||||
|
console.log(`${row.report_date}: already complete`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const trends = await trendByPlate(report);
|
||||||
|
const updated: DailyMileageReport = {
|
||||||
|
...report,
|
||||||
|
groups: report.groups.map(group => ({
|
||||||
|
...group,
|
||||||
|
vehicles: group.vehicles.map(vehicle => ({
|
||||||
|
...vehicle,
|
||||||
|
trend: trends.get(vehicle.plate) || [],
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
if (apply) await updateDailyReportSnapshotPayload(updated);
|
||||||
|
console.log(`${row.report_date}: ${apply ? 'updated' : 'would update'} ${trends.size} vehicles`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import XLSX from 'xlsx';
|
||||||
|
import type { WorkBook } from 'xlsx';
|
||||||
|
import type { RowDataPacket } from 'mysql2';
|
||||||
|
import pool from '../src/server/db.js';
|
||||||
|
import type {
|
||||||
|
MileageReportBreakdown,
|
||||||
|
MileageReportGroup,
|
||||||
|
MileageReportVehicle,
|
||||||
|
} from '../src/shared/mileage/daily-report.js';
|
||||||
|
import {
|
||||||
|
buildDailyMileageReport,
|
||||||
|
mileageBand,
|
||||||
|
roundMileage,
|
||||||
|
} from '../src/server/routes/mileage/daily-report-model.js';
|
||||||
|
import { saveDailyReportSnapshot } from '../src/server/routes/mileage/daily-report-store.js';
|
||||||
|
|
||||||
|
interface TargetRow extends RowDataPacket { id: number; target_name: string }
|
||||||
|
|
||||||
|
interface ImportSpec {
|
||||||
|
date: string;
|
||||||
|
file: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHEET_SPECS = [
|
||||||
|
{ sheet: '40台普货', targetMatch: '交投40', summaryRow: 2, displayName: '40台普货' },
|
||||||
|
{ sheet: '190台冷藏车', targetMatch: '交投190', summaryRow: 3, displayName: '190台冷链' },
|
||||||
|
{ sheet: '136台冷藏车', targetMatch: '羚牛136', summaryRow: 4, displayName: '136台冷链' },
|
||||||
|
{ sheet: '100台双飞翼', targetMatch: '羚牛100', summaryRow: 5, displayName: '100台18T双飞翼' },
|
||||||
|
{ sheet: '50台普货', targetMatch: '恒运', summaryRow: 6, displayName: '26台恒运普货' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const INVENTORY_REGIONS: Record<string, Record<string, Record<string, number>>> = {
|
||||||
|
'2026-03-30': {
|
||||||
|
'40台普货': { 广东: 2 },
|
||||||
|
'190台冷链': { 嘉兴: 2, 广东: 38 },
|
||||||
|
'136台冷链': { 新疆: 1, 嘉兴: 13, 广东: 31 },
|
||||||
|
'100台18T双飞翼': { 嘉兴: 9, 广东: 29 },
|
||||||
|
'26台恒运普货': { 广东: 11, 嘉兴: 13 },
|
||||||
|
},
|
||||||
|
'2026-03-31': {
|
||||||
|
'40台普货': { 广东: 2 },
|
||||||
|
'190台冷链': { 嘉兴: 2, 广东: 36 },
|
||||||
|
'136台冷链': { 新疆: 1, 嘉兴: 13, 广东: 32 },
|
||||||
|
'100台18T双飞翼': { 嘉兴: 8, 广东: 29 },
|
||||||
|
'26台恒运普货': { 广东: 11, 嘉兴: 12 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseArgs(values: string[]): ImportSpec[] {
|
||||||
|
return values.map(value => {
|
||||||
|
const split = value.indexOf(':');
|
||||||
|
if (split < 10) throw new Error(`参数格式应为 YYYY-MM-DD:/path/file.xlsx,收到 ${value}`);
|
||||||
|
const date = value.slice(0, split);
|
||||||
|
const file = value.slice(split + 1);
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !file) throw new Error(`参数不合法:${value}`);
|
||||||
|
return { date, file };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueNumber(value: unknown): number {
|
||||||
|
return Number(value) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sheetRows(workbook: WorkBook, name: string): unknown[][] {
|
||||||
|
const sheet = workbook.Sheets[name];
|
||||||
|
if (!sheet) throw new Error(`工作表不存在:${name}`);
|
||||||
|
return XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, raw: true, defval: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
function trendDate(reportDate: string, label: unknown): string {
|
||||||
|
const match = /^(\d{1,2})\.(\d{1,2})日/.exec(String(label || ''));
|
||||||
|
if (!match) throw new Error(`无法识别趋势日期:${String(label)}`);
|
||||||
|
let year = Number(reportDate.slice(0, 4));
|
||||||
|
const candidate = `${year}-${String(Number(match[1])).padStart(2, '0')}-${String(Number(match[2])).padStart(2, '0')}`;
|
||||||
|
if (candidate > reportDate) year -= 1;
|
||||||
|
return `${year}-${String(Number(match[1])).padStart(2, '0')}-${String(Number(match[2])).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function breakdown(rows: MileageReportVehicle[], key: 'department'): MileageReportBreakdown[] {
|
||||||
|
const grouped = new Map<string, MileageReportVehicle[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const name = row[key] || '未标注';
|
||||||
|
const items = grouped.get(name) || [];
|
||||||
|
items.push(row);
|
||||||
|
grouped.set(name, items);
|
||||||
|
}
|
||||||
|
return Array.from(grouped.entries()).map(([name, items]) => ({
|
||||||
|
name,
|
||||||
|
vehicleCount: items.length,
|
||||||
|
totalMileage: roundMileage(items.reduce((total, item) => total + item.dailyMileage, 0)),
|
||||||
|
averageMileage: items.length > 0
|
||||||
|
? roundMileage(items.reduce((total, item) => total + item.dailyMileage, 0) / items.length)
|
||||||
|
: 0,
|
||||||
|
})).sort((a, b) => b.vehicleCount - a.vehicleCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inventoryBreakdown(date: string, groupName: string): MileageReportBreakdown[] {
|
||||||
|
return Object.entries(INVENTORY_REGIONS[date]?.[groupName] || {}).map(([name, count]) => ({
|
||||||
|
name,
|
||||||
|
vehicleCount: count,
|
||||||
|
totalMileage: 0,
|
||||||
|
averageMileage: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatedAt(date: string): string {
|
||||||
|
const next = new Date(`${date}T00:00:00Z`);
|
||||||
|
next.setUTCDate(next.getUTCDate() + 1);
|
||||||
|
return `${next.toISOString().slice(0, 10)}T09:30:00+08:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importWorkbook(spec: ImportSpec, targets: TargetRow[]): Promise<void> {
|
||||||
|
const workbook = XLSX.readFile(spec.file, { cellDates: true });
|
||||||
|
const summary = sheetRows(workbook, '汇总');
|
||||||
|
const groups: MileageReportGroup[] = SHEET_SPECS.map(sheetSpec => {
|
||||||
|
const rows = sheetRows(workbook, sheetSpec.sheet);
|
||||||
|
const headerIndex = rows.findIndex(row => row[0] === '序号');
|
||||||
|
if (headerIndex < 0) throw new Error(`${sheetSpec.sheet} 未找到明细表头`);
|
||||||
|
const header = rows[headerIndex];
|
||||||
|
const currentDayIndex = header.findIndex(value => String(value || '').includes(`${Number(spec.date.slice(5, 7))}.${Number(spec.date.slice(8, 10))}日行驶里程`));
|
||||||
|
if (currentDayIndex < 0) throw new Error(`${sheetSpec.sheet} 未找到 ${spec.date} 日里程列`);
|
||||||
|
const trendStartIndex = currentDayIndex - 6;
|
||||||
|
const trendDates = header.slice(trendStartIndex, currentDayIndex + 1).map(label => trendDate(spec.date, label));
|
||||||
|
const detailRows = rows.slice(headerIndex + 1).filter(row => typeof row[0] === 'number');
|
||||||
|
const vehicles: MileageReportVehicle[] = detailRows.map(row => {
|
||||||
|
const rawStatus = String(row[1] || '在库');
|
||||||
|
const dailyMileage = roundMileage(valueNumber(row[currentDayIndex]));
|
||||||
|
const status = rawStatus === '租赁' || rawStatus === '自营' ? rawStatus : '在库';
|
||||||
|
return {
|
||||||
|
plate: String(row[2] || ''),
|
||||||
|
status,
|
||||||
|
customer: row[3] ? String(row[3]) : null,
|
||||||
|
department: status === '在库' || !row[4] ? null : String(row[4]),
|
||||||
|
inventoryRegion: null,
|
||||||
|
dailyMileage,
|
||||||
|
cumulativeMileage: roundMileage(valueNumber(row[13])),
|
||||||
|
completionRate: Math.round(valueNumber(row[17]) * 10000) / 100,
|
||||||
|
mileageBand: mileageBand(status, dailyMileage),
|
||||||
|
trend: trendDates.map((date, index) => ({
|
||||||
|
date,
|
||||||
|
totalMileage: roundMileage(valueNumber(row[trendStartIndex + index])),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const operating = vehicles.filter(vehicle => vehicle.mileageBand !== 'INVENTORY');
|
||||||
|
const inventory = vehicles.filter(vehicle => vehicle.mileageBand === 'INVENTORY');
|
||||||
|
const summaryRow = summary[sheetSpec.summaryRow];
|
||||||
|
const target = targets.find(item => item.target_name.includes(sheetSpec.targetMatch));
|
||||||
|
if (!target) throw new Error(`未找到考核目标:${sheetSpec.targetMatch}`);
|
||||||
|
const periods = String(summaryRow[5] || '').split(/\n+|\s{2,}/).map(value => value.trim()).filter(Boolean);
|
||||||
|
const trend = trendDates.map((date, index) => ({
|
||||||
|
date,
|
||||||
|
totalMileage: roundMileage(detailRows.reduce((total, row) => total + valueNumber(row[trendStartIndex + index]), 0)),
|
||||||
|
}));
|
||||||
|
const dailyMileage = roundMileage(vehicles.reduce((total, vehicle) => total + vehicle.dailyMileage, 0));
|
||||||
|
const yesterdayMileage = trend[trend.length - 2]?.totalMileage || 0;
|
||||||
|
const currentYearTarget = roundMileage(valueNumber(summaryRow[10]));
|
||||||
|
const currentYearCompleted = roundMileage(valueNumber(summaryRow[11]));
|
||||||
|
return {
|
||||||
|
targetId: target.id,
|
||||||
|
targetName: target.target_name,
|
||||||
|
displayName: sheetSpec.displayName,
|
||||||
|
vehicleCount: vehicles.length,
|
||||||
|
operatingCount: operating.length,
|
||||||
|
inventoryCount: inventory.length,
|
||||||
|
dailyMileage,
|
||||||
|
operatingMileage: roundMileage(operating.reduce((total, vehicle) => total + vehicle.dailyMileage, 0)),
|
||||||
|
inventoryMileage: roundMileage(inventory.reduce((total, vehicle) => total + vehicle.dailyMileage, 0)),
|
||||||
|
yesterdayMileage,
|
||||||
|
dayOverDayDelta: 0,
|
||||||
|
dayOverDayRate: null,
|
||||||
|
lowMileageCount: operating.filter(vehicle => vehicle.mileageBand === 'LOW').length,
|
||||||
|
highMileageCount: operating.filter(vehicle => vehicle.mileageBand === 'HIGH').length,
|
||||||
|
cumulativeMileage: roundMileage(valueNumber(summaryRow[3])),
|
||||||
|
totalMileageTarget: roundMileage(valueNumber(summaryRow[2])),
|
||||||
|
completionRate: Math.round(valueNumber(summaryRow[4]) * 10000) / 100,
|
||||||
|
qualifiedCount: valueNumber(summaryRow[7]),
|
||||||
|
halfQualifiedCount: valueNumber(summaryRow[8]),
|
||||||
|
currentYearTarget,
|
||||||
|
currentYearCompleted,
|
||||||
|
remainingMileage: roundMileage(valueNumber(summaryRow[12])),
|
||||||
|
dailyRequiredMileage: roundMileage(valueNumber(summaryRow[14])),
|
||||||
|
assessmentPeriods: periods,
|
||||||
|
trend,
|
||||||
|
inventoryRegions: inventoryBreakdown(spec.date, sheetSpec.displayName),
|
||||||
|
departments: breakdown(operating, 'department'),
|
||||||
|
vehicles: vehicles.sort((a, b) => b.dailyMileage - a.dailyMileage),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const qualityNotes = [
|
||||||
|
{ level: 'info' as const, message: '历史日报由已审核人工台账导入,车辆规模与当日台账保持一致。' },
|
||||||
|
];
|
||||||
|
if (spec.date === '2026-03-30') {
|
||||||
|
qualityNotes.push({ level: 'info', message: '136台冷链库存按车辆明细修正为45台;100km车辆按低里程口径统计。' });
|
||||||
|
}
|
||||||
|
qualityNotes.push({ level: 'info', message: '190台冷链累计里程采用190辆明细求和及汇总表值,忽略明细页失效的手工合计单元格。' });
|
||||||
|
|
||||||
|
const report = buildDailyMileageReport({
|
||||||
|
reportDate: spec.date,
|
||||||
|
status: 'ARCHIVED',
|
||||||
|
source: 'XLSX_IMPORT',
|
||||||
|
generatedAt: generatedAt(spec.date),
|
||||||
|
sourceUpdatedAt: null,
|
||||||
|
groups,
|
||||||
|
qualityNotes,
|
||||||
|
});
|
||||||
|
const inserted = await saveDailyReportSnapshot(report, 'XLSX_IMPORT', { replace });
|
||||||
|
console.log(`${spec.date}: ${inserted ? (replace ? 'replaced' : 'imported') : 'already exists'}; ${report.totals.vehicleCount} vehicles; ${report.totals.dailyMileage} km`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const replace = args.includes('--replace');
|
||||||
|
const specs = parseArgs(args.filter(value => value !== '--replace'));
|
||||||
|
if (specs.length === 0) throw new Error('请提供至少一个 YYYY-MM-DD:/path/file.xlsx 参数');
|
||||||
|
const [targets] = await pool.query<TargetRow[]>(
|
||||||
|
'SELECT id, target_name FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
for (const spec of specs) await importWorkbook(spec, targets);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
+69
-54
@@ -1,67 +1,64 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||||
import { Truck, Route, Activity, Zap } from 'lucide-react';
|
import { Shell } from "./components/Shell";
|
||||||
import { Shell, type ModuleConfig } from './components/Shell';
|
import AuthProvider from "./auth/AuthProvider";
|
||||||
import AssetsModule from './modules/assets/AssetsModule';
|
import { useAuth } from "./auth/useAuth";
|
||||||
import MileageModule from './modules/mileage/MileageModule';
|
import UnauthorizedPage from "./auth/UnauthorizedPage";
|
||||||
import SchedulingModule from './modules/scheduling/SchedulingModule';
|
import { canAccessEnergy } from "./shared/auth/roles";
|
||||||
import EnergyModule from './modules/energy/EnergyModule';
|
import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface";
|
||||||
import EleImportPage from './modules/ele/EleImportPage';
|
import { buildModules } from "./app/modules";
|
||||||
import FeedbackAdminPage from './modules/admin/FeedbackAdminPage';
|
import {
|
||||||
import AuthProvider from './auth/AuthProvider';
|
normalizeBrowserPath,
|
||||||
import { useAuth } from './auth/useAuth';
|
readBrowserRoute,
|
||||||
import UnauthorizedPage from './auth/UnauthorizedPage';
|
} from "./app/routing";
|
||||||
import { canAccessScheduling, canAccessEnergy } from './shared/auth/roles';
|
|
||||||
|
|
||||||
const BASE_MODULES: ModuleConfig[] = [
|
const EleImportPage = lazy(() => import("./modules/ele/EleImportPage"));
|
||||||
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage"));
|
||||||
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
normalizeBrowserPath();
|
||||||
];
|
|
||||||
|
|
||||||
const ENERGY_MODULE: ModuleConfig = {
|
|
||||||
id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule,
|
|
||||||
};
|
|
||||||
|
|
||||||
const SCHEDULING_MODULE: ModuleConfig = {
|
|
||||||
id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule,
|
|
||||||
};
|
|
||||||
|
|
||||||
function getRouteKey(): string {
|
|
||||||
if (typeof window === 'undefined') return '';
|
|
||||||
const path = window.location.pathname;
|
|
||||||
const hash = window.location.hash;
|
|
||||||
if (path === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') return 'ele/import';
|
|
||||||
if (path === '/admin/feedback' || hash === '#/admin/feedback' || hash === '#admin/feedback') return 'admin/feedback';
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthGate() {
|
function AuthGate() {
|
||||||
const { isLoading, isAuthenticated, error, user } = useAuth();
|
const { isLoading, isAuthenticated, error, user } = useAuth();
|
||||||
const [routeKey, setRouteKey] = useState(getRouteKey);
|
const [route, setRoute] = useState(readBrowserRoute);
|
||||||
|
const { routeKey, pathSet } = route;
|
||||||
|
|
||||||
// 监听 hashchange / popstate,让 a href="#/..." 跳转能即时生效
|
// 监听 hashchange / popstate,让 a href="#/..." 跳转与浏览器前进后退能即时生效
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const update = () => setRouteKey(getRouteKey());
|
const update = () => {
|
||||||
window.addEventListener('hashchange', update);
|
setRoute(readBrowserRoute());
|
||||||
window.addEventListener('popstate', update);
|
};
|
||||||
|
window.addEventListener("hashchange", update);
|
||||||
|
window.addEventListener("popstate", update);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('hashchange', update);
|
window.removeEventListener("hashchange", update);
|
||||||
window.removeEventListener('popstate', update);
|
window.removeEventListener("popstate", update);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const modules = useMemo(() => {
|
useEffect(() => {
|
||||||
const result = [...BASE_MODULES];
|
document.title = pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI";
|
||||||
if (canAccessEnergy(user?.roles)) result.push(ENERGY_MODULE);
|
}, [pathSet]);
|
||||||
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
|
|
||||||
return result;
|
const modules = useMemo(
|
||||||
}, [user?.roles]);
|
() => buildModules(pathSet, user?.roles),
|
||||||
|
[pathSet, user?.roles],
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
|
<div className="min-h-screen bg-[var(--app-bg)] p-4 text-slate-800 md:p-8">
|
||||||
<div className="text-center">
|
<div className="mx-auto flex max-w-5xl flex-col gap-4">
|
||||||
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
|
<div className="rounded-2xl border border-white/70 bg-white/86 p-5 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
|
||||||
<p className="text-xs text-slate-400 font-bold">正在验证身份...</p>
|
<div className="mb-3 text-[11px] font-black text-blue-600">LN BI ACCESS</div>
|
||||||
|
<div className="text-xl font-black text-slate-950">正在进入羚牛氢能 BI</div>
|
||||||
|
<div className="mt-2 text-xs font-bold text-slate-400">校验登录态、权限与本地开发环境配置</div>
|
||||||
|
</div>
|
||||||
|
<SurfaceCard className="p-4">
|
||||||
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
|
{[0, 1, 2, 3].map(item => <SkeletonBlock key={item} className="h-24" />)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<LoadingState label="正在验证身份" />
|
||||||
|
</div>
|
||||||
|
</SurfaceCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -72,10 +69,28 @@ function AuthGate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
|
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
|
||||||
if (routeKey === 'ele/import') return <EleImportPage />;
|
if (routeKey === "ele/import") {
|
||||||
if (routeKey === 'admin/feedback') return <FeedbackAdminPage />;
|
return (
|
||||||
|
<Suspense fallback={<LoadingState label="正在加载导入工作台" />}>
|
||||||
|
<EleImportPage />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (routeKey === "admin/feedback") {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingState label="正在加载反馈后台" />}>
|
||||||
|
<FeedbackAdminPage />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return <Shell modules={modules} />;
|
// /energy 整组按能源权限控制
|
||||||
|
if (pathSet === "energy" && !canAccessEnergy(user?.roles)) {
|
||||||
|
return <UnauthorizedPage message="无能源管理模块访问权限" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// key={pathSet} 让两套底栏切换时 Shell 内部 state 重置,避免残留旧 activeModule
|
||||||
|
return <Shell key={pathSet} modules={modules} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { buildModules } from './modules.js';
|
||||||
|
|
||||||
|
function moduleIds(roles?: string[]): string[] {
|
||||||
|
return buildModules('asset', roles).map(module => module.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function heatmapChildIds(roles?: string[]): string[] {
|
||||||
|
const heatmap = buildModules('asset', roles).find(module => module.id === 'heatmap');
|
||||||
|
return heatmap?.children?.map(module => module.id) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
test('keeps the base asset navigation and role-gated scheduling module', () => {
|
||||||
|
assert.deepEqual(moduleIds(), ['assets', 'mileage', 'heatmap']);
|
||||||
|
assert.deepEqual(moduleIds(['BI-SCHEDULE-OPT']), [
|
||||||
|
'assets',
|
||||||
|
'mileage',
|
||||||
|
'heatmap',
|
||||||
|
'scheduling',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps energy heatmap visibility and the independent energy navigation', () => {
|
||||||
|
assert.deepEqual(heatmapChildIds(), ['vehicle-heatmap']);
|
||||||
|
assert.deepEqual(heatmapChildIds(['BI-LEADER-ENERGY']), [
|
||||||
|
'hydrogen-heatmap',
|
||||||
|
'vehicle-heatmap',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(heatmapChildIds(['所有权限']), [
|
||||||
|
'hydrogen-heatmap',
|
||||||
|
'vehicle-heatmap',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
buildModules('energy', []).map(module => module.id),
|
||||||
|
['hydrogen', 'electric', 'etc'],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { lazy } from 'react';
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
BatteryCharging,
|
||||||
|
Fuel,
|
||||||
|
MapPinned,
|
||||||
|
Receipt,
|
||||||
|
Route,
|
||||||
|
Truck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { ModuleConfig } from '../components/Shell';
|
||||||
|
import { canAccessEnergy, canAccessScheduling } from '../shared/auth/roles';
|
||||||
|
import type { PathSet } from './routing';
|
||||||
|
|
||||||
|
const AssetsModule = lazy(() => import('../modules/assets/AssetsModule'));
|
||||||
|
const MileageModule = lazy(() => import('../modules/mileage/MileageModule'));
|
||||||
|
const SchedulingModule = lazy(() => import('../modules/scheduling/SchedulingModule'));
|
||||||
|
const HydrogenModule = lazy(() => import('../modules/energy/HydrogenModule'));
|
||||||
|
const ElectricModule = lazy(() => import('../modules/energy/ElectricModule'));
|
||||||
|
const EtcModule = lazy(() => import('../modules/energy/EtcModule'));
|
||||||
|
const VehicleHeatmapModule = lazy(() => import('../modules/vehicle-heatmap/VehicleHeatmapModule'));
|
||||||
|
const HydrogenHeatmapModule = lazy(() => import('../modules/hydrogen-heatmap/HydrogenHeatmapModule'));
|
||||||
|
|
||||||
|
const ASSET_MODULES: ModuleConfig[] = [
|
||||||
|
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
||||||
|
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
||||||
|
];
|
||||||
|
|
||||||
|
const VEHICLE_HEATMAP_MODULE: ModuleConfig = {
|
||||||
|
id: 'vehicle-heatmap',
|
||||||
|
label: '车辆',
|
||||||
|
icon: MapPinned,
|
||||||
|
component: VehicleHeatmapModule,
|
||||||
|
};
|
||||||
|
|
||||||
|
const HYDROGEN_HEATMAP_MODULE: ModuleConfig = {
|
||||||
|
id: 'hydrogen-heatmap',
|
||||||
|
label: '加氢',
|
||||||
|
icon: MapPinned,
|
||||||
|
component: HydrogenHeatmapModule,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCHEDULING_MODULE: ModuleConfig = {
|
||||||
|
id: 'scheduling',
|
||||||
|
label: '智能调度',
|
||||||
|
icon: Activity,
|
||||||
|
component: SchedulingModule,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENERGY_MODULES: ModuleConfig[] = [
|
||||||
|
{ id: 'hydrogen', label: '氢能', icon: Fuel, component: HydrogenModule },
|
||||||
|
{ id: 'electric', label: '电能', icon: BatteryCharging, component: ElectricModule },
|
||||||
|
{ id: 'etc', label: 'ETC', icon: Receipt, component: EtcModule },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 根据主路径和角色生成导航;返回新数组,避免调用方修改静态注册表。 */
|
||||||
|
export function buildModules(
|
||||||
|
pathSet: PathSet,
|
||||||
|
roles: readonly string[] | null | undefined,
|
||||||
|
): ModuleConfig[] {
|
||||||
|
if (pathSet === 'energy') return [...ENERGY_MODULES];
|
||||||
|
|
||||||
|
const heatmapChildren = canAccessEnergy(roles)
|
||||||
|
? [HYDROGEN_HEATMAP_MODULE, VEHICLE_HEATMAP_MODULE]
|
||||||
|
: [VEHICLE_HEATMAP_MODULE];
|
||||||
|
const modules: ModuleConfig[] = [
|
||||||
|
...ASSET_MODULES,
|
||||||
|
{ id: 'heatmap', label: '热力图', icon: MapPinned, children: heatmapChildren },
|
||||||
|
];
|
||||||
|
if (canAccessScheduling(roles)) modules.push(SCHEDULING_MODULE);
|
||||||
|
return modules;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { getPathSet, getRouteKey, resolveCanonicalUrl } from './routing.js';
|
||||||
|
|
||||||
|
test('normalizes legacy asset paths without losing search parameters', () => {
|
||||||
|
assert.equal(resolveCanonicalUrl({ pathname: '/mileage', search: '?date=2026-08-12', hash: '' }), '/asset?date=2026-08-12#mileage');
|
||||||
|
assert.equal(resolveCanonicalUrl({ pathname: '/unknown', search: '?x=1', hash: '#mileage' }), '/asset?x=1#mileage');
|
||||||
|
});
|
||||||
|
test('keeps canonical and hidden administration paths unchanged', () => {
|
||||||
|
assert.equal(resolveCanonicalUrl({ pathname: '/asset', search: '', hash: '#assets' }), null);
|
||||||
|
assert.equal(resolveCanonicalUrl({ pathname: '/admin/feedback', search: '', hash: '' }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('derives path groups and hidden routes from path or hash', () => {
|
||||||
|
assert.equal(getPathSet('/energy'), 'energy');
|
||||||
|
assert.equal(getPathSet('/asset'), 'asset');
|
||||||
|
assert.equal(getRouteKey('/asset', '#/ele/import'), 'ele/import');
|
||||||
|
assert.equal(getRouteKey('/admin/feedback', ''), 'admin/feedback');
|
||||||
|
assert.equal(getRouteKey('/asset', '#mileage'), '');
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
export type PathSet = 'asset' | 'energy';
|
||||||
|
|
||||||
|
interface BrowserLocation {
|
||||||
|
pathname: string;
|
||||||
|
search: string;
|
||||||
|
hash: string;
|
||||||
|
}
|
||||||
|
const ROOT_PATHS = new Set(['/asset', '/energy', '/ele/import', '/admin/feedback']);
|
||||||
|
|
||||||
|
const LEGACY_PATHS: Record<string, { path: string; hash?: string }> = {
|
||||||
|
'/': { path: '/asset' },
|
||||||
|
'/vehicle': { path: '/asset', hash: 'assets' },
|
||||||
|
'/assets': { path: '/asset', hash: 'assets' },
|
||||||
|
'/mileage': { path: '/asset', hash: 'mileage' },
|
||||||
|
'/scheduling': { path: '/asset', hash: 'scheduling' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 返回旧地址应替换成的规范地址;主路径返回 null。 */
|
||||||
|
export function resolveCanonicalUrl(location: BrowserLocation): string | null {
|
||||||
|
if (ROOT_PATHS.has(location.pathname)) return null;
|
||||||
|
|
||||||
|
const target = LEGACY_PATHS[location.pathname] ?? { path: '/asset' };
|
||||||
|
const finalHash = target.hash ? `#${target.hash}` : location.hash;
|
||||||
|
return `${target.path}${location.search}${finalHash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBrowserPath(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const target = resolveCanonicalUrl(window.location);
|
||||||
|
if (target) window.history.replaceState(null, '', target);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPathSet(pathname: string): PathSet {
|
||||||
|
return pathname === '/energy' ? 'energy' : 'asset';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRouteKey(pathname: string, hash: string): string {
|
||||||
|
if (pathname === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') {
|
||||||
|
return 'ele/import';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
pathname === '/admin/feedback'
|
||||||
|
|| hash === '#/admin/feedback'
|
||||||
|
|| hash === '#admin/feedback'
|
||||||
|
) {
|
||||||
|
return 'admin/feedback';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readBrowserRoute(): { routeKey: string; pathSet: PathSet } {
|
||||||
|
return {
|
||||||
|
routeKey: getRouteKey(window.location.pathname, window.location.hash),
|
||||||
|
pathSet: getPathSet(window.location.pathname),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,19 +1,24 @@
|
|||||||
import { ShieldX, Monitor, Smartphone } from 'lucide-react';
|
import { ShieldX, Monitor, Smartphone } from 'lucide-react';
|
||||||
|
import { PageFrame, SurfaceCard } from '../components/ui/surface';
|
||||||
|
|
||||||
export default function UnauthorizedPage({ message }: { message?: string }) {
|
export default function UnauthorizedPage({ message }: { message?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center p-6">
|
<PageFrame
|
||||||
<div className="text-center max-w-sm">
|
title="未授权访问"
|
||||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-slate-100 flex items-center justify-center">
|
subtitle={message || '获取用户认证信息失败,可能是跳转令牌已过期或无效。'}
|
||||||
<ShieldX size={36} className="text-slate-400" />
|
icon={ShieldX}
|
||||||
|
eyebrow="ACCESS CONTROL"
|
||||||
|
meta="请从授权入口重新进入"
|
||||||
|
maxWidth="max-w-xl"
|
||||||
|
>
|
||||||
|
<SurfaceCard className="p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
|
||||||
|
<ShieldX size={30} />
|
||||||
|
</div>
|
||||||
|
<p className="mb-4 text-[10px] font-bold uppercase tracking-wider text-slate-400">请通过以下方式进入</p>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-lg font-black text-slate-800 mb-2">未授权访问</h1>
|
<div className="space-y-3">
|
||||||
<p className="text-xs text-slate-400 mb-4">
|
|
||||||
{message || '获取用户认证信息失败,可能是跳转令牌已过期或无效'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 p-4 text-left space-y-3">
|
|
||||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider text-center">请通过以下方式进入</p>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-3 p-2.5 rounded-xl bg-slate-50">
|
<div className="flex items-start gap-3 p-2.5 rounded-xl bg-slate-50">
|
||||||
<Monitor size={16} className="text-blue-500 flex-shrink-0 mt-0.5" />
|
<Monitor size={16} className="text-blue-500 flex-shrink-0 mt-0.5" />
|
||||||
@@ -31,7 +36,7 @@ export default function UnauthorizedPage({ message }: { message?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SurfaceCard>
|
||||||
</div>
|
</PageFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-352
@@ -1,24 +1,15 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
|
||||||
import {
|
|
||||||
MessageCircleHeart, X, ChevronRight, ChevronLeft, Check, Sparkles,
|
|
||||||
ImagePlus, Loader2, Inbox, Lightbulb, Bug, Palette, NotebookPen, Settings2,
|
|
||||||
type LucideIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { fetchJson } from '../auth/api-client';
|
import { fetchJson } from '../auth/api-client';
|
||||||
import { useAuth } from '../auth/useAuth';
|
import { useAuth } from '../auth/useAuth';
|
||||||
import { canManageFeedback } from '../shared/auth/roles';
|
import { canManageFeedback } from '../shared/auth/roles';
|
||||||
import FeedbackHistoryDrawer from './FeedbackHistoryDrawer';
|
import FeedbackHistoryDrawer from './FeedbackHistoryDrawer';
|
||||||
import RotatingFooterHint from './RotatingFooterHint';
|
import FeedbackLauncher from './feedback/FeedbackLauncher';
|
||||||
|
import FeedbackModal from './feedback/FeedbackModal';
|
||||||
|
import type { FeedbackStep, FeedbackType, UploadedImg } from './feedback/types';
|
||||||
|
|
||||||
const MAX_SCREENSHOTS = 6;
|
const MAX_SCREENSHOTS = 6;
|
||||||
const MAX_IMG_SIZE_MB = 5;
|
const MAX_IMG_SIZE_MB = 5;
|
||||||
|
|
||||||
interface UploadedImg {
|
|
||||||
url: string;
|
|
||||||
thumbDataUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadImage(file: File): Promise<string> {
|
async function uploadImage(file: File): Promise<string> {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', file);
|
fd.append('file', file);
|
||||||
@@ -42,38 +33,6 @@ function readAsDataUrl(file: File): Promise<string> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other';
|
|
||||||
|
|
||||||
interface TypeOption {
|
|
||||||
key: FeedbackType;
|
|
||||||
icon: LucideIcon;
|
|
||||||
label: string;
|
|
||||||
sub: string;
|
|
||||||
iconBg: string;
|
|
||||||
iconFg: string;
|
|
||||||
ring: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TYPE_OPTIONS: TypeOption[] = [
|
|
||||||
{ key: 'dimension', icon: Lightbulb, label: '想看新的统计维度', sub: '比如按 XX 维度切片',
|
|
||||||
iconBg: 'bg-amber-50', iconFg: 'text-amber-500', ring: 'ring-amber-200' },
|
|
||||||
{ key: 'bug', icon: Bug, label: '报告一个 Bug', sub: '哪里看着不对劲',
|
|
||||||
iconBg: 'bg-rose-50', iconFg: 'text-rose-500', ring: 'ring-rose-200' },
|
|
||||||
{ key: 'ux', icon: Palette, label: '界面 / 体验建议', sub: '哪里能更顺手',
|
|
||||||
iconBg: 'bg-violet-50', iconFg: 'text-violet-500', ring: 'ring-violet-200' },
|
|
||||||
{ key: 'other', icon: NotebookPen, label: '其他想法', sub: '欢迎随便聊聊',
|
|
||||||
iconBg: 'bg-blue-50', iconFg: 'text-blue-500', ring: 'ring-blue-200' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const MODULE_LABELS: Record<string, string> = {
|
|
||||||
assets: '资产管理',
|
|
||||||
mileage: '里程管理',
|
|
||||||
energy: '能源管理',
|
|
||||||
scheduling: '智能调度',
|
|
||||||
ele: '充电导入',
|
|
||||||
'': '通用',
|
|
||||||
};
|
|
||||||
|
|
||||||
function detectModule(): string {
|
function detectModule(): string {
|
||||||
const hash = (window.location.hash || '').slice(1);
|
const hash = (window.location.hash || '').slice(1);
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
@@ -94,7 +53,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [step, setStep] = useState<1 | 2 | 3>(1); // 1=选类型, 2=写内容, 3=成功页
|
const [step, setStep] = useState<FeedbackStep>(1);
|
||||||
const [type, setType] = useState<FeedbackType | null>(null);
|
const [type, setType] = useState<FeedbackType | null>(null);
|
||||||
const [mod, setMod] = useState<string>('');
|
const [mod, setMod] = useState<string>('');
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
@@ -128,14 +87,12 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Open: detect current module
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && step === 1) {
|
if (open && step === 1) {
|
||||||
setMod(moduleProp ?? detectModule());
|
setMod(moduleProp ?? detectModule());
|
||||||
}
|
}
|
||||||
}, [open, step, moduleProp]);
|
}, [open, step, moduleProp]);
|
||||||
|
|
||||||
// Lock scroll when open
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const prev = document.body.style.overflow;
|
const prev = document.body.style.overflow;
|
||||||
@@ -153,7 +110,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
|||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setTimeout(reset, 300); // 等动画完
|
setTimeout(reset, 300);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
@@ -193,319 +150,48 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const back = () => setStep((Math.max(1, step - 1)) as typeof step);
|
const back = () => setStep((Math.max(1, step - 1)) as FeedbackStep);
|
||||||
|
|
||||||
const canNext = step === 1 ? !!type : step === 2 ? content.trim().length > 0 : true;
|
const canNext = step === 1 ? !!type : step === 2 ? content.trim().length > 0 : true;
|
||||||
const progress = step >= 3 ? 100 : (step / 2) * 100;
|
const progress = step >= 3 ? 100 : (step / 2) * 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Floating Action Button */}
|
<FeedbackLauncher
|
||||||
<div className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-[60]">
|
isAdmin={isAdmin}
|
||||||
<motion.button
|
menuOpen={menuOpen}
|
||||||
initial={{ scale: 0, opacity: 0 }}
|
onCloseMenu={() => setMenuOpen(false)}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
onOpenFeedback={() => { setMenuOpen(false); setOpen(true); }}
|
||||||
transition={{ delay: 0.4, type: 'spring', stiffness: 300, damping: 20 }}
|
onOpenHistory={() => { setMenuOpen(false); setHistoryOpen(true); }}
|
||||||
whileHover={{ scale: 1.08 }}
|
onToggleMenu={() => setMenuOpen(current => !current)}
|
||||||
whileTap={{ scale: 0.92 }}
|
/>
|
||||||
onClick={() => setMenuOpen(m => !m)}
|
|
||||||
className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-cyan-400 text-white shadow-lg shadow-blue-200 flex items-center justify-center group"
|
|
||||||
aria-label="反馈"
|
|
||||||
title="提建议 / 我的反馈"
|
|
||||||
>
|
|
||||||
<MessageCircleHeart size={20} className="drop-shadow group-hover:scale-110 transition-transform" />
|
|
||||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-amber-300 ring-2 ring-white animate-pulse" />
|
|
||||||
</motion.button>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{menuOpen && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.9, y: 8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.9, y: 8 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
className="absolute bottom-14 right-0 bg-white rounded-2xl shadow-xl border border-slate-100 p-1.5 min-w-[148px] flex flex-col gap-0.5"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => { setMenuOpen(false); setOpen(true); }}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-blue-50 hover:text-blue-600 text-left"
|
|
||||||
>
|
|
||||||
<Sparkles size={14} className="text-blue-500" /> 提个建议
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { setMenuOpen(false); setHistoryOpen(true); }}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-emerald-50 hover:text-emerald-600 text-left"
|
|
||||||
>
|
|
||||||
<Inbox size={14} className="text-emerald-500" /> 我的反馈
|
|
||||||
</button>
|
|
||||||
{isAdmin && (
|
|
||||||
<>
|
|
||||||
<div className="h-px bg-slate-100 my-0.5" />
|
|
||||||
<a
|
|
||||||
href="#/admin/feedback"
|
|
||||||
onClick={() => setMenuOpen(false)}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-violet-50 hover:text-violet-600"
|
|
||||||
>
|
|
||||||
<Settings2 size={14} className="text-violet-500" /> 反馈管理
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* 点击外面关菜单 */}
|
|
||||||
{menuOpen && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-[-1]"
|
|
||||||
onClick={() => setMenuOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FeedbackHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
<FeedbackHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
||||||
|
|
||||||
{/* Modal */}
|
<FeedbackModal
|
||||||
<AnimatePresence>
|
canNext={canNext}
|
||||||
{open && (
|
content={content}
|
||||||
<motion.div
|
error={error}
|
||||||
initial={{ opacity: 0 }}
|
fileRef={fileRef}
|
||||||
animate={{ opacity: 1 }}
|
maxScreenshots={MAX_SCREENSHOTS}
|
||||||
exit={{ opacity: 0 }}
|
module={mod}
|
||||||
className="fixed inset-0 z-[90] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
open={open}
|
||||||
// 不允许点击背景关闭:避免用户输入到一半误触遮罩丢失内容
|
progress={progress}
|
||||||
>
|
screenshots={shots}
|
||||||
<motion.div
|
step={step}
|
||||||
initial={{ y: '100%', opacity: 0 }}
|
submitting={submitting}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
textareaRef={taRef}
|
||||||
exit={{ y: '100%', opacity: 0 }}
|
type={type}
|
||||||
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
|
uploading={uploading}
|
||||||
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
|
onAddFiles={addFiles}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onBack={back}
|
||||||
>
|
onChangeContent={setContent}
|
||||||
{/* Drag handle */}
|
onChangeModule={setMod}
|
||||||
<div className="flex justify-center pt-2.5 pb-1">
|
onClose={close}
|
||||||
<div className="w-10 h-1 rounded-full bg-slate-300" />
|
onNext={next}
|
||||||
</div>
|
onRemoveScreenshot={(index) => setShots(prev => prev.filter((_, current) => current !== index))}
|
||||||
|
onSelectType={(selectedType) => { setType(selectedType); setStep(2); }}
|
||||||
{/* Header + progress */}
|
/>
|
||||||
<div className="px-4 pb-3 border-b border-slate-100">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center">
|
|
||||||
<Sparkles size={14} className="text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-black text-slate-800 leading-tight">
|
|
||||||
{step === 3 ? '收到啦~' : '提个建议'}
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-slate-400 font-bold">
|
|
||||||
{step === 1 && '第一步 / 共 2 步'}
|
|
||||||
{step === 2 && '第二步 / 共 2 步'}
|
|
||||||
{step === 3 && '感谢你的反馈'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={close} className="p-1.5 -mr-1 text-slate-400 hover:text-slate-700">
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="h-1 bg-slate-100 rounded-full overflow-hidden">
|
|
||||||
<motion.div
|
|
||||||
initial={false}
|
|
||||||
animate={{ width: `${progress}%` }}
|
|
||||||
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
|
|
||||||
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 rounded-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
{step === 1 && (
|
|
||||||
<motion.div key="s1" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }}>
|
|
||||||
<p className="text-[13px] font-bold text-slate-700 mb-1.5">想反馈点什么?</p>
|
|
||||||
<RotatingFooterHint className="justify-start mb-4" />
|
|
||||||
<div className="grid grid-cols-1 gap-2">
|
|
||||||
{TYPE_OPTIONS.map((opt, i) => {
|
|
||||||
const Icon = opt.icon;
|
|
||||||
const selected = type === opt.key;
|
|
||||||
return (
|
|
||||||
<motion.button
|
|
||||||
key={opt.key}
|
|
||||||
initial={{ opacity: 0, y: 6 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: i * 0.04, duration: 0.2 }}
|
|
||||||
whileHover={{ y: -1 }}
|
|
||||||
whileTap={{ scale: 0.99 }}
|
|
||||||
onClick={() => { setType(opt.key); setStep(2); }}
|
|
||||||
className={`text-left p-3.5 rounded-2xl border bg-white transition-all flex items-center gap-3 group ${selected ? `ring-2 ${opt.ring} border-transparent shadow-sm` : 'border-slate-100 hover:border-slate-200 hover:shadow-sm'}`}
|
|
||||||
>
|
|
||||||
<div className={`w-10 h-10 rounded-xl ${opt.iconBg} flex items-center justify-center flex-shrink-0`}>
|
|
||||||
<Icon size={18} className={opt.iconFg} strokeWidth={2.2} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-[13px] font-bold text-slate-800 leading-tight">{opt.label}</div>
|
|
||||||
<div className="text-[11px] text-slate-400 font-medium mt-0.5">{opt.sub}</div>
|
|
||||||
</div>
|
|
||||||
<ChevronRight size={15} className="text-slate-300 flex-shrink-0 group-hover:text-slate-500 group-hover:translate-x-0.5 transition-all" />
|
|
||||||
</motion.button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 2 && (
|
|
||||||
<motion.div key="s2" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }} className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-[12px] font-bold text-slate-600 mb-2">说说具体内容</p>
|
|
||||||
<textarea
|
|
||||||
ref={taRef}
|
|
||||||
value={content}
|
|
||||||
onChange={(e) => setContent(e.target.value)}
|
|
||||||
onPaste={(e) => {
|
|
||||||
const items = e.clipboardData?.items;
|
|
||||||
if (!items) return;
|
|
||||||
const imgs: File[] = [];
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
const it = items[i];
|
|
||||||
if (it.kind === 'file' && it.type.startsWith('image/')) {
|
|
||||||
const f = it.getAsFile();
|
|
||||||
if (f) imgs.push(f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (imgs.length > 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
addFiles(imgs);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
rows={5}
|
|
||||||
maxLength={1000}
|
|
||||||
placeholder={
|
|
||||||
type === 'dimension' ? '比如:希望按客户/区域/日期范围 等等切片看里程数据…'
|
|
||||||
: type === 'bug' ? '比如:氢能页面 04-28 嘉燃经开站显示 153.81,但…(可粘贴截图)'
|
|
||||||
: type === 'ux' ? '比如:能不能把外部 tab 默认收起,加载快一点…'
|
|
||||||
: '随便聊聊你的想法'
|
|
||||||
}
|
|
||||||
className="w-full bg-slate-50 border-none rounded-xl p-3 text-[12px] text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20 resize-none"
|
|
||||||
/>
|
|
||||||
<div className="text-right text-[10px] text-slate-300 font-bold mt-1">{content.length} / 1000</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 截图上传 */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
|
||||||
<p className="text-[10px] font-bold text-slate-400 uppercase">截图(可选)</p>
|
|
||||||
<span className="text-[10px] text-slate-300 font-bold">{shots.length}/{MAX_SCREENSHOTS},可粘贴</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
ref={fileRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{shots.map((s, i) => (
|
|
||||||
<div key={i} className="relative w-16 h-16 rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
|
|
||||||
<img src={s.thumbDataUrl} alt="" className="w-full h-full object-cover" />
|
|
||||||
<button
|
|
||||||
onClick={() => setShots(prev => prev.filter((_, idx) => idx !== i))}
|
|
||||||
className="absolute top-0.5 right-0.5 w-4 h-4 rounded-full bg-slate-900/70 text-white flex items-center justify-center hover:bg-slate-900"
|
|
||||||
>
|
|
||||||
<X size={10} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{shots.length < MAX_SCREENSHOTS && (
|
|
||||||
<button
|
|
||||||
onClick={() => fileRef.current?.click()}
|
|
||||||
disabled={uploading}
|
|
||||||
className="w-16 h-16 rounded-lg border border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 flex flex-col items-center justify-center gap-0.5 text-slate-400"
|
|
||||||
>
|
|
||||||
{uploading ? <Loader2 size={16} className="animate-spin" /> : <ImagePlus size={16} />}
|
|
||||||
<span className="text-[9px] font-bold">{uploading ? '上传中' : '加截图'}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">所在板块</p>
|
|
||||||
<div className="flex gap-1 flex-wrap">
|
|
||||||
{Object.entries(MODULE_LABELS).map(([k, label]) => (
|
|
||||||
<button
|
|
||||||
key={k}
|
|
||||||
onClick={() => setMod(k)}
|
|
||||||
className={`px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all ${mod === k ? 'bg-blue-50 border-blue-200 text-blue-600' : 'bg-white border-slate-200 text-slate-500 hover:border-slate-300'}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="text-[11px] text-rose-500 font-bold">{error}</div>}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 3 && (
|
|
||||||
<motion.div key="s3" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.3 }} className="text-center py-6">
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0, rotate: -180 }}
|
|
||||||
animate={{ scale: 1, rotate: 0 }}
|
|
||||||
transition={{ type: 'spring', damping: 12, stiffness: 200, delay: 0.1 }}
|
|
||||||
className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-400 to-cyan-400 mx-auto flex items-center justify-center mb-3"
|
|
||||||
>
|
|
||||||
<Check size={28} strokeWidth={3} className="text-white" />
|
|
||||||
</motion.div>
|
|
||||||
<div className="text-base font-black text-slate-800 mb-1">谢谢你的反馈 ❤️</div>
|
|
||||||
<div className="text-[12px] text-slate-500 font-bold leading-relaxed">产品同学会认真看每一条<br />有进展可以在「我的反馈」里查看</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
{step < 3 && (
|
|
||||||
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
|
|
||||||
{step > 1 && (
|
|
||||||
<button
|
|
||||||
onClick={back}
|
|
||||||
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50 flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<ChevronLeft size={14} /> 上一步
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="flex-1" />
|
|
||||||
<button
|
|
||||||
onClick={next}
|
|
||||||
disabled={!canNext || submitting}
|
|
||||||
className="px-4 py-2 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100 disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none flex items-center gap-1"
|
|
||||||
>
|
|
||||||
{submitting ? '提交中…' : step === 2 ? '提交' : '下一步'}
|
|
||||||
{!submitting && step !== 2 && <ChevronRight size={14} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{step === 3 && (
|
|
||||||
<div className="px-4 py-3 border-t border-slate-100">
|
|
||||||
<button
|
|
||||||
onClick={close}
|
|
||||||
className="w-full py-2.5 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100"
|
|
||||||
>
|
|
||||||
完成
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+141
-59
@@ -1,71 +1,88 @@
|
|||||||
import { useState, useEffect, useMemo, type ComponentType } from 'react';
|
import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { Building2, ChevronRight, ShieldCheck } from 'lucide-react';
|
||||||
import { useAuth } from '../auth/useAuth';
|
import { useAuth } from '../auth/useAuth';
|
||||||
import { DemoModeProvider } from './Blur';
|
import { DemoModeProvider } from './Blur';
|
||||||
import FeedbackFab from './FeedbackFab';
|
import FeedbackFab from './FeedbackFab';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
|
import { LoadingState } from './ui/surface';
|
||||||
|
|
||||||
export interface ModuleConfig {
|
export interface ModuleConfig {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: ComponentType<{ size?: number; className?: string }>;
|
icon: ComponentType<{ size?: number; className?: string }>;
|
||||||
component: ComponentType;
|
component?: ElementType;
|
||||||
|
children?: ModuleConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** path 到模块 id 的映射 */
|
function flattenModules(modules: ModuleConfig[]): ModuleConfig[] {
|
||||||
const PATH_MAP: Record<string, string> = {
|
return modules.flatMap((module) => module.children ?? [module]);
|
||||||
'/vehicle': 'assets',
|
}
|
||||||
'/assets': 'assets',
|
|
||||||
'/mileage': 'mileage',
|
/** hash 一级段(`#<id>` 或 `#<id>/<sub>` 都只取 id) */
|
||||||
'/scheduling': 'scheduling',
|
function getHashHead(): string {
|
||||||
'/energy': 'energy',
|
return window.location.hash.slice(1).split('/')[0];
|
||||||
};
|
}
|
||||||
|
|
||||||
function getInitialModule(modules: ModuleConfig[]): string {
|
function getInitialModule(modules: ModuleConfig[]): string {
|
||||||
// 优先看 hash
|
const head = getHashHead();
|
||||||
const hash = window.location.hash.slice(1);
|
if (modules.some((m) => m.id === head)) return head;
|
||||||
if (modules.some((m) => m.id === hash)) return hash;
|
|
||||||
// 再看 pathname
|
|
||||||
const pathModule = PATH_MAP[window.location.pathname];
|
|
||||||
if (pathModule && modules.some((m) => m.id === pathModule)) return pathModule;
|
|
||||||
// 默认第一个
|
|
||||||
return modules[0]?.id ?? '';
|
return modules[0]?.id ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHashModule(modules: ModuleConfig[]): string {
|
function getHashModule(modules: ModuleConfig[]): string {
|
||||||
const hash = window.location.hash.slice(1);
|
const head = getHashHead();
|
||||||
return modules.some((m) => m.id === hash) ? hash : '';
|
return modules.some((m) => m.id === head) ? head : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||||
const [activeModule, setActiveModule] = useState(() => getInitialModule(modules));
|
const flatModules = useMemo(() => flattenModules(modules), [modules]);
|
||||||
|
const [activeModule, setActiveModule] = useState(() => getInitialModule(flattenModules(modules)));
|
||||||
|
const [openGroupId, setOpenGroupId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onHashChange = () => {
|
const onHashChange = () => {
|
||||||
const h = getHashModule(modules);
|
const h = getHashModule(flatModules);
|
||||||
if (h) setActiveModule(h);
|
if (h) setActiveModule(h);
|
||||||
};
|
};
|
||||||
window.addEventListener('hashchange', onHashChange);
|
window.addEventListener('hashchange', onHashChange);
|
||||||
return () => window.removeEventListener('hashchange', onHashChange);
|
return () => window.removeEventListener('hashchange', onHashChange);
|
||||||
}, [modules]);
|
}, [flatModules]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 同步 hash 到当前模块:使用 replaceState 避免产生多余的 history 记录,
|
if (!openGroupId) return undefined;
|
||||||
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (window.location.hash.slice(1) !== activeModule) {
|
if (event.key === 'Escape') setOpenGroupId(null);
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [openGroupId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 同步 hash 一段到当前模块:使用 replaceState 避免产生多余的 history 记录,
|
||||||
|
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出。
|
||||||
|
// 注意只比对一级段,避免把子模块写入的 `#<id>/<sub>` 二级段抹掉。
|
||||||
|
if (getHashHead() !== activeModule) {
|
||||||
const { pathname, search } = window.location;
|
const { pathname, search } = window.location;
|
||||||
window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`);
|
window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`);
|
||||||
}
|
}
|
||||||
}, [activeModule]);
|
}, [activeModule]);
|
||||||
|
|
||||||
const switchModule = (id: string) => {
|
const switchModule = (id: string) => {
|
||||||
if (window.location.hash.slice(1) === id) return;
|
setOpenGroupId(null);
|
||||||
|
if (getHashHead() === id) {
|
||||||
|
setActiveModule(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { pathname, search } = window.location;
|
const { pathname, search } = window.location;
|
||||||
window.history.replaceState(null, '', `${pathname}${search}#${id}`);
|
window.history.replaceState(null, '', `${pathname}${search}#${id}`);
|
||||||
setActiveModule(id);
|
setActiveModule(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component;
|
const ActiveComponent = flatModules.find((module) => module.id === activeModule)?.component ?? flatModules[0]?.component;
|
||||||
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const activeLabel = flatModules.find((module) => module.id === activeModule)?.label ?? '业务看板';
|
||||||
const watermarkText = useMemo(() => {
|
const watermarkText = useMemo(() => {
|
||||||
const name = user?.userName || '未登录';
|
const name = user?.userName || '未登录';
|
||||||
const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-');
|
const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-');
|
||||||
@@ -74,7 +91,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DemoModeProvider enabled={false}>
|
<DemoModeProvider enabled={false}>
|
||||||
<div className="flex min-h-screen">
|
<div className="enterprise-grid-bg flex min-h-screen">
|
||||||
{/* 全局水印 */}
|
{/* 全局水印 */}
|
||||||
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
|
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
|
||||||
<div className="absolute inset-0" style={{
|
<div className="absolute inset-0" style={{
|
||||||
@@ -83,49 +100,114 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
|||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
{/* Web 侧边栏 (md 及以上) */}
|
{/* Web 侧边栏 (md 及以上) */}
|
||||||
<nav className="hidden md:flex flex-col items-center w-16 bg-white border-r border-gray-100 fixed top-0 left-0 h-full z-50 py-6 gap-2">
|
<nav className="fixed left-0 top-0 z-50 hidden h-full w-20 flex-col items-center border-r border-white/10 bg-slate-950 px-2 py-4 text-white shadow-[12px_0_40px_rgba(15,23,42,0.16)] md:flex">
|
||||||
{modules.map((m) => {
|
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-white text-blue-600 shadow-lg shadow-blue-950/20">
|
||||||
const Icon = m.icon;
|
<Building2 size={22} />
|
||||||
const isActive = m.id === activeModule;
|
</div>
|
||||||
return (
|
<div className="flex w-full flex-1 flex-col items-center gap-2">
|
||||||
<button
|
{modules.map((m) => {
|
||||||
key={m.id}
|
const Icon = m.icon;
|
||||||
onClick={() => switchModule(m.id)}
|
const isGroup = Boolean(m.children?.length);
|
||||||
className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${
|
const isActive = m.id === activeModule || Boolean(m.children?.some((child) => child.id === activeModule));
|
||||||
isActive
|
const isOpen = openGroupId === m.id;
|
||||||
? 'bg-blue-50 text-blue-600'
|
return (
|
||||||
: 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
|
<div key={m.id} className="relative">
|
||||||
}`}
|
<button
|
||||||
>
|
type="button"
|
||||||
<Icon size={22} />
|
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)}
|
||||||
<span className="text-[10px] mt-1 leading-tight">{m.label}</span>
|
aria-expanded={isGroup ? isOpen : undefined}
|
||||||
</button>
|
aria-haspopup={isGroup ? 'menu' : undefined}
|
||||||
);
|
className={cn(
|
||||||
})}
|
'group relative flex h-16 w-16 flex-col items-center justify-center rounded-2xl text-[10px] font-black transition-all',
|
||||||
|
isActive ? 'text-white' : 'text-slate-400 hover:bg-white/8 hover:text-white',
|
||||||
|
)}
|
||||||
|
title={m.label}
|
||||||
|
>
|
||||||
|
{isActive ? (
|
||||||
|
<motion.span layoutId="desktop-shell-active" className="absolute inset-0 rounded-2xl bg-blue-600 shadow-lg shadow-blue-950/30" transition={{ type: 'spring', stiffness: 430, damping: 34 }} />
|
||||||
|
) : null}
|
||||||
|
<Icon size={21} className="relative mb-1" />
|
||||||
|
<span className="relative leading-tight">{m.label}</span>
|
||||||
|
{isGroup ? <ChevronRight size={11} className={cn('absolute right-1 top-1/2 -translate-y-1/2 transition-transform', isOpen && 'rotate-180')} /> : null}
|
||||||
|
</button>
|
||||||
|
{isGroup && isOpen ? (
|
||||||
|
<div role="menu" aria-label={`${m.label}二级菜单`} className="absolute left-[calc(100%+12px)] top-1/2 w-36 -translate-y-1/2 rounded-2xl border border-slate-700 bg-slate-900 p-2 shadow-2xl shadow-slate-950/30">
|
||||||
|
{m.children!.map((child) => {
|
||||||
|
const ChildIcon = child.icon;
|
||||||
|
const childActive = child.id === activeModule;
|
||||||
|
return (
|
||||||
|
<button key={child.id} type="button" role="menuitem" onClick={() => switchModule(child.id)} className={cn('flex h-11 w-full items-center gap-2 rounded-xl px-3 text-xs font-black transition-colors', childActive ? 'bg-blue-600 text-white' : 'text-slate-300 hover:bg-white/8 hover:text-white')}>
|
||||||
|
<ChildIcon size={16} />{child.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col items-center gap-2 border-t border-white/10 pt-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-400/10 text-emerald-300 ring-1 ring-emerald-300/20" title={user?.userName || '当前用户'}>
|
||||||
|
<ShieldCheck size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="max-w-16 truncate text-center text-[9px] font-bold text-slate-400">{user?.userName || '未登录'}</div>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* 内容区 */}
|
{/* 内容区 */}
|
||||||
<main className="flex-1 md:ml-16 pb-16 md:pb-0 min-w-0" style={{ overflowX: 'clip' }}>
|
<main className="min-w-0 flex-1 pb-16 md:ml-20 md:pb-0" style={{ overflowX: 'clip' }}>
|
||||||
{ActiveComponent && <ActiveComponent />}
|
<div className="pointer-events-none fixed left-20 right-0 top-0 z-20 hidden h-12 items-center border-b border-white/60 bg-white/55 px-6 text-xs font-bold text-slate-400 backdrop-blur-xl md:flex">
|
||||||
|
<span className="text-slate-600">羚牛氢能 BI</span>
|
||||||
|
<span className="mx-2 text-slate-300">/</span>
|
||||||
|
<span>{activeLabel}</span>
|
||||||
|
</div>
|
||||||
|
<Suspense fallback={<div className="p-3 md:p-6"><LoadingState label="正在加载业务模块" /></div>}>
|
||||||
|
{ActiveComponent && <ActiveComponent />}
|
||||||
|
</Suspense>
|
||||||
<FeedbackFab module={activeModule} />
|
<FeedbackFab module={activeModule} />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* 移动端底部导航 (md 以下) */}
|
{/* 移动端底部导航 (md 以下) */}
|
||||||
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-2 flex justify-around items-center md:hidden z-50">
|
<nav className="fixed inset-x-0 bottom-0 z-50 border-t border-white/70 bg-white/92 px-3 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 shadow-[0_-18px_40px_rgba(15,23,42,0.08)] backdrop-blur-xl md:hidden">
|
||||||
|
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${modules.length}, minmax(0, 1fr))` }}>
|
||||||
{modules.map((m) => {
|
{modules.map((m) => {
|
||||||
const Icon = m.icon;
|
const Icon = m.icon;
|
||||||
const isActive = m.id === activeModule;
|
const isGroup = Boolean(m.children?.length);
|
||||||
|
const isActive = m.id === activeModule || Boolean(m.children?.some((child) => child.id === activeModule));
|
||||||
|
const isOpen = openGroupId === m.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<div key={m.id} className="relative flex justify-center">
|
||||||
key={m.id}
|
<button
|
||||||
onClick={() => switchModule(m.id)}
|
type="button"
|
||||||
className={`flex flex-col items-center ${isActive ? 'text-blue-600' : 'text-gray-400'}`}
|
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)}
|
||||||
>
|
aria-expanded={isGroup ? isOpen : undefined}
|
||||||
<Icon size={20} />
|
aria-haspopup={isGroup ? 'menu' : undefined}
|
||||||
<span className="text-[10px] mt-1">{m.label}</span>
|
className={cn('relative flex min-h-12 w-full flex-col items-center justify-center gap-0.5 rounded-2xl text-[10px] font-medium transition-colors', isActive ? 'text-blue-700' : 'text-slate-400')}
|
||||||
</button>
|
>
|
||||||
|
{isActive ? (
|
||||||
|
<motion.span layoutId="mobile-shell-active" className="absolute inset-0 rounded-2xl bg-blue-50 ring-1 ring-blue-100" transition={{ type: 'spring', stiffness: 430, damping: 34 }} />
|
||||||
|
) : null}
|
||||||
|
<Icon size={20} className="relative" />
|
||||||
|
<span className="relative">{m.label}</span>
|
||||||
|
</button>
|
||||||
|
{isGroup && isOpen ? (
|
||||||
|
<div role="menu" aria-label={`${m.label}二级菜单`} className="absolute bottom-[calc(100%+14px)] left-1/2 w-36 -translate-x-1/2 rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl shadow-slate-900/15">
|
||||||
|
{m.children!.map((child) => {
|
||||||
|
const ChildIcon = child.icon;
|
||||||
|
const childActive = child.id === activeModule;
|
||||||
|
return (
|
||||||
|
<button key={child.id} type="button" role="menuitem" onClick={() => switchModule(child.id)} className={cn('flex h-11 w-full items-center gap-2 rounded-xl px-3 text-xs font-medium transition-colors', childActive ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-50')}>
|
||||||
|
<ChildIcon size={16} />{child.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</DemoModeProvider>
|
</DemoModeProvider>
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import type { RefObject } from 'react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { ImagePlus, Loader2, X } from 'lucide-react';
|
||||||
|
import type { FeedbackType, UploadedImg } from './types';
|
||||||
|
|
||||||
|
const MODULE_LABELS: Record<string, string> = {
|
||||||
|
assets: '资产管理',
|
||||||
|
mileage: '里程管理',
|
||||||
|
energy: '能源管理',
|
||||||
|
scheduling: '智能调度',
|
||||||
|
ele: '充电导入',
|
||||||
|
'': '通用',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPlaceholder(type: FeedbackType | null): string {
|
||||||
|
if (type === 'dimension') return '比如:希望按客户/区域/日期范围 等等切片看里程数据…';
|
||||||
|
if (type === 'bug') return '比如:氢能页面 04-28 嘉燃经开站显示 153.81,但…(可粘贴截图)';
|
||||||
|
if (type === 'ux') return '比如:能不能把外部 tab 默认收起,加载快一点…';
|
||||||
|
return '随便聊聊你的想法';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
content: string;
|
||||||
|
error: string | null;
|
||||||
|
fileRef: RefObject<HTMLInputElement | null>;
|
||||||
|
maxScreenshots: number;
|
||||||
|
module: string;
|
||||||
|
screenshots: UploadedImg[];
|
||||||
|
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||||
|
type: FeedbackType | null;
|
||||||
|
uploading: boolean;
|
||||||
|
onAddFiles: (files: FileList | File[]) => void | Promise<void>;
|
||||||
|
onChangeContent: (content: string) => void;
|
||||||
|
onChangeModule: (module: string) => void;
|
||||||
|
onRemoveScreenshot: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedbackFormStep({
|
||||||
|
content,
|
||||||
|
error,
|
||||||
|
fileRef,
|
||||||
|
maxScreenshots,
|
||||||
|
module,
|
||||||
|
screenshots,
|
||||||
|
textareaRef,
|
||||||
|
type,
|
||||||
|
uploading,
|
||||||
|
onAddFiles,
|
||||||
|
onChangeContent,
|
||||||
|
onChangeModule,
|
||||||
|
onRemoveScreenshot,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 12 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -12 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-[12px] font-bold text-slate-600 mb-2">说说具体内容</p>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => onChangeContent(e.target.value)}
|
||||||
|
onPaste={(e) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
const imgs: File[] = [];
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const it = items[i];
|
||||||
|
if (it.kind === 'file' && it.type.startsWith('image/')) {
|
||||||
|
const f = it.getAsFile();
|
||||||
|
if (f) imgs.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (imgs.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
onAddFiles(imgs);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
rows={5}
|
||||||
|
maxLength={1000}
|
||||||
|
placeholder={getPlaceholder(type)}
|
||||||
|
className="w-full bg-slate-50 border-none rounded-xl p-3 text-[12px] text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20 resize-none"
|
||||||
|
/>
|
||||||
|
<div className="text-right text-[10px] text-slate-300 font-bold mt-1">{content.length} / 1000</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<p className="text-[10px] font-bold text-slate-400 uppercase">截图(可选)</p>
|
||||||
|
<span className="text-[10px] text-slate-300 font-bold">{screenshots.length}/{maxScreenshots},可粘贴</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => { if (e.target.files) onAddFiles(e.target.files); e.target.value = ''; }}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{screenshots.map((s, i) => (
|
||||||
|
<div key={i} className="relative w-16 h-16 rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
|
||||||
|
<img src={s.thumbDataUrl} alt="" className="w-full h-full object-cover" />
|
||||||
|
<button
|
||||||
|
onClick={() => onRemoveScreenshot(i)}
|
||||||
|
className="absolute top-0.5 right-0.5 w-4 h-4 rounded-full bg-slate-900/70 text-white flex items-center justify-center hover:bg-slate-900"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{screenshots.length < maxScreenshots && (
|
||||||
|
<button
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="w-16 h-16 rounded-lg border border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 flex flex-col items-center justify-center gap-0.5 text-slate-400"
|
||||||
|
>
|
||||||
|
{uploading ? <Loader2 size={16} className="animate-spin" /> : <ImagePlus size={16} />}
|
||||||
|
<span className="text-[9px] font-bold">{uploading ? '上传中' : '加截图'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">所在板块</p>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{Object.entries(MODULE_LABELS).map(([key, label]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => onChangeModule(key)}
|
||||||
|
className={`px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all ${module === key ? 'bg-blue-50 border-blue-200 text-blue-600' : 'bg-white border-slate-200 text-slate-500 hover:border-slate-300'}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="text-[11px] text-rose-500 font-bold">{error}</div>}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import {
|
||||||
|
Inbox,
|
||||||
|
MessageCircleHeart,
|
||||||
|
Settings2,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isAdmin: boolean;
|
||||||
|
menuOpen: boolean;
|
||||||
|
onCloseMenu: () => void;
|
||||||
|
onOpenFeedback: () => void;
|
||||||
|
onOpenHistory: () => void;
|
||||||
|
onToggleMenu: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedbackLauncher({
|
||||||
|
isAdmin,
|
||||||
|
menuOpen,
|
||||||
|
onCloseMenu,
|
||||||
|
onOpenFeedback,
|
||||||
|
onOpenHistory,
|
||||||
|
onToggleMenu,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-[60]">
|
||||||
|
<motion.button
|
||||||
|
initial={{ scale: 0, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ delay: 0.4, type: 'spring', stiffness: 300, damping: 20 }}
|
||||||
|
whileHover={{ scale: 1.08 }}
|
||||||
|
whileTap={{ scale: 0.92 }}
|
||||||
|
onClick={onToggleMenu}
|
||||||
|
className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-cyan-400 text-white shadow-lg shadow-blue-200 flex items-center justify-center group"
|
||||||
|
aria-label="反馈"
|
||||||
|
title="提建议 / 我的反馈"
|
||||||
|
>
|
||||||
|
<MessageCircleHeart size={20} className="drop-shadow group-hover:scale-110 transition-transform" />
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-amber-300 ring-2 ring-white animate-pulse" />
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{menuOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: 8 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="absolute bottom-14 right-0 bg-white rounded-2xl shadow-xl border border-slate-100 p-1.5 min-w-[148px] flex flex-col gap-0.5"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onOpenFeedback}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-blue-50 hover:text-blue-600 text-left"
|
||||||
|
>
|
||||||
|
<Sparkles size={14} className="text-blue-500" /> 提个建议
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onOpenHistory}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-emerald-50 hover:text-emerald-600 text-left"
|
||||||
|
>
|
||||||
|
<Inbox size={14} className="text-emerald-500" /> 我的反馈
|
||||||
|
</button>
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<div className="h-px bg-slate-100 my-0.5" />
|
||||||
|
<a
|
||||||
|
href="#/admin/feedback"
|
||||||
|
onClick={onCloseMenu}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-violet-50 hover:text-violet-600"
|
||||||
|
>
|
||||||
|
<Settings2 size={14} className="text-violet-500" /> 反馈管理
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{menuOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[-1]"
|
||||||
|
onClick={onCloseMenu}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import type { RefObject } from 'react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import { ChevronLeft, ChevronRight, Sparkles, X } from 'lucide-react';
|
||||||
|
import FeedbackFormStep from './FeedbackFormStep';
|
||||||
|
import FeedbackSuccessStep from './FeedbackSuccessStep';
|
||||||
|
import FeedbackTypeStep from './FeedbackTypeStep';
|
||||||
|
import type { FeedbackStep, FeedbackType, UploadedImg } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
canNext: boolean;
|
||||||
|
content: string;
|
||||||
|
error: string | null;
|
||||||
|
fileRef: RefObject<HTMLInputElement | null>;
|
||||||
|
maxScreenshots: number;
|
||||||
|
module: string;
|
||||||
|
open: boolean;
|
||||||
|
progress: number;
|
||||||
|
screenshots: UploadedImg[];
|
||||||
|
step: FeedbackStep;
|
||||||
|
submitting: boolean;
|
||||||
|
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||||
|
type: FeedbackType | null;
|
||||||
|
uploading: boolean;
|
||||||
|
onAddFiles: (files: FileList | File[]) => void | Promise<void>;
|
||||||
|
onBack: () => void;
|
||||||
|
onChangeContent: (content: string) => void;
|
||||||
|
onChangeModule: (module: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onRemoveScreenshot: (index: number) => void;
|
||||||
|
onSelectType: (type: FeedbackType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedbackModal({
|
||||||
|
canNext,
|
||||||
|
content,
|
||||||
|
error,
|
||||||
|
fileRef,
|
||||||
|
maxScreenshots,
|
||||||
|
module,
|
||||||
|
open,
|
||||||
|
progress,
|
||||||
|
screenshots,
|
||||||
|
step,
|
||||||
|
submitting,
|
||||||
|
textareaRef,
|
||||||
|
type,
|
||||||
|
uploading,
|
||||||
|
onAddFiles,
|
||||||
|
onBack,
|
||||||
|
onChangeContent,
|
||||||
|
onChangeModule,
|
||||||
|
onClose,
|
||||||
|
onNext,
|
||||||
|
onRemoveScreenshot,
|
||||||
|
onSelectType,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-[90] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: '100%', opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: '100%', opacity: 0 }}
|
||||||
|
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
|
||||||
|
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex justify-center pt-2.5 pb-1">
|
||||||
|
<div className="w-10 h-1 rounded-full bg-slate-300" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 pb-3 border-b border-slate-100">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center">
|
||||||
|
<Sparkles size={14} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-black text-slate-800 leading-tight">
|
||||||
|
{step === 3 ? '收到啦~' : '提个建议'}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold">
|
||||||
|
{step === 1 && '第一步 / 共 2 步'}
|
||||||
|
{step === 2 && '第二步 / 共 2 步'}
|
||||||
|
{step === 3 && '感谢你的反馈'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-1.5 -mr-1 text-slate-400 hover:text-slate-700">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="h-1 bg-slate-100 rounded-full overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
initial={false}
|
||||||
|
animate={{ width: `${progress}%` }}
|
||||||
|
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
|
||||||
|
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{step === 1 && (
|
||||||
|
<FeedbackTypeStep
|
||||||
|
key="s1"
|
||||||
|
selectedType={type}
|
||||||
|
onSelect={onSelectType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 2 && (
|
||||||
|
<FeedbackFormStep
|
||||||
|
key="s2"
|
||||||
|
content={content}
|
||||||
|
error={error}
|
||||||
|
fileRef={fileRef}
|
||||||
|
maxScreenshots={maxScreenshots}
|
||||||
|
module={module}
|
||||||
|
screenshots={screenshots}
|
||||||
|
textareaRef={textareaRef}
|
||||||
|
type={type}
|
||||||
|
uploading={uploading}
|
||||||
|
onAddFiles={onAddFiles}
|
||||||
|
onChangeContent={onChangeContent}
|
||||||
|
onChangeModule={onChangeModule}
|
||||||
|
onRemoveScreenshot={onRemoveScreenshot}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 3 && <FeedbackSuccessStep key="s3" />}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step < 3 && (
|
||||||
|
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
|
||||||
|
{step > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} /> 上一步
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!canNext || submitting}
|
||||||
|
className="px-4 py-2 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100 disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{submitting ? '提交中…' : step === 2 ? '提交' : '下一步'}
|
||||||
|
{!submitting && step !== 2 && <ChevronRight size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="px-4 py-3 border-t border-slate-100">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-full py-2.5 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100"
|
||||||
|
>
|
||||||
|
完成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function FeedbackSuccessStep() {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="text-center py-6"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0, rotate: -180 }}
|
||||||
|
animate={{ scale: 1, rotate: 0 }}
|
||||||
|
transition={{ type: 'spring', damping: 12, stiffness: 200, delay: 0.1 }}
|
||||||
|
className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-400 to-cyan-400 mx-auto flex items-center justify-center mb-3"
|
||||||
|
>
|
||||||
|
<Check size={28} strokeWidth={3} className="text-white" />
|
||||||
|
</motion.div>
|
||||||
|
<div className="text-base font-black text-slate-800 mb-1">谢谢你的反馈 ❤️</div>
|
||||||
|
<div className="text-[12px] text-slate-500 font-bold leading-relaxed">产品同学会认真看每一条<br />有进展可以在「我的反馈」里查看</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { motion } from 'motion/react';
|
||||||
|
import {
|
||||||
|
Bug,
|
||||||
|
ChevronRight,
|
||||||
|
Lightbulb,
|
||||||
|
NotebookPen,
|
||||||
|
Palette,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import RotatingFooterHint from '../RotatingFooterHint';
|
||||||
|
import type { FeedbackType } from './types';
|
||||||
|
|
||||||
|
interface TypeOption {
|
||||||
|
key: FeedbackType;
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
sub: string;
|
||||||
|
iconBg: string;
|
||||||
|
iconFg: string;
|
||||||
|
ring: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_OPTIONS: TypeOption[] = [
|
||||||
|
{ key: 'dimension', icon: Lightbulb, label: '想看新的统计维度', sub: '比如按 XX 维度切片',
|
||||||
|
iconBg: 'bg-amber-50', iconFg: 'text-amber-500', ring: 'ring-amber-200' },
|
||||||
|
{ key: 'bug', icon: Bug, label: '报告一个 Bug', sub: '哪里看着不对劲',
|
||||||
|
iconBg: 'bg-rose-50', iconFg: 'text-rose-500', ring: 'ring-rose-200' },
|
||||||
|
{ key: 'ux', icon: Palette, label: '界面 / 体验建议', sub: '哪里能更顺手',
|
||||||
|
iconBg: 'bg-violet-50', iconFg: 'text-violet-500', ring: 'ring-violet-200' },
|
||||||
|
{ key: 'other', icon: NotebookPen, label: '其他想法', sub: '欢迎随便聊聊',
|
||||||
|
iconBg: 'bg-blue-50', iconFg: 'text-blue-500', ring: 'ring-blue-200' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedType: FeedbackType | null;
|
||||||
|
onSelect: (type: FeedbackType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedbackTypeStep({ selectedType, onSelect }: Props) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 12 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -12 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<p className="text-[13px] font-bold text-slate-700 mb-1.5">想反馈点什么?</p>
|
||||||
|
<RotatingFooterHint className="justify-start mb-4" />
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{TYPE_OPTIONS.map((opt, i) => {
|
||||||
|
const Icon = opt.icon;
|
||||||
|
const selected = selectedType === opt.key;
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
key={opt.key}
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: i * 0.04, duration: 0.2 }}
|
||||||
|
whileHover={{ y: -1 }}
|
||||||
|
whileTap={{ scale: 0.99 }}
|
||||||
|
onClick={() => onSelect(opt.key)}
|
||||||
|
className={`text-left p-3.5 rounded-2xl border bg-white transition-all flex items-center gap-3 group ${selected ? `ring-2 ${opt.ring} border-transparent shadow-sm` : 'border-slate-100 hover:border-slate-200 hover:shadow-sm'}`}
|
||||||
|
>
|
||||||
|
<div className={`w-10 h-10 rounded-xl ${opt.iconBg} flex items-center justify-center flex-shrink-0`}>
|
||||||
|
<Icon size={18} className={opt.iconFg} strokeWidth={2.2} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[13px] font-bold text-slate-800 leading-tight">{opt.label}</div>
|
||||||
|
<div className="text-[11px] text-slate-400 font-medium mt-0.5">{opt.sub}</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={15} className="text-slate-300 flex-shrink-0 group-hover:text-slate-500 group-hover:translate-x-0.5 transition-all" />
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export type FeedbackStep = 1 | 2 | 3;
|
||||||
|
|
||||||
|
export type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other';
|
||||||
|
|
||||||
|
export interface UploadedImg {
|
||||||
|
url: string;
|
||||||
|
thumbDataUrl: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { useState, type ComponentType, type ReactNode } from 'react';
|
||||||
|
import { AlertCircle, Info, Loader2, SearchX, X } from 'lucide-react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import { cn } from '../../lib/cn';
|
||||||
|
|
||||||
|
export type SurfaceIcon = ComponentType<{ size?: number; className?: string }>;
|
||||||
|
|
||||||
|
export function PageFrame({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
icon: Icon,
|
||||||
|
eyebrow,
|
||||||
|
meta,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
maxWidth = 'max-w-6xl',
|
||||||
|
compactInfo = false,
|
||||||
|
hideHeader = false,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
icon?: SurfaceIcon;
|
||||||
|
eyebrow?: string;
|
||||||
|
meta?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
maxWidth?: string;
|
||||||
|
compactInfo?: boolean;
|
||||||
|
hideHeader?: boolean;
|
||||||
|
}) {
|
||||||
|
const [infoOpen, setInfoOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[var(--app-bg)] text-slate-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
||||||
|
<div className={cn(maxWidth, 'mx-auto flex flex-col gap-4 pb-20 md:pb-6')}>
|
||||||
|
{!hideHeader ? <motion.header
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.28, ease: 'easeOut' }}
|
||||||
|
className={cn(
|
||||||
|
'relative rounded-2xl border border-white/70 bg-white/86 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl',
|
||||||
|
compactInfo ? 'overflow-visible' : 'overflow-hidden',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-blue-600 via-cyan-400 to-emerald-400" />
|
||||||
|
{compactInfo ? (
|
||||||
|
<>
|
||||||
|
<div className="flex min-h-[48px] items-center gap-2 px-3 py-1.5 md:min-h-[54px] md:gap-3 md:px-4 md:py-2">
|
||||||
|
{Icon ? (
|
||||||
|
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100 md:h-9 md:w-9">
|
||||||
|
<Icon size={17} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{eyebrow ? <span className="shrink-0 text-[11px] font-black text-blue-600">{eyebrow}</span> : null}
|
||||||
|
{meta ? <span className="hidden truncate text-[11px] font-bold text-slate-400 sm:inline">{meta}</span> : null}
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-0.5 truncate text-base font-black tracking-tight text-slate-950 md:text-lg">{title}</h1>
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="hidden shrink-0 items-center gap-2 md:flex">{actions}</div> : null}
|
||||||
|
{subtitle ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setInfoOpen(open => !open)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border text-slate-400 transition-colors md:h-9 md:w-9',
|
||||||
|
infoOpen ? 'border-blue-100 bg-blue-50 text-blue-600' : 'border-slate-100 bg-slate-50 hover:bg-blue-50 hover:text-blue-600',
|
||||||
|
)}
|
||||||
|
aria-label={infoOpen ? '收起页面说明' : '展开页面说明'}
|
||||||
|
title={infoOpen ? '收起说明' : '页面说明'}
|
||||||
|
>
|
||||||
|
{infoOpen ? <X size={16} /> : <Info size={16} />}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{infoOpen && subtitle ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||||
|
className="absolute left-0 right-0 top-full z-40 mt-2 overflow-hidden rounded-2xl border border-slate-100 bg-white shadow-xl md:static md:mt-0 md:rounded-none md:border-x-0 md:border-b-0 md:shadow-none"
|
||||||
|
>
|
||||||
|
<div className="px-4 py-3 text-xs font-bold leading-relaxed text-slate-500 md:text-sm">
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4 p-4 md:flex-row md:items-end md:justify-between md:p-5">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
|
{Icon ? (
|
||||||
|
<span className="inline-flex h-8 w-8 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
|
||||||
|
<Icon size={17} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{eyebrow ? <span className="text-[11px] font-black text-blue-600">{eyebrow}</span> : null}
|
||||||
|
{meta ? <span className="text-[11px] font-bold text-slate-400">{meta}</span> : null}
|
||||||
|
</div>
|
||||||
|
<h1 className="truncate text-xl font-black tracking-tight text-slate-950 md:text-2xl">{title}</h1>
|
||||||
|
{subtitle ? <p className="mt-2 max-w-3xl text-xs font-bold leading-relaxed text-slate-500 md:text-sm">{subtitle}</p> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.header> : null}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SurfaceCard({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
title?: ReactNode;
|
||||||
|
subtitle?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className={cn('rounded-2xl border border-slate-100 bg-white shadow-sm', className)}>
|
||||||
|
{(title || subtitle || actions) && (
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-slate-100 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
{title ? <h2 className="text-sm font-black text-slate-900">{title}</h2> : null}
|
||||||
|
{subtitle ? <p className="mt-1 text-[11px] font-bold text-slate-400">{subtitle}</p> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricTile({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
unit,
|
||||||
|
helper,
|
||||||
|
icon: Icon,
|
||||||
|
tone = 'blue',
|
||||||
|
className,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: ReactNode;
|
||||||
|
unit?: string;
|
||||||
|
helper?: string;
|
||||||
|
icon?: SurfaceIcon;
|
||||||
|
tone?: 'blue' | 'emerald' | 'amber' | 'rose' | 'slate';
|
||||||
|
className?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const toneClass = {
|
||||||
|
blue: 'bg-blue-50 text-blue-600 ring-blue-100',
|
||||||
|
emerald: 'bg-emerald-50 text-emerald-600 ring-emerald-100',
|
||||||
|
amber: 'bg-amber-50 text-amber-600 ring-amber-100',
|
||||||
|
rose: 'bg-rose-50 text-rose-600 ring-rose-100',
|
||||||
|
slate: 'bg-slate-100 text-slate-600 ring-slate-200',
|
||||||
|
}[tone];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
'rounded-2xl border border-slate-100 bg-white shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md',
|
||||||
|
compact ? 'p-3' : 'p-4',
|
||||||
|
className,
|
||||||
|
)}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 text-[11px] font-black text-slate-400">{label}</div>
|
||||||
|
{Icon ? (
|
||||||
|
<span className={cn('inline-flex shrink-0 items-center justify-center rounded-xl ring-1', compact ? 'h-8 w-8' : 'h-9 w-9', toneClass)}>
|
||||||
|
<Icon size={compact ? 16 : 18} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={cn('flex min-w-0 items-end gap-1', compact ? 'mt-2' : 'mt-3')}>
|
||||||
|
<span className={cn(
|
||||||
|
'min-w-0 whitespace-nowrap font-black leading-none tracking-tight text-slate-950 tabular-nums',
|
||||||
|
compact ? 'text-[clamp(1.45rem,6vw,1.7rem)]' : 'text-[clamp(1.65rem,6vw,2rem)]',
|
||||||
|
)}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
{unit ? <span className="shrink-0 pb-0.5 text-[11px] font-black text-slate-400">{unit}</span> : null}
|
||||||
|
</div>
|
||||||
|
{helper ? <div className={cn('font-bold leading-relaxed text-slate-500', compact ? 'mt-2 text-[10px]' : 'mt-3 text-[11px]')}>{helper}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SegmentedNav<T extends string>({
|
||||||
|
tabs,
|
||||||
|
active,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
tabs: readonly { id: T; label: string; icon?: SurfaceIcon }[];
|
||||||
|
active: T;
|
||||||
|
onChange: (id: T) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn('rounded-2xl border border-white/70 bg-white/90 p-1 shadow-sm backdrop-blur-xl', className)}>
|
||||||
|
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))` }}>
|
||||||
|
{tabs.map(({ id, label, icon: Icon }) => {
|
||||||
|
const isActive = active === id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(id)}
|
||||||
|
className={cn(
|
||||||
|
'relative flex min-h-10 items-center justify-center gap-1.5 rounded-xl px-2 text-[12px] font-black transition-colors',
|
||||||
|
isActive ? 'text-blue-700' : 'text-slate-400 hover:bg-slate-50 hover:text-slate-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isActive ? (
|
||||||
|
<motion.span
|
||||||
|
layoutId="segmented-active-pill"
|
||||||
|
className="absolute inset-0 rounded-xl bg-blue-50 shadow-sm ring-1 ring-blue-100"
|
||||||
|
transition={{ type: 'spring', stiffness: 430, damping: 34 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{Icon ? <Icon size={15} className="relative" /> : null}
|
||||||
|
<span className="relative truncate">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonBlock({ className }: { className?: string }) {
|
||||||
|
return <div className={cn('overflow-hidden rounded-xl bg-slate-100 shimmer', className)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingState({ label = '数据加载中' }: { label?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-sm">
|
||||||
|
<Loader2 className="mx-auto mb-3 animate-spin text-blue-500" size={22} />
|
||||||
|
<div className="text-xs font-black text-slate-500">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
title = '暂无数据',
|
||||||
|
description = '换个筛选条件或稍后再试',
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-sm">
|
||||||
|
<SearchX className="mx-auto mb-3 text-slate-300" size={26} />
|
||||||
|
<div className="text-sm font-black text-slate-700">{title}</div>
|
||||||
|
<div className="mt-1 text-xs font-bold text-slate-400">{description}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorState({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-rose-100 bg-rose-50 p-4 text-rose-700 shadow-sm">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle size={18} className="mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-black">加载失败</div>
|
||||||
|
<div className="mt-1 text-xs font-bold leading-relaxed">{message}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FadeIn({ children, className }: { children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -6 }}
|
||||||
|
transition={{ duration: 0.22, ease: 'easeOut' }}
|
||||||
|
className={cn('w-full min-w-0', className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--app-bg: #f4f7fb;
|
||||||
|
--panel-bg: rgba(255, 255, 255, 0.9);
|
||||||
|
--hairline: rgba(148, 163, 184, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
background: var(--app-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
@@ -13,4 +20,79 @@ html {
|
|||||||
body {
|
body {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
color: #0f172a;
|
||||||
|
background: #fff;
|
||||||
|
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input, select {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amap-logo,
|
||||||
|
.amap-copyright {
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amap-toolbar {
|
||||||
|
border: 1px solid #e2e8f0 !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
box-shadow: 0 8px 24px rgb(15 23 42 / 10%) !important;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.amap-logo,
|
||||||
|
.amap-copyright {
|
||||||
|
bottom: 60px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes marquee {
|
||||||
|
from { transform: translateX(0); }
|
||||||
|
to { transform: translateX(-50%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes floatUp {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility animate-marquee {
|
||||||
|
animation: marquee 30s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility no-scrollbar {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
&::-webkit-scrollbar { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.shimmer {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shimmer::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.72), transparent);
|
||||||
|
animation: shimmer 1.4s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enterprise-grid-bg {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 16% 0%, rgba(59, 130, 246, 0.08), transparent 28%),
|
||||||
|
radial-gradient(circle at 90% 12%, rgba(20, 184, 166, 0.08), transparent 26%),
|
||||||
|
linear-gradient(180deg, #f8fbff 0%, var(--app-bg) 42%, #f7f9fc 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-date-input::-webkit-calendar-picker-indicator {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function cn(...parts: Array<string | false | null | undefined>) {
|
||||||
|
return parts.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react';
|
import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react';
|
||||||
import { fetchJson } from '../../auth/api-client';
|
import { fetchJson } from '../../auth/api-client';
|
||||||
|
import { EmptyState, LoadingState, PageFrame, SurfaceCard } from '../../components/ui/surface';
|
||||||
|
|
||||||
interface FeedbackItem {
|
interface FeedbackItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -20,10 +21,10 @@ interface FeedbackItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_LABEL: Record<string, string> = {
|
const TYPE_LABEL: Record<string, string> = {
|
||||||
dimension: '💡 新维度',
|
dimension: '新维度',
|
||||||
bug: '🐛 Bug',
|
bug: 'Bug',
|
||||||
ux: '🎨 体验',
|
ux: '体验',
|
||||||
other: '📝 其他',
|
other: '其他',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [
|
const STATUS_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [
|
||||||
@@ -126,36 +127,36 @@ export default function FeedbackAdminPage() {
|
|||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] p-4 md:p-8">
|
<PageFrame
|
||||||
<div className="max-w-5xl mx-auto space-y-4">
|
title="用户反馈管理"
|
||||||
<header className="flex items-center justify-between">
|
subtitle="查看、回复、跟进用户提交的建议,形成从问题发现到处理闭环的运营后台。"
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
icon={Inbox}
|
||||||
|
eyebrow="FEEDBACK OPS"
|
||||||
|
meta={`当前 ${items.length} 条反馈`}
|
||||||
|
actions={(
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// 优先 history.back(来自 SPA 内部跳转);否则回到主页
|
// 优先 history.back(来自 SPA 内部跳转);否则回到主页
|
||||||
if (window.history.length > 1) window.history.back();
|
if (window.history.length > 1) window.history.back();
|
||||||
else { window.location.hash = '#mileage'; }
|
else { window.location.hash = '#mileage'; }
|
||||||
}}
|
}}
|
||||||
className="w-9 h-9 rounded-xl bg-white border border-slate-100 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 text-slate-500 flex items-center justify-center transition-colors flex-shrink-0"
|
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-100 bg-white text-slate-500 transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600"
|
||||||
title="返回"
|
title="返回"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
</button>
|
</button>
|
||||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center flex-shrink-0">
|
<button onClick={reload} className="flex h-9 w-9 items-center justify-center rounded-xl bg-slate-900 text-white shadow-sm transition-colors hover:bg-slate-800" title="刷新">
|
||||||
<Inbox size={18} className="text-white" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h1 className="text-lg font-black text-slate-900 leading-tight">用户反馈管理</h1>
|
|
||||||
<p className="text-[11px] font-bold text-slate-400">查看、回复、跟进用户提交的建议</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={reload} className="p-2 text-slate-400 hover:text-blue-500 flex-shrink-0" title="刷新">
|
|
||||||
<RotateCcw size={16} className={loading ? 'animate-spin' : ''} />
|
<RotateCcw size={16} className={loading ? 'animate-spin' : ''} />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</div>
|
||||||
|
)}
|
||||||
|
maxWidth="max-w-5xl"
|
||||||
|
>
|
||||||
|
|
||||||
{/* 状态过滤 */}
|
{/* 状态过滤 */}
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-2 flex items-center gap-1 overflow-x-auto">
|
<SurfaceCard className="p-2">
|
||||||
|
<div className="flex items-center gap-1 overflow-x-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => setStatusFilter('')}
|
onClick={() => setStatusFilter('')}
|
||||||
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === '' ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
|
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === '' ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||||
@@ -170,6 +171,7 @@ export default function FeedbackAdminPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</SurfaceCard>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-rose-50 border border-rose-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-rose-600">
|
<div className="bg-rose-50 border border-rose-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-rose-600">
|
||||||
@@ -192,9 +194,9 @@ export default function FeedbackAdminPage() {
|
|||||||
{/* 列表 */}
|
{/* 列表 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{loading && items.length === 0 ? (
|
{loading && items.length === 0 ? (
|
||||||
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold">加载中…</div>
|
<LoadingState label="正在加载用户反馈" />
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold">还没有反馈</div>
|
<EmptyState title="还没有反馈" description="新的用户反馈会出现在这里" />
|
||||||
) : items.map(it => {
|
) : items.map(it => {
|
||||||
const shots = parseScreenshots(it.screenshots);
|
const shots = parseScreenshots(it.screenshots);
|
||||||
const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status);
|
const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status);
|
||||||
@@ -218,7 +220,7 @@ export default function FeedbackAdminPage() {
|
|||||||
{(shots.length > 0 || it.contact) && (
|
{(shots.length > 0 || it.contact) && (
|
||||||
<div className="flex items-center gap-3 mt-2 text-[10px] text-slate-400 font-bold">
|
<div className="flex items-center gap-3 mt-2 text-[10px] text-slate-400 font-bold">
|
||||||
{shots.length > 0 && <span className="flex items-center gap-0.5"><ImageIcon size={11} />{shots.length} 张</span>}
|
{shots.length > 0 && <span className="flex items-center gap-0.5"><ImageIcon size={11} />{shots.length} 张</span>}
|
||||||
{it.contact && <span>📞 {it.contact}</span>}
|
{it.contact && <span>联系 {it.contact}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{it.reply_content && (
|
{it.reply_content && (
|
||||||
@@ -236,8 +238,6 @@ export default function FeedbackAdminPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 详情 / 回复弹窗 */}
|
{/* 详情 / 回复弹窗 */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{active && (
|
{active && (
|
||||||
@@ -336,6 +336,6 @@ export default function FeedbackAdminPage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</PageFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+253
-1395
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,43 @@ export interface WeeklyDetailItem {
|
|||||||
customer_name: string | null;
|
customer_name: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FlowType = 'delivered' | 'returned' | 'replaced';
|
||||||
|
|
||||||
|
export interface FlowDailyPoint {
|
||||||
|
date: string;
|
||||||
|
delivered: number;
|
||||||
|
returned: number;
|
||||||
|
replaced: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowDetailItem {
|
||||||
|
id: string;
|
||||||
|
type: FlowType;
|
||||||
|
typeLabel: string;
|
||||||
|
date: string;
|
||||||
|
truckId: string;
|
||||||
|
plateNumber: string;
|
||||||
|
eventTime: string | null;
|
||||||
|
submitTime: string | null;
|
||||||
|
department: string;
|
||||||
|
manager: string;
|
||||||
|
customerName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowStatsResponse {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
daily: FlowDailyPoint[];
|
||||||
|
totals: {
|
||||||
|
delivered: number;
|
||||||
|
returned: number;
|
||||||
|
replaced: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
details: FlowDetailItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchDeptStats(subject?: string | null): Promise<DeptGroup[]> {
|
export async function fetchDeptStats(subject?: string | null): Promise<DeptGroup[]> {
|
||||||
return fetchJson<DeptGroup[]>(withSubject(`${BASE}/dept-stats`, subject));
|
return fetchJson<DeptGroup[]>(withSubject(`${BASE}/dept-stats`, subject));
|
||||||
}
|
}
|
||||||
@@ -125,3 +162,13 @@ export async function fetchWeeklyDetail(
|
|||||||
if (filters?.source) params.set('source', filters.source);
|
if (filters?.source) params.set('source', filters.source);
|
||||||
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?${params.toString()}`);
|
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchFlowStats(params: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
subject?: string | null;
|
||||||
|
}): Promise<FlowStatsResponse> {
|
||||||
|
const query = new URLSearchParams({ start: params.start, end: params.end });
|
||||||
|
if (params.subject) query.set('subject', params.subject);
|
||||||
|
return fetchJson<FlowStatsResponse>(`${BASE}/flow-stats?${query.toString()}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { ChevronDown, Filter, Loader2, Search } from 'lucide-react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import type { SubjectOption } from '../api';
|
||||||
|
|
||||||
|
export type AssetsTab = 'overview' | 'department' | 'region' | 'customer';
|
||||||
|
export type AssetsTheme = 'soft' | 'minimal' | 'vibrant';
|
||||||
|
|
||||||
|
interface AssetsHeaderProps {
|
||||||
|
activeTab: AssetsTab;
|
||||||
|
setActiveTab: React.Dispatch<React.SetStateAction<AssetsTab>>;
|
||||||
|
theme: AssetsTheme;
|
||||||
|
setTheme: React.Dispatch<React.SetStateAction<AssetsTheme>>;
|
||||||
|
lastUpdate: string;
|
||||||
|
loading: boolean;
|
||||||
|
selectedSubject: string | null;
|
||||||
|
setSelectedSubject: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
|
subjects: SubjectOption[];
|
||||||
|
subjectDropdownOpen: boolean;
|
||||||
|
setSubjectDropdownOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
subjectSearch: string;
|
||||||
|
setSubjectSearch: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
subjectDropdownRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
}
|
||||||
|
// --- Constants ---
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'overview', label: '总览' },
|
||||||
|
{ id: 'department', label: '按部门' },
|
||||||
|
{ id: 'region', label: '按区域' },
|
||||||
|
{ id: 'customer', label: '按客户' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function MarqueeBanner() {
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
|
const innerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [overflow, setOverflow] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const check = () => {
|
||||||
|
if (!trackRef.current || !innerRef.current) return;
|
||||||
|
setOverflow(innerRef.current.scrollWidth > trackRef.current.clientWidth);
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
const ro = new ResizeObserver(check);
|
||||||
|
ro.observe(trackRef.current!);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const text = '车辆资产已于 2026 年 6 月 18 日完成“运营状态”与“业务关联”校验';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative -mx-6 mb-4 bg-green-50 border-y border-green-200">
|
||||||
|
<div ref={trackRef} className="overflow-hidden">
|
||||||
|
<div className={`flex w-max py-2 ${overflow ? 'animate-marquee' : 'w-full justify-center'}`}>
|
||||||
|
<span ref={innerRef} className="inline-block whitespace-nowrap px-6 text-xs text-green-700 font-medium">
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
{overflow && (
|
||||||
|
<span className="inline-block whitespace-nowrap px-6 text-xs text-green-700 font-medium">
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssetsHeader({
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
theme,
|
||||||
|
setTheme,
|
||||||
|
lastUpdate,
|
||||||
|
loading,
|
||||||
|
selectedSubject,
|
||||||
|
setSelectedSubject,
|
||||||
|
subjects,
|
||||||
|
subjectDropdownOpen,
|
||||||
|
setSubjectDropdownOpen,
|
||||||
|
subjectSearch,
|
||||||
|
setSubjectSearch,
|
||||||
|
subjectDropdownRef,
|
||||||
|
}: AssetsHeaderProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Compact Header Bar */}
|
||||||
|
<div className="sticky top-0 z-40 -mx-3 -mt-3 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80 md:-mx-6 md:-mt-6">
|
||||||
|
{/* Title row */}
|
||||||
|
<div className="relative flex items-center justify-center px-4 pt-3 pb-1">
|
||||||
|
<h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide">羚牛氢能-资产BI</h1>
|
||||||
|
{/* Right: status + theme */}
|
||||||
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||||
|
<div className="hidden sm:flex items-center gap-1 text-[10px] text-gray-400">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse inline-block" />
|
||||||
|
<span>{lastUpdate}</span>
|
||||||
|
</div>
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-gray-400">
|
||||||
|
<Loader2 className="animate-spin" size={10} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="hidden sm:flex bg-gray-100 p-0.5 rounded-lg text-[10px]">
|
||||||
|
{(['soft','minimal','vibrant'] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTheme(t)}
|
||||||
|
className={`px-2 py-0.5 rounded-md transition-all ${theme === t ? 'bg-white text-blue-600 shadow-sm font-semibold' : 'text-gray-400 hover:text-gray-600'}`}
|
||||||
|
>
|
||||||
|
{t === 'soft' ? '柔和' : t === 'minimal' ? '简约' : '经典'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 归属公司作用域筛选 (Scope Chip) */}
|
||||||
|
<div className="flex items-center justify-center px-4 pt-1">
|
||||||
|
<div className="relative" ref={subjectDropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSubjectDropdownOpen((o) => !o);
|
||||||
|
setSubjectSearch('');
|
||||||
|
}}
|
||||||
|
className={`group inline-flex items-center gap-1.5 h-7 pl-2.5 pr-2 rounded-full border text-[11px] font-normal transition-all cursor-pointer ${
|
||||||
|
selectedSubject
|
||||||
|
? 'bg-blue-50 border-blue-300 text-blue-700 hover:bg-blue-100'
|
||||||
|
: 'bg-white border-gray-200 text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
title={selectedSubject || '全部公司'}
|
||||||
|
>
|
||||||
|
<Filter size={11} className={selectedSubject ? 'text-blue-500' : 'text-gray-400'} />
|
||||||
|
<span className="max-w-[180px] truncate">
|
||||||
|
所属公司:{selectedSubject || '全部公司'}
|
||||||
|
</span>
|
||||||
|
{selectedSubject ? (
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedSubject(null);
|
||||||
|
}}
|
||||||
|
className="ml-0.5 w-3.5 h-3.5 inline-flex items-center justify-center rounded-full text-blue-500 hover:bg-blue-200 hover:text-blue-700 cursor-pointer"
|
||||||
|
aria-label="清除归属公司筛选"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={11} className="text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{subjectDropdownOpen && (
|
||||||
|
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-1.5 w-[320px] max-h-[380px] bg-white border border-gray-200 rounded-lg shadow-lg z-50 flex flex-col">
|
||||||
|
<div className="p-2 border-b border-gray-100">
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={subjectSearch}
|
||||||
|
onChange={(e) => setSubjectSearch(e.target.value)}
|
||||||
|
placeholder="搜索公司名"
|
||||||
|
className="w-full h-7 pl-6 pr-2 text-[11px] bg-gray-50 border border-gray-100 rounded focus:outline-none focus:border-blue-300 focus:bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto flex-1 py-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSubject(null);
|
||||||
|
setSubjectDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center justify-between px-3 py-1.5 text-[11px] hover:bg-gray-50 cursor-pointer ${
|
||||||
|
!selectedSubject ? 'text-blue-600 font-medium' : 'text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${!selectedSubject ? 'bg-blue-500' : 'bg-gray-300'}`} />
|
||||||
|
全部公司
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-400">
|
||||||
|
{subjects.reduce((s, x) => s + x.total, 0)} 台
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div className="my-1 mx-3 border-t border-gray-100" />
|
||||||
|
{subjects
|
||||||
|
.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase()))
|
||||||
|
.map((s) => {
|
||||||
|
const active = selectedSubject === s.name;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSubject(s.name);
|
||||||
|
setSubjectDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center justify-between gap-2 px-3 py-1.5 text-[11px] hover:bg-gray-50 cursor-pointer ${
|
||||||
|
active ? 'text-blue-600 font-medium bg-blue-50/40' : 'text-gray-700'
|
||||||
|
}`}
|
||||||
|
title={s.name}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${active ? 'bg-blue-500' : 'bg-gray-300'}`} />
|
||||||
|
<span className="truncate">{s.name}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-400 flex-shrink-0 tabular-nums">
|
||||||
|
{s.total} 台
|
||||||
|
<span className="mx-1 text-gray-200">·</span>
|
||||||
|
<span className="text-green-500">运营 {s.operating}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{subjects.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase())).length === 0 && (
|
||||||
|
<div className="px-3 py-6 text-center text-[11px] text-gray-400">未找到匹配公司</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab row */}
|
||||||
|
<div className="flex items-center justify-center gap-1 px-4 pb-0 overflow-x-auto no-scrollbar">
|
||||||
|
{TABS.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id as typeof activeTab)}
|
||||||
|
className={`relative px-4 py-2 text-[13px] font-normal transition-all whitespace-nowrap ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'text-blue-600 font-medium'
|
||||||
|
: 'text-gray-400 hover:text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
{activeTab === tab.id && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="activeTab"
|
||||||
|
className="absolute bottom-0 left-2 right-2 h-[1.5px] bg-blue-600 rounded-full"
|
||||||
|
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Status row */}
|
||||||
|
<div className="flex items-center justify-center gap-4 py-1.5 text-[10px] text-gray-400">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="w-1 h-1 rounded-full bg-blue-400 inline-block" />
|
||||||
|
最后更新: {lastUpdate}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OneOS 迁移提示滚动条 */}
|
||||||
|
<MarqueeBanner />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Download, X } from 'lucide-react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import type { FlowDetailItem, FlowType } from '../api';
|
||||||
|
import type { FlowSelection } from '../model';
|
||||||
|
|
||||||
|
export const FLOW_META: Record<FlowType, { label: string; tone: string; chip: string }> = {
|
||||||
|
delivered: { label: '交车', tone: 'text-blue-600 bg-blue-50 border-blue-100', chip: 'bg-blue-50 text-blue-700 border-blue-100' },
|
||||||
|
returned: { label: '还车', tone: 'text-orange-600 bg-orange-50 border-orange-100', chip: 'bg-orange-50 text-orange-700 border-orange-100' },
|
||||||
|
replaced: { label: '替换交车', tone: 'text-violet-600 bg-violet-50 border-violet-100', chip: 'bg-violet-50 text-violet-700 border-violet-100' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FlowDetailModalProps {
|
||||||
|
selectedFlow: FlowSelection | null;
|
||||||
|
selectedFlowDetails: FlowDetailItem[];
|
||||||
|
setSelectedFlow: React.Dispatch<React.SetStateAction<FlowSelection | null>>;
|
||||||
|
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
|
||||||
|
}
|
||||||
|
export function FlowDetailModal({
|
||||||
|
selectedFlow,
|
||||||
|
selectedFlowDetails,
|
||||||
|
setSelectedFlow,
|
||||||
|
exportFlowDetails,
|
||||||
|
}: FlowDetailModalProps) {
|
||||||
|
const dateLabel = selectedFlow
|
||||||
|
? ('date' in selectedFlow ? selectedFlow.date : `${selectedFlow.start} 至 ${selectedFlow.end}`)
|
||||||
|
: '';
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Flow Detail Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{selectedFlow && (
|
||||||
|
<div className="fixed inset-0 z-[1000] flex items-end justify-center bg-slate-950/45 p-0 backdrop-blur-sm sm:items-center sm:p-4">
|
||||||
|
<motion.div
|
||||||
|
data-testid="asset-flow-detail-modal"
|
||||||
|
initial={{ opacity: 0, y: 28, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 20, scale: 0.98 }}
|
||||||
|
className="flex max-h-[88vh] w-full flex-col overflow-hidden rounded-t-3xl bg-white shadow-2xl sm:max-w-5xl sm:rounded-3xl"
|
||||||
|
>
|
||||||
|
<div className="border-b border-slate-100 bg-slate-950 px-4 py-4 text-white sm:px-5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-black uppercase tracking-wide text-slate-400">运维签章时间口径 · 单位:车次</div>
|
||||||
|
<h3 className="mt-1 text-lg font-black">
|
||||||
|
{dateLabel} · {FLOW_META[selectedFlow.type].label}明细
|
||||||
|
</h3>
|
||||||
|
<div className="mt-1 text-[12px] font-bold text-slate-300">
|
||||||
|
共 {selectedFlowDetails.length} 车次,包含车牌、运维签章时间、建单时间、部门、负责人和客户
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="关闭流转明细"
|
||||||
|
onClick={() => setSelectedFlow(null)}
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => exportFlowDetails(selectedFlowDetails, `${dateLabel}-${FLOW_META[selectedFlow.type].label}明细`)}
|
||||||
|
disabled={selectedFlowDetails.length === 0}
|
||||||
|
className="inline-flex h-8 items-center gap-1 rounded-xl bg-white px-3 text-[11px] font-black text-slate-900 transition hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Download size={13} />
|
||||||
|
导出当前明细
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-auto bg-slate-50 p-3 sm:p-4">
|
||||||
|
<div className="hidden overflow-hidden rounded-2xl border border-slate-100 bg-white lg:block">
|
||||||
|
<table className="w-full table-fixed text-left">
|
||||||
|
<thead className="bg-slate-50 text-[11px] font-black text-slate-400">
|
||||||
|
<tr>
|
||||||
|
<th className="w-28 px-3 py-3">车牌</th>
|
||||||
|
<th className="w-40 px-3 py-3">运维签章时间</th>
|
||||||
|
<th className="w-40 px-3 py-3">建单时间</th>
|
||||||
|
<th className="w-36 px-3 py-3">部门</th>
|
||||||
|
<th className="w-28 px-3 py-3">业务负责人</th>
|
||||||
|
<th className="px-3 py-3">客户</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100 text-[12px] font-bold text-slate-700">
|
||||||
|
{selectedFlowDetails.map((item) => (
|
||||||
|
<tr key={item.id} className="hover:bg-blue-50/40">
|
||||||
|
<td className="px-3 py-3 font-black text-slate-950">{item.plateNumber}{item.type === 'replaced' && <span className="mt-1 block text-[10px] text-violet-600">替换交车</span>}</td>
|
||||||
|
<td className="px-3 py-3 text-slate-500">{item.eventTime || '-'}</td>
|
||||||
|
<td className="px-3 py-3 text-blue-600">{item.submitTime || '-'}</td>
|
||||||
|
<td className="px-3 py-3">{item.department || '-'}</td>
|
||||||
|
<td className="px-3 py-3">{item.manager || '-'}</td>
|
||||||
|
<td className="px-3 py-3">{item.customerName || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 lg:hidden">
|
||||||
|
{selectedFlowDetails.map((item) => (
|
||||||
|
<div key={item.id} className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-black text-slate-950">{item.plateNumber}</div>
|
||||||
|
<div className={`mt-1 inline-flex rounded-full border px-2 py-0.5 text-[10px] font-black ${FLOW_META[item.type].chip}`}>
|
||||||
|
{item.typeLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-[10px] font-bold text-slate-400">
|
||||||
|
<div>建单</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-blue-600">{item.submitTime?.slice(5, 16) || '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px]">
|
||||||
|
<div className="rounded-xl bg-slate-50 p-2">
|
||||||
|
<div className="font-black text-slate-400">运维签章时间</div>
|
||||||
|
<div className="mt-1 font-bold text-slate-700">{item.eventTime || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-slate-50 p-2">
|
||||||
|
<div className="font-black text-slate-400">负责人</div>
|
||||||
|
<div className="mt-1 font-bold text-slate-700">{item.manager || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-slate-50 p-2">
|
||||||
|
<div className="font-black text-slate-400">部门</div>
|
||||||
|
<div className="mt-1 font-bold text-slate-700">{item.department || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-slate-50 p-2">
|
||||||
|
<div className="font-black text-slate-400">客户</div>
|
||||||
|
<div className="mt-1 line-clamp-2 font-bold text-slate-700">{item.customerName || '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedFlowDetails.length === 0 && (
|
||||||
|
<div className="rounded-2xl bg-white px-4 py-10 text-center text-sm font-bold text-slate-400">当前日期暂无明细</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { AssetSummarySection } from './overview/AssetSummarySection';
|
||||||
|
import { FlowStatisticsCard } from './overview/FlowStatisticsCard';
|
||||||
|
import { InventoryStatisticsSection } from './overview/InventoryStatisticsSection';
|
||||||
|
import { SummaryMetrics } from './overview/SummaryMetrics';
|
||||||
|
import type { OverviewViewProps } from './overview/types';
|
||||||
|
|
||||||
|
export function OverviewView(props: OverviewViewProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SummaryMetrics
|
||||||
|
summary={props.summary}
|
||||||
|
operatingRate={props.operatingRate}
|
||||||
|
inventoryRate={props.inventoryRate}
|
||||||
|
pendingRate={props.pendingRate}
|
||||||
|
setVehicleSelection={props.setVehicleSelection}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FlowStatisticsCard
|
||||||
|
flowRange={props.flowRange}
|
||||||
|
setFlowRange={props.setFlowRange}
|
||||||
|
flowLoading={props.flowLoading}
|
||||||
|
flowStats={props.flowStats}
|
||||||
|
exportFlowDetails={props.exportFlowDetails}
|
||||||
|
flowDailyExpanded={props.flowDailyExpanded}
|
||||||
|
setFlowDailyExpanded={props.setFlowDailyExpanded}
|
||||||
|
setSelectedFlow={props.setSelectedFlow}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AssetSummarySection
|
||||||
|
processedData={props.processedData}
|
||||||
|
theme={props.theme}
|
||||||
|
expandedAssetTypes={props.expandedAssetTypes}
|
||||||
|
toggleAssetType={props.toggleAssetType}
|
||||||
|
expandedModels={props.expandedModels}
|
||||||
|
toggleModel={props.toggleModel}
|
||||||
|
setVehicleSelection={props.setVehicleSelection}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InventoryStatisticsSection
|
||||||
|
inventoryFilters={props.inventoryFilters}
|
||||||
|
setInventoryFilters={props.setInventoryFilters}
|
||||||
|
draftInventoryFilters={props.draftInventoryFilters}
|
||||||
|
setDraftInventoryFilters={props.setDraftInventoryFilters}
|
||||||
|
isInventoryFilterOpen={props.isInventoryFilterOpen}
|
||||||
|
setIsInventoryFilterOpen={props.setIsInventoryFilterOpen}
|
||||||
|
uniqueInventoryRegions={props.uniqueInventoryRegions}
|
||||||
|
uniqueInventoryCities={props.uniqueInventoryCities}
|
||||||
|
uniqueInventoryBrands={props.uniqueInventoryBrands}
|
||||||
|
uniqueInventoryTypes={props.uniqueInventoryTypes}
|
||||||
|
uniqueInventoryModelsForType={props.uniqueInventoryModelsForType}
|
||||||
|
filteredInventoryStats={props.filteredInventoryStats}
|
||||||
|
inventoryTab={props.inventoryTab}
|
||||||
|
setInventoryTab={props.setInventoryTab}
|
||||||
|
inventoryByRegion={props.inventoryByRegion}
|
||||||
|
expandedInventoryRegions={props.expandedInventoryRegions}
|
||||||
|
toggleInventoryRegion={props.toggleInventoryRegion}
|
||||||
|
inventoryByModel={props.inventoryByModel}
|
||||||
|
expandedInventoryTypes={props.expandedInventoryTypes}
|
||||||
|
toggleInventoryType={props.toggleInventoryType}
|
||||||
|
setVehicleSelection={props.setVehicleSelection}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ChevronDown, Filter, Loader2, PlusCircle, Truck } from 'lucide-react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import Blur from '../../../components/Blur';
|
||||||
|
import { SearchSelect } from '../../../components/SearchSelect';
|
||||||
|
import type { WeeklyDetailItem } from '../api';
|
||||||
|
import type { ModalVehicleFilters, VehicleModalSelection } from '../model';
|
||||||
|
import type { VehicleListItem } from '../types';
|
||||||
|
|
||||||
|
interface VehicleDetailModalProps {
|
||||||
|
selection: VehicleModalSelection | null;
|
||||||
|
setSelection: React.Dispatch<React.SetStateAction<VehicleModalSelection | null>>;
|
||||||
|
isFilterExpanded: boolean;
|
||||||
|
setIsFilterExpanded: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
filters: ModalVehicleFilters;
|
||||||
|
setFilters: React.Dispatch<React.SetStateAction<ModalVehicleFilters>>;
|
||||||
|
plates: string[];
|
||||||
|
models: string[];
|
||||||
|
brands: string[];
|
||||||
|
locations: string[];
|
||||||
|
loading: boolean;
|
||||||
|
weeklyDetails: WeeklyDetailItem[];
|
||||||
|
filteredWeeklyDetails: WeeklyDetailItem[];
|
||||||
|
filteredVehicles: VehicleListItem[];
|
||||||
|
}
|
||||||
|
export function VehicleDetailModal({
|
||||||
|
selection: showPlateNumbers,
|
||||||
|
setSelection: setShowPlateNumbers,
|
||||||
|
isFilterExpanded: isModalFilterExpanded,
|
||||||
|
setIsFilterExpanded: setIsModalFilterExpanded,
|
||||||
|
filters: modalFilters,
|
||||||
|
setFilters: setModalFilters,
|
||||||
|
plates: uniqueModalPlates,
|
||||||
|
models: uniqueModalModels,
|
||||||
|
brands: uniqueModalBrands,
|
||||||
|
locations: uniqueModalLocations,
|
||||||
|
loading: modalLoading,
|
||||||
|
weeklyDetails: modalWeeklyDetail,
|
||||||
|
filteredWeeklyDetails: filteredModalWeeklyDetail,
|
||||||
|
filteredVehicles: filteredModalVehicles,
|
||||||
|
}: VehicleDetailModalProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Vehicle Detail Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showPlateNumbers && (
|
||||||
|
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||||
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
|
className={`bg-white rounded-xl shadow-2xl w-full max-w-[95vw] ${showPlateNumbers.source === 'customer' ? 'lg:max-w-6xl' : 'lg:max-w-4xl'} overflow-hidden flex flex-col max-h-[85vh] sm:max-h-[90vh] min-h-[40vh]`}
|
||||||
|
>
|
||||||
|
<div className="p-4 border-b border-gray-100 flex justify-between items-center bg-slate-800 text-white shrink-0">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-base flex items-center gap-2">
|
||||||
|
<Truck size={18} className="text-blue-400" />
|
||||||
|
{showPlateNumbers.title || (
|
||||||
|
(showPlateNumbers.manager ? `${showPlateNumbers.manager} 的${showPlateNumbers.type || ''}车辆` :
|
||||||
|
showPlateNumbers.customer ? `${showPlateNumbers.customer} 的${showPlateNumbers.type || ''}车辆` :
|
||||||
|
showPlateNumbers.batch === 'All' ? '全量批次' : `${showPlateNumbers.batch} 批次`) + ' - 运营明细'
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[10px] opacity-60 mt-0.5">
|
||||||
|
{showPlateNumbers.model === 'All' ? '全量型号' : showPlateNumbers.model} |
|
||||||
|
{showPlateNumbers.category === 'Pending' ? '待交车' :
|
||||||
|
showPlateNumbers.category === 'Delivered' ? '本周已交车' :
|
||||||
|
showPlateNumbers.category === 'Returned' ? '已还车' :
|
||||||
|
showPlateNumbers.category === 'Replaced' ? '已替换' :
|
||||||
|
showPlateNumbers.category === 'Abnormal' ? '异动' :
|
||||||
|
showPlateNumbers.category === 'Inventory' ? `${showPlateNumbers.location}库存` :
|
||||||
|
showPlateNumbers.category === 'Operating' ? '正在运营' : '全部状态'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowPlateNumbers(null)} className="hover:bg-white/10 p-2 rounded-full transition-colors">
|
||||||
|
<PlusCircle className="rotate-45" size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Filters */}
|
||||||
|
<div className="px-4 py-2 bg-slate-50 border-b border-gray-200 shrink-0">
|
||||||
|
<div
|
||||||
|
className="flex justify-between items-center cursor-pointer py-1"
|
||||||
|
onClick={() => setIsModalFilterExpanded(!isModalFilterExpanded)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-slate-600">
|
||||||
|
<Filter size={14} />
|
||||||
|
<span className="text-xs font-bold">筛选条件</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Quick Search always visible when collapsed */}
|
||||||
|
{!isModalFilterExpanded && (
|
||||||
|
<div className="w-40 sm:w-64" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<SearchSelect value={modalFilters.plateNumber} onChange={(v) => setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="快速搜索车牌..." className="text-[11px] py-1 px-2" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: isModalFilterExpanded ? 180 : 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<ChevronDown size={16} className="text-slate-400" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isModalFilterExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 py-3 border-t border-gray-100 mt-1">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-[10px] text-gray-500 font-medium">车牌号码</label>
|
||||||
|
<SearchSelect value={modalFilters.plateNumber} onChange={(v) => setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="全部车牌" className="text-[11px] py-1.5 px-2" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-[10px] text-gray-500 font-medium">车型型号</label>
|
||||||
|
<select value={modalFilters.model} onChange={(e) => setModalFilters({...modalFilters, model: e.target.value})} className="w-full text-[11px] px-2 py-1.5 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||||
|
<option value="">全部车型</option>
|
||||||
|
{uniqueModalModels.map(m => <option key={m} value={m}>{m}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-[10px] text-gray-500 font-medium">品牌名称</label>
|
||||||
|
<select value={modalFilters.brand} onChange={(e) => setModalFilters({...modalFilters, brand: e.target.value})} className="w-full text-[11px] px-2 py-1.5 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||||
|
<option value="">全部品牌</option>
|
||||||
|
{uniqueModalBrands.map(b => <option key={b} value={b}>{b}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-[10px] text-gray-500 font-medium">运营所在地</label>
|
||||||
|
<select value={modalFilters.location} onChange={(e) => setModalFilters({...modalFilters, location: e.target.value})} className="w-full text-[11px] px-2 py-1.5 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||||
|
<option value="">全部地区</option>
|
||||||
|
{uniqueModalLocations.map(l => <option key={l} value={l}>{l}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end pb-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setModalFilters({ plateNumber: '', model: '', brand: '', location: '' })}
|
||||||
|
className="text-[10px] text-blue-500 hover:text-blue-600 font-medium"
|
||||||
|
>
|
||||||
|
重置筛选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-0 sm:p-4 bg-gray-50 min-h-0 overscroll-contain">
|
||||||
|
{modalLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="animate-spin text-blue-500" size={32} />
|
||||||
|
</div>
|
||||||
|
) : modalWeeklyDetail.length > 0 ? (
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden w-full">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-700 text-white text-[10px] uppercase tracking-wider sticky top-0 z-20 shadow-sm">
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 text-center">车牌</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 text-center">客户名称</th>
|
||||||
|
<th className="p-2 font-semibold text-center">日期</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="text-[11px]">
|
||||||
|
{filteredModalWeeklyDetail.map((v, i) => (
|
||||||
|
<tr key={`${v.truck_id}-${i}`} className={`border-b border-gray-100 hover:bg-blue-50/50 transition-colors ${i % 2 === 0 ? 'bg-white' : 'bg-gray-50/30'}`}>
|
||||||
|
<td className="p-2 border-r border-gray-100 font-mono font-bold text-blue-700 text-center"><Blur>{v.plate_number}</Blur></td>
|
||||||
|
<td className="p-2 border-r border-gray-100 text-gray-600 text-center"><Blur>{v.customer_name || '—'}</Blur></td>
|
||||||
|
<td className="p-2 text-gray-500 text-center">{v.handover_date ? v.handover_date.slice(0, 10) : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 shadow-sm w-full">
|
||||||
|
<table className="min-w-full w-max text-left border-collapse">
|
||||||
|
<thead className="sticky top-0 z-20 shadow-sm">
|
||||||
|
<tr className="bg-slate-700 text-white text-[10px] uppercase tracking-wider whitespace-nowrap">
|
||||||
|
{showPlateNumbers.source === 'customer' ? (
|
||||||
|
<>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-24">业务部门</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-24">业务负责人</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-16">品牌</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-16">车型</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-48">资产归属</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-48">客户名称</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-24">车牌</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-16 text-center">状态</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-24 text-center">提车时间</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-24 text-center">合同到期时间</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-24">运营区域</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-20 text-center">离到期</th>
|
||||||
|
<th className="p-2 font-semibold w-48">签约公司</th>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 text-center">车牌</th>
|
||||||
|
{showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && (
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 text-center">客户名称</th>
|
||||||
|
)}
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 text-center">品牌</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 text-center">车型</th>
|
||||||
|
<th className="p-2 font-semibold text-center">所在地</th>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="text-[11px]">
|
||||||
|
{filteredModalVehicles.map((v, idx) => (
|
||||||
|
<tr key={v.id} className={`border-b border-gray-100 hover:bg-blue-50/50 transition-colors whitespace-nowrap ${idx % 2 === 0 ? 'bg-white' : 'bg-gray-50/30'}`}>
|
||||||
|
{showPlateNumbers.source === 'customer' ? (
|
||||||
|
<>
|
||||||
|
<td className="p-2 border-r border-gray-100 text-gray-600">{v.departmentName || '—'}</td>
|
||||||
|
<td className="p-2 border-r border-gray-100 font-medium text-gray-700"><Blur>{v.customerManager || '—'}</Blur></td>
|
||||||
|
<td className="p-2 border-r border-gray-100 text-gray-600">{v.brandLabel || '—'}</td>
|
||||||
|
<td className="p-2 border-r border-gray-100 text-gray-600">{v.type}</td>
|
||||||
|
<td className="p-2 border-r border-gray-100 text-gray-500 text-[10px]"><Blur>{v.subjectOrg || '—'}</Blur></td>
|
||||||
|
<td className="p-2 border-r border-gray-100 font-bold text-gray-800"><Blur>{v.customerName || '—'}</Blur></td>
|
||||||
|
<td className={`p-2 border-r border-gray-100 font-mono font-bold ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
|
||||||
|
<td className="p-2 border-r border-gray-100 text-center">
|
||||||
|
<span className={`px-1.5 py-0.5 rounded-full text-[9px] font-bold ${
|
||||||
|
v.status === 'Operating' ? 'bg-green-100 text-green-700' :
|
||||||
|
v.status === 'Inventory' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{v.status === 'Operating' ? '在租' : v.status === 'Inventory' ? '库存' : '异常'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-2 border-r border-gray-100 text-center text-gray-500">{'—'}</td>
|
||||||
|
<td className="p-2 border-r border-gray-100 text-center text-gray-500">{'—'}</td>
|
||||||
|
<td className="p-2 border-r border-gray-100 text-gray-600">{v.location === '其他' ? '对接中' : v.location}</td>
|
||||||
|
<td className="p-2 border-r border-gray-100 text-center font-bold text-orange-600">{'—'}</td>
|
||||||
|
<td className="p-2 text-gray-500 text-[10px]"><Blur>{v.orgName || '—'}</Blur></td>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td className={`p-2 border-r border-gray-100 font-mono font-bold text-center ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
|
||||||
|
{showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && (
|
||||||
|
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 text-center"><Blur>{v.customerName || '—'}</Blur></td>
|
||||||
|
)}
|
||||||
|
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.brandLabel || '—'}</td>
|
||||||
|
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.type}</td>
|
||||||
|
<td className="p-2 text-gray-600 text-center">{v.location === '其他' ? '对接中' : v.location}</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{filteredModalVehicles.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={showPlateNumbers.source === 'customer' ? 13 : ((showPlateNumbers.source === 'asset' || showPlateNumbers.category === 'Inventory') ? 4 : 5)} className="p-8 text-center text-gray-400 italic">
|
||||||
|
暂无符合条件的车辆数据
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-white border-t border-gray-100 flex justify-between items-center shrink-0">
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
共计 <span className="font-bold text-blue-600">{filteredModalWeeklyDetail.length > 0 ? filteredModalWeeklyDetail.length : filteredModalVehicles.length}</span> 台车辆
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPlateNumbers(null)}
|
||||||
|
className="px-6 py-2 bg-slate-800 text-white rounded-lg text-xs font-bold hover:bg-slate-700 transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
确认并关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import type { AssetSummarySectionProps } from './types';
|
||||||
|
|
||||||
|
export function AssetSummaryDesktopTable({
|
||||||
|
processedData,
|
||||||
|
theme,
|
||||||
|
expandedAssetTypes,
|
||||||
|
toggleAssetType,
|
||||||
|
expandedModels,
|
||||||
|
toggleModel,
|
||||||
|
setVehicleSelection: setShowPlateNumbers,
|
||||||
|
}: AssetSummarySectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="hidden lg:block overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse table-fixed min-w-[1300px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50 text-[11px] text-gray-500 uppercase tracking-wider border-b border-gray-100">
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 w-24">车型</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 w-48">品牌型号</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-blue-50/30 w-24">车辆总资产</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存总数</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-江浙沪</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-广东</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-北京</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-新疆</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-其他</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center text-amber-600 w-24">异动</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">待交车</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-green-50/30 w-24">当前在运营</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-blue-50/20 w-24">本周交车</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-orange-50/20 w-24">本周还车</th>
|
||||||
|
<th className="p-3 font-semibold text-center bg-purple-50/20 w-24">本周替换</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="text-xs">
|
||||||
|
{processedData.map((typeGroup) => (
|
||||||
|
<React.Fragment key={typeGroup.type}>
|
||||||
|
{/* Category Header Row */}
|
||||||
|
<tr className={`border-b border-gray-100 cursor-pointer transition-all ${
|
||||||
|
theme === 'vibrant' ? 'bg-blue-600 text-white hover:bg-blue-700' :
|
||||||
|
theme === 'minimal' ? 'bg-white border-l-4 border-blue-500 hover:bg-gray-50' :
|
||||||
|
'bg-blue-50/50 hover:bg-blue-50 transition-colors'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleAssetType(typeGroup.type)}>
|
||||||
|
<td colSpan={15} className={`p-3 font-bold ${theme === 'vibrant' ? 'text-white' : 'text-blue-700'}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedAssetTypes.has(typeGroup.type) ?
|
||||||
|
<ChevronDown size={16} className={theme === 'vibrant' ? 'text-white' : 'text-blue-500'} /> :
|
||||||
|
<ChevronRight size={16} className={theme === 'vibrant' ? 'text-white/70' : 'text-gray-400'} />
|
||||||
|
}
|
||||||
|
<span>{typeGroup.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`flex gap-6 text-[11px] font-normal mr-4 ${theme === 'vibrant' ? 'text-white/80' : 'text-gray-500'}`}>
|
||||||
|
<span>资产 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span>
|
||||||
|
<span>库存 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span></span>
|
||||||
|
<span>异动 <span className="font-bold">{typeGroup.totalAbnormal}</span></span>
|
||||||
|
<span>运营 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{expandedAssetTypes.has(typeGroup.type) && typeGroup.models.map((model) => (
|
||||||
|
<React.Fragment key={model.model}>
|
||||||
|
<motion.tr
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors cursor-pointer ${expandedModels.has(model.model) ? 'bg-blue-50/10' : ''}`}
|
||||||
|
onClick={() => toggleModel(model.model)}
|
||||||
|
>
|
||||||
|
<td className="p-3 border-r border-gray-100 text-gray-300 text-center italic">{typeGroup.type}</td>
|
||||||
|
<td className="p-3 border-r border-gray-100 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedModels.has(model.model) ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
|
||||||
|
<span className={expandedModels.has(model.model) ? 'font-bold text-blue-700' : ''}>{model.model}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 text-[9px] font-normal text-gray-400">
|
||||||
|
<span>资产 <span className="font-bold text-gray-600">{model.total}</span></span>
|
||||||
|
<span>库存 <span className="font-bold text-blue-500">{model.inventory}</span></span>
|
||||||
|
<span>运营 <span className="font-bold text-green-500">{model.operating}</span></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100 font-medium">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', source: 'asset', title: model.model });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.total}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100 font-medium">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.inventory}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
{['嘉兴', '广东', '北京', '新疆', '其他'].map(reg => (
|
||||||
|
<td key={reg} className="p-3 text-center border-r border-gray-100">
|
||||||
|
{model.inventoryRegions[reg] > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: reg, category: 'Inventory', source: 'asset', title: `${model.model} - ${reg} 库存` });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.inventoryRegions[reg]}
|
||||||
|
</button>
|
||||||
|
) : ''}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="p-3 text-center border-r border-gray-100">
|
||||||
|
<button type="button" className="font-bold text-amber-600 hover:underline"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Abnormal', source: 'asset', title: `${model.model} - 异动` }); }}>
|
||||||
|
{model.abnormal}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100">
|
||||||
|
{model.pending > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Pending', source: 'asset', title: `${model.model} - 待交车` });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.pending}
|
||||||
|
</button>
|
||||||
|
) : model.pending}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100 text-green-600 font-bold bg-green-50/10">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Operating', source: 'asset', title: `${model.model} - 在运营` });
|
||||||
|
}}
|
||||||
|
className="text-green-600 hover:underline font-bold"
|
||||||
|
>
|
||||||
|
{model.operating}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100 text-blue-600 bg-blue-50/5">
|
||||||
|
{model.weeklyDelivered > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Delivered', source: 'asset', title: `${model.model} - 本周交车` });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.weeklyDelivered}
|
||||||
|
</button>
|
||||||
|
) : model.weeklyDelivered}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100 text-orange-600 bg-orange-50/5">
|
||||||
|
{model.weeklyReturned > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Returned', source: 'asset', title: `${model.model} - 本周还车` });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.weeklyReturned}
|
||||||
|
</button>
|
||||||
|
) : model.weeklyReturned}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center text-purple-600 bg-purple-50/5 font-medium">
|
||||||
|
{model.weeklyReplaced > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Replaced', source: 'asset', title: `${model.model} - 本周替换` });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.weeklyReplaced}
|
||||||
|
</button>
|
||||||
|
) : model.weeklyReplaced}
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import type { AssetSummarySectionProps } from './types';
|
||||||
|
|
||||||
|
export function AssetSummaryMobileCards({
|
||||||
|
processedData,
|
||||||
|
theme,
|
||||||
|
expandedAssetTypes,
|
||||||
|
toggleAssetType,
|
||||||
|
expandedModels,
|
||||||
|
toggleModel,
|
||||||
|
setVehicleSelection: setShowPlateNumbers,
|
||||||
|
}: AssetSummarySectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="lg:hidden p-4 space-y-4">
|
||||||
|
{processedData.map((typeGroup) => (
|
||||||
|
<div key={typeGroup.type} className="space-y-3">
|
||||||
|
<div
|
||||||
|
className={`px-3 py-2 rounded flex justify-between items-center shadow-sm cursor-pointer transition-all ${
|
||||||
|
theme === 'vibrant' ? 'bg-blue-600 text-white active:bg-blue-700' :
|
||||||
|
theme === 'minimal' ? 'bg-white border-l-4 border-blue-500 text-gray-800 active:bg-gray-50' :
|
||||||
|
'bg-blue-50 border border-blue-100 text-blue-700 active:bg-blue-100'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleAssetType(typeGroup.type)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedAssetTypes.has(typeGroup.type) ?
|
||||||
|
<ChevronDown size={16} className={theme === 'vibrant' ? 'text-white' : 'text-blue-500'} /> :
|
||||||
|
<ChevronRight size={16} className={theme === 'vibrant' ? 'text-white/70' : 'text-blue-300'} />
|
||||||
|
}
|
||||||
|
<span className="text-xs font-bold">{typeGroup.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`flex gap-3 text-[9px] font-normal ${theme === 'vibrant' ? 'opacity-90' : 'text-gray-500'}`}>
|
||||||
|
<span>资产 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span>
|
||||||
|
<span>库存 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span></span>
|
||||||
|
<span>异动 <span className="font-bold">{typeGroup.totalAbnormal}</span></span>
|
||||||
|
<span>运营 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{expandedAssetTypes.has(typeGroup.type) && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
className="space-y-3 overflow-hidden"
|
||||||
|
>
|
||||||
|
{typeGroup.models.map((model) => (
|
||||||
|
<div key={model.model} className="bg-white rounded-lg border border-gray-100 shadow-sm overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="p-3 flex justify-between items-center cursor-pointer active:bg-gray-50"
|
||||||
|
onClick={() => toggleModel(model.model)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedModels.has(model.model) ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
|
||||||
|
<span className="text-xs font-bold text-gray-700">{model.model}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<span className="text-[10px] bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-blue-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', source: 'asset', title: model.model }); }}>资产 {model.total}</span>
|
||||||
|
<span className="text-[10px] bg-orange-50 text-orange-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-orange-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` }); }}>库存 {model.inventory}</span>
|
||||||
|
<button type="button" className="text-[10px] bg-amber-50 text-amber-700 px-1.5 py-0.5 rounded font-bold" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Abnormal', source: 'asset', title: `${model.model} - 异动` }); }}>异动 {model.abnormal}</button>
|
||||||
|
<span className="text-[10px] bg-green-50 text-green-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-green-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Operating', source: 'asset', title: `${model.model} - 在运营` }); }}>运营 {model.operating}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedModels.has(model.model) && (
|
||||||
|
<div className="px-3 pb-3 pt-1 border-t border-gray-50 bg-gray-50/30">
|
||||||
|
<div className="grid grid-cols-2 gap-y-3 gap-x-4 mt-2">
|
||||||
|
<div className="flex justify-between items-center cursor-pointer hover:bg-gray-100 p-1 rounded transition-colors"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` })}>
|
||||||
|
<span className="text-[10px] text-gray-400">总库存</span>
|
||||||
|
<span className="text-xs font-bold text-blue-600">{model.inventory}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center cursor-pointer hover:bg-gray-100 p-1 rounded transition-colors"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Pending', source: 'asset', title: `${model.model} - 待交车` })}>
|
||||||
|
<span className="text-[10px] text-gray-400">待交车</span>
|
||||||
|
<span className="text-xs font-bold text-gray-600">{model.pending}</span>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 grid grid-cols-5 gap-1 py-2 border-y border-gray-100">
|
||||||
|
{['嘉兴', '广东', '北京', '新疆', '其他'].map(reg => (
|
||||||
|
<div key={reg} className="text-center">
|
||||||
|
<div className="text-[8px] text-gray-400 mb-0.5">{reg === '嘉兴' ? '浙' : reg === '广东' ? '粤' : reg === '北京' ? '京' : reg === '新疆' ? '新' : '其'}</div>
|
||||||
|
{model.inventoryRegions[reg] > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: reg, category: 'Inventory', source: 'asset', title: `${model.model} - ${reg} 库存` });
|
||||||
|
}}
|
||||||
|
className="text-[10px] font-bold text-blue-500 hover:underline"
|
||||||
|
>
|
||||||
|
{model.inventoryRegions[reg]}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="text-[10px] font-bold text-gray-300">-</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 grid grid-cols-3 gap-2 pt-1">
|
||||||
|
<div className="bg-blue-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-blue-100/50 transition-colors"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Delivered', source: 'asset', title: `${model.model} - 本周交车` })}>
|
||||||
|
<span className="text-[8px] text-gray-400 mb-1">本周已交车</span>
|
||||||
|
<span className="text-xs font-bold text-blue-600">{model.weeklyDelivered}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-orange-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-orange-100/50 transition-colors"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Returned', source: 'asset', title: `${model.model} - 本周还车` })}>
|
||||||
|
<span className="text-[8px] text-gray-400 mb-1">已还车</span>
|
||||||
|
<span className="text-xs font-bold text-orange-600">{model.weeklyReturned}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-purple-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-purple-100/50 transition-colors"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Replaced', source: 'asset', title: `${model.model} - 本周替换` })}>
|
||||||
|
<span className="text-[8px] text-gray-400 mb-1">已替换</span>
|
||||||
|
<span className="text-xs font-bold text-purple-600">{model.weeklyReplaced}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Info } from 'lucide-react';
|
||||||
|
import { AssetSummaryDesktopTable } from './AssetSummaryDesktopTable';
|
||||||
|
import { AssetSummaryMobileCards } from './AssetSummaryMobileCards';
|
||||||
|
import type { AssetSummarySectionProps } from './types';
|
||||||
|
|
||||||
|
export function AssetSummarySection(props: AssetSummarySectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden mb-6">
|
||||||
|
<div className="p-4 border-b border-gray-50 bg-gray-50/50 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-4 sm:gap-6">
|
||||||
|
<h2 className="text-sm font-bold text-gray-700">资产数据实时汇总</h2>
|
||||||
|
<div className="hidden md:flex items-center gap-1 text-[10px] text-blue-500 bg-blue-50 px-2 py-0.5 rounded">
|
||||||
|
<Info size={10} />
|
||||||
|
点击型号展开详情
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop View Table */}
|
||||||
|
<AssetSummaryDesktopTable {...props} />
|
||||||
|
|
||||||
|
{/* Mobile View Cards for Asset Summary */}
|
||||||
|
<AssetSummaryMobileCards {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { ArrowRight, CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||||
|
import { getWeeklyFlowRange, type DateRange } from '../../model';
|
||||||
|
|
||||||
|
const weekdays = ['一', '二', '三', '四', '五', '六', '日'];
|
||||||
|
const ymd = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
|
const parseDate = (value: string) => new Date(`${value}T12:00:00`);
|
||||||
|
const shiftDay = (date: Date, offset: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + offset);
|
||||||
|
const dayCount = (start: string, end: string) => Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000) + 1;
|
||||||
|
|
||||||
|
export function FlowDateRangePicker({ value, onChange }: { value: DateRange; onChange: (range: DateRange) => void }) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const [draft, setDraft] = useState(value);
|
||||||
|
const [selecting, setSelecting] = useState<'start' | 'end'>('start');
|
||||||
|
const [month, setMonth] = useState(() => parseDate(value.start));
|
||||||
|
const [hovered, setHovered] = useState('');
|
||||||
|
const today = ymd(new Date());
|
||||||
|
const count = draft.end ? dayCount(draft.start, draft.end) : 0;
|
||||||
|
const valid = count > 0 && count <= 370;
|
||||||
|
const monthStart = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||||
|
const previewEnd = draft.end || hovered || draft.start;
|
||||||
|
const rangeStart = draft.start < previewEnd ? draft.start : previewEnd;
|
||||||
|
const rangeEnd = draft.start > previewEnd ? draft.start : previewEnd;
|
||||||
|
const quickPicks = [
|
||||||
|
{ label: '今天', range: { start: today, end: today } },
|
||||||
|
{ label: '昨天', range: { start: ymd(shiftDay(new Date(), -1)), end: ymd(shiftDay(new Date(), -1)) } },
|
||||||
|
{ label: '业务周', range: getWeeklyFlowRange() },
|
||||||
|
{ label: '近 7 天', range: { start: ymd(shiftDay(new Date(), -6)), end: today } },
|
||||||
|
{ label: '近 30 天', range: { start: ymd(shiftDay(new Date(), -29)), end: today } },
|
||||||
|
];
|
||||||
|
const open = () => {
|
||||||
|
setDraft(value); setSelecting('start'); setMonth(parseDate(value.start)); setHovered('');
|
||||||
|
dialog.current?.showModal();
|
||||||
|
};
|
||||||
|
const choose = (date: string) => {
|
||||||
|
if (selecting === 'start') {
|
||||||
|
setDraft({ start: date, end: '' }); setSelecting('end');
|
||||||
|
} else {
|
||||||
|
setDraft(date < draft.start ? { start: date, end: draft.start } : { start: draft.start, end: date });
|
||||||
|
setSelecting('start');
|
||||||
|
}
|
||||||
|
setHovered('');
|
||||||
|
};
|
||||||
|
const calendar = (offset: number) => {
|
||||||
|
const current = new Date(monthStart.getFullYear(), monthStart.getMonth() + offset, 1);
|
||||||
|
const year = current.getFullYear(), m = current.getMonth();
|
||||||
|
const padding = (current.getDay() + 6) % 7;
|
||||||
|
const length = new Date(year, m + 1, 0).getDate();
|
||||||
|
return <div className={offset ? 'hidden sm:block' : ''} key={offset}>
|
||||||
|
<div className="mb-3 text-center text-sm font-bold text-slate-700">{year} 年 {m + 1} 月</div>
|
||||||
|
<div className="grid grid-cols-7 text-center">
|
||||||
|
{weekdays.map(day => <div key={day} className="py-2 text-[11px] font-medium text-slate-400">{day}</div>)}
|
||||||
|
{Array.from({ length: padding }, (_, i) => <div key={`blank-${i}`} />)}
|
||||||
|
{Array.from({ length }, (_, i) => {
|
||||||
|
const date = ymd(new Date(year, m, i + 1));
|
||||||
|
const endpoint = date === draft.start || date === draft.end;
|
||||||
|
const inRange = date >= rangeStart && date <= rangeEnd;
|
||||||
|
return <button key={date} type="button" aria-label={date} aria-pressed={endpoint}
|
||||||
|
onClick={() => choose(date)} onMouseEnter={() => { if (!draft.end) setHovered(date); }}
|
||||||
|
className={`relative my-0.5 h-10 text-xs font-semibold transition focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-blue-500 ${endpoint ? 'rounded-xl bg-blue-600 text-white shadow-sm' : inRange ? 'bg-blue-50 text-blue-700' : 'rounded-xl text-slate-600 hover:bg-slate-100'} ${date === today && !endpoint ? 'font-black text-blue-600' : ''}`}>
|
||||||
|
{i + 1}{date === today && <span className={`absolute bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full ${endpoint ? 'bg-white' : 'bg-blue-500'}`} />}
|
||||||
|
</button>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
};
|
||||||
|
return <>
|
||||||
|
<button type="button" data-testid="asset-flow-date-range" aria-haspopup="dialog" onClick={open}
|
||||||
|
className="mt-3 flex w-full items-center gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-left shadow-sm transition hover:border-blue-300 hover:bg-blue-50/40 focus-visible:outline-2 focus-visible:outline-blue-500">
|
||||||
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600"><CalendarDays size={18} /></span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block text-[10px] font-medium text-slate-400">统计日期 · 运维签章时间</span>
|
||||||
|
<span className="mt-1 flex items-center gap-2 text-[13px] font-bold text-slate-700"><span>{value.start.replaceAll('-', '/')}</span><ArrowRight size={13} className="shrink-0 text-slate-300" /><span>{value.end.replaceAll('-', '/')}</span></span>
|
||||||
|
</span>
|
||||||
|
<span className="hidden rounded-lg bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-500 sm:block">{dayCount(value.start, value.end)} 天</span>
|
||||||
|
<ChevronDown size={16} className="shrink-0 text-slate-400" />
|
||||||
|
</button>
|
||||||
|
<dialog ref={dialog} aria-labelledby="flow-date-title" onClick={e => { if (e.target === e.currentTarget) dialog.current?.close(); }}
|
||||||
|
className="fixed inset-0 m-auto max-h-[90dvh] w-[calc(100%-24px)] max-w-[680px] overflow-y-auto rounded-3xl border-0 bg-white p-0 text-slate-700 shadow-2xl backdrop:bg-slate-950/30 backdrop:backdrop-blur-sm">
|
||||||
|
<div className="p-5 sm:p-6">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div><h3 id="flow-date-title" className="text-base font-bold text-slate-900">选择统计日期</h3><p className="mt-1 text-xs text-slate-400">先选开始日期,再选结束日期</p></div>
|
||||||
|
<button type="button" aria-label="关闭日期选择" onClick={() => dialog.current?.close()} className="rounded-full p-2 text-slate-400 hover:bg-slate-100"><X size={18} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{quickPicks.map(pick => <button key={pick.label} type="button" onClick={() => { setDraft(pick.range); setMonth(parseDate(pick.range.start)); setSelecting('start'); setHovered(''); }}
|
||||||
|
className={`rounded-lg border px-3 py-2 text-xs font-medium transition ${draft.start === pick.range.start && draft.end === pick.range.end ? 'border-blue-200 bg-blue-50 text-blue-600' : 'border-slate-100 text-slate-500 hover:border-blue-200 hover:bg-blue-50'}`}>{pick.label}</button>)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||||
|
{(['start', 'end'] as const).map(field => <button key={field} type="button" onClick={() => { setSelecting(field); setHovered(''); setMonth(parseDate(draft[field] || draft.start)); }}
|
||||||
|
className={`rounded-xl border px-3 py-2.5 text-left transition ${selecting === field ? 'border-blue-400 bg-blue-50/60 ring-2 ring-blue-50' : 'border-slate-200 bg-slate-50/50'}`}>
|
||||||
|
<span className="block text-[10px] text-slate-400">{field === 'start' ? '开始日期' : '结束日期'}</span><span className="mt-1 block text-sm font-bold">{draft[field] || '请选择结束日期'}</span>
|
||||||
|
</button>)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex items-center justify-between gap-2">
|
||||||
|
<button type="button" aria-label="上个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronLeft size={18} /></button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select aria-label="年份" value={monthStart.getFullYear()} onChange={e => setMonth(new Date(Number(e.target.value), monthStart.getMonth(), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
|
||||||
|
{Array.from({ length: Math.max(new Date().getFullYear() + 1, monthStart.getFullYear()) - Math.min(2016, monthStart.getFullYear()) + 1 }, (_, i) => Math.min(2016, monthStart.getFullYear()) + i).map(year => <option key={year} value={year}>{year} 年</option>)}
|
||||||
|
</select>
|
||||||
|
<select aria-label="月份" value={monthStart.getMonth()} onChange={e => setMonth(new Date(monthStart.getFullYear(), Number(e.target.value), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
|
||||||
|
{Array.from({ length: 12 }, (_, i) => <option key={i} value={i}>{i + 1} 月</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="button" aria-label="下个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronRight size={18} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-6 sm:grid-cols-2" onMouseLeave={() => setHovered('')}>{calendar(0)}{calendar(1)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="sticky bottom-0 flex items-center justify-between gap-3 border-t border-slate-100 bg-white px-5 py-4 sm:px-6">
|
||||||
|
<span role="status" className={`text-xs ${count > 370 ? 'text-red-500' : 'text-slate-400'}`}>{!draft.end ? '请选择结束日期' : count > 370 ? '最多选择 370 天' : `已选 ${count} 天(包含首尾)`}</span>
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
<button type="button" onClick={() => dialog.current?.close()} className="rounded-xl px-3 py-2.5 text-xs font-semibold text-slate-500 hover:bg-slate-100">取消</button>
|
||||||
|
<button type="button" disabled={!valid} onClick={() => { onChange(draft); dialog.current?.close(); }} className="flex items-center gap-1 rounded-xl bg-blue-600 px-4 py-2.5 text-xs font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40"><Check size={14} />应用日期</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { CalendarDays, ChevronDown, Download, Loader2 } from 'lucide-react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import type { FlowType } from '../../api';
|
||||||
|
import { FLOW_META } from '../FlowDetailModal';
|
||||||
|
import type { FlowStatisticsCardProps } from './types';
|
||||||
|
import { FlowDateRangePicker } from './FlowDateRangePicker';
|
||||||
|
|
||||||
|
const FLOW_TYPES: FlowType[] = ['delivered', 'returned', 'replaced'];
|
||||||
|
|
||||||
|
export function FlowStatisticsCard({
|
||||||
|
flowRange,
|
||||||
|
setFlowRange,
|
||||||
|
flowLoading,
|
||||||
|
flowStats,
|
||||||
|
exportFlowDetails,
|
||||||
|
flowDailyExpanded,
|
||||||
|
setFlowDailyExpanded,
|
||||||
|
setSelectedFlow,
|
||||||
|
}: FlowStatisticsCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-3 mb-4">
|
||||||
|
<div data-testid="asset-flow-card" className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 text-[13px] font-black text-slate-700">
|
||||||
|
<CalendarDays size={14} className="text-blue-500" />
|
||||||
|
<span>交还车统计(车次)</span>
|
||||||
|
{flowLoading && <Loader2 size={12} className="animate-spin text-slate-400" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => exportFlowDetails()}
|
||||||
|
disabled={!flowStats?.details.length}
|
||||||
|
className="inline-flex h-8 items-center justify-center gap-1 rounded-xl border border-slate-200 bg-slate-50 px-2.5 text-[11px] font-black text-slate-600 transition hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Download size={13} />
|
||||||
|
导出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[11px] text-slate-400">按运维签章时间统计;交车包含替换交车,合计=交车+还车,同车多笔业务分别计次。</p>
|
||||||
|
<FlowDateRangePicker value={flowRange} onChange={setFlowRange} />
|
||||||
|
<div className="mt-2 rounded-2xl border border-slate-100 bg-slate-50/70 px-2 py-2.5">
|
||||||
|
<div className="grid grid-cols-4 items-center text-center">
|
||||||
|
<div className="px-2">
|
||||||
|
<div className="text-lg font-black leading-none text-slate-950">{flowStats?.totals.total ?? 0}</div>
|
||||||
|
<div className="mt-1 text-[10px] font-black text-slate-400">合计</div>
|
||||||
|
</div>
|
||||||
|
{FLOW_TYPES.map((type) => (
|
||||||
|
<button key={type} type="button"
|
||||||
|
data-testid={`asset-flow-total-${type}`}
|
||||||
|
disabled={flowLoading || !flowStats?.totals[type]}
|
||||||
|
onClick={() => flowStats && setSelectedFlow({ start: flowStats.start, end: flowStats.end, type })}
|
||||||
|
className="border-l border-slate-200/70 px-2 transition hover:bg-slate-100 focus-visible:outline-blue-500 disabled:cursor-not-allowed disabled:opacity-35">
|
||||||
|
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>
|
||||||
|
{flowStats?.totals[type] ?? 0}
|
||||||
|
</div>
|
||||||
|
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{type === 'replaced' ? '其中:替换交车' : FLOW_META[type].label}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="asset-flow-daily-toggle"
|
||||||
|
onClick={() => setFlowDailyExpanded((prev) => !prev)}
|
||||||
|
className="mt-2 flex w-full items-center justify-between rounded-xl border border-slate-100 bg-white px-3 py-1.5 text-[12px] font-black text-slate-500 transition hover:border-blue-100 hover:bg-blue-50/50 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
<span>{flowDailyExpanded ? '收起每日明细' : '展开每日明细'}</span>
|
||||||
|
<span className="flex items-center gap-2 text-[11px] text-slate-400">
|
||||||
|
{flowStats?.daily.length ?? 0} 天
|
||||||
|
<ChevronDown size={15} className={`transition-transform ${flowDailyExpanded ? 'rotate-180' : ''}`} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{flowDailyExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="mt-3 max-h-[280px] space-y-2 overflow-y-auto pr-1">
|
||||||
|
{flowLoading && (
|
||||||
|
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400">正在加载交还车统计...</div>
|
||||||
|
)}
|
||||||
|
{!flowLoading && flowStats?.daily.map((day) => (
|
||||||
|
<div key={day.date} className="rounded-2xl border border-slate-100 bg-white px-3 py-3 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-[11px] font-black text-slate-400">{day.date}</div>
|
||||||
|
<div className="text-[10px] font-bold italic text-slate-300">运维签章时间</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-4 items-center text-center">
|
||||||
|
<div className="px-2">
|
||||||
|
<div className="text-lg font-black leading-none text-slate-900">{day.total}</div>
|
||||||
|
<div className="mt-1 text-[10px] font-black text-slate-400">合计</div>
|
||||||
|
</div>
|
||||||
|
{FLOW_TYPES.map((type) => {
|
||||||
|
const count = day[type];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
data-testid={`asset-flow-cell-${day.date}-${type}`}
|
||||||
|
disabled={count === 0}
|
||||||
|
onClick={() => setSelectedFlow({ date: day.date, type })}
|
||||||
|
className="border-l border-slate-100 px-2 text-center transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-35"
|
||||||
|
>
|
||||||
|
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>{count}</div>
|
||||||
|
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{type === 'replaced' ? '其中:替换交车' : FLOW_META[type].label}</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!flowLoading && !flowStats?.daily.length && (
|
||||||
|
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400">暂无交换车数据</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import type { InventoryStatisticsSectionProps } from './types';
|
||||||
|
|
||||||
|
type InventoryDesktopTableProps = Pick<
|
||||||
|
InventoryStatisticsSectionProps,
|
||||||
|
| 'inventoryTab'
|
||||||
|
| 'inventoryByRegion'
|
||||||
|
| 'expandedInventoryRegions'
|
||||||
|
| 'toggleInventoryRegion'
|
||||||
|
| 'inventoryByModel'
|
||||||
|
| 'expandedInventoryTypes'
|
||||||
|
| 'toggleInventoryType'
|
||||||
|
| 'setVehicleSelection'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function InventoryDesktopTable({
|
||||||
|
inventoryTab,
|
||||||
|
inventoryByRegion,
|
||||||
|
expandedInventoryRegions,
|
||||||
|
toggleInventoryRegion,
|
||||||
|
inventoryByModel,
|
||||||
|
expandedInventoryTypes,
|
||||||
|
toggleInventoryType,
|
||||||
|
setVehicleSelection: setShowPlateNumbers,
|
||||||
|
}: InventoryDesktopTableProps) {
|
||||||
|
return (
|
||||||
|
<div className="hidden lg:block overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-50 text-[11px] text-slate-500 uppercase tracking-wider border-b border-slate-100">
|
||||||
|
<th className="p-3 font-semibold w-64">{inventoryTab === 'region' ? '区域 / 城市' : '车型分类 / 型号'}</th>
|
||||||
|
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '品牌' : '品牌'}</th>
|
||||||
|
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '车型' : '所在区域/城市'}</th>
|
||||||
|
<th className="p-3 font-semibold text-center w-32">库存数量</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="text-xs">
|
||||||
|
{inventoryTab === 'region' ? (
|
||||||
|
Object.entries(inventoryByRegion).map(([region, cities]) => (
|
||||||
|
<React.Fragment key={region}>
|
||||||
|
<tr
|
||||||
|
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
|
||||||
|
onClick={() => toggleInventoryRegion(region)}
|
||||||
|
>
|
||||||
|
<td colSpan={4} className="p-3 font-bold text-slate-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||||
|
<span>{region}</span>
|
||||||
|
<span className="text-[10px] font-normal text-slate-400 ml-2 cursor-pointer hover:text-blue-500 transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: 'All', location: region, category: 'Inventory', source: 'asset', title: `库存统计 - ${region}` });
|
||||||
|
}}>
|
||||||
|
(共 {Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<AnimatePresence>
|
||||||
|
{expandedInventoryRegions.has(region) && Object.entries(cities).map(([city, stats]) => (
|
||||||
|
<React.Fragment key={city}>
|
||||||
|
{stats.map((stat, idx) => (
|
||||||
|
<motion.tr
|
||||||
|
key={`${city}-${stat.model}-${idx}`}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="border-b border-slate-50 hover:bg-slate-50/20 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="p-3 pl-8 text-slate-500 border-r border-slate-50">
|
||||||
|
{idx === 0 ? <span className="font-medium text-slate-600">{city}</span> : ''}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.brand}</td>
|
||||||
|
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.model}</td>
|
||||||
|
<td className="p-3 text-center font-bold text-blue-600">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` });
|
||||||
|
}}
|
||||||
|
className="text-blue-600 hover:underline font-bold"
|
||||||
|
>
|
||||||
|
{stat.quantity}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</React.Fragment>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
Object.entries(inventoryByModel).map(([type, models]) => (
|
||||||
|
<React.Fragment key={type}>
|
||||||
|
<tr
|
||||||
|
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
|
||||||
|
onClick={() => toggleInventoryType(type)}
|
||||||
|
>
|
||||||
|
<td colSpan={4} className="p-3 font-bold text-slate-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||||
|
<span>{type}</span>
|
||||||
|
<span className="text-[10px] font-normal text-slate-400 ml-2 cursor-pointer hover:text-blue-500 transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', type: type, category: 'Inventory', source: 'asset', title: `库存统计 - ${type}` });
|
||||||
|
}}>
|
||||||
|
(共 {Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<AnimatePresence>
|
||||||
|
{expandedInventoryTypes.has(type) && Object.entries(models).map(([model, stats]) => (
|
||||||
|
<React.Fragment key={model}>
|
||||||
|
{stats.map((stat, idx) => (
|
||||||
|
<motion.tr
|
||||||
|
key={`${model}-${stat.region}-${stat.city}-${idx}`}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="border-b border-slate-50 hover:bg-slate-50/20 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="p-3 pl-8 text-slate-500 border-r border-slate-50">
|
||||||
|
{idx === 0 ? <span className="font-medium text-slate-600">{model}</span> : ''}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.brand}</td>
|
||||||
|
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.region} / {stat.city}</td>
|
||||||
|
<td className="p-3 text-center font-bold text-blue-600">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` });
|
||||||
|
}}
|
||||||
|
className="text-blue-600 hover:underline font-bold"
|
||||||
|
>
|
||||||
|
{stat.quantity}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</React.Fragment>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
import type { InventoryStatisticsSectionProps } from './types';
|
||||||
|
|
||||||
|
type InventoryMobileListProps = Pick<
|
||||||
|
InventoryStatisticsSectionProps,
|
||||||
|
| 'inventoryTab'
|
||||||
|
| 'inventoryByRegion'
|
||||||
|
| 'expandedInventoryRegions'
|
||||||
|
| 'toggleInventoryRegion'
|
||||||
|
| 'inventoryByModel'
|
||||||
|
| 'expandedInventoryTypes'
|
||||||
|
| 'toggleInventoryType'
|
||||||
|
| 'setVehicleSelection'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function InventoryMobileList({
|
||||||
|
inventoryTab,
|
||||||
|
inventoryByRegion,
|
||||||
|
expandedInventoryRegions,
|
||||||
|
toggleInventoryRegion,
|
||||||
|
inventoryByModel,
|
||||||
|
expandedInventoryTypes,
|
||||||
|
toggleInventoryType,
|
||||||
|
setVehicleSelection: setShowPlateNumbers,
|
||||||
|
}: InventoryMobileListProps) {
|
||||||
|
return (
|
||||||
|
<div className="lg:hidden p-3 space-y-3">
|
||||||
|
{inventoryTab === 'region' ? (
|
||||||
|
Object.entries(inventoryByRegion).map(([region, cities]) => (
|
||||||
|
<div key={region} className="border border-slate-100 rounded overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-slate-50 p-3 flex justify-between items-center cursor-pointer"
|
||||||
|
onClick={() => toggleInventoryRegion(region)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||||
|
<span className="text-xs font-bold text-slate-700">{region}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-bold text-blue-600">
|
||||||
|
{Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedInventoryRegions.has(region) && (
|
||||||
|
<div className="p-2 space-y-2 bg-white">
|
||||||
|
{Object.entries(cities).map(([city, stats]) => (
|
||||||
|
<div key={city} className="border-l-2 border-slate-200 pl-3 py-1">
|
||||||
|
<div className="text-[10px] font-bold text-slate-500 mb-2">{city}</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{stats.map((stat, idx) => (
|
||||||
|
<div key={idx} className="flex justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-slate-400 text-[9px]">{stat.brand}</span>
|
||||||
|
<span className="text-slate-700 font-medium">{stat.model}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` })}
|
||||||
|
className="font-bold text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{stat.quantity} 台
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
Object.entries(inventoryByModel).map(([type, models]) => (
|
||||||
|
<div key={type} className="border border-slate-100 rounded overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-slate-50 p-3 flex justify-between items-center cursor-pointer"
|
||||||
|
onClick={() => toggleInventoryType(type)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||||
|
<span className="text-xs font-bold text-slate-700">{type}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-bold text-blue-600">
|
||||||
|
{Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedInventoryTypes.has(type) && (
|
||||||
|
<div className="p-2 space-y-2 bg-white">
|
||||||
|
{Object.entries(models).map(([model, stats]) => (
|
||||||
|
<div key={model} className="border-l-2 border-slate-200 pl-3 py-1">
|
||||||
|
<div className="text-[10px] font-bold text-slate-500 mb-2">{model}</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{stats.map((stat, idx) => (
|
||||||
|
<div key={idx} className="flex justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-slate-400 text-[9px]">{stat.brand}</span>
|
||||||
|
<span className="text-slate-700 font-medium">{stat.region} / {stat.city}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` })}
|
||||||
|
className="font-bold text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{stat.quantity} 台
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { Filter } from 'lucide-react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import { InventoryDesktopTable } from './InventoryDesktopTable';
|
||||||
|
import { InventoryMobileList } from './InventoryMobileList';
|
||||||
|
import type { InventoryStatisticsSectionProps } from './types';
|
||||||
|
|
||||||
|
export function InventoryStatisticsSection({
|
||||||
|
inventoryFilters,
|
||||||
|
setInventoryFilters,
|
||||||
|
draftInventoryFilters,
|
||||||
|
setDraftInventoryFilters,
|
||||||
|
isInventoryFilterOpen,
|
||||||
|
setIsInventoryFilterOpen,
|
||||||
|
uniqueInventoryRegions,
|
||||||
|
uniqueInventoryCities,
|
||||||
|
uniqueInventoryBrands,
|
||||||
|
uniqueInventoryTypes,
|
||||||
|
uniqueInventoryModelsForType,
|
||||||
|
filteredInventoryStats,
|
||||||
|
inventoryTab,
|
||||||
|
setInventoryTab,
|
||||||
|
inventoryByRegion,
|
||||||
|
expandedInventoryRegions,
|
||||||
|
toggleInventoryRegion,
|
||||||
|
inventoryByModel,
|
||||||
|
expandedInventoryTypes,
|
||||||
|
toggleInventoryType,
|
||||||
|
setVehicleSelection: setShowPlateNumbers,
|
||||||
|
}: InventoryStatisticsSectionProps) {
|
||||||
|
const listProps = {
|
||||||
|
inventoryTab,
|
||||||
|
inventoryByRegion,
|
||||||
|
expandedInventoryRegions,
|
||||||
|
toggleInventoryRegion,
|
||||||
|
inventoryByModel,
|
||||||
|
expandedInventoryTypes,
|
||||||
|
toggleInventoryType,
|
||||||
|
setVehicleSelection: setShowPlateNumbers,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm mb-6 overflow-hidden min-h-[420px]">
|
||||||
|
<div className="p-3 sm:p-4 border-b border-gray-50 bg-white flex items-center justify-between relative">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-1.5 h-6 bg-blue-600 rounded-full"></div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<h2 className="text-lg font-bold text-gray-800 leading-tight">库存统计</h2>
|
||||||
|
<span className="text-[10px] text-gray-400 font-medium">实时更新库存数据</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6 ml-auto pr-2">
|
||||||
|
<div className="flex flex-col items-end cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}>
|
||||||
|
<span className="text-[10px] text-gray-400 font-bold tracking-wider uppercase mb-0.5">总库存数据</span>
|
||||||
|
<span className="text-2xl font-black text-gray-900 leading-none">
|
||||||
|
{filteredInventoryStats.reduce((acc, s) => acc + s.quantity, 0)}
|
||||||
|
<span className="text-xs font-bold text-gray-400 ml-1">台</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => { if (!isInventoryFilterOpen) setDraftInventoryFilters({...inventoryFilters}); setIsInventoryFilterOpen(!isInventoryFilterOpen); }}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
isInventoryFilterOpen || (inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model)
|
||||||
|
? 'bg-blue-600 text-white shadow-md'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Filter size={14} />
|
||||||
|
<span>筛选</span>
|
||||||
|
{(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && (
|
||||||
|
<span className="w-2 h-2 bg-white rounded-full animate-pulse"></span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isInventoryFilterOpen && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||||
|
className="fixed inset-x-4 top-20 max-h-[80vh] overflow-auto sm:inset-auto sm:top-20 sm:right-4 sm:w-72 bg-white rounded-xl shadow-2xl border border-slate-100 z-50 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-xs font-bold text-slate-800">库存统计 - 数据筛选</h3>
|
||||||
|
<button onClick={() => setDraftInventoryFilters({ region: '', city: '', brand: '', type: '', model: '' })} className="text-[10px] text-blue-500 hover:underline">重置所有</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 text-left">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-slate-400 block mb-1">区域</label>
|
||||||
|
<select value={draftInventoryFilters.region} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, region: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||||
|
<option value="">全部区域</option>
|
||||||
|
{uniqueInventoryRegions.map(r => <option key={r} value={r}>{r}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-slate-400 block mb-1">城市</label>
|
||||||
|
<select value={draftInventoryFilters.city} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, city: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||||
|
<option value="">全部城市</option>
|
||||||
|
{uniqueInventoryCities.map(c => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-slate-400 block mb-1">品牌</label>
|
||||||
|
<select value={draftInventoryFilters.brand} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, brand: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||||
|
<option value="">全部品牌</option>
|
||||||
|
{uniqueInventoryBrands.map(b => <option key={b} value={b}>{b}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-slate-400 block mb-1">车型</label>
|
||||||
|
<select value={draftInventoryFilters.type} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, type: e.target.value, model: ''})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||||
|
<option value="">全部车型</option>
|
||||||
|
{uniqueInventoryTypes.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-slate-400 block mb-1">批次</label>
|
||||||
|
<select value={draftInventoryFilters.model} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, model: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||||
|
<option value="">全部批次</option>
|
||||||
|
{uniqueInventoryModelsForType.map(m => <option key={m} value={m}>{m}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => { setInventoryFilters({...draftInventoryFilters}); setIsInventoryFilterOpen(false); }} className="w-full mt-4 py-2 bg-blue-600 text-white rounded-lg text-xs font-bold hover:bg-blue-700 transition-colors">确认</button>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3 bg-gray-50/50 border-b border-gray-100">
|
||||||
|
<div className="flex bg-gray-200/50 p-1 rounded-lg w-fit shadow-inner">
|
||||||
|
<button
|
||||||
|
onClick={() => setInventoryTab('region')}
|
||||||
|
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
|
||||||
|
inventoryTab === 'region' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
按区域
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setInventoryTab('model')}
|
||||||
|
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
|
||||||
|
inventoryTab === 'model' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
按车型
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Filters Bar */}
|
||||||
|
{(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && (
|
||||||
|
<div className="px-4 py-2 border-b border-gray-100 flex flex-wrap gap-2 items-center">
|
||||||
|
{inventoryFilters.region && (
|
||||||
|
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||||
|
区域: {inventoryFilters.region}
|
||||||
|
<button onClick={() => setInventoryFilters({...inventoryFilters, region: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{inventoryFilters.city && (
|
||||||
|
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||||
|
城市: {inventoryFilters.city}
|
||||||
|
<button onClick={() => setInventoryFilters({...inventoryFilters, city: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{inventoryFilters.brand && (
|
||||||
|
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||||
|
品牌: {inventoryFilters.brand}
|
||||||
|
<button onClick={() => setInventoryFilters({...inventoryFilters, brand: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{inventoryFilters.type && (
|
||||||
|
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||||
|
车型: {inventoryFilters.type}
|
||||||
|
<button onClick={() => setInventoryFilters({...inventoryFilters, type: '', model: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{inventoryFilters.model && (
|
||||||
|
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||||
|
批次: {inventoryFilters.model}
|
||||||
|
<button onClick={() => setInventoryFilters({...inventoryFilters, model: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button onClick={() => setInventoryFilters({ region: '', city: '', brand: '', type: '', model: '' })} className="text-[11px] text-red-500 font-bold ml-auto hover:text-red-600">清除</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Desktop View Table */}
|
||||||
|
<InventoryDesktopTable {...listProps} />
|
||||||
|
|
||||||
|
{/* Mobile View */}
|
||||||
|
<InventoryMobileList {...listProps} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { Activity, TriangleAlert, PlusCircle, Truck, Warehouse } from 'lucide-react';
|
||||||
|
import type { SummaryMetricsProps } from './types';
|
||||||
|
|
||||||
|
export function SummaryMetrics({
|
||||||
|
summary: SUMMARY,
|
||||||
|
operatingRate,
|
||||||
|
inventoryRate,
|
||||||
|
pendingRate,
|
||||||
|
setVehicleSelection: setShowPlateNumbers,
|
||||||
|
}: SummaryMetricsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Header Summary - Ultra Compact */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 mb-2">
|
||||||
|
{/* Total Assets */}
|
||||||
|
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', source: 'asset', title: '资产概览' })}>
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
|
||||||
|
<Truck size={14} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5">资产总数</div>
|
||||||
|
<div className="text-base font-bold text-gray-800 leading-none">{SUMMARY.totalAssets.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Operating */}
|
||||||
|
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Operating', source: 'asset', title: '正在运营' })}>
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500">
|
||||||
|
<Activity size={14} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5">总运营</div>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-base font-bold text-blue-600 leading-none">{SUMMARY.operating.total}</span>
|
||||||
|
<span className="text-[8px] text-gray-400 leading-none">自{SUMMARY.operating.self} 租{SUMMARY.operating.leased}{SUMMARY.operating.hanging > 0 && ` 挂${SUMMARY.operating.hanging}`}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inventory */}
|
||||||
|
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}>
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
|
||||||
|
<Warehouse size={14} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5">总库存</div>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-base font-bold text-gray-800 leading-none">{SUMMARY.inventory.total}</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" data-testid="asset-abnormal-card"
|
||||||
|
className="rounded-2xl border border-amber-100 bg-white p-3 shadow-sm flex items-center gap-2 text-left transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Abnormal', source: 'asset', title: '异动车辆' })}>
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-amber-50 flex items-center justify-center text-amber-600"><TriangleAlert size={14} /></div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-amber-600 font-bold leading-none mb-0.5">异动</div>
|
||||||
|
<div className="text-base font-bold text-amber-600 leading-none">{SUMMARY.inventory.abnormal}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Pending */}
|
||||||
|
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset', title: '待交车' })}>
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500">
|
||||||
|
<PlusCircle size={14} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-blue-500 font-bold uppercase leading-none mb-0.5">待交车</div>
|
||||||
|
<div className="text-base font-bold text-blue-600 leading-none">{SUMMARY.pendingDelivery}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-testid="asset-operation-ratio-strip" className="mb-3 rounded-2xl border border-slate-100 bg-white/85 px-4 py-3 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="shrink-0 text-[11px] font-black text-slate-400">运营概览</div>
|
||||||
|
<div className="grid min-w-0 flex-1 grid-cols-4 divide-x divide-slate-100 text-center">
|
||||||
|
<div className="px-2">
|
||||||
|
<div className="text-[10px] font-black text-slate-400">运营率</div>
|
||||||
|
<div className="mt-0.5 text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-2">
|
||||||
|
<div className="text-[10px] font-black text-slate-400">库存率</div>
|
||||||
|
<div className="mt-0.5 text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-2">
|
||||||
|
<div className="text-[10px] font-black text-slate-400">异动率</div>
|
||||||
|
<div className="mt-0.5 text-base font-black text-amber-600">{(SUMMARY.totalAssets > 0 ? SUMMARY.inventory.abnormal / SUMMARY.totalAssets * 100 : 0).toFixed(1)}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-2">
|
||||||
|
<div className="text-[10px] font-black text-slate-400">待交率</div>
|
||||||
|
<div className="mt-0.5 text-base font-black text-amber-600">{pendingRate.toFixed(1)}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
import type { FlowDetailItem, FlowStatsResponse, FlowType } from '../../api';
|
||||||
|
import type {
|
||||||
|
DateRange,
|
||||||
|
FlowSelection,
|
||||||
|
InventoryFilters,
|
||||||
|
VehicleModalSelection,
|
||||||
|
} from '../../model';
|
||||||
|
import type { RegionalInventoryStats, SummaryData, TypeSummary } from '../../types';
|
||||||
|
import type { AssetsTheme } from '../AssetsHeader';
|
||||||
|
|
||||||
|
export interface SummaryMetricsProps {
|
||||||
|
summary: SummaryData;
|
||||||
|
operatingRate: number;
|
||||||
|
inventoryRate: number;
|
||||||
|
pendingRate: number;
|
||||||
|
setVehicleSelection: Dispatch<SetStateAction<VehicleModalSelection | null>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowStatisticsCardProps {
|
||||||
|
flowRange: DateRange;
|
||||||
|
setFlowRange: Dispatch<SetStateAction<DateRange>>;
|
||||||
|
flowLoading: boolean;
|
||||||
|
flowStats: FlowStatsResponse | null;
|
||||||
|
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
|
||||||
|
flowDailyExpanded: boolean;
|
||||||
|
setFlowDailyExpanded: Dispatch<SetStateAction<boolean>>;
|
||||||
|
setSelectedFlow: Dispatch<SetStateAction<FlowSelection | null>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetSummarySectionProps {
|
||||||
|
processedData: TypeSummary[];
|
||||||
|
theme: AssetsTheme;
|
||||||
|
expandedAssetTypes: Set<string>;
|
||||||
|
toggleAssetType: (type: string) => void;
|
||||||
|
expandedModels: Set<string>;
|
||||||
|
toggleModel: (model: string) => void;
|
||||||
|
setVehicleSelection: Dispatch<SetStateAction<VehicleModalSelection | null>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryStatisticsSectionProps {
|
||||||
|
inventoryFilters: InventoryFilters;
|
||||||
|
setInventoryFilters: Dispatch<SetStateAction<InventoryFilters>>;
|
||||||
|
draftInventoryFilters: InventoryFilters;
|
||||||
|
setDraftInventoryFilters: Dispatch<SetStateAction<InventoryFilters>>;
|
||||||
|
isInventoryFilterOpen: boolean;
|
||||||
|
setIsInventoryFilterOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
|
uniqueInventoryRegions: string[];
|
||||||
|
uniqueInventoryCities: string[];
|
||||||
|
uniqueInventoryBrands: string[];
|
||||||
|
uniqueInventoryTypes: string[];
|
||||||
|
uniqueInventoryModelsForType: string[];
|
||||||
|
filteredInventoryStats: RegionalInventoryStats[];
|
||||||
|
inventoryTab: 'region' | 'model';
|
||||||
|
setInventoryTab: Dispatch<SetStateAction<'region' | 'model'>>;
|
||||||
|
inventoryByRegion: Record<string, Record<string, RegionalInventoryStats[]>>;
|
||||||
|
expandedInventoryRegions: Set<string>;
|
||||||
|
toggleInventoryRegion: (region: string) => void;
|
||||||
|
inventoryByModel: Record<string, Record<string, RegionalInventoryStats[]>>;
|
||||||
|
expandedInventoryTypes: Set<string>;
|
||||||
|
toggleInventoryType: (type: string) => void;
|
||||||
|
setVehicleSelection: Dispatch<SetStateAction<VehicleModalSelection | null>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OverviewViewProps = SummaryMetricsProps
|
||||||
|
& FlowStatisticsCardProps
|
||||||
|
& AssetSummarySectionProps
|
||||||
|
& InventoryStatisticsSectionProps
|
||||||
|
& {
|
||||||
|
allTypesExpanded: boolean;
|
||||||
|
toggleAllAssetTypes: () => void;
|
||||||
|
};
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import type { FlowStatsResponse, WeeklyDetailItem } from './api';
|
||||||
|
import type {
|
||||||
|
CustomerStats,
|
||||||
|
DeptGroup,
|
||||||
|
ManagerStats,
|
||||||
|
RegionalInventoryStats,
|
||||||
|
VehicleListItem,
|
||||||
|
} from './types';
|
||||||
|
import {
|
||||||
|
buildCustomerPieData,
|
||||||
|
buildVehicleModalRequest,
|
||||||
|
deriveCustomerView,
|
||||||
|
deriveDepartmentView,
|
||||||
|
deriveInventoryView,
|
||||||
|
deriveModalVehicleView,
|
||||||
|
formatLocalDate,
|
||||||
|
formatLocalDateTime,
|
||||||
|
getWeeklyFlowRange,
|
||||||
|
groupInventoryByModel,
|
||||||
|
selectFlowDetails,
|
||||||
|
} from './model';
|
||||||
|
|
||||||
|
const emptyInventoryFilters = { region: '', city: '', brand: '', type: '', model: '' };
|
||||||
|
const emptyCustomerFilters = { customer: [], brand: '', department: '', manager: '', region: '' };
|
||||||
|
const emptyModalFilters = { plateNumber: '', model: '', brand: '', location: '' };
|
||||||
|
|
||||||
|
function inventory(overrides: Partial<RegionalInventoryStats>): RegionalInventoryStats {
|
||||||
|
return {
|
||||||
|
region: '广东',
|
||||||
|
city: '广州',
|
||||||
|
brand: '现代',
|
||||||
|
type: '18T',
|
||||||
|
model: 'M1',
|
||||||
|
batch: 'B1',
|
||||||
|
quantity: 1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function manager(overrides: Partial<ManagerStats>): ManagerStats {
|
||||||
|
return {
|
||||||
|
manager: '负责人甲',
|
||||||
|
department: '业务一部',
|
||||||
|
t4_5: 0,
|
||||||
|
t4_5c: 0,
|
||||||
|
t18: 0,
|
||||||
|
t49: 0,
|
||||||
|
trailer: 0,
|
||||||
|
other: 0,
|
||||||
|
total: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function department(name: string, managers: ManagerStats[]): DeptGroup {
|
||||||
|
return {
|
||||||
|
department: name,
|
||||||
|
totalAssets: managers.reduce((sum, item) => sum + item.total, 0),
|
||||||
|
operatingCount: 0,
|
||||||
|
idleCount: 0,
|
||||||
|
attendanceRate: 0,
|
||||||
|
avgMileage: 0,
|
||||||
|
managers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function customer(overrides: Partial<CustomerStats>): CustomerStats {
|
||||||
|
return {
|
||||||
|
customer: '客户甲',
|
||||||
|
manager: '负责人甲',
|
||||||
|
brand: '现代',
|
||||||
|
department: '业务一部',
|
||||||
|
region: '广东',
|
||||||
|
city: '广州',
|
||||||
|
t4_5: 0,
|
||||||
|
t4_5c: 0,
|
||||||
|
t18: 0,
|
||||||
|
t49: 0,
|
||||||
|
trailer: 0,
|
||||||
|
other: 0,
|
||||||
|
total: 1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function vehicle(overrides: Partial<VehicleListItem>): VehicleListItem {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
plateNumber: '粤A00001',
|
||||||
|
vin: 'VIN-1',
|
||||||
|
type: '18T',
|
||||||
|
model: 'M1',
|
||||||
|
location: '广州',
|
||||||
|
province: '广东',
|
||||||
|
city: '广州',
|
||||||
|
status: '运营',
|
||||||
|
ownership: '自营',
|
||||||
|
contractNo: null,
|
||||||
|
customerName: null,
|
||||||
|
subjectOrg: null,
|
||||||
|
departmentName: null,
|
||||||
|
customerManager: null,
|
||||||
|
brandLabel: '现代',
|
||||||
|
orgName: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('本地日期格式不引入 UTC 偏移', () => {
|
||||||
|
const date = new Date(2026, 7, 3, 9, 7, 5);
|
||||||
|
assert.equal(formatLocalDate(date), '2026-08-03');
|
||||||
|
assert.equal(formatLocalDateTime(date), '2026-08-03 09:07:05');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('周流转区间按周六至周五计算', () => {
|
||||||
|
assert.deepEqual(getWeeklyFlowRange(new Date(2026, 7, 12)), {
|
||||||
|
start: '2026-08-08',
|
||||||
|
end: '2026-08-14',
|
||||||
|
});
|
||||||
|
assert.deepEqual(getWeeklyFlowRange(new Date(2026, 7, 15)), {
|
||||||
|
start: '2026-08-08',
|
||||||
|
end: '2026-08-14',
|
||||||
|
});
|
||||||
|
assert.deepEqual(getWeeklyFlowRange(new Date(2026, 7, 16)), {
|
||||||
|
start: '2026-08-08',
|
||||||
|
end: '2026-08-14',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('交还替明细走周接口且原样保留周筛选参数', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
buildVehicleModalRequest({
|
||||||
|
batch: 'All',
|
||||||
|
model: 'M1',
|
||||||
|
location: '广州',
|
||||||
|
category: 'Delivered',
|
||||||
|
source: 'asset',
|
||||||
|
}, '主体甲'),
|
||||||
|
{
|
||||||
|
kind: 'weekly',
|
||||||
|
type: 'delivered',
|
||||||
|
filters: { model: 'M1', batch: 'All', location: '广州', source: 'asset' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('待交付与车型映射继续走车辆列表参数', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
buildVehicleModalRequest({
|
||||||
|
batch: 'B1',
|
||||||
|
model: 'M1',
|
||||||
|
location: '广州',
|
||||||
|
category: 'Pending',
|
||||||
|
type: '4.5T',
|
||||||
|
isColdChain: true,
|
||||||
|
isTrailer: false,
|
||||||
|
manager: '负责人甲',
|
||||||
|
}, '主体甲'),
|
||||||
|
{
|
||||||
|
kind: 'vehicles',
|
||||||
|
params: {
|
||||||
|
batch: 'B1',
|
||||||
|
model: 'M1',
|
||||||
|
location: '广州',
|
||||||
|
category: 'Pending',
|
||||||
|
manager: '负责人甲',
|
||||||
|
vehicleType: '4.5T冷链',
|
||||||
|
subject: '主体甲',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
buildVehicleModalRequest({
|
||||||
|
batch: 'All',
|
||||||
|
model: 'All',
|
||||||
|
location: 'All',
|
||||||
|
isColdChain: false,
|
||||||
|
isTrailer: true,
|
||||||
|
}, null),
|
||||||
|
{
|
||||||
|
kind: 'vehicles',
|
||||||
|
params: { isColdChain: 'false', isTrailer: 'true', subject: null },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('库存筛选与区域、车型分组保持既有顺序', () => {
|
||||||
|
const rows = [
|
||||||
|
inventory({ region: '广东', city: '广州', type: '18T', model: 'M1', quantity: 2 }),
|
||||||
|
inventory({ region: '浙江', city: '嘉兴', type: '4.5T普货', model: 'M2', quantity: 3 }),
|
||||||
|
inventory({ region: '广东', city: '佛山', type: '18T', model: 'M3', quantity: 4 }),
|
||||||
|
inventory({ region: '广东', city: '广州', type: '新增车型', model: 'M4', quantity: 5 }),
|
||||||
|
];
|
||||||
|
const view = deriveInventoryView(
|
||||||
|
rows,
|
||||||
|
{ ...emptyInventoryFilters, region: '广东', type: '18T' },
|
||||||
|
'18T',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(view.filtered.map((item) => item.model), ['M1', 'M3']);
|
||||||
|
assert.deepEqual(view.modelsForType, ['M1', 'M3']);
|
||||||
|
assert.deepEqual(Object.keys(view.byRegion), ['广东']);
|
||||||
|
assert.deepEqual(Object.keys(view.byRegion['广东']), ['广州', '佛山']);
|
||||||
|
assert.deepEqual(Object.keys(view.byModel), ['18T']);
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.keys(groupInventoryByModel(rows)),
|
||||||
|
['4.5T普货', '18T', '新增车型'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('部门负责人去重、分组并按车辆数倒序', () => {
|
||||||
|
const departments = [
|
||||||
|
department('业务二部', [
|
||||||
|
manager({ manager: '负责人乙', department: '业务二部', total: 3 }),
|
||||||
|
manager({ manager: '负责人甲', department: '业务二部', total: 8 }),
|
||||||
|
]),
|
||||||
|
department('业务一部', [manager({ manager: '负责人甲', total: 5 })]),
|
||||||
|
];
|
||||||
|
const view = deriveDepartmentView(departments, 'All');
|
||||||
|
|
||||||
|
assert.deepEqual(view.managers, ['负责人乙', '负责人甲']);
|
||||||
|
assert.deepEqual(view.groupedManagers, [
|
||||||
|
{ department: '业务二部', managers: ['负责人乙', '负责人甲'] },
|
||||||
|
{ department: '业务一部', managers: ['负责人甲'] },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(view.managerStats.map((item) => item.total), [8, 5, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('客户筛选与负责人分组合并部门和客户来源', () => {
|
||||||
|
const departments = [
|
||||||
|
department('业务二部', [manager({ manager: '负责人乙', department: '业务二部' })]),
|
||||||
|
department('业务一部', [manager({ manager: '负责人甲' })]),
|
||||||
|
];
|
||||||
|
const customers = [
|
||||||
|
customer({ customer: '客户甲', department: '业务一部', manager: '负责人甲', total: 2 }),
|
||||||
|
customer({ customer: '客户乙', department: '业务二部', manager: '负责人丙', region: '浙江', city: '嘉兴', total: 3 }),
|
||||||
|
customer({ customer: '公务车', department: '公务车', manager: '负责人丁', total: 1 }),
|
||||||
|
];
|
||||||
|
const view = deriveCustomerView(
|
||||||
|
customers,
|
||||||
|
departments,
|
||||||
|
{ ...emptyCustomerFilters, customer: ['客户乙'], region: '浙江' },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(view.filtered.map((item) => item.customer), ['客户乙']);
|
||||||
|
assert.deepEqual(view.departments, ['业务一部', '业务二部', '公务车']);
|
||||||
|
assert.deepEqual(view.managersByDepartment, [
|
||||||
|
{ department: '业务一部', managers: ['负责人甲'] },
|
||||||
|
{ department: '业务二部', managers: ['负责人乙', '负责人丙'] },
|
||||||
|
{ department: '公务车', managers: ['负责人丁'] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('弹窗筛选沿用车牌为空时回退 VIN 的规则', () => {
|
||||||
|
const vehicles = [
|
||||||
|
vehicle({ id: 1 }),
|
||||||
|
vehicle({ id: 2, plateNumber: '', vin: 'VIN-2', model: 'M2', brandLabel: '福田', location: '嘉兴' }),
|
||||||
|
];
|
||||||
|
const weeklyDetails: WeeklyDetailItem[] = [
|
||||||
|
{ truck_id: 1, plate_number: '粤A00001', handover_date: null, contract_type: null, customer_name: null },
|
||||||
|
{ truck_id: 2, plate_number: 'VIN-2', handover_date: null, contract_type: null, customer_name: null },
|
||||||
|
];
|
||||||
|
const view = deriveModalVehicleView(
|
||||||
|
vehicles,
|
||||||
|
weeklyDetails,
|
||||||
|
{ ...emptyModalFilters, plateNumber: 'VIN-2' },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(view.plates, ['粤A00001', 'VIN-2']);
|
||||||
|
assert.deepEqual(view.filteredVehicles.map((item) => item.id), [2]);
|
||||||
|
assert.deepEqual(view.filteredWeeklyDetails.map((item) => item.truck_id), [2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('流转明细按日期和类型同时筛选,客户饼图按区域汇总', () => {
|
||||||
|
const flowStats: FlowStatsResponse = {
|
||||||
|
start: '2026-08-08',
|
||||||
|
end: '2026-08-14',
|
||||||
|
daily: [],
|
||||||
|
totals: { delivered: 1, returned: 1, replaced: 0, total: 2 },
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
id: '1', type: 'delivered', typeLabel: '交车', date: '2026-08-12', truckId: '1',
|
||||||
|
plateNumber: '粤A00001', eventTime: null, submitTime: null, department: '', manager: '', customerName: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2', type: 'returned', typeLabel: '还车', date: '2026-08-12', truckId: '2',
|
||||||
|
plateNumber: '粤A00002', eventTime: null, submitTime: null, department: '', manager: '', customerName: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.deepEqual(
|
||||||
|
selectFlowDetails(flowStats, { date: '2026-08-12', type: 'delivered' }).map((item) => item.id),
|
||||||
|
['1'],
|
||||||
|
);
|
||||||
|
|
||||||
|
flowStats.details.push(
|
||||||
|
{ ...flowStats.details[0], id: '3', type: 'replaced', typeLabel: '替换交车' },
|
||||||
|
{ ...flowStats.details[0], id: '4', type: 'replaced', typeLabel: '替换交车', date: '2026-08-13' },
|
||||||
|
);
|
||||||
|
assert.deepEqual(selectFlowDetails(flowStats, { date: '2026-08-12', type: 'delivered' }).map(x => x.id), ['1', '3']);
|
||||||
|
assert.deepEqual(selectFlowDetails(flowStats, { date: '2026-08-12', type: 'replaced' }).map(x => x.id), ['3']);
|
||||||
|
assert.deepEqual(selectFlowDetails(flowStats, { date: '2026-08-12', type: 'returned' }).map(x => x.id), ['2']);
|
||||||
|
|
||||||
|
assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-12', end: '2026-08-13', type: 'delivered' }).map(x => x.id), ['1', '3', '4']);
|
||||||
|
assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-12', end: '2026-08-13', type: 'returned' }).map(x => x.id), ['2']);
|
||||||
|
assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-12', end: '2026-08-13', type: 'replaced' }).map(x => x.id), ['3', '4']);
|
||||||
|
assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-13', end: '2026-08-13', type: 'delivered' }).map(x => x.id), ['4']);
|
||||||
|
assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-14', end: '2026-08-15', type: 'delivered' }), []);
|
||||||
|
|
||||||
|
const customers = [
|
||||||
|
customer({ region: '广东', total: 2 }),
|
||||||
|
customer({ customer: '客户乙', region: '浙江', total: 5 }),
|
||||||
|
customer({ customer: '客户丙', region: '广东', total: 3 }),
|
||||||
|
];
|
||||||
|
assert.deepEqual(buildCustomerPieData(customers, 'region', []), [
|
||||||
|
{ name: '广东', value: 5 },
|
||||||
|
{ name: '浙江', value: 5 },
|
||||||
|
]);
|
||||||
|
const provinceData = [{ name: '广东省', value: 10 }];
|
||||||
|
assert.equal(buildCustomerPieData(customers, 'province', provinceData), provinceData);
|
||||||
|
});
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
import type { FlowStatsResponse, FlowType, WeeklyDetailItem } from './api';
|
||||||
|
import type {
|
||||||
|
CustomerStats,
|
||||||
|
DeptGroup,
|
||||||
|
RegionalInventoryStats,
|
||||||
|
VehicleListItem,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const INVENTORY_TYPE_ORDER = ['4.5T普货', '4.5T冷链', '18T', '49T', '挂车', '其他'];
|
||||||
|
const CHINESE_NUMBER_ORDER: Record<string, number> = {
|
||||||
|
一: 1,
|
||||||
|
二: 2,
|
||||||
|
三: 3,
|
||||||
|
四: 4,
|
||||||
|
五: 5,
|
||||||
|
六: 6,
|
||||||
|
七: 7,
|
||||||
|
八: 8,
|
||||||
|
九: 9,
|
||||||
|
十: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DateRange {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryFilters {
|
||||||
|
region: string;
|
||||||
|
city: string;
|
||||||
|
brand: string;
|
||||||
|
type: string;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerFilters {
|
||||||
|
customer: string[];
|
||||||
|
brand: string;
|
||||||
|
department: string;
|
||||||
|
manager: string;
|
||||||
|
region: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModalVehicleFilters {
|
||||||
|
plateNumber: string;
|
||||||
|
model: string;
|
||||||
|
brand: string;
|
||||||
|
location: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VehicleModalCategory =
|
||||||
|
| 'Inventory'
|
||||||
|
| 'Abnormal'
|
||||||
|
| 'Pending'
|
||||||
|
| 'Delivered'
|
||||||
|
| 'Returned'
|
||||||
|
| 'Replaced'
|
||||||
|
| 'Operating';
|
||||||
|
|
||||||
|
export interface VehicleModalSelection {
|
||||||
|
batch: string;
|
||||||
|
model: string;
|
||||||
|
location: string;
|
||||||
|
category?: VehicleModalCategory;
|
||||||
|
vehicleType?: string;
|
||||||
|
manager?: string;
|
||||||
|
customer?: string;
|
||||||
|
department?: string;
|
||||||
|
attendance?: 'active' | 'idle';
|
||||||
|
isColdChain?: boolean;
|
||||||
|
isTrailer?: boolean;
|
||||||
|
type?: string;
|
||||||
|
source?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VehicleListRequestParams {
|
||||||
|
batch?: string;
|
||||||
|
model?: string;
|
||||||
|
location?: string;
|
||||||
|
category?: 'Inventory' | 'Abnormal' | 'Operating' | 'Pending';
|
||||||
|
vehicleType?: string;
|
||||||
|
manager?: string;
|
||||||
|
customer?: string;
|
||||||
|
isColdChain?: string;
|
||||||
|
isTrailer?: string;
|
||||||
|
department?: string;
|
||||||
|
attendance?: string;
|
||||||
|
subject?: string | null;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VehicleModalRequest =
|
||||||
|
| {
|
||||||
|
kind: 'weekly';
|
||||||
|
type: FlowType;
|
||||||
|
filters: { model: string; batch: string; location: string; source?: string };
|
||||||
|
}
|
||||||
|
| { kind: 'vehicles'; params: VehicleListRequestParams };
|
||||||
|
|
||||||
|
export type FlowSelection =
|
||||||
|
| { date: string; type: FlowType }
|
||||||
|
| { start: string; end: string; type: FlowType };
|
||||||
|
|
||||||
|
export function formatLocalDateTime(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLocalDate(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date: Date, days: number): Date {
|
||||||
|
const next = new Date(date);
|
||||||
|
next.setDate(next.getDate() + days);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 资产流转周报按周六至周五统计;周末查看时仍停留在刚结束的周五。
|
||||||
|
export function getWeeklyFlowRange(referenceDate = new Date()): DateRange {
|
||||||
|
const day = referenceDate.getDay();
|
||||||
|
const end = day === 6
|
||||||
|
? addDays(referenceDate, -1)
|
||||||
|
: day === 0
|
||||||
|
? addDays(referenceDate, -2)
|
||||||
|
: addDays(referenceDate, 5 - day);
|
||||||
|
return {
|
||||||
|
start: formatLocalDate(addDays(end, -6)),
|
||||||
|
end: formatLocalDate(end),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKLY_FLOW_TYPE_BY_CATEGORY: Partial<Record<VehicleModalCategory, FlowType>> = {
|
||||||
|
Delivered: 'delivered',
|
||||||
|
Returned: 'returned',
|
||||||
|
Replaced: 'replaced',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pending 不是周流转事件,必须继续走车辆列表接口以保留型号、批次和区域筛选。
|
||||||
|
export function buildVehicleModalRequest(
|
||||||
|
selection: VehicleModalSelection,
|
||||||
|
subject: string | null,
|
||||||
|
): VehicleModalRequest {
|
||||||
|
const weeklyType = selection.category
|
||||||
|
? WEEKLY_FLOW_TYPE_BY_CATEGORY[selection.category]
|
||||||
|
: undefined;
|
||||||
|
if (weeklyType) {
|
||||||
|
return {
|
||||||
|
kind: 'weekly',
|
||||||
|
type: weeklyType,
|
||||||
|
filters: {
|
||||||
|
model: selection.model,
|
||||||
|
batch: selection.batch,
|
||||||
|
location: selection.location,
|
||||||
|
source: selection.source,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const params: VehicleListRequestParams = {};
|
||||||
|
if (selection.vehicleType) params.vehicleType = selection.vehicleType;
|
||||||
|
if (selection.batch !== 'All') params.batch = selection.batch;
|
||||||
|
if (selection.model !== 'All') params.model = selection.model;
|
||||||
|
if (selection.location !== 'All') params.location = selection.location;
|
||||||
|
if (selection.source) params.source = selection.source;
|
||||||
|
if (selection.category === 'Abnormal') params.category = 'Abnormal';
|
||||||
|
if (selection.category === 'Inventory') params.category = 'Inventory';
|
||||||
|
if (selection.category === 'Operating') params.category = 'Operating';
|
||||||
|
if (selection.category === 'Pending') params.category = 'Pending';
|
||||||
|
if (selection.manager) params.manager = selection.manager;
|
||||||
|
if (selection.customer) params.customer = selection.customer;
|
||||||
|
if (selection.department) params.department = selection.department;
|
||||||
|
if (selection.attendance) params.attendance = selection.attendance;
|
||||||
|
|
||||||
|
if (!selection.type) {
|
||||||
|
if (selection.isColdChain !== undefined) params.isColdChain = String(selection.isColdChain);
|
||||||
|
if (selection.isTrailer !== undefined) params.isTrailer = String(selection.isTrailer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面车型分组与列表接口的 vehicleType 取值并不完全一致,这里集中保留既有映射。
|
||||||
|
if (selection.type === '4.5T') {
|
||||||
|
if (selection.isColdChain === true) params.vehicleType = '4.5T冷链';
|
||||||
|
if (selection.isColdChain === false) params.vehicleType = '4.5T普货';
|
||||||
|
} else if (
|
||||||
|
selection.type === '4.5T普货'
|
||||||
|
|| selection.type === '4.5T冷链'
|
||||||
|
|| selection.type === '18T'
|
||||||
|
|| selection.type === '49T'
|
||||||
|
|| selection.type === '挂车'
|
||||||
|
|| selection.type === '其他'
|
||||||
|
) {
|
||||||
|
params.vehicleType = selection.type;
|
||||||
|
} else if (selection.type === '其他车型') {
|
||||||
|
if (selection.isTrailer === true) params.isTrailer = 'true';
|
||||||
|
if (selection.isTrailer === false) params.vehicleType = '其他';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'vehicles', params: { ...params, subject } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueNonEmpty(values: Array<string | null | undefined>): string[] {
|
||||||
|
return Array.from(new Set(values.filter((value): value is string => Boolean(value))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDepartmentOrder(name: string): number {
|
||||||
|
const match = name.match(/[一二三四五六七八九十]/);
|
||||||
|
return match ? (CHINESE_NUMBER_ORDER[match[0]] || 99) : 99;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterInventoryStats(
|
||||||
|
inventory: RegionalInventoryStats[],
|
||||||
|
filters: InventoryFilters,
|
||||||
|
): RegionalInventoryStats[] {
|
||||||
|
return inventory.filter((item) => (
|
||||||
|
(!filters.region || item.region === filters.region)
|
||||||
|
&& (!filters.city || item.city === filters.city)
|
||||||
|
&& (!filters.brand || item.brand === filters.brand)
|
||||||
|
&& (!filters.type || item.type === filters.type)
|
||||||
|
&& (!filters.model || item.model === filters.model)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupInventoryByRegion(
|
||||||
|
inventory: RegionalInventoryStats[],
|
||||||
|
): Record<string, Record<string, RegionalInventoryStats[]>> {
|
||||||
|
const result: Record<string, Record<string, RegionalInventoryStats[]>> = {};
|
||||||
|
for (const item of inventory) {
|
||||||
|
if (!result[item.region]) result[item.region] = {};
|
||||||
|
if (!result[item.region][item.city]) result[item.region][item.city] = [];
|
||||||
|
result[item.region][item.city].push(item);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupInventoryByModel(
|
||||||
|
inventory: RegionalInventoryStats[],
|
||||||
|
): Record<string, Record<string, RegionalInventoryStats[]>> {
|
||||||
|
const raw: Record<string, Record<string, RegionalInventoryStats[]>> = {};
|
||||||
|
for (const item of inventory) {
|
||||||
|
if (!raw[item.type]) raw[item.type] = {};
|
||||||
|
if (!raw[item.type][item.model]) raw[item.type][item.model] = [];
|
||||||
|
raw[item.type][item.model].push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已知车型按报表约定排序,新增的未知车型保留接口返回时的首次出现顺序。
|
||||||
|
const result: Record<string, Record<string, RegionalInventoryStats[]>> = {};
|
||||||
|
for (const type of INVENTORY_TYPE_ORDER) {
|
||||||
|
if (raw[type]) result[type] = raw[type];
|
||||||
|
}
|
||||||
|
for (const type of Object.keys(raw)) {
|
||||||
|
if (!result[type]) result[type] = raw[type];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveInventoryView(
|
||||||
|
inventory: RegionalInventoryStats[],
|
||||||
|
filters: InventoryFilters,
|
||||||
|
modelTypeFilter: string,
|
||||||
|
) {
|
||||||
|
const filtered = filterInventoryStats(inventory, filters);
|
||||||
|
const modelSource = modelTypeFilter
|
||||||
|
? inventory.filter((item) => item.type === modelTypeFilter)
|
||||||
|
: inventory;
|
||||||
|
const types = uniqueNonEmpty(inventory.map((item) => item.type));
|
||||||
|
|
||||||
|
return {
|
||||||
|
filtered,
|
||||||
|
brands: uniqueNonEmpty(inventory.map((item) => item.brand)),
|
||||||
|
regions: Array.from(new Set(inventory.map((item) => item.region))),
|
||||||
|
cities: uniqueNonEmpty(inventory.map((item) => item.city)),
|
||||||
|
types: types.sort(
|
||||||
|
(left, right) => INVENTORY_TYPE_ORDER.indexOf(left) - INVENTORY_TYPE_ORDER.indexOf(right),
|
||||||
|
),
|
||||||
|
modelsForType: uniqueNonEmpty(modelSource.map((item) => item.model)),
|
||||||
|
byRegion: groupInventoryByRegion(filtered),
|
||||||
|
byModel: groupInventoryByModel(filtered),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveDepartmentView(departments: DeptGroup[], selectedManager: string) {
|
||||||
|
const managers = departments
|
||||||
|
.flatMap((department) => department.managers.map((manager) => manager.manager))
|
||||||
|
.filter((value, index, all) => all.indexOf(value) === index)
|
||||||
|
.sort();
|
||||||
|
const groupedManagers = departments.map((department) => ({
|
||||||
|
department: department.department,
|
||||||
|
managers: department.managers.map((manager) => manager.manager),
|
||||||
|
}));
|
||||||
|
const managerStats = departments
|
||||||
|
.flatMap((department) => department.managers)
|
||||||
|
.filter((manager) => selectedManager === 'All' || manager.manager === selectedManager)
|
||||||
|
.sort((left, right) => right.total - left.total);
|
||||||
|
|
||||||
|
return { managers, groupedManagers, managerStats };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterCustomerStats(
|
||||||
|
customers: CustomerStats[],
|
||||||
|
filters: CustomerFilters,
|
||||||
|
): CustomerStats[] {
|
||||||
|
return customers.filter((customer) => (
|
||||||
|
(filters.customer.length === 0 || filters.customer.includes(customer.customer))
|
||||||
|
&& (!filters.brand || customer.brand === filters.brand)
|
||||||
|
&& (!filters.department || customer.department === filters.department)
|
||||||
|
&& (!filters.manager || customer.manager === filters.manager)
|
||||||
|
&& (!filters.region || customer.region === filters.region)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupCustomerManagers(
|
||||||
|
customers: CustomerStats[],
|
||||||
|
departments: DeptGroup[],
|
||||||
|
): Array<{ department: string; managers: string[] }> {
|
||||||
|
const departmentManagers = new Map<string, Set<string>>();
|
||||||
|
for (const department of departments) {
|
||||||
|
if (!departmentManagers.has(department.department)) {
|
||||||
|
departmentManagers.set(department.department, new Set());
|
||||||
|
}
|
||||||
|
for (const manager of department.managers) {
|
||||||
|
departmentManagers.get(department.department)!.add(manager.manager);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const customer of customers) {
|
||||||
|
if (!customer.manager || !customer.department) continue;
|
||||||
|
if (!departmentManagers.has(customer.department)) {
|
||||||
|
departmentManagers.set(customer.department, new Set());
|
||||||
|
}
|
||||||
|
departmentManagers.get(customer.department)!.add(customer.manager);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(departmentManagers.entries())
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftOrder = left[0] === '公务车' ? 100 : getDepartmentOrder(left[0]);
|
||||||
|
const rightOrder = right[0] === '公务车' ? 100 : getDepartmentOrder(right[0]);
|
||||||
|
return leftOrder - rightOrder;
|
||||||
|
})
|
||||||
|
.map(([department, managers]) => ({ department, managers: Array.from(managers) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveCustomerView(
|
||||||
|
customers: CustomerStats[],
|
||||||
|
departments: DeptGroup[],
|
||||||
|
filters: CustomerFilters,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
filtered: filterCustomerStats(customers, filters),
|
||||||
|
brands: uniqueNonEmpty(customers.map((customer) => customer.brand)),
|
||||||
|
departments: uniqueNonEmpty(customers.map((customer) => customer.department))
|
||||||
|
.sort((left, right) => getDepartmentOrder(left) - getDepartmentOrder(right)),
|
||||||
|
regions: Array.from(new Set(customers.map((customer) => customer.region))),
|
||||||
|
cities: uniqueNonEmpty(customers.map((customer) => customer.city)),
|
||||||
|
customerNames: uniqueNonEmpty(customers.map((customer) => customer.customer)),
|
||||||
|
managersByDepartment: groupCustomerManagers(customers, departments),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveModalVehicleView(
|
||||||
|
vehicles: VehicleListItem[],
|
||||||
|
weeklyDetails: WeeklyDetailItem[],
|
||||||
|
filters: ModalVehicleFilters,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
plates: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.plateNumber || vehicle.vin)),
|
||||||
|
models: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.model)),
|
||||||
|
brands: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.brandLabel)),
|
||||||
|
locations: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.location)),
|
||||||
|
filteredVehicles: vehicles.filter((vehicle) => (
|
||||||
|
(!filters.plateNumber || (vehicle.plateNumber || vehicle.vin) === filters.plateNumber)
|
||||||
|
&& (!filters.model || vehicle.model === filters.model)
|
||||||
|
&& (!filters.brand || vehicle.brandLabel === filters.brand)
|
||||||
|
&& (!filters.location || vehicle.location === filters.location)
|
||||||
|
)),
|
||||||
|
filteredWeeklyDetails: weeklyDetails.filter((detail) => (
|
||||||
|
!filters.plateNumber || detail.plate_number === filters.plateNumber
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectFlowDetails(
|
||||||
|
flowStats: FlowStatsResponse | null,
|
||||||
|
selection: FlowSelection | null,
|
||||||
|
) {
|
||||||
|
if (!flowStats || !selection) return [];
|
||||||
|
return flowStats.details.filter((detail) => (
|
||||||
|
('date' in selection
|
||||||
|
? detail.date === selection.date
|
||||||
|
: detail.date >= selection.start && detail.date <= selection.end) && (detail.type === selection.type ||
|
||||||
|
(selection.type === 'delivered' && detail.type === 'replaced'))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCustomerPieData(
|
||||||
|
customers: CustomerStats[],
|
||||||
|
view: 'region' | 'province',
|
||||||
|
provinceData: Array<{ name: string; value: number }>,
|
||||||
|
): Array<{ name: string; value: number }> {
|
||||||
|
if (view === 'province') return provinceData;
|
||||||
|
|
||||||
|
const totals: Record<string, number> = {};
|
||||||
|
for (const customer of customers) {
|
||||||
|
totals[customer.region] = (totals[customer.region] || 0) + customer.total;
|
||||||
|
}
|
||||||
|
return Object.entries(totals)
|
||||||
|
.map(([name, value]) => ({ name, value }))
|
||||||
|
.sort((left, right) => right.value - left.value);
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ export interface BatchSummary {
|
|||||||
batch: string;
|
batch: string;
|
||||||
total: number;
|
total: number;
|
||||||
inventory: number;
|
inventory: number;
|
||||||
|
abnormal: number;
|
||||||
inventoryRegions: Record<string, number>;
|
inventoryRegions: Record<string, number>;
|
||||||
pending: number;
|
pending: number;
|
||||||
operating: number;
|
operating: number;
|
||||||
@@ -36,6 +37,7 @@ export interface ModelSummary {
|
|||||||
model: string;
|
model: string;
|
||||||
total: number;
|
total: number;
|
||||||
inventory: number;
|
inventory: number;
|
||||||
|
abnormal: number;
|
||||||
inventoryRegions: Record<string, number>;
|
inventoryRegions: Record<string, number>;
|
||||||
pending: number;
|
pending: number;
|
||||||
operating: number;
|
operating: number;
|
||||||
@@ -49,6 +51,7 @@ export interface TypeSummary {
|
|||||||
type: string;
|
type: string;
|
||||||
totalAssets: number;
|
totalAssets: number;
|
||||||
totalInventory: number;
|
totalInventory: number;
|
||||||
|
totalAbnormal: number;
|
||||||
totalOperating: number;
|
totalOperating: number;
|
||||||
inventoryRegions: Record<string, number>;
|
inventoryRegions: Record<string, number>;
|
||||||
pending: number;
|
pending: number;
|
||||||
@@ -62,6 +65,7 @@ export interface BatchGroup {
|
|||||||
batch: string;
|
batch: string;
|
||||||
total: number;
|
total: number;
|
||||||
inventory: number;
|
inventory: number;
|
||||||
|
abnormal: number;
|
||||||
inventoryRegions: Record<string, number>;
|
inventoryRegions: Record<string, number>;
|
||||||
pending: number;
|
pending: number;
|
||||||
operating: number;
|
operating: number;
|
||||||
@@ -73,6 +77,7 @@ export interface BatchGroup {
|
|||||||
type: string;
|
type: string;
|
||||||
total: number;
|
total: number;
|
||||||
inventory: number;
|
inventory: number;
|
||||||
|
abnormal: number;
|
||||||
inventoryRegions: Record<string, number>;
|
inventoryRegions: Record<string, number>;
|
||||||
pending: number;
|
pending: number;
|
||||||
operating: number;
|
operating: number;
|
||||||
@@ -106,6 +111,7 @@ export interface VehicleListItem {
|
|||||||
city: string | null;
|
city: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
ownership: string;
|
ownership: string;
|
||||||
|
rentCompany?: string | null;
|
||||||
contractNo: string | null;
|
contractNo: string | null;
|
||||||
customerName: string | null;
|
customerName: string | null;
|
||||||
subjectOrg: string | null;
|
subjectOrg: string | null;
|
||||||
@@ -152,6 +158,7 @@ export interface RegionTypeBreakdown {
|
|||||||
total: number;
|
total: number;
|
||||||
operating: number;
|
operating: number;
|
||||||
inventory: number;
|
inventory: number;
|
||||||
|
abnormal: number;
|
||||||
pending: number;
|
pending: number;
|
||||||
customers: string[];
|
customers: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +1,28 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { ArrowLeft, Upload, Zap } from 'lucide-react';
|
||||||
import {
|
|
||||||
Upload, FileSpreadsheet, RotateCcw, CheckCircle2, AlertCircle,
|
|
||||||
Truck, ExternalLink, Layers, Zap, ArrowLeft,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { fetchJson } from '../../auth/api-client';
|
import { fetchJson } from '../../auth/api-client';
|
||||||
import { useAuth } from '../../auth/useAuth';
|
import { useAuth } from '../../auth/useAuth';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
|
||||||
import FeedbackFab from '../../components/FeedbackFab';
|
import FeedbackFab from '../../components/FeedbackFab';
|
||||||
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
import { PageFrame } from '../../components/ui/surface';
|
||||||
|
import { BatchTable } from './import-page/BatchTable';
|
||||||
|
import { RecordFilters } from './import-page/RecordFilters';
|
||||||
|
import { RecordTable } from './import-page/RecordTable';
|
||||||
|
import { SummaryKpis } from './import-page/SummaryKpis';
|
||||||
|
import { UploadDropzone } from './import-page/UploadDropzone';
|
||||||
|
import { UploadFeedback } from './import-page/UploadFeedback';
|
||||||
|
import type {
|
||||||
|
BatchRow,
|
||||||
|
ListItem,
|
||||||
|
OverallRow,
|
||||||
|
UploadResult,
|
||||||
|
VehicleKindFilter,
|
||||||
|
} from './import-page/types';
|
||||||
|
|
||||||
function getJwt(): string | null {
|
function getJwt(): string | null {
|
||||||
return sessionStorage.getItem('bi_jwt');
|
return sessionStorage.getItem('bi_jwt');
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UploadResult {
|
|
||||||
ok: boolean;
|
|
||||||
filename: string;
|
|
||||||
batchId: string;
|
|
||||||
parsed: number;
|
|
||||||
fileDuplicates: number;
|
|
||||||
inserted: number;
|
|
||||||
dbDuplicates: number;
|
|
||||||
breakdown: { internal: number; external: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ListItem {
|
|
||||||
id: number;
|
|
||||||
order_no: string;
|
|
||||||
station_name: string | null;
|
|
||||||
terminal_name: string | null;
|
|
||||||
region: string | null;
|
|
||||||
city: string | null;
|
|
||||||
start_time: string | null;
|
|
||||||
end_time: string | null;
|
|
||||||
duration_min: number | null;
|
|
||||||
kwh: number | null;
|
|
||||||
fee: number | null;
|
|
||||||
e_fee: number | null;
|
|
||||||
service_fee: number | null;
|
|
||||||
plate: string | null;
|
|
||||||
judged_plate: string | null;
|
|
||||||
customer_name: string | null;
|
|
||||||
vehicle_kind: 'internal' | 'external' | 'unknown';
|
|
||||||
batch_id: string;
|
|
||||||
imported_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OverallRow { vehicle_kind: 'internal' | 'external'; records: number; total_kwh: number; total_fee: number; }
|
|
||||||
interface BatchRow { batch_id: string; imported_at: string; records: number; internal_count: number; external_count: number; total_kwh: number; total_fee: number; }
|
|
||||||
|
|
||||||
const KIND_LABEL: Record<string, string> = {
|
|
||||||
internal: '内部',
|
|
||||||
external: '外部',
|
|
||||||
};
|
|
||||||
const KIND_STYLE: Record<string, string> = {
|
|
||||||
internal: 'bg-blue-50 text-blue-600 border-blue-200',
|
|
||||||
external: 'bg-amber-50 text-amber-600 border-amber-200',
|
|
||||||
};
|
|
||||||
|
|
||||||
async function uploadFile(file: File): Promise<UploadResult> {
|
async function uploadFile(file: File): Promise<UploadResult> {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', file);
|
fd.append('file', file);
|
||||||
@@ -82,7 +47,7 @@ export default function EleImportPage() {
|
|||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [overall, setOverall] = useState<OverallRow[]>([]);
|
const [overall, setOverall] = useState<OverallRow[]>([]);
|
||||||
const [batches, setBatches] = useState<BatchRow[]>([]);
|
const [batches, setBatches] = useState<BatchRow[]>([]);
|
||||||
const [filter, setFilter] = useState<'' | 'internal' | 'external'>('');
|
const [filter, setFilter] = useState<VehicleKindFilter>('');
|
||||||
const [batchFilter, setBatchFilter] = useState('');
|
const [batchFilter, setBatchFilter] = useState('');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [searchInput, setSearchInput] = useState('');
|
const [searchInput, setSearchInput] = useState('');
|
||||||
@@ -109,13 +74,13 @@ export default function EleImportPage() {
|
|||||||
reload().catch(e => console.error(e));
|
reload().catch(e => console.error(e));
|
||||||
}, [reload]);
|
}, [reload]);
|
||||||
|
|
||||||
const handleUpload = async (f: File) => {
|
const handleUpload = async (selectedFile: File) => {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
try {
|
try {
|
||||||
const r = await uploadFile(f);
|
const nextResult = await uploadFile(selectedFile);
|
||||||
setResult(r);
|
setResult(nextResult);
|
||||||
await reload();
|
await reload();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
@@ -124,269 +89,87 @@ export default function EleImportPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPick = (f: File | null) => {
|
const onPick = (selectedFile: File | null) => {
|
||||||
setFile(f);
|
setFile(selectedFile);
|
||||||
if (f) handleUpload(f);
|
if (selectedFile) handleUpload(selectedFile);
|
||||||
};
|
};
|
||||||
|
|
||||||
const overallMap = new Map(overall.map(o => [o.vehicle_kind, o]));
|
|
||||||
const totalRecords = overall.reduce((s, o) => s + Number(o.records || 0), 0);
|
|
||||||
const totalKwh = overall.reduce((s, o) => s + Number(o.total_kwh || 0), 0);
|
|
||||||
const totalFee = overall.reduce((s, o) => s + Number(o.total_fee || 0), 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 p-4 md:p-8">
|
<PageFrame
|
||||||
<div className="max-w-6xl mx-auto space-y-4">
|
title="充电记录导入"
|
||||||
<header className="flex items-center justify-between">
|
subtitle="每日上传 xlsx,按订单编号去重,并自动匹配内部/外部车辆归属。"
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
icon={Zap}
|
||||||
<button
|
eyebrow="ELECTRIC IMPORT"
|
||||||
onClick={() => {
|
meta={user?.userName || '导入工作台'}
|
||||||
if (window.history.length > 1) window.history.back();
|
actions={(
|
||||||
else { window.location.hash = '#mileage'; }
|
<div className="flex items-center gap-2">
|
||||||
}}
|
<button
|
||||||
className="w-9 h-9 rounded-xl bg-white border border-slate-100 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 text-slate-500 flex items-center justify-center transition-colors flex-shrink-0"
|
onClick={() => {
|
||||||
title="返回"
|
if (window.history.length > 1) window.history.back();
|
||||||
>
|
else { window.location.hash = '#mileage'; }
|
||||||
<ArrowLeft size={16} />
|
}}
|
||||||
</button>
|
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-100 bg-white text-slate-500 transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600"
|
||||||
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center flex-shrink-0">
|
title="返回"
|
||||||
<Zap size={18} className="text-white" />
|
>
|
||||||
</div>
|
<ArrowLeft size={16} />
|
||||||
<div className="min-w-0">
|
</button>
|
||||||
<h1 className="text-lg font-black text-slate-900 leading-tight">充电记录导入</h1>
|
<button
|
||||||
<p className="text-[11px] font-bold text-slate-400">每日上传 xlsx · 订单编号去重 · 系统车辆自动匹配</p>
|
onClick={() => inputRef.current?.click()}
|
||||||
</div>
|
className="inline-flex h-9 items-center gap-1.5 rounded-xl bg-slate-900 px-3 text-xs font-black text-white shadow-sm transition-colors hover:bg-slate-800"
|
||||||
</div>
|
>
|
||||||
<span className="text-[10px] font-bold text-slate-400 flex-shrink-0">{user?.userName || ''}</span>
|
<Upload size={14} />
|
||||||
</header>
|
上传文件
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<UploadDropzone
|
||||||
|
inputRef={inputRef}
|
||||||
|
file={file}
|
||||||
|
uploading={uploading}
|
||||||
|
dragOver={dragOver}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const droppedFile = e.dataTransfer.files?.[0];
|
||||||
|
if (droppedFile) onPick(droppedFile);
|
||||||
|
}}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onInputChange={(e) => onPick(e.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 上传区 */}
|
<UploadFeedback
|
||||||
<section
|
result={result}
|
||||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
error={error}
|
||||||
onDragLeave={() => setDragOver(false)}
|
onCloseResult={() => setResult(null)}
|
||||||
onDrop={(e) => {
|
onCloseError={() => setError(null)}
|
||||||
e.preventDefault();
|
/>
|
||||||
setDragOver(false);
|
|
||||||
const f = e.dataTransfer.files?.[0];
|
|
||||||
if (f) onPick(f);
|
|
||||||
}}
|
|
||||||
onClick={() => inputRef.current?.click()}
|
|
||||||
className={`bg-white rounded-2xl border-2 border-dashed shadow-sm cursor-pointer transition-all ${
|
|
||||||
dragOver ? 'border-blue-400 bg-blue-50/40' : uploading ? 'border-slate-200' : 'border-slate-200 hover:border-blue-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".xlsx,.xls"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => onPick(e.target.files?.[0] || null)}
|
|
||||||
/>
|
|
||||||
<div className="px-6 py-10 flex flex-col items-center text-center">
|
|
||||||
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3">
|
|
||||||
{uploading ? <RotateCcw size={22} className="text-blue-500 animate-spin" /> : <Upload size={22} className="text-blue-500" />}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-bold text-slate-700 mb-1">
|
|
||||||
{uploading ? '正在解析...' : file ? file.name : '点击或拖拽 xlsx 文件到此处'}
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-slate-400 max-w-md leading-relaxed">
|
|
||||||
支持「充电成功记录明细」格式;订单编号已存在的会自动跳过
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 上传结果提示 */}
|
<SummaryKpis overall={overall} />
|
||||||
<AnimatePresence>
|
|
||||||
{result && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 8 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -8 }}
|
|
||||||
className="bg-white rounded-2xl border border-emerald-100 shadow-sm p-4 flex items-start gap-3"
|
|
||||||
>
|
|
||||||
<CheckCircle2 size={18} className="text-emerald-500 flex-shrink-0 mt-0.5" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-[12px] font-bold text-slate-700 mb-1">
|
|
||||||
上传成功:<span className="text-slate-500 font-mono">{result.filename}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 text-[11px]">
|
|
||||||
<Stat label="解析" value={result.parsed} color="text-slate-700" />
|
|
||||||
<Stat label="新增" value={result.inserted} color="text-blue-600" />
|
|
||||||
<Stat label="重复跳过" value={result.fileDuplicates + result.dbDuplicates} color="text-slate-500" />
|
|
||||||
<Stat label="内部" value={result.breakdown.internal} color="text-blue-600" />
|
|
||||||
<Stat label="外部(含无车牌)" value={result.breakdown.external} color="text-amber-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setResult(null)} className="text-slate-300 hover:text-slate-600 text-[10px] font-bold">关闭</button>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
<AnimatePresence>
|
|
||||||
{error && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 8 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -8 }}
|
|
||||||
className="bg-red-50 rounded-2xl border border-red-200 shadow-sm p-4 flex items-start gap-3"
|
|
||||||
>
|
|
||||||
<AlertCircle size={18} className="text-red-500 flex-shrink-0 mt-0.5" />
|
|
||||||
<div className="flex-1 text-[12px] font-bold text-red-700">{error}</div>
|
|
||||||
<button onClick={() => setError(null)} className="text-red-300 hover:text-red-600 text-[10px] font-bold">关闭</button>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* 聚合卡 */}
|
<BatchTable
|
||||||
<section className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
batches={batches}
|
||||||
<KpiCard icon={<Layers size={14} />} label="总记录" value={totalRecords.toLocaleString()} />
|
batchFilter={batchFilter}
|
||||||
<KpiCard icon={<Truck size={14} />} label="内部记录" value={(overallMap.get('internal')?.records ?? 0).toLocaleString()} accent="blue" />
|
onClearFilter={() => setBatchFilter('')}
|
||||||
<KpiCard icon={<ExternalLink size={14} />} label="外部记录" value={(overallMap.get('external')?.records ?? 0).toLocaleString()} accent="amber" />
|
onSelectBatch={(batchId) => setBatchFilter(batchFilter === batchId ? '' : batchId)}
|
||||||
<KpiCard icon={<Zap size={14} />} label="累计电量" value={`${totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} />
|
/>
|
||||||
<KpiCard icon={<Zap size={14} />} label="内部电量" value={`${(overallMap.get('internal')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="blue" />
|
|
||||||
<KpiCard icon={<Zap size={14} />} label="外部电量" value={`${(overallMap.get('external')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="amber" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 批次 */}
|
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||||
{batches.length > 0 && (
|
<RecordFilters
|
||||||
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
total={total}
|
||||||
<div className="px-3 py-2 bg-slate-50 flex items-center justify-between">
|
searchInput={searchInput}
|
||||||
<span className="text-[11px] font-bold text-slate-500">最近上传批次</span>
|
filter={filter}
|
||||||
{batchFilter && (
|
onSearchInputChange={(e) => setSearchInput(e.target.value)}
|
||||||
<button onClick={() => setBatchFilter('')} className="text-[10px] font-bold text-blue-500">取消筛选</button>
|
onSearchKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput); }}
|
||||||
)}
|
onFilterChange={setFilter}
|
||||||
</div>
|
/>
|
||||||
<div className="overflow-x-auto">
|
<RecordTable items={items} />
|
||||||
<table className="w-full text-[11px]">
|
</section>
|
||||||
<thead className="text-slate-400 font-bold bg-slate-50/40">
|
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-2 text-left">导入时间</th>
|
|
||||||
<th className="px-3 py-2 text-right">总数</th>
|
|
||||||
<th className="px-3 py-2 text-right">内部</th>
|
|
||||||
<th className="px-3 py-2 text-right">外部</th>
|
|
||||||
<th className="px-3 py-2 text-right">电量(度)</th>
|
|
||||||
<th className="px-3 py-2 text-right">费用(元)</th>
|
|
||||||
<th className="px-3 py-2 text-right">批次</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{batches.map(b => (
|
|
||||||
<tr
|
|
||||||
key={b.batch_id}
|
|
||||||
onClick={() => setBatchFilter(batchFilter === b.batch_id ? '' : b.batch_id)}
|
|
||||||
className={`border-t border-slate-100 cursor-pointer transition-colors ${batchFilter === b.batch_id ? 'bg-blue-50/40' : 'hover:bg-slate-50/60'}`}
|
|
||||||
>
|
|
||||||
<td className="px-3 py-2 text-slate-600 whitespace-nowrap">{(b.imported_at || '').replace('T', ' ').slice(0, 19)}</td>
|
|
||||||
<td className="px-3 py-2 text-right font-bold text-slate-700">{Number(b.records).toLocaleString()}</td>
|
|
||||||
<td className="px-3 py-2 text-right text-blue-600 font-bold">{Number(b.internal_count).toLocaleString()}</td>
|
|
||||||
<td className="px-3 py-2 text-right text-amber-600 font-bold">{Number(b.external_count).toLocaleString()}</td>
|
|
||||||
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">{Number(b.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })}</td>
|
|
||||||
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">¥{Number(b.total_fee ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</td>
|
|
||||||
<td className="px-3 py-2 text-right text-slate-300 font-mono text-[10px]">{b.batch_id.slice(0, 12)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 列表 */}
|
<RotatingFooterHint className="pb-4" />
|
||||||
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
|
||||||
<div className="px-3 py-2 bg-slate-50 flex items-center gap-2 flex-wrap">
|
|
||||||
<span className="text-[11px] font-bold text-slate-500">最新记录</span>
|
|
||||||
<span className="text-[10px] font-bold text-slate-400">共 {total.toLocaleString()} 条</span>
|
|
||||||
<div className="flex-1" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchInput}
|
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput); }}
|
|
||||||
placeholder="搜索订单/车牌/电站"
|
|
||||||
className="bg-white border border-slate-200 rounded-lg px-2 py-1 text-[11px] outline-none focus:ring-1 focus:ring-blue-500/20 w-44"
|
|
||||||
/>
|
|
||||||
<div className="flex gap-1 bg-white p-0.5 rounded-lg border border-slate-200">
|
|
||||||
{([['', '全部'], ['internal', '内部'], ['external', '外部']] as const).map(([k, label]) => (
|
|
||||||
<button
|
|
||||||
key={k}
|
|
||||||
onClick={() => setFilter(k as typeof filter)}
|
|
||||||
className={`px-2 py-0.5 rounded text-[10px] font-bold transition-colors ${filter === k ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
|
|
||||||
>{label}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-[11px]">
|
|
||||||
<thead className="bg-slate-50/40 text-slate-400 font-bold">
|
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-2 text-left whitespace-nowrap">充电时间</th>
|
|
||||||
<th className="px-3 py-2 text-left whitespace-nowrap">车牌</th>
|
|
||||||
<th className="px-3 py-2 text-center whitespace-nowrap">分类</th>
|
|
||||||
<th className="px-3 py-2 text-left">电站 / 终端</th>
|
|
||||||
<th className="px-3 py-2 text-right whitespace-nowrap">电量(度)</th>
|
|
||||||
<th className="px-3 py-2 text-right whitespace-nowrap">费用(元)</th>
|
|
||||||
<th className="px-3 py-2 text-right whitespace-nowrap">时长(分)</th>
|
|
||||||
<th className="px-3 py-2 text-left whitespace-nowrap">订单编号</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{items.map(it => (
|
|
||||||
<tr key={it.id} className="border-t border-slate-100 hover:bg-slate-50/60">
|
|
||||||
<td className="px-3 py-2 text-slate-600 whitespace-nowrap font-mono">{(it.start_time || '').replace('T', ' ').slice(0, 16)}</td>
|
|
||||||
<td className="px-3 py-2 font-bold text-slate-700 font-mono whitespace-nowrap">{it.plate || it.judged_plate || <span className="text-slate-300">—</span>}</td>
|
|
||||||
<td className="px-3 py-2 text-center">
|
|
||||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold border ${KIND_STYLE[it.vehicle_kind]}`}>
|
|
||||||
{KIND_LABEL[it.vehicle_kind]}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-slate-600 truncate max-w-xs">{it.station_name || '—'}{it.terminal_name ? ` · ${it.terminal_name}` : ''}</td>
|
|
||||||
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{Number(it.kwh ?? 0).toFixed(2)}</td>
|
|
||||||
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{Number(it.fee ?? 0).toFixed(2)}</td>
|
|
||||||
<td className="px-3 py-2 text-right text-slate-500 tabular-nums">{it.duration_min ?? '—'}</td>
|
|
||||||
<td className="px-3 py-2 text-slate-400 font-mono text-[10px]">{it.order_no}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{items.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={8} className="px-3 py-8 text-center text-slate-300 text-[11px] font-bold">
|
|
||||||
<FileSpreadsheet size={18} className="mx-auto mb-2 text-slate-200" />
|
|
||||||
尚无记录,先上传一份 xlsx 试试
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<RotatingFooterHint className="pb-4" />
|
|
||||||
</div>
|
|
||||||
<FeedbackFab module="ele" />
|
<FeedbackFab module="ele" />
|
||||||
</div>
|
</PageFrame>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
|
|
||||||
return (
|
|
||||||
<div className="bg-slate-50 rounded-lg px-2 py-1.5">
|
|
||||||
<div className="text-[9px] text-slate-400 uppercase font-bold">{label}</div>
|
|
||||||
<div className={`text-sm font-black tabular-nums ${color}`}>{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function KpiCard({ icon, label, value, accent = 'slate' }: { icon: React.ReactNode; label: string; value: string; accent?: 'slate' | 'blue' | 'amber' }) {
|
|
||||||
const accentMap: Record<string, string> = {
|
|
||||||
slate: 'text-slate-700',
|
|
||||||
blue: 'text-blue-600',
|
|
||||||
amber: 'text-amber-600',
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-xl border border-slate-100 shadow-sm p-3">
|
|
||||||
<div className="flex items-center gap-1 text-[10px] font-bold text-slate-400 uppercase">
|
|
||||||
<span className={accentMap[accent]}>{icon}</span>
|
|
||||||
<span>{label}</span>
|
|
||||||
</div>
|
|
||||||
<div className={`text-base font-black tabular-nums leading-tight mt-0.5 ${accentMap[accent]}`}>{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { formatCount, formatDateTime, formatDecimal } from './model';
|
||||||
|
import type { BatchRow } from './types';
|
||||||
|
|
||||||
|
interface BatchTableProps {
|
||||||
|
batches: BatchRow[];
|
||||||
|
batchFilter: string;
|
||||||
|
onClearFilter: () => void;
|
||||||
|
onSelectBatch: (batchId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BatchTable({ batches, batchFilter, onClearFilter, onSelectBatch }: BatchTableProps) {
|
||||||
|
if (batches.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||||
|
<div className="px-3 py-2 bg-slate-50 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-bold text-slate-500">最近上传批次</span>
|
||||||
|
{batchFilter && (
|
||||||
|
<button onClick={onClearFilter} className="text-[10px] font-bold text-blue-500">取消筛选</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead className="text-slate-400 font-bold bg-slate-50/40">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left">导入时间</th>
|
||||||
|
<th className="px-3 py-2 text-right">总数</th>
|
||||||
|
<th className="px-3 py-2 text-right">内部</th>
|
||||||
|
<th className="px-3 py-2 text-right">外部</th>
|
||||||
|
<th className="px-3 py-2 text-right">电量(度)</th>
|
||||||
|
<th className="px-3 py-2 text-right">费用(元)</th>
|
||||||
|
<th className="px-3 py-2 text-right">批次</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{batches.map(batch => (
|
||||||
|
<tr
|
||||||
|
key={batch.batch_id}
|
||||||
|
onClick={() => onSelectBatch(batch.batch_id)}
|
||||||
|
className={`border-t border-slate-100 cursor-pointer transition-colors ${batchFilter === batch.batch_id ? 'bg-blue-50/40' : 'hover:bg-slate-50/60'}`}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 text-slate-600 whitespace-nowrap">{formatDateTime(batch.imported_at, 19)}</td>
|
||||||
|
<td className="px-3 py-2 text-right font-bold text-slate-700">{formatCount(batch.records)}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-blue-600 font-bold">{formatCount(batch.internal_count)}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-amber-600 font-bold">{formatCount(batch.external_count)}</td>
|
||||||
|
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">{formatDecimal(batch.total_kwh, 1)}</td>
|
||||||
|
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">¥{formatDecimal(batch.total_fee, 2)}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-slate-300 font-mono text-[10px]">{batch.batch_id.slice(0, 12)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { ChangeEventHandler, KeyboardEventHandler } from 'react';
|
||||||
|
import type { VehicleKindFilter } from './types';
|
||||||
|
|
||||||
|
interface RecordFiltersProps {
|
||||||
|
total: number;
|
||||||
|
searchInput: string;
|
||||||
|
filter: VehicleKindFilter;
|
||||||
|
onSearchInputChange: ChangeEventHandler<HTMLInputElement>;
|
||||||
|
onSearchKeyDown: KeyboardEventHandler<HTMLInputElement>;
|
||||||
|
onFilterChange: (filter: VehicleKindFilter) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTER_OPTIONS: ReadonlyArray<readonly [VehicleKindFilter, string]> = [
|
||||||
|
['', '全部'],
|
||||||
|
['internal', '内部'],
|
||||||
|
['external', '外部'],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function RecordFilters({
|
||||||
|
total,
|
||||||
|
searchInput,
|
||||||
|
filter,
|
||||||
|
onSearchInputChange,
|
||||||
|
onSearchKeyDown,
|
||||||
|
onFilterChange,
|
||||||
|
}: RecordFiltersProps) {
|
||||||
|
return (
|
||||||
|
<div className="px-3 py-2 bg-slate-50 flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-[11px] font-bold text-slate-500">最新记录</span>
|
||||||
|
<span className="text-[10px] font-bold text-slate-400">共 {total.toLocaleString()} 条</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchInput}
|
||||||
|
onChange={onSearchInputChange}
|
||||||
|
onKeyDown={onSearchKeyDown}
|
||||||
|
placeholder="搜索订单/车牌/电站"
|
||||||
|
className="bg-white border border-slate-200 rounded-lg px-2 py-1 text-[11px] outline-none focus:ring-1 focus:ring-blue-500/20 w-44"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1 bg-white p-0.5 rounded-lg border border-slate-200">
|
||||||
|
{FILTER_OPTIONS.map(([key, label]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => onFilterChange(key)}
|
||||||
|
className={`px-2 py-0.5 rounded text-[10px] font-bold transition-colors ${filter === key ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||||
|
>{label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { FileSpreadsheet } from 'lucide-react';
|
||||||
|
import { formatDateTime, formatFixed, KIND_LABEL, KIND_STYLE } from './model';
|
||||||
|
import type { ListItem } from './types';
|
||||||
|
|
||||||
|
interface RecordTableProps {
|
||||||
|
items: ListItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecordTable({ items }: RecordTableProps) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead className="bg-slate-50/40 text-slate-400 font-bold">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left whitespace-nowrap">充电时间</th>
|
||||||
|
<th className="px-3 py-2 text-left whitespace-nowrap">车牌</th>
|
||||||
|
<th className="px-3 py-2 text-center whitespace-nowrap">分类</th>
|
||||||
|
<th className="px-3 py-2 text-left">电站 / 终端</th>
|
||||||
|
<th className="px-3 py-2 text-right whitespace-nowrap">电量(度)</th>
|
||||||
|
<th className="px-3 py-2 text-right whitespace-nowrap">费用(元)</th>
|
||||||
|
<th className="px-3 py-2 text-right whitespace-nowrap">时长(分)</th>
|
||||||
|
<th className="px-3 py-2 text-left whitespace-nowrap">订单编号</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map(item => (
|
||||||
|
<tr key={item.id} className="border-t border-slate-100 hover:bg-slate-50/60">
|
||||||
|
<td className="px-3 py-2 text-slate-600 whitespace-nowrap font-mono">{formatDateTime(item.start_time, 16)}</td>
|
||||||
|
<td className="px-3 py-2 font-bold text-slate-700 font-mono whitespace-nowrap">{item.plate || item.judged_plate || <span className="text-slate-300">—</span>}</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold border ${KIND_STYLE[item.vehicle_kind]}`}>
|
||||||
|
{KIND_LABEL[item.vehicle_kind]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-slate-600 truncate max-w-xs">{item.station_name || '—'}{item.terminal_name ? ` · ${item.terminal_name}` : ''}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{formatFixed(item.kwh, 2)}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{formatFixed(item.fee, 2)}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-slate-500 tabular-nums">{item.duration_min ?? '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-slate-400 font-mono text-[10px]">{item.order_no}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-3 py-8 text-center text-slate-300 text-[11px] font-bold">
|
||||||
|
<FileSpreadsheet size={18} className="mx-auto mb-2 text-slate-200" />
|
||||||
|
尚无记录,先上传一份 xlsx 试试
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { ExternalLink, Layers, Truck, Zap } from 'lucide-react';
|
||||||
|
import { summarizeOverall } from './model';
|
||||||
|
import type { OverallRow } from './types';
|
||||||
|
|
||||||
|
interface SummaryKpisProps {
|
||||||
|
overall: OverallRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SummaryKpis({ overall }: SummaryKpisProps) {
|
||||||
|
const summary = summarizeOverall(overall);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
|
<KpiCard icon={<Layers size={14} />} label="总记录" value={summary.totalRecords.toLocaleString()} />
|
||||||
|
<KpiCard icon={<Truck size={14} />} label="内部记录" value={summary.internalRecords.toLocaleString()} accent="blue" />
|
||||||
|
<KpiCard icon={<ExternalLink size={14} />} label="外部记录" value={summary.externalRecords.toLocaleString()} accent="amber" />
|
||||||
|
<KpiCard icon={<Zap size={14} />} label="累计电量" value={`${summary.totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} />
|
||||||
|
<KpiCard icon={<Zap size={14} />} label="内部电量" value={`${summary.internalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="blue" />
|
||||||
|
<KpiCard icon={<Zap size={14} />} label="外部电量" value={`${summary.externalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="amber" />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({ icon, label, value, accent = 'slate' }: { icon: ReactNode; label: string; value: string; accent?: 'slate' | 'blue' | 'amber' }) {
|
||||||
|
const accentMap: Record<string, string> = {
|
||||||
|
slate: 'text-slate-700',
|
||||||
|
blue: 'text-blue-600',
|
||||||
|
amber: 'text-amber-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-100 shadow-sm p-3">
|
||||||
|
<div className="flex items-center gap-1 text-[10px] font-bold text-slate-400 uppercase">
|
||||||
|
<span className={accentMap[accent]}>{icon}</span>
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`text-base font-black tabular-nums leading-tight mt-0.5 ${accentMap[accent]}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type {
|
||||||
|
ChangeEventHandler,
|
||||||
|
DragEventHandler,
|
||||||
|
MouseEventHandler,
|
||||||
|
RefObject,
|
||||||
|
} from 'react';
|
||||||
|
import { RotateCcw, Upload } from 'lucide-react';
|
||||||
|
|
||||||
|
interface UploadDropzoneProps {
|
||||||
|
inputRef: RefObject<HTMLInputElement | null>;
|
||||||
|
file: File | null;
|
||||||
|
uploading: boolean;
|
||||||
|
dragOver: boolean;
|
||||||
|
onClick: MouseEventHandler<HTMLElement>;
|
||||||
|
onDragOver: DragEventHandler<HTMLElement>;
|
||||||
|
onDragLeave: DragEventHandler<HTMLElement>;
|
||||||
|
onDrop: DragEventHandler<HTMLElement>;
|
||||||
|
onInputChange: ChangeEventHandler<HTMLInputElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadDropzone({
|
||||||
|
inputRef,
|
||||||
|
file,
|
||||||
|
uploading,
|
||||||
|
dragOver,
|
||||||
|
onClick,
|
||||||
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
onDrop,
|
||||||
|
onInputChange,
|
||||||
|
}: UploadDropzoneProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`bg-white rounded-2xl border-2 border-dashed shadow-sm cursor-pointer transition-all ${
|
||||||
|
dragOver ? 'border-blue-400 bg-blue-50/40' : uploading ? 'border-slate-200' : 'border-slate-200 hover:border-blue-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
className="hidden"
|
||||||
|
onChange={onInputChange}
|
||||||
|
/>
|
||||||
|
<div className="px-6 py-10 flex flex-col items-center text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3">
|
||||||
|
{uploading ? <RotateCcw size={22} className="text-blue-500 animate-spin" /> : <Upload size={22} className="text-blue-500" />}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-slate-700 mb-1">
|
||||||
|
{uploading ? '正在解析...' : file ? file.name : '点击或拖拽 xlsx 文件到此处'}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400 max-w-md leading-relaxed">
|
||||||
|
支持「充电成功记录明细」格式;订单编号已存在的会自动跳过
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import { AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||||
|
import type { UploadResult } from './types';
|
||||||
|
|
||||||
|
interface UploadFeedbackProps {
|
||||||
|
result: UploadResult | null;
|
||||||
|
error: string | null;
|
||||||
|
onCloseResult: () => void;
|
||||||
|
onCloseError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadFeedback({ result, error, onCloseResult, onCloseError }: UploadFeedbackProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AnimatePresence>
|
||||||
|
{result && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -8 }}
|
||||||
|
className="bg-white rounded-2xl border border-emerald-100 shadow-sm p-4 flex items-start gap-3"
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={18} className="text-emerald-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[12px] font-bold text-slate-700 mb-1">
|
||||||
|
上传成功:<span className="text-slate-500 font-mono">{result.filename}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 text-[11px]">
|
||||||
|
<Stat label="解析" value={result.parsed} color="text-slate-700" />
|
||||||
|
<Stat label="新增" value={result.inserted} color="text-blue-600" />
|
||||||
|
<Stat label="重复跳过" value={result.fileDuplicates + result.dbDuplicates} color="text-slate-500" />
|
||||||
|
<Stat label="内部" value={result.breakdown.internal} color="text-blue-600" />
|
||||||
|
<Stat label="外部(含无车牌)" value={result.breakdown.external} color="text-amber-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onCloseResult} className="text-slate-300 hover:text-slate-600 text-[10px] font-bold">关闭</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<AnimatePresence>
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -8 }}
|
||||||
|
className="bg-red-50 rounded-2xl border border-red-200 shadow-sm p-4 flex items-start gap-3"
|
||||||
|
>
|
||||||
|
<AlertCircle size={18} className="text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 text-[12px] font-bold text-red-700">{error}</div>
|
||||||
|
<button onClick={onCloseError} className="text-red-300 hover:text-red-600 text-[10px] font-bold">关闭</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-50 rounded-lg px-2 py-1.5">
|
||||||
|
<div className="text-[9px] text-slate-400 uppercase font-bold">{label}</div>
|
||||||
|
<div className={`text-sm font-black tabular-nums ${color}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
formatCount,
|
||||||
|
formatDateTime,
|
||||||
|
formatDecimal,
|
||||||
|
formatFixed,
|
||||||
|
summarizeOverall,
|
||||||
|
} from './model';
|
||||||
|
|
||||||
|
test('summarizeOverall preserves aggregate totals and per-kind values', () => {
|
||||||
|
assert.deepEqual(summarizeOverall([
|
||||||
|
{ vehicle_kind: 'internal', records: 3, total_kwh: 12.34, total_fee: 20.5 },
|
||||||
|
{ vehicle_kind: 'external', records: 2, total_kwh: 5.66, total_fee: 9.5 },
|
||||||
|
]), {
|
||||||
|
totalRecords: 5,
|
||||||
|
totalKwh: 18,
|
||||||
|
totalFee: 30,
|
||||||
|
internalRecords: 3,
|
||||||
|
externalRecords: 2,
|
||||||
|
internalKwh: 12.34,
|
||||||
|
externalKwh: 5.66,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizeOverall preserves empty fallbacks', () => {
|
||||||
|
assert.deepEqual(summarizeOverall([]), {
|
||||||
|
totalRecords: 0,
|
||||||
|
totalKwh: 0,
|
||||||
|
totalFee: 0,
|
||||||
|
internalRecords: 0,
|
||||||
|
externalRecords: 0,
|
||||||
|
internalKwh: 0,
|
||||||
|
externalKwh: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatters preserve the import page display rules', () => {
|
||||||
|
assert.equal(formatDateTime('2026-08-13T09:08:07.000Z', 19), '2026-08-13 09:08:07');
|
||||||
|
assert.equal(formatDateTime(null, 16), '');
|
||||||
|
assert.equal(formatCount(1234), (1234).toLocaleString());
|
||||||
|
assert.equal(formatDecimal(1234.56, 1), (1234.56).toLocaleString('zh-CN', { maximumFractionDigits: 1 }));
|
||||||
|
assert.equal(formatFixed(null, 2), '0.00');
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { OverallRow } from './types';
|
||||||
|
|
||||||
|
export const KIND_LABEL: Record<string, string> = {
|
||||||
|
internal: '内部',
|
||||||
|
external: '外部',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const KIND_STYLE: Record<string, string> = {
|
||||||
|
internal: 'bg-blue-50 text-blue-600 border-blue-200',
|
||||||
|
external: 'bg-amber-50 text-amber-600 border-amber-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function summarizeOverall(rows: OverallRow[]) {
|
||||||
|
const byKind = new Map(rows.map(row => [row.vehicle_kind, row]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalRecords: rows.reduce((sum, row) => sum + Number(row.records || 0), 0),
|
||||||
|
totalKwh: rows.reduce((sum, row) => sum + Number(row.total_kwh || 0), 0),
|
||||||
|
totalFee: rows.reduce((sum, row) => sum + Number(row.total_fee || 0), 0),
|
||||||
|
internalRecords: byKind.get('internal')?.records ?? 0,
|
||||||
|
externalRecords: byKind.get('external')?.records ?? 0,
|
||||||
|
internalKwh: byKind.get('internal')?.total_kwh ?? 0,
|
||||||
|
externalKwh: byKind.get('external')?.total_kwh ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(value: string | null | undefined, length: number) {
|
||||||
|
return (value || '').replace('T', ' ').slice(0, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCount(value: number) {
|
||||||
|
return Number(value).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDecimal(value: number | null | undefined, maximumFractionDigits: number) {
|
||||||
|
return Number(value ?? 0).toLocaleString('zh-CN', { maximumFractionDigits });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFixed(value: number | null | undefined, fractionDigits: number) {
|
||||||
|
return Number(value ?? 0).toFixed(fractionDigits);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
export interface UploadResult {
|
||||||
|
ok: boolean;
|
||||||
|
filename: string;
|
||||||
|
batchId: string;
|
||||||
|
parsed: number;
|
||||||
|
fileDuplicates: number;
|
||||||
|
inserted: number;
|
||||||
|
dbDuplicates: number;
|
||||||
|
breakdown: { internal: number; external: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VehicleKind = 'internal' | 'external' | 'unknown';
|
||||||
|
export type VehicleKindFilter = '' | 'internal' | 'external';
|
||||||
|
|
||||||
|
export interface ListItem {
|
||||||
|
id: number;
|
||||||
|
order_no: string;
|
||||||
|
station_name: string | null;
|
||||||
|
terminal_name: string | null;
|
||||||
|
region: string | null;
|
||||||
|
city: string | null;
|
||||||
|
start_time: string | null;
|
||||||
|
end_time: string | null;
|
||||||
|
duration_min: number | null;
|
||||||
|
kwh: number | null;
|
||||||
|
fee: number | null;
|
||||||
|
e_fee: number | null;
|
||||||
|
service_fee: number | null;
|
||||||
|
plate: string | null;
|
||||||
|
judged_plate: string | null;
|
||||||
|
customer_name: string | null;
|
||||||
|
vehicle_kind: VehicleKind;
|
||||||
|
batch_id: string;
|
||||||
|
imported_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverallRow {
|
||||||
|
vehicle_kind: 'internal' | 'external';
|
||||||
|
records: number;
|
||||||
|
total_kwh: number;
|
||||||
|
total_fee: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchRow {
|
||||||
|
batch_id: string;
|
||||||
|
imported_at: string;
|
||||||
|
records: number;
|
||||||
|
internal_count: number;
|
||||||
|
external_count: number;
|
||||||
|
total_kwh: number;
|
||||||
|
total_fee: number;
|
||||||
|
}
|
||||||
@@ -1,37 +1,46 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronRight, Plug } from 'lucide-react';
|
import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Wallet } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import TrendBadge from './TrendBadge';
|
import TrendBadge from './TrendBadge';
|
||||||
import { fetchElectricMonthly } from './api';
|
import { fetchElectricMonthly } from './api';
|
||||||
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
|
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
import { EmptyState, ErrorState, LoadingState, MetricTile } from '../../components/ui/surface';
|
||||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
import DailyRangeControls from './daily-range/DailyRangeControls';
|
||||||
{ id: 'thisWeek', label: '本周' },
|
import {
|
||||||
{ id: 'thisMonth', label: '本月' },
|
getQuickRange,
|
||||||
{ id: 'last15', label: '近 15 天' },
|
getRangeModeLabel,
|
||||||
];
|
normalizeRange,
|
||||||
|
type RangeMode,
|
||||||
|
} from './daily-range/model';
|
||||||
|
import { summarizeElectricMonths } from './electric-daily/model';
|
||||||
|
|
||||||
export default function ElectricDaily() {
|
export default function ElectricDaily() {
|
||||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
||||||
const [pick, setPick] = useState<DateQuickPick>('last15');
|
const [pick, setPick] = useState<RangeMode>('last15');
|
||||||
|
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
|
||||||
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
|
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
|
||||||
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
|
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setError(null);
|
setError(null);
|
||||||
fetchElectricMonthly(customer, pick)
|
const query = pick === 'custom'
|
||||||
|
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
||||||
|
: { range: pick };
|
||||||
|
fetchElectricMonthly(customer, query)
|
||||||
.then(m => {
|
.then(m => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setMonths(m);
|
setMonths(m);
|
||||||
// 默认展开最新一个月
|
// 默认展开最新一个月
|
||||||
if (m.length > 0) setOpenMonths(prev => prev.size > 0 ? prev : new Set([m[0].month]));
|
if (m.length > 0) setOpenMonths(new Set([m[0].month]));
|
||||||
})
|
})
|
||||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [customer, pick]);
|
}, [customer, pick, effectiveRange.start, effectiveRange.end]);
|
||||||
|
|
||||||
const toggleMonth = (m: string) => setOpenMonths(prev => {
|
const toggleMonth = (m: string) => setOpenMonths(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -39,41 +48,53 @@ export default function ElectricDaily() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalKwh = useMemo(() => (months ?? []).reduce((s, m) => s + (m.kwh || 0), 0), [months]);
|
const {
|
||||||
|
totalKwh,
|
||||||
|
totalFee,
|
||||||
|
activeDays,
|
||||||
|
abnormalDays,
|
||||||
|
avgKwh,
|
||||||
|
avgPrice,
|
||||||
|
hasFeeDetail,
|
||||||
|
} = useMemo(() => summarizeElectricMonths(months), [months]);
|
||||||
|
const scopeLabel = getRangeModeLabel(pick);
|
||||||
|
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
||||||
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
|
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
|
||||||
|
|
||||||
|
const applyQuickPick = (nextPick: DateQuickPick) => {
|
||||||
|
setPick(nextPick);
|
||||||
|
setDateRange(getQuickRange(nextPick));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDateRange = (field: 'start' | 'end', value: string) => {
|
||||||
|
if (!value) return;
|
||||||
|
setPick('custom');
|
||||||
|
setDateRange(prev => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* 日期速选 */}
|
<DailyRangeControls
|
||||||
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
|
pick={pick}
|
||||||
{QUICK_PICK_OPTIONS.map(opt => (
|
dateRange={dateRange}
|
||||||
<button
|
customer={customer}
|
||||||
key={opt.id}
|
onQuickPick={applyQuickPick}
|
||||||
onClick={() => setPick(opt.id)}
|
onCustomPick={() => setPick('custom')}
|
||||||
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
|
onDateRangeChange={updateDateRange}
|
||||||
pick === opt.id
|
onCustomerChange={setCustomer}
|
||||||
? 'bg-blue-50 text-blue-600 border-blue-200'
|
/>
|
||||||
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 客户类型 */}
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
|
<MetricTile icon={BatteryCharging} label={`${scopeLabel}充电量`} value={totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={rangeText} />
|
||||||
{(['lingniu', 'external'] as const).map(c => (
|
<MetricTile
|
||||||
<button
|
icon={Wallet}
|
||||||
key={c}
|
label="充电费用"
|
||||||
onClick={() => setCustomer(c)}
|
value={hasFeeDetail ? `¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '待同步'}
|
||||||
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
|
helper={hasFeeDetail ? `均价 ${avgPrice.toFixed(2)} 元/度` : '当前明细仅返回充电量'}
|
||||||
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
|
tone={hasFeeDetail ? 'emerald' : 'slate'}
|
||||||
}`}
|
/>
|
||||||
>
|
<MetricTile icon={CalendarDays} label="有效天数" value={`${activeDays}`} unit="天" helper={`日均 ${avgKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} tone="amber" />
|
||||||
{c === 'external' ? '外部车辆' : '羚牛车辆'}
|
<MetricTile icon={TrendingUp} label="波动提醒" value={abnormalDays} unit="天" helper="环比超过 30% 标记" tone={abnormalDays > 0 ? 'rose' : 'slate'} />
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 外部车辆 数据未就绪 */}
|
{/* 外部车辆 数据未就绪 */}
|
||||||
@@ -106,11 +127,11 @@ export default function ElectricDaily() {
|
|||||||
<span className="text-right">环比</span>
|
<span className="text-right">环比</span>
|
||||||
</div>
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">加载失败:{error}</div>
|
<div className="p-3"><ErrorState message={error} /></div>
|
||||||
) : months === null ? (
|
) : months === null ? (
|
||||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">加载中…</div>
|
<div className="p-3"><LoadingState label="正在加载充电明细" /></div>
|
||||||
) : months.length === 0 ? (
|
) : months.length === 0 ? (
|
||||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">暂无数据</div>
|
<div className="p-3"><EmptyState title="暂无充电数据" description="请切换时间范围或车辆归属" /></div>
|
||||||
) : months.map(m => {
|
) : months.map(m => {
|
||||||
const open = openMonths.has(m.month);
|
const open = openMonths.has(m.month);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { LayoutDashboard, CalendarDays } from 'lucide-react';
|
||||||
|
import { AnimatePresence } from 'motion/react';
|
||||||
|
import ElectricView, { type ElectricSubTab } from './ElectricView';
|
||||||
|
import SubTabs from './SubTabs';
|
||||||
|
import { useHashSubTab } from './useHashSubTab';
|
||||||
|
import { FadeIn, PageFrame } from '../../components/ui/surface';
|
||||||
|
|
||||||
|
const SUB_TABS = [
|
||||||
|
{ id: 'daily', label: '每日', icon: CalendarDays },
|
||||||
|
{ id: 'overview', label: '总览', icon: LayoutDashboard },
|
||||||
|
] as const satisfies readonly { id: ElectricSubTab; label: string; icon: typeof CalendarDays }[];
|
||||||
|
|
||||||
|
const SUB_IDS: readonly ElectricSubTab[] = ['daily', 'overview'];
|
||||||
|
|
||||||
|
export default function ElectricModule() {
|
||||||
|
const [sub, setSub] = useHashSubTab<ElectricSubTab>('electric', SUB_IDS);
|
||||||
|
return (
|
||||||
|
<PageFrame
|
||||||
|
title="电能成本看板"
|
||||||
|
subtitle="围绕充电量、费用、日趋势和车辆归属展示电能支出结构,辅助识别费用波动。"
|
||||||
|
icon={CalendarDays}
|
||||||
|
eyebrow="ELECTRIC BI"
|
||||||
|
meta="时间单位清晰标注 · 支持日/总览切换"
|
||||||
|
>
|
||||||
|
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<FadeIn key={sub}>
|
||||||
|
<ElectricView sub={sub} />
|
||||||
|
</FadeIn>
|
||||||
|
</AnimatePresence>
|
||||||
|
</PageFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Wallet, CalendarClock } from 'lucide-react';
|
import { BatteryCharging, Gauge, Wallet, CalendarClock } from 'lucide-react';
|
||||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
|
||||||
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
|
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||||
|
|
||||||
function fmtYuan(yuan: number) {
|
function fmtYuan(yuan: number) {
|
||||||
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
||||||
@@ -24,10 +25,10 @@ export default function ElectricOverview() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">加载失败:{error}</div>;
|
return <ErrorState message={error} />;
|
||||||
}
|
}
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6 text-center text-slate-400 text-sm">加载中…</div>;
|
return <LoadingState label="正在加载电能总览" />;
|
||||||
}
|
}
|
||||||
const k = data.kpi;
|
const k = data.kpi;
|
||||||
const trendData = data.trend;
|
const trendData = data.trend;
|
||||||
@@ -37,6 +38,12 @@ export default function ElectricOverview() {
|
|||||||
const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
|
const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
|
||||||
? `${trendMonthLabel} 每日充电`
|
? `${trendMonthLabel} 每日充电`
|
||||||
: '本月每日充电';
|
: '本月每日充电';
|
||||||
|
const activeDays = trendData.filter(item => item.kwh > 0).length;
|
||||||
|
const avgDailyKwh = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.kwh, 0) / activeDays : 0;
|
||||||
|
const avgDailyFee = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.fee, 0) / activeDays : 0;
|
||||||
|
const peakDay = trendData.reduce<typeof trendData[number] | null>((best, item) => (!best || item.kwh > best.kwh ? item : best), null);
|
||||||
|
const avgPrice = k.totalKwh > 0 ? k.totalFee / k.totalKwh : 0;
|
||||||
|
const monthPrice = k.monthKwh > 0 ? k.monthFee / k.monthKwh : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -44,28 +51,36 @@ export default function ElectricOverview() {
|
|||||||
龙王路停车场充电站,期初 2025-01-01,手工导入每日更新
|
龙王路停车场充电站,期初 2025-01-01,手工导入每日更新
|
||||||
</div>
|
</div>
|
||||||
{/* 横向 mini KPI 头 */}
|
{/* 横向 mini KPI 头 */}
|
||||||
<div className="grid grid-cols-2 gap-2 md:gap-3">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
<MetricTile icon={Wallet} label="累计充电费" value={fmtYuan(k.totalFee)} helper={fmtKwh(k.totalKwh)} />
|
||||||
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
|
<MetricTile icon={CalendarClock} label="本月充电费" value={fmtYuan(k.monthFee)} helper={fmtKwh(k.monthKwh)} tone="emerald" />
|
||||||
<Wallet size={11} className="text-blue-600" />累计
|
<MetricTile icon={Gauge} label="累计均价" value={avgPrice.toFixed(2)} unit="元/度" helper={`本月 ${monthPrice.toFixed(2)} 元/度`} tone="amber" />
|
||||||
</div>
|
<MetricTile icon={BatteryCharging} label="今日充电" value={k.todayKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={`${fmtYuan(k.todayFee)} · ${k.todayChainPct >= 0 ? '+' : ''}${(k.todayChainPct * 100).toFixed(1)}%`} tone={Math.abs(k.todayChainPct) >= 0.3 ? 'rose' : 'slate'} />
|
||||||
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.totalFee)}</div>
|
</div>
|
||||||
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.totalKwh)}</div>
|
|
||||||
</div>
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
<SurfaceCard className="p-3">
|
||||||
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
|
<div className="text-[11px] font-black text-slate-400">有效充电日</div>
|
||||||
<CalendarClock size={11} className="text-blue-600" />本月
|
<div className="mt-1 text-xl font-black text-slate-900">{activeDays}<span className="ml-1 text-[11px] text-slate-400">天</span></div>
|
||||||
</div>
|
<div className="mt-1 text-[11px] font-bold text-slate-500">日均 {avgDailyKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度</div>
|
||||||
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.monthFee)}</div>
|
</SurfaceCard>
|
||||||
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.monthKwh)}</div>
|
<SurfaceCard className="p-3">
|
||||||
</div>
|
<div className="text-[11px] font-black text-slate-400">峰值日</div>
|
||||||
|
<div className="mt-1 text-xl font-black text-blue-600">{peakDay ? peakDay.date.slice(5) : '—'}</div>
|
||||||
|
<div className="mt-1 text-[11px] font-bold text-slate-500">{peakDay ? `${peakDay.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度 · ${fmtYuan(peakDay.fee)}` : '暂无数据'}</div>
|
||||||
|
</SurfaceCard>
|
||||||
|
<SurfaceCard className="p-3">
|
||||||
|
<div className="text-[11px] font-black text-slate-400">月度占比</div>
|
||||||
|
<div className="mt-1 text-xl font-black text-emerald-600">{k.totalFee > 0 ? (k.monthFee / k.totalFee * 100).toFixed(1) : '0.0'}%</div>
|
||||||
|
<div className="mt-1 text-[11px] font-bold text-slate-500">本月费用 / 累计费用</div>
|
||||||
|
</SurfaceCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 本月每日充电柱图 */}
|
{/* 本月每日充电柱图 */}
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
<SurfaceCard className="p-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-sm font-bold text-slate-700">{chartTitle}</span>
|
<span className="text-sm font-bold text-slate-700">{chartTitle}</span>
|
||||||
<span className="text-[11px] text-slate-400 font-bold">单位 元</span>
|
<span className="text-[11px] text-slate-400 font-bold">时间单位:日 · 单位 元 · 均线为日均费用</span>
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={160}>
|
<ResponsiveContainer width="100%" height={160}>
|
||||||
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||||
@@ -85,6 +100,14 @@ export default function ElectricOverview() {
|
|||||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||||
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
|
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
|
||||||
/>
|
/>
|
||||||
|
{avgDailyFee > 0 && (
|
||||||
|
<ReferenceLine
|
||||||
|
y={avgDailyFee}
|
||||||
|
stroke="#f59e0b"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Bar dataKey="fee" radius={[4, 4, 0, 0]}>
|
<Bar dataKey="fee" radius={[4, 4, 0, 0]}>
|
||||||
{trendData.map((_, i) => (
|
{trendData.map((_, i) => (
|
||||||
<Cell key={i} fill="url(#electricBarGrad)" />
|
<Cell key={i} fill="url(#electricBarGrad)" />
|
||||||
@@ -98,7 +121,7 @@ export default function ElectricOverview() {
|
|||||||
</defs>
|
</defs>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</SurfaceCard>
|
||||||
<RotatingFooterHint />
|
<RotatingFooterHint />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Fuel, BatteryCharging, Receipt, LayoutDashboard, CalendarDays } from 'lucide-react';
|
|
||||||
import { motion } from 'motion/react';
|
|
||||||
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
|
|
||||||
import ElectricView, { type ElectricSubTab } from './ElectricView';
|
|
||||||
import ETCView from './ETCView';
|
|
||||||
|
|
||||||
type TopTab = 'hydrogen' | 'electric' | 'etc';
|
|
||||||
type SubTabId = HydrogenSubTab | ElectricSubTab; // 'daily' | 'overview'
|
|
||||||
|
|
||||||
const TABS: { key: TopTab; label: string; icon: typeof Fuel }[] = [
|
|
||||||
{ key: 'hydrogen', label: '氢能', icon: Fuel },
|
|
||||||
{ key: 'electric', label: '电能', icon: BatteryCharging },
|
|
||||||
{ key: 'etc', label: 'ETC', icon: Receipt },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SUB_TABS: { id: SubTabId; label: string; icon: typeof LayoutDashboard }[] = [
|
|
||||||
{ id: 'daily', label: '每日', icon: CalendarDays },
|
|
||||||
{ id: 'overview', label: '总览', icon: LayoutDashboard },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function EnergyModule() {
|
|
||||||
const [activeTab, setActiveTab] = useState<TopTab>('hydrogen');
|
|
||||||
const [hydroSub, setHydroSub] = useState<HydrogenSubTab>('daily');
|
|
||||||
const [electricSub, setElectricSub] = useState<ElectricSubTab>('daily');
|
|
||||||
const showSubTabs = activeTab === 'hydrogen' || activeTab === 'electric';
|
|
||||||
const currentSub: SubTabId = activeTab === 'electric' ? electricSub : hydroSub;
|
|
||||||
const setSub = (id: SubTabId) => activeTab === 'electric' ? setElectricSub(id) : setHydroSub(id);
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
|
||||||
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
|
|
||||||
|
|
||||||
{/* 统一 sticky 头部:top tab + (氢能时) 子 tab;同一张卡片,无间隙 */}
|
|
||||||
{/* pb-4 留一点底部缓冲,避免下方快捷选按钮在滚动时贴着 sticky 半截露脸 */}
|
|
||||||
<div className="sticky top-0 z-30 -mx-3 md:-mx-6 px-3 md:px-6 -mt-3 md:-mt-6 pt-3 md:pt-6 pb-4 bg-[#F8F9FB] shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)]">
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
|
||||||
{/* 顶部 tab:氢能 / 电能 / ETC */}
|
|
||||||
<div className={`px-4 py-2 flex items-center gap-6 ${showSubTabs ? 'border-b border-slate-50' : ''}`}>
|
|
||||||
{TABS.map(tab => {
|
|
||||||
const Icon = tab.icon;
|
|
||||||
const active = activeTab === tab.key;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab.key}
|
|
||||||
onClick={() => setActiveTab(tab.key)}
|
|
||||||
className={`flex items-center gap-2 py-1 transition-colors relative ${active ? 'text-blue-600' : 'text-slate-400 hover:text-slate-600'}`}
|
|
||||||
>
|
|
||||||
<Icon size={14} />
|
|
||||||
<span className="text-[11px] font-bold">{tab.label}</span>
|
|
||||||
{active && (
|
|
||||||
<motion.div layoutId="activeEnergyTopTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{/* 子 tab:氢能 / 电能 都显示 每日 / 总览 */}
|
|
||||||
{showSubTabs && (
|
|
||||||
<div className="p-1 flex gap-1">
|
|
||||||
{SUB_TABS.map(({ id, label, icon: Icon }) => {
|
|
||||||
const active = currentSub === id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={id}
|
|
||||||
onClick={() => setSub(id)}
|
|
||||||
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
|
|
||||||
active ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon size={14} />
|
|
||||||
<span>{label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === 'hydrogen' && <HydrogenView sub={hydroSub} />}
|
|
||||||
{activeTab === 'electric' && <ElectricView sub={electricSub} />}
|
|
||||||
{activeTab === 'etc' && <ETCView />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Receipt } from 'lucide-react';
|
||||||
|
import ETCView from './ETCView';
|
||||||
|
import { PageFrame } from '../../components/ui/surface';
|
||||||
|
|
||||||
|
export default function EtcModule() {
|
||||||
|
return (
|
||||||
|
<PageFrame
|
||||||
|
title="ETC 通行费看板"
|
||||||
|
subtitle="规划按车、按月、按线路拆分通行费,让车辆运营成本口径逐步完整。"
|
||||||
|
icon={Receipt}
|
||||||
|
eyebrow="ETC BI"
|
||||||
|
meta="数据对接中 · 页面能力预留"
|
||||||
|
>
|
||||||
|
<ETCView />
|
||||||
|
</PageFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,37 +1,55 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronRight, Plug } from 'lucide-react';
|
import { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
|
||||||
import TrendBadge from './TrendBadge';
|
import TrendBadge from './TrendBadge';
|
||||||
import { fetchHydrogenDaily } from './api';
|
import { fetchHydrogenDaily } from './api';
|
||||||
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
||||||
|
import {
|
||||||
|
getQuickRange,
|
||||||
|
getRangeModeLabel,
|
||||||
|
normalizeRange,
|
||||||
|
summarizeHydrogenRows,
|
||||||
|
type RangeMode,
|
||||||
|
} from './hydrogen-daily/model';
|
||||||
|
import DailyRangeControls from './daily-range/DailyRangeControls';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
|
||||||
{ id: 'thisWeek', label: '本周' },
|
|
||||||
{ id: 'thisMonth', label: '本月' },
|
|
||||||
{ id: 'last15', label: '近 15 天' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function HydrogenDaily() {
|
export default function HydrogenDaily() {
|
||||||
const [pick, setPick] = useState<DateQuickPick>('last15');
|
const [pick, setPick] = useState<RangeMode>('last15');
|
||||||
|
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
|
||||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setError(null);
|
setError(null);
|
||||||
fetchHydrogenDaily(pick, customer)
|
const query = pick === 'custom'
|
||||||
|
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
||||||
|
: { range: pick };
|
||||||
|
fetchHydrogenDaily(query, customer)
|
||||||
.then(r => { if (!cancelled) setRows(r); })
|
.then(r => { if (!cancelled) setRows(r); })
|
||||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [pick, customer]);
|
}, [pick, customer, effectiveRange.start, effectiveRange.end]);
|
||||||
|
|
||||||
// 柱图:按日期升序,用于"从左到右时间流"
|
const {
|
||||||
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
|
trendData,
|
||||||
const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0);
|
totalKg,
|
||||||
|
activeDays,
|
||||||
|
stationCount,
|
||||||
|
avgKg,
|
||||||
|
peakDay,
|
||||||
|
lowDay,
|
||||||
|
zeroDays,
|
||||||
|
} = useMemo(() => summarizeHydrogenRows(rows), [rows]);
|
||||||
|
const scopeLabel = getRangeModeLabel(pick);
|
||||||
|
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
||||||
|
|
||||||
const toggle = (date: string) => setExpanded(prev => {
|
const toggle = (date: string) => setExpanded(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -39,38 +57,34 @@ export default function HydrogenDaily() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const applyQuickPick = (nextPick: DateQuickPick) => {
|
||||||
|
setPick(nextPick);
|
||||||
|
setDateRange(getQuickRange(nextPick));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDateRange = (field: 'start' | 'end', value: string) => {
|
||||||
|
if (!value) return;
|
||||||
|
setPick('custom');
|
||||||
|
setDateRange(prev => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* 日期速选 */}
|
<DailyRangeControls
|
||||||
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
|
pick={pick}
|
||||||
{QUICK_PICK_OPTIONS.map(opt => (
|
dateRange={dateRange}
|
||||||
<button
|
customer={customer}
|
||||||
key={opt.id}
|
onQuickPick={applyQuickPick}
|
||||||
onClick={() => setPick(opt.id)}
|
onCustomPick={() => setPick('custom')}
|
||||||
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
|
onDateRangeChange={updateDateRange}
|
||||||
pick === opt.id
|
onCustomerChange={setCustomer}
|
||||||
? 'bg-blue-50 text-blue-600 border-blue-200'
|
/>
|
||||||
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 客户类型 segmented */}
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
|
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
|
||||||
{(['lingniu', 'external'] as const).map(c => (
|
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
|
||||||
<button
|
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
|
||||||
key={c}
|
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
|
||||||
onClick={() => setCustomer(c)}
|
|
||||||
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
|
|
||||||
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{c === 'external' ? '外部车辆' : '羚牛车辆'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 外部车辆:新系统数据还没准备好 */}
|
{/* 外部车辆:新系统数据还没准备好 */}
|
||||||
@@ -96,13 +110,34 @@ export default function HydrogenDaily() {
|
|||||||
|
|
||||||
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
|
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
|
||||||
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
|
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
<SurfaceCard>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between px-4 pt-4 mb-2">
|
||||||
<span className="text-sm font-bold text-slate-700">每日加氢量</span>
|
<span className="text-sm font-bold text-slate-700">每日加氢量</span>
|
||||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
<span className="text-[11px] text-slate-400 font-bold">时间单位:日 · 单位 Kg</span>
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={160}>
|
<div className="mx-4 mb-2 grid grid-cols-3 gap-2 rounded-xl bg-slate-50 p-2">
|
||||||
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
<div>
|
||||||
|
<div className="text-[10px] font-black text-slate-400">峰值日</div>
|
||||||
|
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
|
||||||
|
{peakDay ? `${peakDay.date.slice(5)} · ${peakDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-black text-slate-400">低谷日</div>
|
||||||
|
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
|
||||||
|
{lowDay ? `${lowDay.date.slice(5)} · ${lowDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-black text-slate-400">零数据日</div>
|
||||||
|
<div className={`mt-0.5 text-[11px] font-black ${zeroDays > 0 ? 'text-amber-600' : 'text-emerald-600'}`}>
|
||||||
|
{zeroDays} 天
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-[180px] min-w-0 px-2 pb-2">
|
||||||
|
<ResponsiveContainer width="100%" height={180} minWidth={0}>
|
||||||
|
<BarChart data={trendData} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
tickFormatter={(v: string) => v.slice(5)}
|
tickFormatter={(v: string) => v.slice(5)}
|
||||||
@@ -112,13 +147,27 @@ export default function HydrogenDaily() {
|
|||||||
interval="preserveStartEnd"
|
interval="preserveStartEnd"
|
||||||
minTickGap={8}
|
minTickGap={8}
|
||||||
/>
|
/>
|
||||||
<YAxis hide />
|
<YAxis
|
||||||
|
width={42}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fontSize: 9, fill: '#94a3b8' }}
|
||||||
|
tickFormatter={(v: number) => v >= 1000 ? `${Math.round(v / 1000)}k` : `${Math.round(v)}`}
|
||||||
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']}
|
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']}
|
||||||
labelFormatter={(d) => `日期 ${d}`}
|
labelFormatter={(d) => `日期 ${d}`}
|
||||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||||
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
|
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
|
||||||
/>
|
/>
|
||||||
|
{avgKg > 0 && (
|
||||||
|
<ReferenceLine
|
||||||
|
y={avgKg}
|
||||||
|
stroke="#f59e0b"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
|
<Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
|
||||||
{trendData.map((_, i) => (
|
{trendData.map((_, i) => (
|
||||||
<Cell key={i} fill="url(#hydrogenBarGrad)" />
|
<Cell key={i} fill="url(#hydrogenBarGrad)" />
|
||||||
@@ -132,7 +181,8 @@ export default function HydrogenDaily() {
|
|||||||
</defs>
|
</defs>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
</SurfaceCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
|
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
|
||||||
@@ -154,11 +204,11 @@ export default function HydrogenDaily() {
|
|||||||
</div>
|
</div>
|
||||||
{/* 主行 + 子行 */}
|
{/* 主行 + 子行 */}
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">加载失败:{error}</div>
|
<div className="p-3"><ErrorState message={error} /></div>
|
||||||
) : rows === null ? (
|
) : rows === null ? (
|
||||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">加载中…</div>
|
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">暂无数据</div>
|
<div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div>
|
||||||
) : rows.map(r => {
|
) : rows.map(r => {
|
||||||
const open = expanded.has(r.date);
|
const open = expanded.has(r.date);
|
||||||
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
|
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { LayoutDashboard, CalendarDays } from 'lucide-react';
|
||||||
|
import { AnimatePresence } from 'motion/react';
|
||||||
|
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
|
||||||
|
import SubTabs from './SubTabs';
|
||||||
|
import { useHashSubTab } from './useHashSubTab';
|
||||||
|
import { FadeIn, PageFrame } from '../../components/ui/surface';
|
||||||
|
|
||||||
|
const SUB_TABS = [
|
||||||
|
{ id: 'daily', label: '每日', icon: CalendarDays },
|
||||||
|
{ id: 'overview', label: '总览', icon: LayoutDashboard },
|
||||||
|
] as const satisfies readonly { id: HydrogenSubTab; label: string; icon: typeof CalendarDays }[];
|
||||||
|
|
||||||
|
const SUB_IDS: readonly HydrogenSubTab[] = ['daily', 'overview'];
|
||||||
|
|
||||||
|
export default function HydrogenModule() {
|
||||||
|
const [sub, setSub] = useHashSubTab<HydrogenSubTab>('hydrogen', SUB_IDS);
|
||||||
|
return (
|
||||||
|
<PageFrame
|
||||||
|
title="氢能经营看板"
|
||||||
|
subtitle="按时间、车辆归属、加氢站和区域统一展示加氢量、费用、收入与异常波动。"
|
||||||
|
icon={CalendarDays}
|
||||||
|
eyebrow="ENERGY BI"
|
||||||
|
meta="数据单位清晰标注 · 支持日/总览切换"
|
||||||
|
>
|
||||||
|
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<FadeIn key={sub}>
|
||||||
|
<HydrogenView sub={sub} />
|
||||||
|
</FadeIn>
|
||||||
|
</AnimatePresence>
|
||||||
|
</PageFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,89 +1,16 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import {
|
|
||||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
|
|
||||||
} from 'recharts';
|
|
||||||
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw } from 'lucide-react';
|
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
|
||||||
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
||||||
|
import { DistributionCharts } from './hydrogen-overview/components/DistributionCharts';
|
||||||
|
import { HydrogenOverviewSkeleton } from './hydrogen-overview/components/HydrogenOverviewSkeleton';
|
||||||
|
import { InsightCards } from './hydrogen-overview/components/InsightCards';
|
||||||
|
import { KpiSection } from './hydrogen-overview/components/KpiSection';
|
||||||
|
import { MonthlyCharts } from './hydrogen-overview/components/MonthlyCharts';
|
||||||
|
import { OverviewHeader } from './hydrogen-overview/components/OverviewHeader';
|
||||||
|
import { RefreshOverlay } from './hydrogen-overview/components/RefreshOverlay';
|
||||||
|
import { CustomerSummaryTable, StationSummaryTable } from './hydrogen-overview/components/SummaryTables';
|
||||||
|
import { deriveOverviewMetrics, formatYuan as fmtYuan } from './hydrogen-overview/model';
|
||||||
|
|
||||||
const REGION_COLORS = [
|
|
||||||
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
|
||||||
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
|
|
||||||
'#94a3b8',
|
|
||||||
];
|
|
||||||
|
|
||||||
interface YAxisTickProps {
|
|
||||||
x?: number;
|
|
||||||
y?: number;
|
|
||||||
index?: number;
|
|
||||||
payload?: { value: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
|
|
||||||
return (
|
|
||||||
<g transform={`translate(${x},${y})`}>
|
|
||||||
<circle cx={-172} cy={0} r={9} fill="#3b82f6" />
|
|
||||||
<text x={-172} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
|
|
||||||
{index + 1}
|
|
||||||
</text>
|
|
||||||
<text x={-154} y={4} textAnchor="start" fontSize={11} fill="#475569">
|
|
||||||
{payload?.value}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 数字格式化 ----------
|
|
||||||
function fmtKg(kg: number): { value: string; unit: string } {
|
|
||||||
if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' };
|
|
||||||
return { value: kg.toFixed(2), unit: 'Kg' };
|
|
||||||
}
|
|
||||||
function fmtYuan(yuan: number): { value: string; unit: string } {
|
|
||||||
const abs = Math.abs(yuan);
|
|
||||||
if (abs >= 100_000_000) return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' };
|
|
||||||
if (abs >= 10_000) {
|
|
||||||
const w = yuan / 10_000;
|
|
||||||
return { value: w.toLocaleString('zh-CN', { maximumFractionDigits: 2 }), unit: '万元' };
|
|
||||||
}
|
|
||||||
return { value: yuan.toLocaleString('zh-CN', { maximumFractionDigits: 0 }), unit: '元' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- KPI 卡 ----------
|
|
||||||
interface KpiCardProps {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
hero: { value: string; unit: string };
|
|
||||||
rows: { label: string; value: string; valueClass?: string }[];
|
|
||||||
accentClass: string;
|
|
||||||
iconBg: string;
|
|
||||||
}
|
|
||||||
function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className={`w-7 h-7 rounded-xl flex items-center justify-center ${iconBg}`}>
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
<span className="text-[11px] font-bold text-slate-500">{label}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-baseline gap-1">
|
|
||||||
<span className={`text-xl md:text-2xl font-black tabular-nums leading-none ${accentClass}`}>{hero.value}</span>
|
|
||||||
<span className="text-[11px] text-slate-400 font-bold">{hero.unit}</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5 pt-1 border-t border-slate-50">
|
|
||||||
{rows.map((r, i) => (
|
|
||||||
<div key={i} className="flex items-center justify-between text-[11px] font-bold">
|
|
||||||
<span className="text-slate-400">{r.label}</span>
|
|
||||||
<span className={`tabular-nums ${r.valueClass ?? 'text-slate-700'}`}>{r.value}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
export default function HydrogenOverview() {
|
export default function HydrogenOverview() {
|
||||||
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
|
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -124,518 +51,56 @@ export default function HydrogenOverview() {
|
|||||||
if (!data) {
|
if (!data) {
|
||||||
return <HydrogenOverviewSkeleton />;
|
return <HydrogenOverviewSkeleton />;
|
||||||
}
|
}
|
||||||
const k = data.kpi;
|
|
||||||
const top5 = data.top5;
|
|
||||||
const regions = data.regions;
|
|
||||||
const monthly = data.monthly;
|
|
||||||
const customers = data.customers;
|
|
||||||
const stations = data.stations;
|
|
||||||
const availableYears = data.availableYears;
|
|
||||||
const activeYear = data.year;
|
|
||||||
|
|
||||||
const yearKgFmt = fmtKg(k.yearKg);
|
const { kpi, top5, regions, monthly, customers, stations, availableYears, year: activeYear } = data;
|
||||||
const yearFeeFmt = fmtYuan(k.yearFee);
|
const {
|
||||||
const yearProfitFmt = fmtYuan(k.yearProfit);
|
monthAvgKg,
|
||||||
const ourYearKgFmt = fmtKg(k.ourYearKg);
|
bestMonth,
|
||||||
const customerYearKgFmt = fmtKg(k.customerYearKg);
|
latestMonth,
|
||||||
const monthKgFmt = fmtKg(k.monthKg);
|
monthMomentum,
|
||||||
const monthFeeFmt = fmtYuan(k.monthFee);
|
top5Share,
|
||||||
const todayKgFmt = fmtKg(k.todayKg);
|
profitYield,
|
||||||
const todayFeeFmt = fmtYuan(k.todayFee);
|
stationAvgKg,
|
||||||
const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee);
|
monthlyDual,
|
||||||
const customerYearFeeFmt = fmtYuan(customerYearFee);
|
} = deriveOverviewMetrics(data);
|
||||||
const yearRevenueFmt = fmtYuan(k.yearRevenue);
|
const yearProfitFmt = fmtYuan(kpi.yearProfit);
|
||||||
|
const yearRevenueFmt = fmtYuan(kpi.yearRevenue);
|
||||||
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
|
|
||||||
|
|
||||||
// 月度收支组合数据(推算"年内每月"图)
|
|
||||||
const monthlyDual = monthly.map(m => ({
|
|
||||||
...m,
|
|
||||||
monthLabel: m.month.slice(5).replace(/^0/, '') + '月',
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 relative">
|
<div className="flex flex-col gap-3 relative">
|
||||||
{/* 顶部说明条 + 年份切换 + 刷新按钮 */}
|
<OverviewHeader
|
||||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
|
activeYear={activeYear}
|
||||||
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRelative(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
|
availableYears={availableYears}
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
lastRefreshAt={lastRefreshAt}
|
||||||
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
|
refreshing={refreshing}
|
||||||
{availableYears.map(y => {
|
onSelectYear={setYear}
|
||||||
const active = y === activeYear;
|
onRefresh={() => void load(year, true)}
|
||||||
return (
|
/>
|
||||||
<button
|
|
||||||
key={y}
|
|
||||||
onClick={() => setYear(y)}
|
|
||||||
className={`px-2 py-0.5 text-[11px] font-bold rounded-md transition-all ${
|
|
||||||
active ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{y}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => void load(year, true)}
|
|
||||||
disabled={refreshing}
|
|
||||||
className="flex items-center gap-1 px-2 py-0.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
|
||||||
title="手动刷新(绕过缓存)"
|
|
||||||
>
|
|
||||||
<RefreshCw size={11} className={refreshing ? 'animate-spin' : ''} strokeWidth={2.6} />
|
|
||||||
<span className="text-[11px] font-bold">刷新</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* KPI 5 卡 */}
|
<KpiSection kpi={kpi} />
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
|
|
||||||
<KpiCard
|
|
||||||
icon={<Fuel size={14} className="text-cyan-600" strokeWidth={2.4} />}
|
|
||||||
iconBg="bg-cyan-50"
|
|
||||||
accentClass="text-slate-800"
|
|
||||||
label="累计加氢量"
|
|
||||||
hero={yearKgFmt}
|
|
||||||
rows={[
|
|
||||||
{ label: '我司', value: `${ourYearKgFmt.value} ${ourYearKgFmt.unit}` },
|
|
||||||
{ label: '客户', value: `${customerYearKgFmt.value} ${customerYearKgFmt.unit}` },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
icon={<Wallet size={14} className="text-blue-600" strokeWidth={2.4} />}
|
|
||||||
iconBg="bg-blue-50"
|
|
||||||
accentClass="text-slate-800"
|
|
||||||
label="累计加氢费"
|
|
||||||
hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }}
|
|
||||||
rows={[
|
|
||||||
{ label: '我司承担', value: `¥${fmtYuan(k.ourYearFee).value} ${fmtYuan(k.ourYearFee).unit}` },
|
|
||||||
{ label: '客户承担', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
icon={<TrendingUp size={14} className="text-emerald-600" strokeWidth={2.4} />}
|
|
||||||
iconBg="bg-emerald-50"
|
|
||||||
accentClass={profitColor}
|
|
||||||
label="时享加氢获利"
|
|
||||||
hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }}
|
|
||||||
rows={[
|
|
||||||
{ label: '收入', value: `¥${yearRevenueFmt.value} ${yearRevenueFmt.unit}` },
|
|
||||||
{ label: '成本', value: `¥${yearFeeFmt.value} ${yearFeeFmt.unit}` },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
icon={<CalendarDays size={14} className="text-amber-600" strokeWidth={2.4} />}
|
|
||||||
iconBg="bg-amber-50"
|
|
||||||
accentClass="text-amber-600"
|
|
||||||
label="本月加氢"
|
|
||||||
hero={monthKgFmt}
|
|
||||||
rows={[
|
|
||||||
{ label: '加氢费', value: `¥${monthFeeFmt.value} ${monthFeeFmt.unit}` },
|
|
||||||
{ label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
icon={<Sparkles size={14} className="text-violet-600" strokeWidth={2.4} />}
|
|
||||||
iconBg="bg-violet-50"
|
|
||||||
accentClass="text-violet-600"
|
|
||||||
label="本日加氢"
|
|
||||||
hero={todayKgFmt}
|
|
||||||
rows={[
|
|
||||||
{ label: '加氢费', value: `¥${todayFeeFmt.value} ${todayFeeFmt.unit}` },
|
|
||||||
{ label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 月度趋势:年内每月加氢量 */}
|
<InsightCards
|
||||||
{monthly.length > 0 && (
|
monthAvgKg={monthAvgKg}
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
bestMonth={bestMonth}
|
||||||
<div className="flex items-center justify-between mb-2">
|
latestMonth={latestMonth}
|
||||||
<span className="text-sm font-bold text-slate-700">{activeYear} 年月度加氢量</span>
|
monthMomentum={monthMomentum}
|
||||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
top5Share={top5Share}
|
||||||
</div>
|
profitYield={profitYield}
|
||||||
<ResponsiveContainer width="100%" height={140}>
|
stationAvgKg={stationAvgKg}
|
||||||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
stationCount={stations.length}
|
||||||
<XAxis
|
yearProfitValue={yearProfitFmt.value}
|
||||||
dataKey="monthLabel"
|
yearProfitUnit={yearProfitFmt.unit}
|
||||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
yearRevenueValue={yearRevenueFmt.value}
|
||||||
tickLine={false}
|
yearRevenueUnit={yearRevenueFmt.unit}
|
||||||
axisLine={false}
|
/>
|
||||||
interval={0}
|
|
||||||
/>
|
|
||||||
<YAxis hide />
|
|
||||||
<Tooltip
|
|
||||||
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })} Kg`, '加氢量']}
|
|
||||||
labelFormatter={(d) => `${d}`}
|
|
||||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
|
||||||
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="kg" radius={[4, 4, 0, 0]}>
|
|
||||||
{monthlyDual.map((_, i) => (
|
|
||||||
<Cell key={i} fill="url(#monthlyBarGrad)" />
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="monthlyBarGrad" x1="0" x2="0" y1="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#22d3ee" />
|
|
||||||
<stop offset="100%" stopColor="#3b82f6" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 月度收支对比 */}
|
<MonthlyCharts activeYear={activeYear} monthly={monthly} monthlyDual={monthlyDual} />
|
||||||
{monthly.length > 0 && (
|
<DistributionCharts top5={top5} regions={regions} yearKg={kpi.yearKg} />
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
<StationSummaryTable stations={stations} />
|
||||||
<div className="flex items-center justify-between mb-2">
|
<CustomerSummaryTable customers={customers} />
|
||||||
<span className="text-sm font-bold text-slate-700">{activeYear} 年月度收支对比</span>
|
|
||||||
<span className="text-[11px] text-slate-400 font-bold">单位 元</span>
|
|
||||||
</div>
|
|
||||||
<ResponsiveContainer width="100%" height={180}>
|
|
||||||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
|
||||||
<XAxis
|
|
||||||
dataKey="monthLabel"
|
|
||||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
interval={0}
|
|
||||||
/>
|
|
||||||
<YAxis hide />
|
|
||||||
<Legend
|
|
||||||
verticalAlign="top"
|
|
||||||
height={20}
|
|
||||||
iconSize={8}
|
|
||||||
wrapperStyle={{ fontSize: 11, paddingBottom: 4 }}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
formatter={(v, name) => {
|
|
||||||
const f = fmtYuan(Number(v ?? 0));
|
|
||||||
return [`¥${f.value} ${f.unit}`, name];
|
|
||||||
}}
|
|
||||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
|
||||||
cursor={{ fill: 'rgba(148, 163, 184, 0.06)' }}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} />
|
|
||||||
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Top5 + 区域占比 */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
{/* Top5 加氢站 */}
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<span className="text-sm font-bold text-slate-700">加氢站加注量 Top5</span>
|
|
||||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
|
||||||
</div>
|
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
|
||||||
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 12 }}>
|
|
||||||
<XAxis type="number" hide />
|
|
||||||
<YAxis
|
|
||||||
type="category"
|
|
||||||
dataKey="name"
|
|
||||||
width={188}
|
|
||||||
tick={<RankYAxisTick />}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN')} Kg`}
|
|
||||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="kg" radius={[6, 6, 6, 6]}>
|
|
||||||
{top5.map((_, i) => (
|
|
||||||
<Cell key={i} fill="url(#topBarGrad)" />
|
|
||||||
))}
|
|
||||||
<LabelList
|
|
||||||
dataKey="kg"
|
|
||||||
position="right"
|
|
||||||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
|
|
||||||
fill="#475569"
|
|
||||||
fontSize={11}
|
|
||||||
fontWeight={700}
|
|
||||||
/>
|
|
||||||
</Bar>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="topBarGrad" x1="0" x2="1" y1="0" y2="0">
|
|
||||||
<stop offset="0%" stopColor="#3b82f6" />
|
|
||||||
<stop offset="100%" stopColor="#22d3ee" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 区域占比 */}
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
|
|
||||||
<span className="text-sm font-bold text-slate-700">各区域加氢占比</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="relative w-1/2 h-[200px]">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={regions}
|
|
||||||
dataKey="kg"
|
|
||||||
nameKey="region"
|
|
||||||
innerRadius={48}
|
|
||||||
outerRadius={80}
|
|
||||||
paddingAngle={1}
|
|
||||||
>
|
|
||||||
{regions.map((_, i) => (
|
|
||||||
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip formatter={(v) => `${(Number(v ?? 0) / 1000).toFixed(2)}T`} contentStyle={{ borderRadius: 12, fontSize: 12 }} />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
|
||||||
<div className="text-[10px] text-slate-400 font-bold">年合计</div>
|
|
||||||
<div className="text-base font-bold text-slate-700 leading-tight">{(k.yearKg / 1000).toFixed(2)}T</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
|
|
||||||
{regions.map((r, i) => (
|
|
||||||
<div key={r.region} className="flex items-center gap-1.5">
|
|
||||||
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
|
|
||||||
<span className="text-slate-600 truncate">{r.region}</span>
|
|
||||||
<span className="text-slate-400 ml-auto font-bold flex-shrink-0">{(r.share * 100).toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 加氢站加氢汇总(全量) */}
|
|
||||||
{stations.length > 0 && (
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-sm font-bold text-slate-700">加氢站加氢汇总</span>
|
|
||||||
<span className="text-[11px] text-slate-400 font-bold">共 {stations.length} 站</span>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto -mx-1 px-1">
|
|
||||||
<table className="w-full text-[11px]">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-slate-400 font-bold border-b border-slate-100">
|
|
||||||
<th className="text-left py-1.5 pl-1 w-8">#</th>
|
|
||||||
<th className="text-left py-1.5">加氢站</th>
|
|
||||||
<th className="text-right py-1.5 w-20">加氢量</th>
|
|
||||||
<th className="text-right py-1.5 pl-2 hidden sm:table-cell">占比</th>
|
|
||||||
<th className="text-right py-1.5 pl-2 w-24">氢费收入</th>
|
|
||||||
<th className="text-right py-1.5 pr-1 hidden md:table-cell">收入占比</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{stations.map((s, i) => {
|
|
||||||
const kgFmt = fmtKg(s.kg);
|
|
||||||
const revFmt = fmtYuan(s.revenue);
|
|
||||||
return (
|
|
||||||
<tr key={s.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
|
||||||
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
|
|
||||||
<td className="py-1.5 text-slate-700 truncate max-w-[180px]">{s.name}</td>
|
|
||||||
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
|
|
||||||
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5 pl-2 text-right hidden sm:table-cell">
|
|
||||||
<div className="inline-flex items-center gap-1.5">
|
|
||||||
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
|
|
||||||
<div className="h-full bg-gradient-to-r from-cyan-400 to-blue-500" style={{ width: `${Math.min(100, s.share * 100)}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="text-slate-500 tabular-nums">{(s.share * 100).toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5 pl-2 text-right tabular-nums font-bold text-emerald-600">
|
|
||||||
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5 pr-1 text-right hidden md:table-cell">
|
|
||||||
<div className="inline-flex items-center gap-1.5">
|
|
||||||
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
|
|
||||||
<div className="h-full bg-gradient-to-r from-emerald-400 to-emerald-600" style={{ width: `${Math.min(100, s.revenueShare * 100)}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="text-slate-500 tabular-nums">{(s.revenueShare * 100).toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 客户账单汇总 Top */}
|
|
||||||
{customers.length > 0 && (
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-sm font-bold text-slate-700">客户账单汇总</span>
|
|
||||||
<span className="text-[11px] text-slate-400 font-bold">Top {customers.length}</span>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto -mx-1 px-1">
|
|
||||||
<table className="w-full text-[11px]">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-slate-400 font-bold border-b border-slate-100">
|
|
||||||
<th className="text-left py-1.5 pl-1 w-8">#</th>
|
|
||||||
<th className="text-left py-1.5">客户</th>
|
|
||||||
<th className="text-center py-1.5 w-14 hidden sm:table-cell">承担方</th>
|
|
||||||
<th className="text-right py-1.5 w-20">加氢量</th>
|
|
||||||
<th className="text-right py-1.5 pl-2 w-24">成本支出</th>
|
|
||||||
<th className="text-right py-1.5 pr-1 w-24 hidden md:table-cell">应收</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{customers.map((c2, i) => {
|
|
||||||
const kgFmt = fmtKg(c2.kg);
|
|
||||||
const costFmt = fmtYuan(c2.cost);
|
|
||||||
const revFmt = fmtYuan(c2.revenue);
|
|
||||||
return (
|
|
||||||
<tr key={c2.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
|
||||||
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
|
|
||||||
<td className="py-1.5 text-slate-700 truncate max-w-[200px]">{c2.name}</td>
|
|
||||||
<td className="py-1.5 text-center hidden sm:table-cell">
|
|
||||||
{c2.payer === 'lingniu' ? (
|
|
||||||
<span className="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-[10px] font-bold">羚牛</span>
|
|
||||||
) : (
|
|
||||||
<span className="px-1.5 py-0.5 rounded bg-amber-50 text-amber-600 text-[10px] font-bold">客户</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
|
|
||||||
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5 pl-2 text-right tabular-nums text-amber-600 font-bold">
|
|
||||||
¥{costFmt.value}<span className="text-slate-400 font-normal ml-0.5">{costFmt.unit}</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5 pr-1 text-right tabular-nums text-emerald-600 font-bold hidden md:table-cell">
|
|
||||||
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<RotatingFooterHint />
|
<RotatingFooterHint />
|
||||||
|
<RefreshOverlay refreshing={refreshing} hasData={Boolean(data)} />
|
||||||
{/* 刷新中:透明遮罩 + 顶部进度条(不替换内容,避免闪烁) */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{refreshing && data && (
|
|
||||||
<motion.div
|
|
||||||
key="refresh-overlay"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="fixed top-0 left-0 right-0 h-0.5 z-50 pointer-events-none overflow-hidden"
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
className="h-full bg-gradient-to-r from-blue-400 via-cyan-400 to-blue-400"
|
|
||||||
initial={{ x: '-100%' }}
|
|
||||||
animate={{ x: '100%' }}
|
|
||||||
transition={{ duration: 1.2, repeat: Infinity, ease: 'linear' }}
|
|
||||||
style={{ width: '40%' }}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRelative(ts: number): string {
|
|
||||||
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
|
||||||
if (s < 5) return '刚刚';
|
|
||||||
if (s < 60) return `${s} 秒前`;
|
|
||||||
const m = Math.floor(s / 60);
|
|
||||||
if (m < 60) return `${m} 分钟前`;
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
if (h < 24) return `${h} 小时前`;
|
|
||||||
return new Date(ts).toLocaleString('zh-CN', { hour12: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
function HydrogenOverviewSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3 animate-pulse">
|
|
||||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-2">
|
|
||||||
<div className="h-3 w-44 bg-slate-100 rounded" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 5 卡占位 */}
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<div key={i} className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-7 h-7 rounded-xl bg-slate-100" />
|
|
||||||
<div className="h-3 w-16 bg-slate-100 rounded" />
|
|
||||||
</div>
|
|
||||||
<div className="h-7 w-24 bg-slate-200 rounded" />
|
|
||||||
<div className="space-y-1.5 pt-1 border-t border-slate-50">
|
|
||||||
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
|
|
||||||
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 月度柱图占位 */}
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="h-4 w-32 bg-slate-100 rounded" />
|
|
||||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-end gap-2 h-[120px]">
|
|
||||||
{[60, 75, 50, 80, 35, 90, 45].map((h, i) => (
|
|
||||||
<div key={i} className="flex-1 bg-slate-100 rounded-t" style={{ height: `${h}%` }} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="h-4 w-32 bg-slate-100 rounded" />
|
|
||||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{[100, 78, 56, 40, 28].map((w, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-3">
|
|
||||||
<div className="w-5 h-5 rounded-full bg-slate-200" />
|
|
||||||
<div className="h-3 w-32 bg-slate-100 rounded" />
|
|
||||||
<div className="flex-1 h-4 rounded-md bg-gradient-to-r from-slate-200 to-slate-100" style={{ maxWidth: `${w}%` }} />
|
|
||||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-3">
|
|
||||||
<div className="h-4 w-28 bg-slate-100 rounded" />
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-1/2 h-[200px] flex items-center justify-center">
|
|
||||||
<div className="w-32 h-32 rounded-full border-[18px] border-slate-100" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-2">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-slate-200" />
|
|
||||||
<div className="h-3 w-16 bg-slate-100 rounded" />
|
|
||||||
<div className="h-3 w-10 bg-slate-100 rounded ml-auto" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center text-[11px] text-slate-400 font-bold flex items-center justify-center gap-1.5">
|
|
||||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
|
||||||
正在加载氢能总览…
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ComponentType } from 'react';
|
||||||
|
import { SegmentedNav } from '../../components/ui/surface';
|
||||||
|
|
||||||
|
interface SubTab<T extends string> {
|
||||||
|
id: T;
|
||||||
|
label: string;
|
||||||
|
icon: ComponentType<{ size?: number; className?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props<T extends string> {
|
||||||
|
tabs: readonly SubTab<T>[];
|
||||||
|
active: T;
|
||||||
|
onChange: (id: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubTabs<T extends string>({ tabs, active, onChange }: Props<T>) {
|
||||||
|
return (
|
||||||
|
<div className="sticky top-0 z-30 -mx-3 bg-[var(--app-bg)] px-3 pb-2 pt-1 shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)] md:-mx-6 md:top-12 md:px-6">
|
||||||
|
<SegmentedNav tabs={tabs} active={active} onChange={onChange} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,8 +27,17 @@ export function fetchHydrogenOverview(year?: number, force = false): Promise<Hyd
|
|||||||
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`);
|
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise<HydrogenDailyRow[]> {
|
export interface HydrogenDailyQuery {
|
||||||
const q = new URLSearchParams({ range, customer });
|
range?: DateQuickPick;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise<HydrogenDailyRow[]> {
|
||||||
|
const q = new URLSearchParams({ customer });
|
||||||
|
if (query.range) q.set('range', query.range);
|
||||||
|
if (query.startDate) q.set('startDate', query.startDate);
|
||||||
|
if (query.endDate) q.set('endDate', query.endDate);
|
||||||
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
|
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +50,10 @@ export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
|
|||||||
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
|
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchElectricMonthly(customer: CustomerType, range: DateQuickPick = 'last15'): Promise<ElectricMonthGroup[]> {
|
export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> {
|
||||||
const q = new URLSearchParams({ customer, range });
|
const q = new URLSearchParams({ customer });
|
||||||
|
if (query.range) q.set('range', query.range);
|
||||||
|
if (query.startDate) q.set('startDate', query.startDate);
|
||||||
|
if (query.endDate) q.set('endDate', query.endDate);
|
||||||
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
|
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Truck } from 'lucide-react';
|
||||||
|
import { SurfaceCard } from '../../../components/ui/surface';
|
||||||
|
import type { CustomerType, DateQuickPick } from '../types';
|
||||||
|
import { QUICK_PICK_OPTIONS, type RangeMode } from './model';
|
||||||
|
|
||||||
|
interface DailyRangeControlsProps {
|
||||||
|
pick: RangeMode;
|
||||||
|
dateRange: { start: string; end: string };
|
||||||
|
customer: CustomerType;
|
||||||
|
onQuickPick: (pick: DateQuickPick) => void;
|
||||||
|
onCustomPick: () => void;
|
||||||
|
onDateRangeChange: (field: 'start' | 'end', value: string) => void;
|
||||||
|
onCustomerChange: (customer: CustomerType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DailyRangeControls({
|
||||||
|
pick,
|
||||||
|
dateRange,
|
||||||
|
customer,
|
||||||
|
onQuickPick,
|
||||||
|
onCustomPick,
|
||||||
|
onDateRangeChange,
|
||||||
|
onCustomerChange,
|
||||||
|
}: DailyRangeControlsProps) {
|
||||||
|
return (
|
||||||
|
<SurfaceCard className="p-2 md:p-3">
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||||
|
{QUICK_PICK_OPTIONS.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
onClick={() => onQuickPick(opt.id)}
|
||||||
|
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
||||||
|
pick === opt.id
|
||||||
|
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||||
|
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={onCustomPick}
|
||||||
|
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
||||||
|
pick === 'custom'
|
||||||
|
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||||
|
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
自定义
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
|
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
||||||
|
<span className="block text-[10px] font-black text-slate-400">开始日期</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateRange.start}
|
||||||
|
onChange={event => onDateRangeChange('start', event.target.value)}
|
||||||
|
onInput={event => onDateRangeChange('start', event.currentTarget.value)}
|
||||||
|
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
||||||
|
<span className="block text-[10px] font-black text-slate-400">结束日期</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateRange.end}
|
||||||
|
onChange={event => onDateRangeChange('end', event.target.value)}
|
||||||
|
onInput={event => onDateRangeChange('end', event.currentTarget.value)}
|
||||||
|
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
|
||||||
|
{(['lingniu', 'external'] as const).map(option => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
onClick={() => onCustomerChange(option)}
|
||||||
|
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
|
||||||
|
customer === option ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Truck size={14} />
|
||||||
|
{option === 'external' ? '外部车辆' : '羚牛车辆'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SurfaceCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { DateQuickPick } from '../types';
|
||||||
|
|
||||||
|
export const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||||
|
{ id: 'thisWeek', label: '本周' },
|
||||||
|
{ id: 'thisMonth', label: '本月' },
|
||||||
|
{ id: 'last15', label: '近 15 天' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export type RangeMode = DateQuickPick | 'custom';
|
||||||
|
|
||||||
|
export function formatYmd(date: Date): string {
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date: Date, days: number): Date {
|
||||||
|
const next = new Date(date);
|
||||||
|
next.setDate(next.getDate() + days);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Energy quick ranges follow the browser's local calendar day, not UTC. */
|
||||||
|
export function getQuickRange(
|
||||||
|
pick: DateQuickPick,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): { start: string; end: string } {
|
||||||
|
const today = new Date(now);
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
if (pick === 'thisWeek') {
|
||||||
|
const day = today.getDay() || 7;
|
||||||
|
return { start: formatYmd(addDays(today, -(day - 1))), end: formatYmd(today) };
|
||||||
|
}
|
||||||
|
if (pick === 'thisMonth') {
|
||||||
|
return {
|
||||||
|
start: formatYmd(new Date(today.getFullYear(), today.getMonth(), 1)),
|
||||||
|
end: formatYmd(today),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { start: formatYmd(addDays(today, -14)), end: formatYmd(today) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRange(start: string, end: string): { start: string; end: string } {
|
||||||
|
return start <= end ? { start, end } : { start: end, end: start };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRangeModeLabel(mode: RangeMode): string {
|
||||||
|
if (mode === 'custom') return '自定义区间';
|
||||||
|
return QUICK_PICK_OPTIONS.find(item => item.id === mode)?.label ?? '当前时段';
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import type { ElectricMonthGroup } from '../types';
|
||||||
|
import { summarizeElectricMonths } from './model';
|
||||||
|
|
||||||
|
test('electric daily summary preserves totals, active-day and 30 percent boundaries', () => {
|
||||||
|
const months: ElectricMonthGroup[] = [
|
||||||
|
{
|
||||||
|
month: '2026-08',
|
||||||
|
kwh: 150,
|
||||||
|
fee: 75,
|
||||||
|
rows: [
|
||||||
|
{ date: '2026-08-01', kwh: 100, fee: 50, chainPct: 0.3 },
|
||||||
|
{ date: '2026-08-02', kwh: 50, fee: 25, chainPct: -0.29 },
|
||||||
|
{ date: '2026-08-03', kwh: 0, fee: 0, chainPct: -1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepEqual(summarizeElectricMonths(months), {
|
||||||
|
totalKwh: 150,
|
||||||
|
totalFee: 75,
|
||||||
|
activeDays: 2,
|
||||||
|
abnormalDays: 2,
|
||||||
|
avgKwh: 75,
|
||||||
|
avgPrice: 0.5,
|
||||||
|
hasFeeDetail: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('electric daily summary preserves empty and missing-fee fallbacks', () => {
|
||||||
|
assert.deepEqual(summarizeElectricMonths(null), {
|
||||||
|
totalKwh: 0,
|
||||||
|
totalFee: 0,
|
||||||
|
activeDays: 0,
|
||||||
|
abnormalDays: 0,
|
||||||
|
avgKwh: 0,
|
||||||
|
avgPrice: 0,
|
||||||
|
hasFeeDetail: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ElectricMonthGroup } from '../types';
|
||||||
|
|
||||||
|
export function summarizeElectricMonths(months: ElectricMonthGroup[] | null) {
|
||||||
|
const source = months ?? [];
|
||||||
|
const totalKwh = source.reduce((sum, month) => sum + (month.kwh || 0), 0);
|
||||||
|
const totalFee = source.reduce((sum, month) => sum + (month.fee || 0), 0);
|
||||||
|
const activeDays = source.reduce(
|
||||||
|
(sum, month) => sum + month.rows.filter(row => row.kwh > 0).length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const abnormalDays = source.reduce(
|
||||||
|
(sum, month) => sum + month.rows.filter(row => Math.abs(row.chainPct) >= 0.3).length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalKwh,
|
||||||
|
totalFee,
|
||||||
|
activeDays,
|
||||||
|
abnormalDays,
|
||||||
|
avgKwh: activeDays > 0 ? totalKwh / activeDays : 0,
|
||||||
|
avgPrice: totalKwh > 0 ? totalFee / totalKwh : 0,
|
||||||
|
hasFeeDetail: totalFee > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import type { HydrogenDailyRow } from '../types.js';
|
||||||
|
import {
|
||||||
|
getQuickRange,
|
||||||
|
getRangeModeLabel,
|
||||||
|
normalizeRange,
|
||||||
|
summarizeHydrogenRows,
|
||||||
|
} from './model.js';
|
||||||
|
|
||||||
|
test('快捷日期继续使用本地自然日并覆盖本周、本月和近15天', () => {
|
||||||
|
const now = new Date(2026, 7, 12, 23, 30);
|
||||||
|
assert.deepEqual(getQuickRange('thisWeek', now), {
|
||||||
|
start: '2026-08-10',
|
||||||
|
end: '2026-08-12',
|
||||||
|
});
|
||||||
|
assert.deepEqual(getQuickRange('thisMonth', now), {
|
||||||
|
start: '2026-08-01',
|
||||||
|
end: '2026-08-12',
|
||||||
|
});
|
||||||
|
assert.deepEqual(getQuickRange('last15', now), {
|
||||||
|
start: '2026-07-29',
|
||||||
|
end: '2026-08-12',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('自定义日期倒置时仅交换查询边界', () => {
|
||||||
|
assert.deepEqual(normalizeRange('2026-08-12', '2026-08-01'), {
|
||||||
|
start: '2026-08-01',
|
||||||
|
end: '2026-08-12',
|
||||||
|
});
|
||||||
|
assert.equal(getRangeModeLabel('custom'), '自定义区间');
|
||||||
|
assert.equal(getRangeModeLabel('last15'), '近 15 天');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('每日加氢统计保持排序、有效天、站点去重和峰谷口径', () => {
|
||||||
|
const rows: HydrogenDailyRow[] = [
|
||||||
|
{
|
||||||
|
date: '2026-08-12',
|
||||||
|
totalKg: 0,
|
||||||
|
chainPct: -1,
|
||||||
|
customerType: 'lingniu',
|
||||||
|
stations: [{ name: 'A站', kg: 0, pricePerKg: 0, chainPct: -1 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '2026-08-10',
|
||||||
|
totalKg: 100,
|
||||||
|
chainPct: 0,
|
||||||
|
customerType: 'lingniu',
|
||||||
|
stations: [{ name: 'A站', kg: 100, pricePerKg: 20, chainPct: 0 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '2026-08-11',
|
||||||
|
totalKg: 300,
|
||||||
|
chainPct: 2,
|
||||||
|
customerType: 'lingniu',
|
||||||
|
stations: [{ name: 'B站', kg: 300, pricePerKg: 25, chainPct: 2 }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const summary = summarizeHydrogenRows(rows);
|
||||||
|
assert.deepEqual(rows.map(row => row.date), ['2026-08-12', '2026-08-10', '2026-08-11']);
|
||||||
|
assert.deepEqual(summary.trendData.map(row => row.date), ['2026-08-10', '2026-08-11', '2026-08-12']);
|
||||||
|
assert.equal(summary.totalKg, 400);
|
||||||
|
assert.equal(summary.activeDays, 2);
|
||||||
|
assert.equal(summary.avgKg, 200);
|
||||||
|
assert.equal(summary.stationCount, 2);
|
||||||
|
assert.equal(summary.peakDay?.date, '2026-08-11');
|
||||||
|
assert.equal(summary.lowDay?.date, '2026-08-10');
|
||||||
|
assert.equal(summary.zeroDays, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空数据保持零值且没有峰谷日', () => {
|
||||||
|
assert.deepEqual(summarizeHydrogenRows(null), {
|
||||||
|
trendData: [],
|
||||||
|
totalKg: 0,
|
||||||
|
activeDays: 0,
|
||||||
|
stationCount: 0,
|
||||||
|
avgKg: 0,
|
||||||
|
peakDay: null,
|
||||||
|
lowDay: null,
|
||||||
|
zeroDays: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { HydrogenDailyRow } from '../types';
|
||||||
|
|
||||||
|
export {
|
||||||
|
formatYmd,
|
||||||
|
getQuickRange,
|
||||||
|
getRangeModeLabel,
|
||||||
|
normalizeRange,
|
||||||
|
QUICK_PICK_OPTIONS,
|
||||||
|
type RangeMode,
|
||||||
|
} from '../daily-range/model';
|
||||||
|
|
||||||
|
export function summarizeHydrogenRows(rows: HydrogenDailyRow[] | null) {
|
||||||
|
const source = rows ?? [];
|
||||||
|
// 图表固定按日期升序;复制数组避免改变接口返回及表格原始顺序。
|
||||||
|
const trendData = [...source].sort((left, right) => left.date.localeCompare(right.date));
|
||||||
|
const totalKg = source.reduce((total, row) => total + row.totalKg, 0);
|
||||||
|
const activeDays = source.filter(row => row.totalKg > 0).length;
|
||||||
|
const stationNames = new Set<string>();
|
||||||
|
source.forEach(row => row.stations.forEach(station => stationNames.add(station.name)));
|
||||||
|
const peakDay = trendData.reduce<HydrogenDailyRow | null>(
|
||||||
|
(best, item) => (!best || item.totalKg > best.totalKg ? item : best),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const lowDay = trendData
|
||||||
|
.filter(item => item.totalKg > 0)
|
||||||
|
.reduce<HydrogenDailyRow | null>(
|
||||||
|
(low, item) => (!low || item.totalKg < low.totalKg ? item : low),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
trendData,
|
||||||
|
totalKg,
|
||||||
|
activeDays,
|
||||||
|
stationCount: stationNames.size,
|
||||||
|
avgKg: activeDays > 0 ? totalKg / activeDays : 0,
|
||||||
|
peakDay,
|
||||||
|
lowDay,
|
||||||
|
zeroDays: source.filter(row => row.totalKg === 0).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
Cell,
|
||||||
|
LabelList,
|
||||||
|
Pie,
|
||||||
|
PieChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import type { HydrogenRegionShare, HydrogenStationTop } from '../../types';
|
||||||
|
|
||||||
|
const REGION_COLORS = [
|
||||||
|
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
||||||
|
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
|
||||||
|
'#94a3b8',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface YAxisTickProps {
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
index?: number;
|
||||||
|
payload?: { value: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
|
||||||
|
return (
|
||||||
|
<g transform={`translate(${x},${y})`}>
|
||||||
|
<circle cx={-172} cy={0} r={9} fill="#3b82f6" />
|
||||||
|
<text x={-172} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
|
||||||
|
{index + 1}
|
||||||
|
</text>
|
||||||
|
<text x={-154} y={4} textAnchor="start" fontSize={11} fill="#475569">
|
||||||
|
{payload?.value}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DistributionChartsProps {
|
||||||
|
top5: HydrogenStationTop[];
|
||||||
|
regions: HydrogenRegionShare[];
|
||||||
|
yearKg: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DistributionCharts({ top5, regions, yearKg }: DistributionChartsProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm font-bold text-slate-700">加氢站加注量 Top5</span>
|
||||||
|
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 12 }}>
|
||||||
|
<XAxis type="number" hide />
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="name"
|
||||||
|
width={188}
|
||||||
|
tick={<RankYAxisTick />}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN')} Kg`}
|
||||||
|
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="kg" radius={[6, 6, 6, 6]}>
|
||||||
|
{top5.map((_, i) => (
|
||||||
|
<Cell key={i} fill="url(#topBarGrad)" />
|
||||||
|
))}
|
||||||
|
<LabelList
|
||||||
|
dataKey="kg"
|
||||||
|
position="right"
|
||||||
|
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
|
||||||
|
fill="#475569"
|
||||||
|
fontSize={11}
|
||||||
|
fontWeight={700}
|
||||||
|
/>
|
||||||
|
</Bar>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="topBarGrad" x1="0" x2="1" y1="0" y2="0">
|
||||||
|
<stop offset="0%" stopColor="#3b82f6" />
|
||||||
|
<stop offset="100%" stopColor="#22d3ee" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-bold text-slate-700">各区域加氢占比</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative w-1/2 h-[200px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={regions}
|
||||||
|
dataKey="kg"
|
||||||
|
nameKey="region"
|
||||||
|
innerRadius={48}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={1}
|
||||||
|
>
|
||||||
|
{regions.map((_, i) => (
|
||||||
|
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip formatter={(v) => `${(Number(v ?? 0) / 1000).toFixed(2)}T`} contentStyle={{ borderRadius: 12, fontSize: 12 }} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold">年合计</div>
|
||||||
|
<div className="text-base font-bold text-slate-700 leading-tight">{(yearKg / 1000).toFixed(2)}T</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
|
||||||
|
{regions.map((r, i) => (
|
||||||
|
<div key={r.region} className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
|
||||||
|
<span className="text-slate-600 truncate">{r.region}</span>
|
||||||
|
<span className="text-slate-400 ml-auto font-bold flex-shrink-0">{(r.share * 100).toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
export function HydrogenOverviewSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 animate-pulse">
|
||||||
|
<div className="bg-white rounded-xl border border-slate-100 px-3 py-2">
|
||||||
|
<div className="h-3 w-44 bg-slate-100 rounded" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5 卡占位 */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 rounded-xl bg-slate-100" />
|
||||||
|
<div className="h-3 w-16 bg-slate-100 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="h-7 w-24 bg-slate-200 rounded" />
|
||||||
|
<div className="space-y-1.5 pt-1 border-t border-slate-50">
|
||||||
|
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
|
||||||
|
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 月度柱图占位 */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="h-4 w-32 bg-slate-100 rounded" />
|
||||||
|
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2 h-[120px]">
|
||||||
|
{[60, 75, 50, 80, 35, 90, 45].map((h, i) => (
|
||||||
|
<div key={i} className="flex-1 bg-slate-100 rounded-t" style={{ height: `${h}%` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="h-4 w-32 bg-slate-100 rounded" />
|
||||||
|
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[100, 78, 56, 40, 28].map((w, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3">
|
||||||
|
<div className="w-5 h-5 rounded-full bg-slate-200" />
|
||||||
|
<div className="h-3 w-32 bg-slate-100 rounded" />
|
||||||
|
<div className="flex-1 h-4 rounded-md bg-gradient-to-r from-slate-200 to-slate-100" style={{ maxWidth: `${w}%` }} />
|
||||||
|
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-3">
|
||||||
|
<div className="h-4 w-28 bg-slate-100 rounded" />
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-1/2 h-[200px] flex items-center justify-center">
|
||||||
|
<div className="w-32 h-32 rounded-full border-[18px] border-slate-100" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-slate-200" />
|
||||||
|
<div className="h-3 w-16 bg-slate-100 rounded" />
|
||||||
|
<div className="h-3 w-10 bg-slate-100 rounded ml-auto" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center text-[11px] text-slate-400 font-bold flex items-center justify-center gap-1.5">
|
||||||
|
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
||||||
|
正在加载氢能总览…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { AlertTriangle, Building2, Gauge } from 'lucide-react';
|
||||||
|
import type { HydrogenMonthlyPoint } from '../../types';
|
||||||
|
import { formatKg as fmtKg } from '../model';
|
||||||
|
|
||||||
|
interface InsightCardsProps {
|
||||||
|
monthAvgKg: number;
|
||||||
|
bestMonth: HydrogenMonthlyPoint | null;
|
||||||
|
latestMonth: HydrogenMonthlyPoint | undefined;
|
||||||
|
monthMomentum: number | null;
|
||||||
|
top5Share: number;
|
||||||
|
profitYield: number;
|
||||||
|
stationAvgKg: number;
|
||||||
|
stationCount: number;
|
||||||
|
yearProfitValue: string;
|
||||||
|
yearProfitUnit: string;
|
||||||
|
yearRevenueValue: string;
|
||||||
|
yearRevenueUnit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InsightCards({
|
||||||
|
monthAvgKg,
|
||||||
|
bestMonth,
|
||||||
|
latestMonth,
|
||||||
|
monthMomentum,
|
||||||
|
top5Share,
|
||||||
|
profitYield,
|
||||||
|
stationAvgKg,
|
||||||
|
stationCount,
|
||||||
|
yearProfitValue,
|
||||||
|
yearProfitUnit,
|
||||||
|
yearRevenueValue,
|
||||||
|
yearRevenueUnit,
|
||||||
|
}: InsightCardsProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 md:gap-3">
|
||||||
|
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-black text-slate-400">月度动能</div>
|
||||||
|
<div className="mt-1 text-lg font-black text-slate-900">
|
||||||
|
{monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
|
||||||
|
<Gauge size={18} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||||
|
{latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'}
|
||||||
|
{bestMonth ? ` · 峰值 ${bestMonth.month}` : ''}
|
||||||
|
{monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-black text-slate-400">站点集中度</div>
|
||||||
|
<div className="mt-1 text-lg font-black text-slate-900">Top5 {top5Share.toFixed(1)}%</div>
|
||||||
|
</div>
|
||||||
|
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-cyan-50 text-cyan-600 ring-1 ring-cyan-100">
|
||||||
|
<Building2 size={18} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||||
|
共 {stationCount} 站 · 单站年均 {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit}
|
||||||
|
{top5Share >= 70 ? ' · 头部站点依赖偏高' : ' · 分布相对健康'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-black text-slate-400">收支健康度</div>
|
||||||
|
<div className={`mt-1 text-lg font-black ${profitYield >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||||
|
{profitYield.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`flex h-9 w-9 items-center justify-center rounded-xl ring-1 ${profitYield >= 0 ? 'bg-emerald-50 text-emerald-600 ring-emerald-100' : 'bg-rose-50 text-rose-600 ring-rose-100'}`}>
|
||||||
|
<AlertTriangle size={18} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||||
|
时享获利 {yearProfitValue}{yearProfitUnit} · 客户收入 {yearRevenueValue}{yearRevenueUnit}
|
||||||
|
{profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { CalendarDays, Fuel, Sparkles, TrendingUp, Wallet } from 'lucide-react';
|
||||||
|
import type { HydrogenKpi } from '../../types';
|
||||||
|
import { formatKg as fmtKg, formatYuan as fmtYuan } from '../model';
|
||||||
|
|
||||||
|
interface KpiCardProps {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
hero: { value: string; unit: string };
|
||||||
|
rows: { label: string; value: string; valueClass?: string }[];
|
||||||
|
accentClass: string;
|
||||||
|
iconBg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-7 h-7 rounded-xl flex items-center justify-center ${iconBg}`}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-bold text-slate-500">{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className={`text-xl md:text-2xl font-black tabular-nums leading-none ${accentClass}`}>{hero.value}</span>
|
||||||
|
<span className="text-[11px] text-slate-400 font-bold">{hero.unit}</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5 pt-1 border-t border-slate-50">
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between text-[11px] font-bold">
|
||||||
|
<span className="text-slate-400">{r.label}</span>
|
||||||
|
<span className={`tabular-nums ${r.valueClass ?? 'text-slate-700'}`}>{r.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KpiSection({ kpi: k }: { kpi: HydrogenKpi }) {
|
||||||
|
const yearKgFmt = fmtKg(k.yearKg);
|
||||||
|
const yearFeeFmt = fmtYuan(k.yearFee);
|
||||||
|
const yearProfitFmt = fmtYuan(k.yearProfit);
|
||||||
|
const ourYearKgFmt = fmtKg(k.ourYearKg);
|
||||||
|
const customerYearKgFmt = fmtKg(k.customerYearKg);
|
||||||
|
const monthKgFmt = fmtKg(k.monthKg);
|
||||||
|
const monthFeeFmt = fmtYuan(k.monthFee);
|
||||||
|
const todayKgFmt = fmtKg(k.todayKg);
|
||||||
|
const todayFeeFmt = fmtYuan(k.todayFee);
|
||||||
|
const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee);
|
||||||
|
const customerYearFeeFmt = fmtYuan(customerYearFee);
|
||||||
|
const yearRevenueFmt = fmtYuan(k.yearRevenue);
|
||||||
|
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
|
||||||
|
<KpiCard
|
||||||
|
icon={<Fuel size={14} className="text-cyan-600" strokeWidth={2.4} />}
|
||||||
|
iconBg="bg-cyan-50"
|
||||||
|
accentClass="text-slate-800"
|
||||||
|
label="累计加氢量"
|
||||||
|
hero={yearKgFmt}
|
||||||
|
rows={[
|
||||||
|
{ label: '我司', value: `${ourYearKgFmt.value} ${ourYearKgFmt.unit}` },
|
||||||
|
{ label: '客户', value: `${customerYearKgFmt.value} ${customerYearKgFmt.unit}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={<Wallet size={14} className="text-blue-600" strokeWidth={2.4} />}
|
||||||
|
iconBg="bg-blue-50"
|
||||||
|
accentClass="text-slate-800"
|
||||||
|
label="累计加氢费"
|
||||||
|
hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }}
|
||||||
|
rows={[
|
||||||
|
{ label: '我司承担', value: `¥${fmtYuan(k.ourYearFee).value} ${fmtYuan(k.ourYearFee).unit}` },
|
||||||
|
{ label: '客户承担', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={<TrendingUp size={14} className="text-emerald-600" strokeWidth={2.4} />}
|
||||||
|
iconBg="bg-emerald-50"
|
||||||
|
accentClass={profitColor}
|
||||||
|
label="时享加氢获利"
|
||||||
|
hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }}
|
||||||
|
rows={[
|
||||||
|
{ label: '收入', value: `¥${yearRevenueFmt.value} ${yearRevenueFmt.unit}` },
|
||||||
|
{ label: '成本', value: `¥${yearFeeFmt.value} ${yearFeeFmt.unit}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={<CalendarDays size={14} className="text-amber-600" strokeWidth={2.4} />}
|
||||||
|
iconBg="bg-amber-50"
|
||||||
|
accentClass="text-amber-600"
|
||||||
|
label="本月加氢"
|
||||||
|
hero={monthKgFmt}
|
||||||
|
rows={[
|
||||||
|
{ label: '加氢费', value: `¥${monthFeeFmt.value} ${monthFeeFmt.unit}` },
|
||||||
|
{ label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={<Sparkles size={14} className="text-violet-600" strokeWidth={2.4} />}
|
||||||
|
iconBg="bg-violet-50"
|
||||||
|
accentClass="text-violet-600"
|
||||||
|
label="本日加氢"
|
||||||
|
hero={todayKgFmt}
|
||||||
|
rows={[
|
||||||
|
{ label: '加氢费', value: `¥${todayFeeFmt.value} ${todayFeeFmt.unit}` },
|
||||||
|
{ label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
Cell,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import type { HydrogenMonthlyPoint } from '../../types';
|
||||||
|
import { formatYuan as fmtYuan } from '../model';
|
||||||
|
|
||||||
|
type MonthlyChartPoint = HydrogenMonthlyPoint & { monthLabel: string };
|
||||||
|
|
||||||
|
interface MonthlyChartsProps {
|
||||||
|
activeYear: number;
|
||||||
|
monthly: HydrogenMonthlyPoint[];
|
||||||
|
monthlyDual: MonthlyChartPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonthlyCharts({ activeYear, monthly, monthlyDual }: MonthlyChartsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{monthly.length > 0 && (
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-bold text-slate-700">{activeYear} 年月度加氢量</span>
|
||||||
|
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={140}>
|
||||||
|
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||||
|
<XAxis
|
||||||
|
dataKey="monthLabel"
|
||||||
|
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
interval={0}
|
||||||
|
/>
|
||||||
|
<YAxis hide />
|
||||||
|
<Tooltip
|
||||||
|
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })} Kg`, '加氢量']}
|
||||||
|
labelFormatter={(d) => `${d}`}
|
||||||
|
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||||
|
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="kg" radius={[4, 4, 0, 0]}>
|
||||||
|
{monthlyDual.map((_, i) => (
|
||||||
|
<Cell key={i} fill="url(#monthlyBarGrad)" />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="monthlyBarGrad" x1="0" x2="0" y1="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#22d3ee" />
|
||||||
|
<stop offset="100%" stopColor="#3b82f6" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{monthly.length > 0 && (
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-bold text-slate-700">{activeYear} 年月度收支对比</span>
|
||||||
|
<span className="text-[11px] text-slate-400 font-bold">单位 元</span>
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||||
|
<XAxis
|
||||||
|
dataKey="monthLabel"
|
||||||
|
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
interval={0}
|
||||||
|
/>
|
||||||
|
<YAxis hide />
|
||||||
|
<Legend
|
||||||
|
verticalAlign="top"
|
||||||
|
height={20}
|
||||||
|
iconSize={8}
|
||||||
|
wrapperStyle={{ fontSize: 11, paddingBottom: 4 }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(v, name) => {
|
||||||
|
const f = fmtYuan(Number(v ?? 0));
|
||||||
|
return [`¥${f.value} ${f.unit}`, name];
|
||||||
|
}}
|
||||||
|
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||||
|
cursor={{ fill: 'rgba(148, 163, 184, 0.06)' }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} />
|
||||||
|
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import { formatRefreshTime } from '../model';
|
||||||
|
|
||||||
|
interface OverviewHeaderProps {
|
||||||
|
activeYear: number;
|
||||||
|
availableYears: number[];
|
||||||
|
lastRefreshAt: number;
|
||||||
|
refreshing: boolean;
|
||||||
|
onSelectYear: (year: number) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverviewHeader({
|
||||||
|
activeYear,
|
||||||
|
availableYears,
|
||||||
|
lastRefreshAt,
|
||||||
|
refreshing,
|
||||||
|
onSelectYear,
|
||||||
|
onRefresh,
|
||||||
|
}: OverviewHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRefreshTime(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
|
||||||
|
{availableYears.map(y => {
|
||||||
|
const active = y === activeYear;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={y}
|
||||||
|
onClick={() => onSelectYear(y)}
|
||||||
|
className={`px-2 py-0.5 text-[11px] font-bold rounded-md transition-all ${
|
||||||
|
active ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{y}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={refreshing}
|
||||||
|
className="flex items-center gap-1 px-2 py-0.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||||
|
title="手动刷新(绕过缓存)"
|
||||||
|
>
|
||||||
|
<RefreshCw size={11} className={refreshing ? 'animate-spin' : ''} strokeWidth={2.6} />
|
||||||
|
<span className="text-[11px] font-bold">刷新</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
|
||||||
|
interface RefreshOverlayProps {
|
||||||
|
refreshing: boolean;
|
||||||
|
hasData: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RefreshOverlay({ refreshing, hasData }: RefreshOverlayProps) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{refreshing && hasData && (
|
||||||
|
<motion.div
|
||||||
|
key="refresh-overlay"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="fixed top-0 left-0 right-0 h-0.5 z-50 pointer-events-none overflow-hidden"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="h-full bg-gradient-to-r from-blue-400 via-cyan-400 to-blue-400"
|
||||||
|
initial={{ x: '-100%' }}
|
||||||
|
animate={{ x: '100%' }}
|
||||||
|
transition={{ duration: 1.2, repeat: Infinity, ease: 'linear' }}
|
||||||
|
style={{ width: '40%' }}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import type { HydrogenCustomerRow, HydrogenStationFull } from '../../types';
|
||||||
|
import { formatKg as fmtKg, formatYuan as fmtYuan } from '../model';
|
||||||
|
|
||||||
|
export function StationSummaryTable({ stations }: { stations: HydrogenStationFull[] }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{stations.length > 0 && (
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-bold text-slate-700">加氢站加氢汇总</span>
|
||||||
|
<span className="text-[11px] text-slate-400 font-bold">共 {stations.length} 站</span>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto -mx-1 px-1">
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-slate-400 font-bold border-b border-slate-100">
|
||||||
|
<th className="text-left py-1.5 pl-1 w-8">#</th>
|
||||||
|
<th className="text-left py-1.5">加氢站</th>
|
||||||
|
<th className="text-right py-1.5 w-20">加氢量</th>
|
||||||
|
<th className="text-right py-1.5 pl-2 hidden sm:table-cell">占比</th>
|
||||||
|
<th className="text-right py-1.5 pl-2 w-24">氢费收入</th>
|
||||||
|
<th className="text-right py-1.5 pr-1 hidden md:table-cell">收入占比</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{stations.map((s, i) => {
|
||||||
|
const kgFmt = fmtKg(s.kg);
|
||||||
|
const revFmt = fmtYuan(s.revenue);
|
||||||
|
return (
|
||||||
|
<tr key={s.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
||||||
|
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
|
||||||
|
<td className="py-1.5 text-slate-700 truncate max-w-[180px]">{s.name}</td>
|
||||||
|
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
|
||||||
|
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 pl-2 text-right hidden sm:table-cell">
|
||||||
|
<div className="inline-flex items-center gap-1.5">
|
||||||
|
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-gradient-to-r from-cyan-400 to-blue-500" style={{ width: `${Math.min(100, s.share * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-slate-500 tabular-nums">{(s.share * 100).toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 pl-2 text-right tabular-nums font-bold text-emerald-600">
|
||||||
|
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 pr-1 text-right hidden md:table-cell">
|
||||||
|
<div className="inline-flex items-center gap-1.5">
|
||||||
|
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-gradient-to-r from-emerald-400 to-emerald-600" style={{ width: `${Math.min(100, s.revenueShare * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-slate-500 tabular-nums">{(s.revenueShare * 100).toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomerSummaryTable({ customers }: { customers: HydrogenCustomerRow[] }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{customers.length > 0 && (
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-bold text-slate-700">客户账单汇总</span>
|
||||||
|
<span className="text-[11px] text-slate-400 font-bold">Top {customers.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto -mx-1 px-1">
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-slate-400 font-bold border-b border-slate-100">
|
||||||
|
<th className="text-left py-1.5 pl-1 w-8">#</th>
|
||||||
|
<th className="text-left py-1.5">客户</th>
|
||||||
|
<th className="text-center py-1.5 w-14 hidden sm:table-cell">承担方</th>
|
||||||
|
<th className="text-right py-1.5 w-20">加氢量</th>
|
||||||
|
<th className="text-right py-1.5 pl-2 w-24">成本支出</th>
|
||||||
|
<th className="text-right py-1.5 pr-1 w-24 hidden md:table-cell">应收</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{customers.map((c2, i) => {
|
||||||
|
const kgFmt = fmtKg(c2.kg);
|
||||||
|
const costFmt = fmtYuan(c2.cost);
|
||||||
|
const revFmt = fmtYuan(c2.revenue);
|
||||||
|
return (
|
||||||
|
<tr key={c2.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
||||||
|
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
|
||||||
|
<td className="py-1.5 text-slate-700 truncate max-w-[200px]">{c2.name}</td>
|
||||||
|
<td className="py-1.5 text-center hidden sm:table-cell">
|
||||||
|
{c2.payer === 'lingniu' ? (
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-[10px] font-bold">羚牛</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-amber-50 text-amber-600 text-[10px] font-bold">客户</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
|
||||||
|
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 pl-2 text-right tabular-nums text-amber-600 font-bold">
|
||||||
|
¥{costFmt.value}<span className="text-slate-400 font-normal ml-0.5">{costFmt.unit}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 pr-1 text-right tabular-nums text-emerald-600 font-bold hidden md:table-cell">
|
||||||
|
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import type { HydrogenOverviewResponse } from '../api.js';
|
||||||
|
import {
|
||||||
|
deriveOverviewMetrics,
|
||||||
|
formatKg,
|
||||||
|
formatRelative,
|
||||||
|
formatYuan,
|
||||||
|
} from './model.js';
|
||||||
|
|
||||||
|
test('重量和金额单位保持现有阈值及负数格式', () => {
|
||||||
|
assert.deepEqual(formatKg(999.5), { value: '999.50', unit: 'Kg' });
|
||||||
|
assert.deepEqual(formatKg(1000), { value: '1.00', unit: 'T' });
|
||||||
|
assert.deepEqual(formatYuan(9999), { value: '9,999', unit: '元' });
|
||||||
|
assert.deepEqual(formatYuan(12345), { value: '1.23', unit: '万元' });
|
||||||
|
assert.deepEqual(formatYuan(-100_000_000), { value: '-1.00', unit: '亿元' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('相对更新时间保持秒、分钟、小时和未来时间边界', () => {
|
||||||
|
const now = new Date(2026, 7, 13, 12, 0, 0).getTime();
|
||||||
|
assert.equal(formatRelative(now + 60_000, now), '刚刚');
|
||||||
|
assert.equal(formatRelative(now - 30_000, now), '30 秒前');
|
||||||
|
assert.equal(formatRelative(now - 5 * 60_000, now), '5 分钟前');
|
||||||
|
assert.equal(formatRelative(now - 3 * 60 * 60_000, now), '3 小时前');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('总览派生指标保持月份顺序、动能、集中度和收益率口径', () => {
|
||||||
|
const data = {
|
||||||
|
kpi: {
|
||||||
|
yearKg: 1000,
|
||||||
|
yearFee: 0,
|
||||||
|
yearProfit: -50,
|
||||||
|
ourYearKg: 0,
|
||||||
|
customerYearKg: 0,
|
||||||
|
ourYearFee: 0,
|
||||||
|
yearRevenue: 500,
|
||||||
|
monthKg: 0,
|
||||||
|
monthFee: 0,
|
||||||
|
monthRevenue: 0,
|
||||||
|
monthProfit: 0,
|
||||||
|
todayKg: 0,
|
||||||
|
todayFee: 0,
|
||||||
|
todayRevenue: 0,
|
||||||
|
todayProfit: 0,
|
||||||
|
lingniuBornKg: 0,
|
||||||
|
lingniuBornFee: 0,
|
||||||
|
},
|
||||||
|
top5: [{ rank: 1, name: 'A', kg: 600, fee: 100, share: 0.6 }],
|
||||||
|
regions: [],
|
||||||
|
monthly: [
|
||||||
|
{ month: '2026-01', kg: 100, fee: 20, revenue: 30, profit: 10 },
|
||||||
|
{ month: '2026-02', kg: 150, fee: 30, revenue: 40, profit: 10 },
|
||||||
|
],
|
||||||
|
customers: [],
|
||||||
|
stations: [
|
||||||
|
{ name: 'A', kg: 600, share: 0.6, revenue: 300, revenueShare: 0.6 },
|
||||||
|
{ name: 'B', kg: 400, share: 0.4, revenue: 200, revenueShare: 0.4 },
|
||||||
|
],
|
||||||
|
availableYears: [2026],
|
||||||
|
year: 2026,
|
||||||
|
} satisfies HydrogenOverviewResponse;
|
||||||
|
|
||||||
|
const metrics = deriveOverviewMetrics(data);
|
||||||
|
assert.equal(metrics.monthAvgKg, 125);
|
||||||
|
assert.equal(metrics.bestMonth?.month, '2026-02');
|
||||||
|
assert.equal(metrics.latestMonth?.month, '2026-02');
|
||||||
|
assert.equal(metrics.monthMomentum, 50);
|
||||||
|
assert.equal(metrics.top5Share, 60);
|
||||||
|
assert.equal(metrics.profitYield, -10);
|
||||||
|
assert.equal(metrics.stationAvgKg, 500);
|
||||||
|
assert.deepEqual(metrics.monthlyDual.map(item => item.monthLabel), ['1月', '2月']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { HydrogenOverviewResponse } from '../api';
|
||||||
|
|
||||||
|
export function formatKg(kg: number): { value: string; unit: string } {
|
||||||
|
if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' };
|
||||||
|
return { value: kg.toFixed(2), unit: 'Kg' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatYuan(yuan: number): { value: string; unit: string } {
|
||||||
|
const absolute = Math.abs(yuan);
|
||||||
|
if (absolute >= 100_000_000) {
|
||||||
|
return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' };
|
||||||
|
}
|
||||||
|
if (absolute >= 10_000) {
|
||||||
|
return {
|
||||||
|
value: (yuan / 10_000).toLocaleString('zh-CN', { maximumFractionDigits: 2 }),
|
||||||
|
unit: '万元',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
value: yuan.toLocaleString('zh-CN', { maximumFractionDigits: 0 }),
|
||||||
|
unit: '元',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelative(timestamp: number, now = Date.now()): string {
|
||||||
|
const seconds = Math.max(0, Math.floor((now - timestamp) / 1000));
|
||||||
|
if (seconds < 5) return '刚刚';
|
||||||
|
if (seconds < 60) return `${seconds} 秒前`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes} 分钟前`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours} 小时前`;
|
||||||
|
return new Date(timestamp).toLocaleString('zh-CN', { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRefreshTime(timestamp: number, now = Date.now()): string {
|
||||||
|
const exactTime = new Date(timestamp).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
return `${formatRelative(timestamp, now)} · ${exactTime.replace(/\//g, '-')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveOverviewMetrics(data: HydrogenOverviewResponse) {
|
||||||
|
const { kpi, monthly, stations, top5 } = data;
|
||||||
|
const monthAvgKg = monthly.length > 0
|
||||||
|
? monthly.reduce((sum, month) => sum + month.kg, 0) / monthly.length
|
||||||
|
: 0;
|
||||||
|
const bestMonth = monthly.reduce<typeof monthly[number] | null>(
|
||||||
|
(best, item) => (!best || item.kg > best.kg ? item : best),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const latestMonth = monthly[monthly.length - 1];
|
||||||
|
const previousMonth = monthly[monthly.length - 2];
|
||||||
|
|
||||||
|
return {
|
||||||
|
monthAvgKg,
|
||||||
|
bestMonth,
|
||||||
|
latestMonth,
|
||||||
|
monthMomentum: latestMonth && previousMonth && previousMonth.kg > 0
|
||||||
|
? ((latestMonth.kg - previousMonth.kg) / previousMonth.kg) * 100
|
||||||
|
: null,
|
||||||
|
top5Share: (top5.reduce((sum, item) => sum + item.kg, 0) / Math.max(1, kpi.yearKg)) * 100,
|
||||||
|
profitYield: kpi.yearRevenue > 0 ? (kpi.yearProfit / kpi.yearRevenue) * 100 : 0,
|
||||||
|
stationAvgKg: stations.length > 0 ? kpi.yearKg / stations.length : 0,
|
||||||
|
monthlyDual: monthly.map(month => ({
|
||||||
|
...month,
|
||||||
|
monthLabel: `${month.month.slice(5).replace(/^0/, '')}月`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把模块内子 tab 状态同步到 URL hash 二级段。
|
||||||
|
* hash 形如 `#<moduleId>`(= 默认 sub)或 `#<moduleId>/<sub>`。
|
||||||
|
* 默认值不写入 hash,刷新页面可恢复。
|
||||||
|
*/
|
||||||
|
export function useHashSubTab<T extends string>(
|
||||||
|
moduleId: string,
|
||||||
|
subs: readonly T[],
|
||||||
|
): [T, (sub: T) => void] {
|
||||||
|
const defaultSub = subs[0];
|
||||||
|
|
||||||
|
const parse = (): T => {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
const [first, second] = hash.split('/');
|
||||||
|
if (first !== moduleId) return defaultSub;
|
||||||
|
if (second && (subs as readonly string[]).includes(second)) return second as T;
|
||||||
|
return defaultSub;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [sub, setSubState] = useState<T>(parse);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => setSubState(parse());
|
||||||
|
window.addEventListener('hashchange', onChange);
|
||||||
|
return () => window.removeEventListener('hashchange', onChange);
|
||||||
|
}, [moduleId]);
|
||||||
|
|
||||||
|
const setSub = (next: T) => {
|
||||||
|
const { pathname, search } = window.location;
|
||||||
|
const newHash = next === defaultSub ? `#${moduleId}` : `#${moduleId}/${next}`;
|
||||||
|
window.history.replaceState(null, '', `${pathname}${search}${newHash}`);
|
||||||
|
setSubState(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return [sub, setSub];
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
export type HydrogenHeatmapMetric = 'kg' | 'refuels' | 'vehicles';
|
||||||
|
export type HydrogenPayer = 'all' | 'lingniu' | 'customer';
|
||||||
|
|
||||||
|
export type HydrogenHeatmapPoint = {
|
||||||
|
lng: number;
|
||||||
|
lat: number;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HydrogenStationRank = {
|
||||||
|
stationId: string;
|
||||||
|
stationName: string;
|
||||||
|
address: string;
|
||||||
|
lng: number;
|
||||||
|
lat: number;
|
||||||
|
kg: number;
|
||||||
|
refuelCount: number;
|
||||||
|
vehicleCount: number;
|
||||||
|
firstRefuel: string;
|
||||||
|
lastRefuel: string;
|
||||||
|
metricValue: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HydrogenStationOption = {
|
||||||
|
stationId: string;
|
||||||
|
stationName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HydrogenHeatmapMeta = {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
totalRefuelCount: number;
|
||||||
|
eligibleRefuelCount: number;
|
||||||
|
excludedRefuelCount: number;
|
||||||
|
gpsCoverageRate: number;
|
||||||
|
totalKg: number;
|
||||||
|
eligibleKg: number;
|
||||||
|
vehicleCount: number;
|
||||||
|
totalStationCount: number;
|
||||||
|
eligibleStationCount: number;
|
||||||
|
stations: HydrogenStationOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HydrogenHeatmapResponse = {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
metric: HydrogenHeatmapMetric;
|
||||||
|
payer: HydrogenPayer;
|
||||||
|
kg: number;
|
||||||
|
refuelCount: number;
|
||||||
|
vehicleCount: number;
|
||||||
|
stationCount: number;
|
||||||
|
dayCount: number;
|
||||||
|
points: HydrogenHeatmapPoint[];
|
||||||
|
max: number;
|
||||||
|
topStations: HydrogenStationRank[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HydrogenNearbyResponse = {
|
||||||
|
center: { lng: number; lat: number };
|
||||||
|
radiusKm: number;
|
||||||
|
kg: number;
|
||||||
|
refuelCount: number;
|
||||||
|
vehicleCount: number;
|
||||||
|
stationCount: number;
|
||||||
|
topStations: HydrogenStationRank[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AmapConfig = {
|
||||||
|
key: string;
|
||||||
|
securityCode: string;
|
||||||
|
};
|
||||||
@@ -1,13 +1,140 @@
|
|||||||
import { FileText } from 'lucide-react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { AnimatePresence } from 'motion/react';
|
||||||
|
import { AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||||
|
import { ErrorState, SurfaceCard } from '../../components/ui/surface';
|
||||||
|
import {
|
||||||
|
fetchDailyMileageReport,
|
||||||
|
type DailyMileageReport,
|
||||||
|
} from './api';
|
||||||
|
import DailyReportLoadingOverlay from './daily-report/DailyReportLoadingOverlay';
|
||||||
|
import GroupRow from './daily-report/GroupRow';
|
||||||
|
import MileageBandDefinition from './daily-report/MileageBandDefinition';
|
||||||
|
import ReportCharts from './daily-report/ReportCharts';
|
||||||
|
import ReportHeader from './daily-report/ReportHeader';
|
||||||
|
import ReportInsights from './daily-report/ReportInsights';
|
||||||
|
import ReportOverview from './daily-report/ReportOverview';
|
||||||
|
import {
|
||||||
|
fmtDate,
|
||||||
|
initialReportDate,
|
||||||
|
isVisibleQualityNote,
|
||||||
|
shanghaiYmd,
|
||||||
|
} from './daily-report/utils';
|
||||||
|
|
||||||
|
// Fast responses still keep the global loading state visible long enough to be perceived.
|
||||||
|
const MIN_REPORT_LOADING_MS = 280;
|
||||||
|
|
||||||
|
function updateReportDateInUrl(date: string): void {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('mileageReportDate', date);
|
||||||
|
window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`);
|
||||||
|
}
|
||||||
|
|
||||||
export default function DailyReportView() {
|
export default function DailyReportView() {
|
||||||
|
const maxDate = shanghaiYmd(-1);
|
||||||
|
const [selectedDate, setSelectedDate] = useState(() => initialReportDate(window.location.search));
|
||||||
|
const [report, setReport] = useState<DailyMileageReport | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [expandedTargetId, setExpandedTargetId] = useState<number | null>(null);
|
||||||
|
const [trendTargetId, setTrendTargetId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
let finishTimer: number | undefined;
|
||||||
|
const loadingStartedAt = performance.now();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
updateReportDateInUrl(selectedDate);
|
||||||
|
fetchDailyMileageReport(selectedDate)
|
||||||
|
.then(data => {
|
||||||
|
if (!active) return;
|
||||||
|
setReport(data);
|
||||||
|
setExpandedTargetId(current => current != null && data.groups.some(group => group.targetId === current) ? current : null);
|
||||||
|
setTrendTargetId(current => current != null && data.groups.some(group => group.targetId === current) ? current : null);
|
||||||
|
})
|
||||||
|
.catch(cause => {
|
||||||
|
if (!active) return;
|
||||||
|
setError(cause instanceof Error ? cause.message : '日报加载失败');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const delay = Math.max(0, MIN_REPORT_LOADING_MS - (performance.now() - loadingStartedAt));
|
||||||
|
finishTimer = window.setTimeout(() => {
|
||||||
|
if (active) setLoading(false);
|
||||||
|
}, delay);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
if (finishTimer != null) window.clearTimeout(finishTimer);
|
||||||
|
};
|
||||||
|
}, [selectedDate]);
|
||||||
|
|
||||||
|
const highMileageRate = report && report.totals.operatingCount > 0
|
||||||
|
? (report.totals.highMileageCount / report.totals.operatingCount) * 100
|
||||||
|
: 0;
|
||||||
|
const visibleQualityNotes = report?.qualityNotes.filter(note => isVisibleQualityNote(note.message)) || [];
|
||||||
|
|
||||||
|
if (loading && !report) return <DailyReportLoadingOverlay reportDate={selectedDate} />;
|
||||||
|
if (!report) return <ErrorState message={error || '日报接口未返回有效数据'} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="relative space-y-3 pb-5" aria-busy={loading}>
|
||||||
<div className="text-center">
|
<ReportHeader
|
||||||
<FileText size={48} className="mx-auto text-gray-300 mb-4" />
|
report={report}
|
||||||
<h2 className="text-lg font-semibold text-gray-500">每日汇报</h2>
|
selectedDate={selectedDate}
|
||||||
<p className="text-sm text-gray-400 mt-2">开发中...</p>
|
maxDate={maxDate}
|
||||||
</div>
|
onDateChange={setSelectedDate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{visibleQualityNotes.length > 0 ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{visibleQualityNotes.map(note => (
|
||||||
|
<div key={note.message} className={`flex items-start gap-2 rounded-lg px-3 py-2 text-[10px] font-bold ${
|
||||||
|
note.level === 'warning' ? 'bg-amber-50 text-amber-700' : 'bg-blue-50 text-blue-700'
|
||||||
|
}`}>
|
||||||
|
{note.level === 'warning' ? <AlertTriangle size={13} className="mt-0.5 shrink-0" /> : <CheckCircle2 size={13} className="mt-0.5 shrink-0" />}
|
||||||
|
<span>{note.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ReportOverview report={report} highMileageRate={highMileageRate} />
|
||||||
|
<ReportInsights insights={report.insights} />
|
||||||
|
<ReportCharts
|
||||||
|
report={report}
|
||||||
|
trendTargetId={trendTargetId}
|
||||||
|
onTrendTargetChange={setTrendTargetId}
|
||||||
|
/>
|
||||||
|
<MileageBandDefinition />
|
||||||
|
|
||||||
|
<SurfaceCard
|
||||||
|
title={(
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
<span>车型运营明细</span>
|
||||||
|
<span className="rounded-md bg-blue-50 px-1.5 py-0.5 text-[10px] font-black text-blue-600 ring-1 ring-blue-100">统计日期 {fmtDate(report.reportDate)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
subtitle="点击车型查看部门、库存地区、考核进度及车辆近7日趋势"
|
||||||
|
>
|
||||||
|
<div className="hidden grid-cols-[minmax(170px,1.3fr)_82px_100px_88px_100px_96px_24px] gap-3 border-b border-slate-100 bg-slate-50 px-4 py-2 text-[10px] font-black text-slate-400 lg:grid">
|
||||||
|
<span>考核车型</span><span className="text-right">当日里程</span><span className="text-right">环比变化</span><span className="text-right">低/高里程</span><span className="text-right">高里程占比</span><span className="text-right">年度达标</span><span />
|
||||||
|
</div>
|
||||||
|
{report.groups.map(group => (
|
||||||
|
<GroupRow
|
||||||
|
key={group.targetId}
|
||||||
|
group={group}
|
||||||
|
reportDate={report.reportDate}
|
||||||
|
expanded={expandedTargetId === group.targetId}
|
||||||
|
onToggle={() => setExpandedTargetId(current => current === group.targetId ? null : group.targetId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SurfaceCard>
|
||||||
|
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{loading ? <DailyReportLoadingOverlay reportDate={selectedDate} /> : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
{error ? <ErrorState message={error} /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,51 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { LayoutDashboard, BarChart3, FileText } from 'lucide-react';
|
import { LayoutDashboard, BarChart3, FileText } from 'lucide-react';
|
||||||
import { motion } from 'motion/react';
|
import { AnimatePresence } from 'motion/react';
|
||||||
import MonitoringView from './MonitoringView';
|
import MonitoringView from './MonitoringView';
|
||||||
import StatisticsView from './StatisticsView';
|
import StatisticsView from './StatisticsView';
|
||||||
import DailyReportView from './DailyReportView';
|
import DailyReportView from './DailyReportView';
|
||||||
|
import { useHashSubTab } from '../energy/useHashSubTab';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
import { FadeIn, PageFrame, SegmentedNav } from '../../components/ui/surface';
|
||||||
|
|
||||||
|
type MileageSubTab = 'monitoring' | 'statistics' | 'report';
|
||||||
|
|
||||||
|
const MILEAGE_TABS = [
|
||||||
|
{ id: 'monitoring', label: '实时监控', icon: LayoutDashboard },
|
||||||
|
{ id: 'statistics', label: '统计报表', icon: BarChart3 },
|
||||||
|
{ id: 'report', label: '每日汇报', icon: FileText },
|
||||||
|
] as const satisfies readonly { id: MileageSubTab; label: string; icon: typeof LayoutDashboard }[];
|
||||||
|
|
||||||
|
const MILEAGE_SUB_IDS: readonly MileageSubTab[] = ['monitoring', 'statistics', 'report'];
|
||||||
|
|
||||||
export default function MileageModule() {
|
export default function MileageModule() {
|
||||||
const [activeSubTab, setActiveSubTab] = useState<'monitoring' | 'statistics' | 'report'>('monitoring');
|
const [activeSubTab, setActiveSubTab] = useHashSubTab<MileageSubTab>('mileage', MILEAGE_SUB_IDS);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
<PageFrame
|
||||||
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 landscape:pb-0 landscape:h-full landscape:flex-1 landscape:overflow-hidden">
|
title="车辆里程中心"
|
||||||
{/* Sub-navigation — sticky */}
|
subtitle="统一监控车辆日里程、累计里程、考核进度与日报经营口径,突出异常车辆和任务压力。"
|
||||||
<div className="bg-white px-4 py-2 rounded-2xl border border-slate-100 shadow-sm flex items-center gap-6 sticky top-0 z-30">
|
icon={LayoutDashboard}
|
||||||
<button
|
eyebrow="MILEAGE BI"
|
||||||
onClick={() => setActiveSubTab('monitoring')}
|
meta="实时监控 · 统计报表 · 每日汇报"
|
||||||
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'monitoring' ? 'text-blue-600' : 'text-slate-400'}`}
|
compactInfo
|
||||||
>
|
hideHeader
|
||||||
<LayoutDashboard size={14} />
|
>
|
||||||
<span className="text-[11px] font-bold">实时监控</span>
|
<div className="sticky top-0 z-30 -mx-3 bg-[var(--app-bg)] px-3 pb-2 pt-1 shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)] md:-mx-6 md:top-12 md:px-6">
|
||||||
{activeSubTab === 'monitoring' && (
|
<SegmentedNav tabs={MILEAGE_TABS} active={activeSubTab} onChange={setActiveSubTab} />
|
||||||
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveSubTab('statistics')}
|
|
||||||
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'statistics' ? 'text-blue-600' : 'text-slate-400'}`}
|
|
||||||
>
|
|
||||||
<BarChart3 size={14} />
|
|
||||||
<span className="text-[11px] font-bold">统计报表</span>
|
|
||||||
{activeSubTab === 'statistics' && (
|
|
||||||
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveSubTab('report')}
|
|
||||||
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'report' ? 'text-blue-600' : 'text-slate-400'}`}
|
|
||||||
>
|
|
||||||
<FileText size={14} />
|
|
||||||
<span className="text-[11px] font-bold">每日汇报</span>
|
|
||||||
{activeSubTab === 'report' && (
|
|
||||||
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeSubTab === 'monitoring' ? (
|
|
||||||
<MonitoringView />
|
|
||||||
) : activeSubTab === 'statistics' ? (
|
|
||||||
<StatisticsView />
|
|
||||||
) : (
|
|
||||||
<DailyReportView />
|
|
||||||
)}
|
|
||||||
<RotatingFooterHint />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<FadeIn key={activeSubTab}>
|
||||||
|
{activeSubTab === 'monitoring' ? (
|
||||||
|
<MonitoringView />
|
||||||
|
) : activeSubTab === 'statistics' ? (
|
||||||
|
<StatisticsView />
|
||||||
|
) : (
|
||||||
|
<DailyReportView />
|
||||||
|
)}
|
||||||
|
</FadeIn>
|
||||||
|
</AnimatePresence>
|
||||||
|
<RotatingFooterHint />
|
||||||
|
</PageFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,65 +1,53 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
|
||||||
import {
|
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
|
||||||
ResponsiveContainer, LineChart, Line, AreaChart, Area,
|
|
||||||
Cell, LabelList,
|
|
||||||
} from 'recharts';
|
|
||||||
import {
|
|
||||||
Truck, ChevronDown, Maximize2, Minimize2,
|
|
||||||
Search, ArrowUpDown, X, RotateCcw, Calendar,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import type { TargetSummary, TargetVehicle, TrendPoint } from './types';
|
import type { TargetSummary, TargetVehicle, TrendPoint } from './types';
|
||||||
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
||||||
import Blur from '../../components/Blur';
|
import AllVehiclesPanel from './statistics/AllVehiclesPanel';
|
||||||
|
import FullscreenTargetTable from './statistics/FullscreenTargetTable';
|
||||||
function getDefaultDate(): string {
|
import StatisticsOverview from './statistics/StatisticsOverview';
|
||||||
const now = new Date();
|
import TargetDetailPanel from './statistics/TargetDetailPanel';
|
||||||
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
|
import TrendPanel from './statistics/TrendPanel';
|
||||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
import {
|
||||||
}
|
deriveSelectedTargetMetrics,
|
||||||
|
getCurrentDateLabel,
|
||||||
function fmtKm(value: number): string {
|
getDefaultDate,
|
||||||
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
|
initialAssessmentYears,
|
||||||
return value.toLocaleString();
|
orderTargets,
|
||||||
}
|
} from './statistics/model';
|
||||||
|
|
||||||
function shortTargetName(name: string): string {
|
|
||||||
// Extract the number and a short description
|
|
||||||
const match = name.match(/(\d+)[辆台](.+)/);
|
|
||||||
if (!match) return name;
|
|
||||||
const count = match[1];
|
|
||||||
let desc = match[2];
|
|
||||||
// Simplify common patterns
|
|
||||||
desc = desc.replace('4.5T普货', '普货');
|
|
||||||
desc = desc.replace('4.5T冷链车', '冷藏车');
|
|
||||||
desc = desc.replace('4.5T冷链', '冷藏车');
|
|
||||||
desc = desc.replace('18T', '18T');
|
|
||||||
return `${count}台${desc}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StatisticsView() {
|
export default function StatisticsView() {
|
||||||
|
const currentDateLabel = getCurrentDateLabel();
|
||||||
const [targets, setTargets] = useState<TargetSummary[]>([]);
|
const [targets, setTargets] = useState<TargetSummary[]>([]);
|
||||||
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
||||||
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
|
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
|
||||||
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(null);
|
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(null);
|
||||||
|
|
||||||
const [chartType, setChartType] = useState<'bar' | 'line' | 'area'>('bar');
|
|
||||||
const [isTableFullscreen, setIsTableFullscreen] = useState(false);
|
const [isTableFullscreen, setIsTableFullscreen] = useState(false);
|
||||||
const [expandedModel, setExpandedModel] = useState<string | null>(null);
|
const [expandedTargetId, setExpandedTargetId] = useState<number | null>(null);
|
||||||
|
const [assessmentYearMap, setAssessmentYearMap] = useState<Record<number, number>>({});
|
||||||
const [viewAllTargetId, setViewAllTargetId] = useState<number | null>(null);
|
const [viewAllTargetId, setViewAllTargetId] = useState<number | null>(null);
|
||||||
const [viewAllTargetName, setViewAllTargetName] = useState<string>('');
|
const [viewAllTargetName, setViewAllTargetName] = useState<string>('');
|
||||||
const [viewAllSearch, setViewAllSearch] = useState('');
|
|
||||||
const [viewAllSort, setViewAllSort] = useState<'asc' | 'desc'>('desc');
|
|
||||||
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
|
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
|
||||||
const [viewAllLoading, setViewAllLoading] = useState(false);
|
const [viewAllLoading, setViewAllLoading] = useState(false);
|
||||||
|
|
||||||
|
const selectedTarget = targets.find(target => target.id === selectedTargetId);
|
||||||
|
const selectedMetrics = deriveSelectedTargetMetrics(
|
||||||
|
selectedTarget,
|
||||||
|
selectedTarget ? assessmentYearMap[selectedTarget.id] : undefined,
|
||||||
|
trendData,
|
||||||
|
);
|
||||||
|
|
||||||
// Load targets on mount
|
// Load targets on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTargets().then(data => {
|
fetchTargets().then(data => {
|
||||||
setTargets(data);
|
const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0];
|
||||||
if (data.length > 0 && !selectedTargetId) {
|
const ordered = orderTargets(data);
|
||||||
setSelectedTargetId(data[0].id);
|
setTargets(ordered);
|
||||||
|
if (ordered.length > 0 && !selectedTargetId) {
|
||||||
|
setSelectedTargetId(focused.id);
|
||||||
|
setExpandedTargetId(focused.id);
|
||||||
|
setAssessmentYearMap(initialAssessmentYears(ordered));
|
||||||
|
fetchTargetVehicles(focused.id).then(vehicles => {
|
||||||
|
setTargetVehiclesMap(previous => ({ ...previous, [focused.id]: vehicles }));
|
||||||
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -67,6 +55,12 @@ export default function StatisticsView() {
|
|||||||
// Load trend when selectedTargetId changes
|
// Load trend when selectedTargetId changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedTargetId === null) return;
|
if (selectedTargetId === null) return;
|
||||||
|
setExpandedTargetId(selectedTargetId);
|
||||||
|
if (!targetVehiclesMap[selectedTargetId]) {
|
||||||
|
fetchTargetVehicles(selectedTargetId).then(vehicles => {
|
||||||
|
setTargetVehiclesMap(previous => ({ ...previous, [selectedTargetId]: vehicles }));
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
|
fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
|
||||||
}, [selectedTargetId]);
|
}, [selectedTargetId]);
|
||||||
|
|
||||||
@@ -75,524 +69,84 @@ export default function StatisticsView() {
|
|||||||
if (viewAllTargetId === null) return;
|
if (viewAllTargetId === null) return;
|
||||||
setViewAllLoading(true);
|
setViewAllLoading(true);
|
||||||
fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => {
|
fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => {
|
||||||
setTargetVehiclesMap(prev => ({ ...prev, [viewAllTargetId]: data }));
|
setTargetVehiclesMap(previous => ({ ...previous, [viewAllTargetId]: data }));
|
||||||
}).catch(() => {}).finally(() => setViewAllLoading(false));
|
}).catch(() => {}).finally(() => setViewAllLoading(false));
|
||||||
}, [viewAllTargetId, viewAllDate]);
|
}, [viewAllTargetId, viewAllDate]);
|
||||||
|
|
||||||
|
function selectTarget(targetId: number) {
|
||||||
|
setSelectedTargetId(targetId);
|
||||||
|
setExpandedTargetId(targetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandTarget(targetId: number) {
|
||||||
|
setExpandedTargetId(targetId);
|
||||||
|
if (!targetVehiclesMap[targetId]) {
|
||||||
|
fetchTargetVehicles(targetId).then(data => {
|
||||||
|
setTargetVehiclesMap(previous => ({ ...previous, [targetId]: data }));
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAllVehicles(target: TargetSummary) {
|
||||||
|
setViewAllTargetId(target.id);
|
||||||
|
setViewAllTargetName(target.targetName);
|
||||||
|
setViewAllDate(getDefaultDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshTargets() {
|
||||||
|
fetchTargets().then(data => {
|
||||||
|
setTargets(data);
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1" style={{ overflowX: 'clip' }}>
|
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1 [overflow-anchor:none]" style={{ overflowX: 'clip' }}>
|
||||||
{/* Project Selector */}
|
<StatisticsOverview
|
||||||
<div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-100 flex gap-1 overflow-x-auto no-scrollbar flex-shrink-0">
|
targets={targets}
|
||||||
{targets.map(target => (
|
selectedTargetId={selectedTargetId}
|
||||||
<button
|
selectedTarget={selectedTarget}
|
||||||
key={target.id}
|
metrics={selectedMetrics}
|
||||||
onClick={() => setSelectedTargetId(target.id)}
|
onSelectTarget={selectTarget}
|
||||||
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
|
/>
|
||||||
selectedTargetId === target.id
|
|
||||||
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
|
|
||||||
: 'bg-slate-50 text-slate-500 hover:bg-slate-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{shortTargetName(target.targetName)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col landscape:flex-row gap-4 flex-1 landscape:overflow-hidden">
|
<div className="flex flex-col landscape:flex-row gap-4 flex-1 landscape:overflow-hidden">
|
||||||
{/* Left Side: Trend Chart / Dashboard Sidebar */}
|
<TrendPanel
|
||||||
<div className="flex-none landscape:flex-1 landscape:w-2/3 space-y-4 flex flex-col overflow-y-auto no-scrollbar min-w-0">
|
selectedTarget={selectedTarget}
|
||||||
{/* KPI Cards in Landscape — linked to selected target */}
|
selectedCompletion={selectedMetrics.completion}
|
||||||
{(() => {
|
trendData={trendData}
|
||||||
const sel = targets.find(t => t.id === selectedTargetId);
|
/>
|
||||||
return (
|
<TargetDetailPanel
|
||||||
<div className="hidden landscape:grid grid-cols-4 gap-3 flex-shrink-0">
|
target={selectedTarget}
|
||||||
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
expandedTargetId={expandedTargetId}
|
||||||
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">今日总里程</div>
|
selectedYear={selectedTarget ? assessmentYearMap[selectedTarget.id] : undefined}
|
||||||
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
vehicles={selectedTarget ? targetVehiclesMap[selectedTarget.id] || [] : []}
|
||||||
{fmtKm(sel?.todayTotal ?? 0)}
|
currentDateLabel={currentDateLabel}
|
||||||
<span className="text-blue-500 text-[10px] ml-1">KM</span>
|
onOpenFullscreen={() => setIsTableFullscreen(true)}
|
||||||
</div>
|
onExpandTarget={expandTarget}
|
||||||
</div>
|
onAssessmentYearChange={(targetId, year) => {
|
||||||
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
setAssessmentYearMap(previous => ({ ...previous, [targetId]: year }));
|
||||||
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">累计总里程</div>
|
}}
|
||||||
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
onViewAll={openAllVehicles}
|
||||||
{fmtKm(sel?.cumulativeTotal ?? 0)}
|
/>
|
||||||
<span className="text-blue-500 text-[10px] ml-1">KM</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
|
||||||
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">考核车辆</div>
|
|
||||||
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
|
||||||
{sel?.vehicleCount ?? 0}
|
|
||||||
<span className="text-blue-500 text-[10px] ml-1">台</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
|
||||||
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">完成率</div>
|
|
||||||
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
|
||||||
{(sel?.avgCompletion ?? 0).toFixed(1)}
|
|
||||||
<span className="text-blue-500 text-[10px] ml-1">%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
<div className="bg-white p-4 rounded-2xl shadow-sm border border-slate-100 flex-1 flex flex-col min-h-[300px] overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-1 h-4 bg-blue-600 rounded-full" />
|
|
||||||
<h3 className="text-sm font-bold text-slate-800">7天里程趋势</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex bg-slate-50 p-1 rounded-lg">
|
|
||||||
{(['bar', 'line', 'area'] as const).map(type => (
|
|
||||||
<button
|
|
||||||
key={type}
|
|
||||||
onClick={() => setChartType(type)}
|
|
||||||
className={`px-2 py-1 rounded-md text-[10px] font-bold transition-all ${
|
|
||||||
chartType === type ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{type === 'bar' ? '柱状' : type === 'line' ? '折线' : '面积'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 w-full min-h-[250px] relative">
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
{chartType === 'bar' ? (
|
|
||||||
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
|
|
||||||
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={(val: string) => { const [m, d] = val.split('-'); return `${parseInt(m)}.${parseInt(d)}`; }} />
|
|
||||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
|
|
||||||
<Tooltip cursor={{ fill: '#f8fafc', fillOpacity: 0.1 }} contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
|
|
||||||
<Bar dataKey="mileage" fill="#3b82f6" radius={[4, 4, 0, 0]} barSize={20}>
|
|
||||||
<LabelList dataKey="mileage" position="top" style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
|
|
||||||
{trendData.map((_entry: TrendPoint, index: number) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={index === trendData.length - 1 ? '#2563eb' : '#60a5fa'} />
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
|
||||||
) : chartType === 'line' ? (
|
|
||||||
<LineChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
|
|
||||||
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={(val: string) => { const [m, d] = val.split('-'); return `${parseInt(m)}.${parseInt(d)}`; }} />
|
|
||||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
|
|
||||||
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
|
|
||||||
<Line type="monotone" dataKey="mileage" stroke="#3b82f6" strokeWidth={3} dot={{ r: 4, fill: '#3b82f6' }}>
|
|
||||||
<LabelList dataKey="mileage" position="top" offset={10} style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
|
|
||||||
</Line>
|
|
||||||
</LineChart>
|
|
||||||
) : (
|
|
||||||
<AreaChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="colorMileage" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3}/>
|
|
||||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
|
|
||||||
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={(val: string) => { const [m, d] = val.split('-'); return `${parseInt(m)}.${parseInt(d)}`; }} />
|
|
||||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
|
|
||||||
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
|
|
||||||
<Area type="monotone" dataKey="mileage" stroke="#3b82f6" fillOpacity={1} fill="url(#colorMileage)">
|
|
||||||
<LabelList dataKey="mileage" position="top" offset={10} style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
|
|
||||||
</Area>
|
|
||||||
</AreaChart>
|
|
||||||
)}
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Side: Summary Section */}
|
|
||||||
<div className="w-full landscape:w-1/3 flex-shrink-0 space-y-2 flex flex-col landscape:overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between px-2 flex-shrink-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-1 h-4 bg-blue-600 rounded-full" />
|
|
||||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest">车型考核里程汇总</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsTableFullscreen(true)}
|
|
||||||
className="p-1.5 bg-white text-slate-400 rounded-lg border border-slate-100 shadow-sm hover:text-blue-600 transition-colors"
|
|
||||||
>
|
|
||||||
<Maximize2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-1.5 overflow-y-auto no-scrollbar pb-2">
|
|
||||||
{targets.map((target, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className="bg-white px-3 py-2 rounded-xl border border-slate-100 shadow-sm flex flex-col active:bg-slate-50 transition-all cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
const name = target.targetName;
|
|
||||||
setExpandedModel(expandedModel === name ? null : name);
|
|
||||||
if (!targetVehiclesMap[target.id]) {
|
|
||||||
fetchTargetVehicles(target.id).then(data => {
|
|
||||||
setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data }));
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3 overflow-hidden flex-1">
|
|
||||||
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center flex-shrink-0">
|
|
||||||
<Truck size={14} className="text-slate-400" />
|
|
||||||
</div>
|
|
||||||
<div className="overflow-hidden flex-1">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-xs font-black text-slate-900">{target.targetName}</span>
|
|
||||||
<span className="text-[8px] px-1 rounded bg-blue-50 text-blue-600 font-bold">{target.vehicleCount}台</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="text-[9px] text-slate-400">完成率:</span>
|
|
||||||
<span className={`text-[9px] font-bold ${target.avgCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{target.avgCompletion.toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="text-[9px] text-slate-400">达标:</span>
|
|
||||||
<span className="text-[9px] font-bold text-slate-600">{target.yearQualifiedCount}台</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right flex-shrink-0 ml-2 flex items-center gap-3">
|
|
||||||
<div className="flex flex-col items-end">
|
|
||||||
<div className="text-sm font-black text-slate-900 leading-none mb-0.5">
|
|
||||||
{fmtKm(target.todayTotal)} <span className="text-[8px] text-slate-300 font-bold uppercase">KM</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-[8px] font-bold text-slate-300">
|
|
||||||
累计: {fmtKm(target.cumulativeTotal)} KM
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<motion.div
|
|
||||||
animate={{ rotate: expandedModel === target.targetName ? 180 : 0 }}
|
|
||||||
className="text-slate-300"
|
|
||||||
>
|
|
||||||
<ChevronDown size={14} />
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{expandedModel === target.targetName && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ height: 0, opacity: 0 }}
|
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
|
||||||
exit={{ height: 0, opacity: 0 }}
|
|
||||||
className="overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="pt-3 mt-2 border-t border-slate-50 grid grid-cols-2 gap-x-4 gap-y-3">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">考核区间</p>
|
|
||||||
{target.periods.map((p, i) => (
|
|
||||||
<p key={i} className="text-[10px] font-black text-slate-700">{p}</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核里程</p>
|
|
||||||
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">年考核任务/辆</p>
|
|
||||||
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle)} km</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">50%达标数</p>
|
|
||||||
<p className="text-[10px] font-black text-blue-600">{target.halfQualifiedCount} 台</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">本年需完成</p>
|
|
||||||
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.currentYearTarget)} km</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">已完成(截止3.31)</p>
|
|
||||||
<p className="text-[10px] font-black text-emerald-600">{fmtKm(target.currentYearCompleted)} km</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">未完成总数</p>
|
|
||||||
<p className="text-[10px] font-black text-rose-500">{fmtKm(target.remaining)} km</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">日均需完成</p>
|
|
||||||
<p className="text-[10px] font-black text-blue-500">{fmtKm(target.dailyTarget)} km</p>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2 flex items-center justify-between bg-slate-50 p-2 rounded-lg">
|
|
||||||
<span className="text-[9px] font-bold text-slate-500">剩余考核天数</span>
|
|
||||||
<span className="text-[10px] font-black text-slate-900">{target.daysLeft} 天</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Vehicle List Detail */}
|
|
||||||
<div className="col-span-2 space-y-2 mt-2">
|
|
||||||
<div className="flex items-center justify-between px-1">
|
|
||||||
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">车辆明细 (前5台)</span>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setViewAllTargetId(target.id);
|
|
||||||
setViewAllTargetName(target.targetName);
|
|
||||||
setViewAllDate(getDefaultDate());
|
|
||||||
}}
|
|
||||||
className="text-[8px] text-blue-500 font-bold hover:underline"
|
|
||||||
>
|
|
||||||
查看全部
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{(targetVehiclesMap[target.id] || []).slice(0, 5).map(tv => (
|
|
||||||
<div key={tv.plateNumber} className="bg-slate-50/50/50 px-2 py-1.5 rounded-lg flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-[10px] font-mono font-bold text-slate-700"><Blur>{tv.plateNumber}</Blur></span>
|
|
||||||
<span className="text-[7px] px-1 rounded bg-green-100 text-green-600 font-bold">
|
|
||||||
在线
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<span className="text-[10px] font-black text-blue-600">{tv.todayMileage}</span>
|
|
||||||
<span className="text-[8px] text-slate-400 ml-1">KM</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fullscreen Table Overlay */}
|
<FullscreenTargetTable
|
||||||
<AnimatePresence>
|
isOpen={isTableFullscreen}
|
||||||
{isTableFullscreen && (
|
targets={targets}
|
||||||
<motion.div
|
assessmentYearMap={assessmentYearMap}
|
||||||
initial={{ opacity: 0 }}
|
onRefresh={refreshTargets}
|
||||||
animate={{ opacity: 1 }}
|
onClose={() => setIsTableFullscreen(false)}
|
||||||
exit={{ opacity: 0 }}
|
/>
|
||||||
className="fixed inset-0 z-[100] bg-slate-950 flex flex-col overflow-hidden"
|
|
||||||
>
|
|
||||||
{/* Top bar: compact inline KPI */}
|
|
||||||
<div className="flex-shrink-0 px-3 py-2 border-b border-slate-800/60 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-0.5 h-4 bg-blue-500 rounded-full"></div>
|
|
||||||
<h2 className="text-white font-bold text-xs">车型考核汇总</h2>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 text-[10px]">
|
|
||||||
<span className="text-slate-500">今日 <span className="text-white font-black">{fmtKm(targets.reduce((sum, t) => sum + t.todayTotal, 0))}</span> <span className="text-blue-400">km</span></span>
|
|
||||||
<span className="text-slate-700">|</span>
|
|
||||||
<span className="text-slate-500">累计 <span className="text-white font-black">{fmtKm(targets.reduce((sum, t) => sum + t.cumulativeTotal, 0))}</span> <span className="text-blue-400">km</span></span>
|
|
||||||
<span className="text-slate-700">|</span>
|
|
||||||
<span className="text-slate-500">车辆 <span className="text-white font-black">{targets.reduce((sum, t) => sum + t.vehicleCount, 0)}</span> 台</span>
|
|
||||||
<span className="text-slate-700">|</span>
|
|
||||||
<span className="text-slate-500">完成率 <span className="text-white font-black">{targets.length > 0 ? (targets.reduce((sum, t) => sum + t.avgCompletion, 0) / targets.length).toFixed(1) : '0.0'}</span> <span className="text-blue-400">%</span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => { fetchTargets().then(data => { setTargets(data); }).catch(() => {}); }}
|
|
||||||
className="p-1.5 text-slate-500 hover:text-blue-400 transition-colors"
|
|
||||||
>
|
|
||||||
<RotateCcw size={13} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsTableFullscreen(false)}
|
|
||||||
className="p-1.5 text-slate-500 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<Minimize2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table Area */}
|
<AllVehiclesPanel
|
||||||
<div className="flex-1 overflow-auto">
|
targetId={viewAllTargetId}
|
||||||
<table className="w-full text-left border-collapse">
|
targetName={viewAllTargetName}
|
||||||
<thead className="sticky top-0 bg-slate-900 z-10">
|
vehicles={viewAllTargetId !== null ? targetVehiclesMap[viewAllTargetId] || [] : []}
|
||||||
<tr className="border-b border-slate-800/60">
|
date={viewAllDate}
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase sticky left-0 bg-slate-900 z-20 min-w-[100px]">车型</th>
|
loading={viewAllLoading}
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-12">台数</th>
|
onDateChange={setViewAllDate}
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase min-w-[140px]">完成进度</th>
|
onClose={() => setViewAllTargetId(null)}
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">年任务/辆</th>
|
/>
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-emerald-500 uppercase text-center">达标</th>
|
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-center">50%达标</th>
|
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-white uppercase text-right">今日里程</th>
|
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">本年目标</th>
|
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">已完成</th>
|
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-rose-400 uppercase text-right">未完成</th>
|
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-14">余天</th>
|
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-right">日均需完成</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-800/30">
|
|
||||||
{targets.map((target, idx) => (
|
|
||||||
<tr key={idx} className="hover:bg-slate-800/20 transition-colors">
|
|
||||||
<td className="px-3 py-3 sticky left-0 bg-slate-950 z-10 border-r border-slate-800/40">
|
|
||||||
<div className="text-xs font-bold text-white whitespace-nowrap">{target.targetName}</div>
|
|
||||||
<div className="text-[9px] text-slate-500 mt-0.5">{target.periods.map((p, i) => <span key={i} className="block">{p}</span>)}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-3 text-xs font-bold text-slate-300 text-center">{target.vehicleCount}</td>
|
|
||||||
<td className="px-3 py-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded-full ${target.avgCompletion >= 90 ? 'bg-emerald-500' : target.avgCompletion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
|
|
||||||
style={{ width: `${Math.min(target.avgCompletion, 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] font-black text-white w-10 text-right">{target.avgCompletion.toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between mt-1 text-[9px] text-slate-500">
|
|
||||||
<span>{fmtKm(target.cumulativeTotal)}</span>
|
|
||||||
<span>/ {fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-3 text-xs text-slate-300 text-right">{fmtKm(target.annualMileagePerVehicle)} km</td>
|
|
||||||
<td className="px-3 py-3 text-xs font-black text-emerald-400 text-center">{target.yearQualifiedCount}</td>
|
|
||||||
<td className="px-3 py-3 text-xs font-black text-blue-400 text-center">{target.halfQualifiedCount}</td>
|
|
||||||
<td className="px-3 py-3 text-xs font-black text-white text-right">{fmtKm(target.todayTotal)} km</td>
|
|
||||||
<td className="px-3 py-3 text-xs text-slate-400 text-right">{fmtKm(target.currentYearTarget)} km</td>
|
|
||||||
<td className="px-3 py-3 text-xs text-emerald-400/80 text-right">{fmtKm(target.currentYearCompleted)} km</td>
|
|
||||||
<td className="px-3 py-3 text-xs font-bold text-rose-400 text-right">{fmtKm(target.remaining)} km</td>
|
|
||||||
<td className="px-3 py-3 text-xs text-slate-300 text-center">{target.daysLeft}</td>
|
|
||||||
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">{fmtKm(target.dailyTarget)} km</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* View All Vehicles Side Panel */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{viewAllTargetId !== null && (
|
|
||||||
<>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
onClick={() => setViewAllTargetId(null)}
|
|
||||||
className="fixed inset-0 z-[110] bg-slate-950/60 backdrop-blur-sm"
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
initial={{ x: '100%' }}
|
|
||||||
animate={{ x: 0 }}
|
|
||||||
exit={{ x: '100%' }}
|
|
||||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
|
||||||
className="fixed top-0 right-0 bottom-0 w-full max-w-sm z-[120] bg-white shadow-2xl flex flex-col"
|
|
||||||
>
|
|
||||||
<div className="p-6 border-b border-slate-100 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-black text-slate-900">{viewAllTargetName}</h3>
|
|
||||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">车辆实时明细清单</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setViewAllTargetId(null);
|
|
||||||
setViewAllSearch('');
|
|
||||||
}}
|
|
||||||
className="p-2 hover:bg-slate-100 rounded-full transition-colors text-slate-400 hover:text-slate-900"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-6 py-3 border-b border-slate-50 space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="搜索车牌号..."
|
|
||||||
value={viewAllSearch}
|
|
||||||
onChange={(e) => setViewAllSearch(e.target.value)}
|
|
||||||
className="w-full pl-9 pr-3 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="relative flex-shrink-0">
|
|
||||||
<Calendar className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" size={13} />
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={viewAllDate}
|
|
||||||
onChange={(e) => setViewAllDate(e.target.value)}
|
|
||||||
className="pl-8 pr-2 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all w-[130px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
|
||||||
{viewAllLoading ? '加载中...' : (() => {
|
|
||||||
const filtered = (viewAllTargetId !== null ? (targetVehiclesMap[viewAllTargetId] || []) : []).filter(tv => tv.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase()));
|
|
||||||
const totalKm = filtered.reduce((sum, tv) => sum + (tv.todayMileage || 0), 0);
|
|
||||||
return `${filtered.length} 辆 · 合计 ${fmtKm(totalKm)} km`;
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setViewAllSort(prev => prev === 'desc' ? 'asc' : 'desc')}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-600 rounded-lg text-[10px] font-black active:scale-95 transition-all"
|
|
||||||
>
|
|
||||||
<ArrowUpDown size={12} />
|
|
||||||
{viewAllSort === 'desc' ? '降序' : '升序'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-2 no-scrollbar">
|
|
||||||
{(viewAllTargetId !== null ? (targetVehiclesMap[viewAllTargetId] || []) : []).filter(tv =>
|
|
||||||
tv.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase())
|
|
||||||
).sort((a, b) => {
|
|
||||||
const valA = a.todayMileage || 0;
|
|
||||||
const valB = b.todayMileage || 0;
|
|
||||||
return viewAllSort === 'desc' ? valB - valA : valA - valB;
|
|
||||||
}).map(tv => (
|
|
||||||
<div key={tv.plateNumber} className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between hover:border-blue-200 transition-all">
|
|
||||||
<div className="flex items-center gap-3 overflow-hidden flex-1">
|
|
||||||
<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" />
|
|
||||||
</div>
|
|
||||||
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${tv.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} />
|
|
||||||
</div>
|
|
||||||
<div className="overflow-hidden flex-1">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{tv.plateNumber}</Blur></span>
|
|
||||||
<span className={`text-[8px] px-1 rounded ${tv.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
|
|
||||||
{tv.isOnline ? '在线' : '离线'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-[8px] text-slate-300 font-bold">{tv.rentStatus || ''}{tv.department ? ` · ${tv.department.replace('业务', '')}` : ''}</span>
|
|
||||||
<span className="text-[9px] font-bold text-slate-600 truncate">{tv.customer || '-'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right flex-shrink-0 ml-2">
|
|
||||||
<div className="text-sm font-black text-blue-600">{tv.todayMileage.toLocaleString()} <span className="text-[8px] text-slate-400">KM</span></div>
|
|
||||||
<div className="text-[9px] font-bold text-slate-300 mt-0.5">累计: {fmtKm(tv.totalMileage || 0)} km</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 bg-slate-50 border-t border-slate-100">
|
|
||||||
<button
|
|
||||||
onClick={() => setViewAllTargetId(null)}
|
|
||||||
className="w-full py-3 bg-slate-900 text-white rounded-xl text-sm font-bold shadow-lg shadow-slate-200 active:scale-[0.98] transition-all"
|
|
||||||
>
|
|
||||||
关闭明细
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import { X, Truck } from 'lucide-react';
|
|||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
|
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import type { MonitoringVehicle } from './types';
|
import type { MileageSourceGroup, MonitoringVehicle } from './types';
|
||||||
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
|
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vehicle: MonitoringVehicle | null;
|
vehicle: MonitoringVehicle | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
sourcePriority: MileageSourceGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type RangeKey = 'last15' | 'month' | 'quarter';
|
type RangeKey = 'last15' | 'month' | 'quarter';
|
||||||
@@ -56,7 +57,13 @@ function formatLabel(date: string, key: RangeKey): string {
|
|||||||
return date.slice(5);
|
return date.slice(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VehicleDetailModal({ vehicle, onClose }: Props) {
|
function daySourceLabel(day: VehicleRecentDay): string {
|
||||||
|
if (day.sourceCategory === 'INSTRUMENT') return '仪表数据';
|
||||||
|
if (day.sourceCategory === 'GPS') return 'GPS数据';
|
||||||
|
return day.isDataSynced ? '来源待接口' : '无数据';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: Props) {
|
||||||
const [days, setDays] = useState<VehicleRecentDay[]>([]);
|
const [days, setDays] = useState<VehicleRecentDay[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [range, setRange] = useState<RangeKey>('last15');
|
const [range, setRange] = useState<RangeKey>('last15');
|
||||||
@@ -74,12 +81,12 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setDays([]);
|
setDays([]);
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetchVehicleRecent(vehicle.plate, { start, end })
|
fetchVehicleRecent(vehicle.plate, { start, end, sourcePriority })
|
||||||
.then(d => { if (!cancelled) setDays(d.days); })
|
.then(d => { if (!cancelled) setDays(d.days); })
|
||||||
.catch(() => { if (!cancelled) setDays([]); })
|
.catch(() => { if (!cancelled) setDays([]); })
|
||||||
.finally(() => { if (!cancelled) setLoading(false); });
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [vehicle?.plate, range]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [vehicle?.plate, range, sourcePriority]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// 锁滚动
|
// 锁滚动
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -280,7 +287,21 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) {
|
|||||||
transition={{ delay: Math.min(i * 0.012, 0.4), duration: 0.18 }}
|
transition={{ delay: Math.min(i * 0.012, 0.4), duration: 0.18 }}
|
||||||
className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-slate-50"
|
className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-slate-50"
|
||||||
>
|
>
|
||||||
<span className="text-[11px] font-mono font-bold text-slate-600">{d.date}</span>
|
<div className="w-[88px] flex-shrink-0">
|
||||||
|
<div className="text-[11px] font-mono font-bold text-slate-600">{d.date}</div>
|
||||||
|
<div
|
||||||
|
className={`text-[8px] font-bold ${
|
||||||
|
d.sourceCategory === 'INSTRUMENT'
|
||||||
|
? 'text-violet-500'
|
||||||
|
: d.sourceCategory === 'GPS'
|
||||||
|
? 'text-emerald-500'
|
||||||
|
: 'text-amber-500'
|
||||||
|
}`}
|
||||||
|
title={d.sourceProtocol || 'OneOS 当前未返回 sourceProtocol'}
|
||||||
|
>
|
||||||
|
{daySourceLabel(d)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-1 ml-3">
|
<div className="flex items-center gap-2 flex-1 ml-3">
|
||||||
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -1,11 +1,33 @@
|
|||||||
import type { MonitoringData, TargetSummary, TargetVehicle, TrendPoint } from './types';
|
import type {
|
||||||
|
MileageSourceCategory,
|
||||||
|
MileageSourceGroup,
|
||||||
|
MileageSourceProtocol,
|
||||||
|
MonitoringData,
|
||||||
|
TargetSummary,
|
||||||
|
TargetVehicle,
|
||||||
|
TrendPoint,
|
||||||
|
} from './types';
|
||||||
import { fetchJson } from '../../auth/api-client';
|
import { fetchJson } from '../../auth/api-client';
|
||||||
|
import type {
|
||||||
|
DailyMileageReport,
|
||||||
|
MileageReportHistoryResponse,
|
||||||
|
} from '../../shared/mileage/daily-report';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
DailyMileageReport,
|
||||||
|
MileageReportBreakdown,
|
||||||
|
MileageReportGroup,
|
||||||
|
MileageReportHistoryItem,
|
||||||
|
MileageReportHistoryResponse,
|
||||||
|
MileageReportInsight,
|
||||||
|
MileageReportVehicle,
|
||||||
|
} from '../../shared/mileage/daily-report';
|
||||||
|
|
||||||
const BASE = '/api/mileage';
|
const BASE = '/api/mileage';
|
||||||
|
|
||||||
export async function fetchMonitoring(params?: {
|
export async function fetchMonitoring(params?: {
|
||||||
sortBy?: string;
|
sortBy?: 'today' | 'total' | 'statisticTime';
|
||||||
sortOrder?: string;
|
sortOrder?: 'asc' | 'desc';
|
||||||
limit?: number;
|
limit?: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
@@ -16,11 +38,16 @@ export async function fetchMonitoring(params?: {
|
|||||||
rentStatus?: string;
|
rentStatus?: string;
|
||||||
platePrefix?: string;
|
platePrefix?: string;
|
||||||
targetName?: string;
|
targetName?: string;
|
||||||
|
targetNames?: string[];
|
||||||
region?: string;
|
region?: string;
|
||||||
plate?: string;
|
plate?: string;
|
||||||
mileageMin?: string;
|
mileageMin?: string;
|
||||||
mileageMax?: string;
|
mileageMax?: string;
|
||||||
date?: string;
|
date?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
brands?: string[];
|
||||||
|
sourcePriority?: MileageSourceGroup[];
|
||||||
}): Promise<MonitoringData> {
|
}): Promise<MonitoringData> {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (params?.sortBy) query.set('sortBy', params.sortBy);
|
if (params?.sortBy) query.set('sortBy', params.sortBy);
|
||||||
@@ -35,11 +62,24 @@ export async function fetchMonitoring(params?: {
|
|||||||
if (params?.rentStatus) query.set('rentStatus', params.rentStatus);
|
if (params?.rentStatus) query.set('rentStatus', params.rentStatus);
|
||||||
if (params?.platePrefix) query.set('platePrefix', params.platePrefix);
|
if (params?.platePrefix) query.set('platePrefix', params.platePrefix);
|
||||||
if (params?.targetName) query.set('targetName', params.targetName);
|
if (params?.targetName) query.set('targetName', params.targetName);
|
||||||
|
if (params?.targetNames) {
|
||||||
|
params.targetNames.forEach(name => {
|
||||||
|
if (name) query.append('targetName', name);
|
||||||
|
});
|
||||||
|
}
|
||||||
if (params?.region) query.set('region', params.region);
|
if (params?.region) query.set('region', params.region);
|
||||||
|
if (params?.brands) {
|
||||||
|
params.brands.forEach(brand => {
|
||||||
|
if (brand) query.append('brand', brand);
|
||||||
|
});
|
||||||
|
}
|
||||||
if (params?.plate) query.set('plate', params.plate);
|
if (params?.plate) query.set('plate', params.plate);
|
||||||
if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
|
if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
|
||||||
if (params?.mileageMax) query.set('mileageMax', params.mileageMax);
|
if (params?.mileageMax) query.set('mileageMax', params.mileageMax);
|
||||||
if (params?.date) query.set('date', params.date);
|
if (params?.date) query.set('date', params.date);
|
||||||
|
if (params?.startDate) query.set('startDate', params.startDate);
|
||||||
|
if (params?.endDate) query.set('endDate', params.endDate);
|
||||||
|
if (params?.sourcePriority?.length) query.set('sourcePriority', params.sourcePriority.join(','));
|
||||||
const qs = query.toString();
|
const qs = query.toString();
|
||||||
return fetchJson<MonitoringData>(`${BASE}/monitoring${qs ? `?${qs}` : ''}`);
|
return fetchJson<MonitoringData>(`${BASE}/monitoring${qs ? `?${qs}` : ''}`);
|
||||||
}
|
}
|
||||||
@@ -66,6 +106,8 @@ export interface VehicleRecentDay {
|
|||||||
date: string;
|
date: string;
|
||||||
dailyKm: number;
|
dailyKm: number;
|
||||||
isDataSynced: boolean;
|
isDataSynced: boolean;
|
||||||
|
sourceProtocol: MileageSourceProtocol | null;
|
||||||
|
sourceCategory: Exclude<MileageSourceCategory, 'MIXED'> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VehicleRecentResponse {
|
export interface VehicleRecentResponse {
|
||||||
@@ -73,17 +115,32 @@ export interface VehicleRecentResponse {
|
|||||||
start?: string;
|
start?: string;
|
||||||
end?: string;
|
end?: string;
|
||||||
days: VehicleRecentDay[];
|
days: VehicleRecentDay[];
|
||||||
|
sourcePriority?: MileageSourceGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchVehicleRecent(
|
export async function fetchVehicleRecent(
|
||||||
plate: string,
|
plate: string,
|
||||||
range: { days?: number; start?: string; end?: string } = { days: 15 },
|
range: {
|
||||||
|
days?: number;
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
sourcePriority?: MileageSourceGroup[];
|
||||||
|
} = { days: 15 },
|
||||||
): Promise<VehicleRecentResponse> {
|
): Promise<VehicleRecentResponse> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (range.start) params.set('start', range.start);
|
if (range.start) params.set('start', range.start);
|
||||||
if (range.end) params.set('end', range.end);
|
if (range.end) params.set('end', range.end);
|
||||||
if (range.days != null) params.set('days', String(range.days));
|
if (range.days != null) params.set('days', String(range.days));
|
||||||
|
if (range.sourcePriority?.length) params.set('sourcePriority', range.sourcePriority.join(','));
|
||||||
return fetchJson<VehicleRecentResponse>(
|
return fetchJson<VehicleRecentResponse>(
|
||||||
`${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}`
|
`${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchDailyMileageReport(date: string): Promise<DailyMileageReport> {
|
||||||
|
return fetchJson<DailyMileageReport>(`${BASE}/reports?date=${encodeURIComponent(date)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDailyMileageReportHistory(limit = 90): Promise<MileageReportHistoryResponse> {
|
||||||
|
return fetchJson<MileageReportHistoryResponse>(`${BASE}/reports/history?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { fmtDate } from './utils';
|
||||||
|
|
||||||
|
export default function DailyReportLoadingOverlay({ reportDate }: { reportDate: string }) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.16, ease: 'easeOut' }}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950/20 p-5 backdrop-blur-[2px]"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 8, scale: 0.98 }}
|
||||||
|
transition={{ duration: 0.22, ease: 'easeOut' }}
|
||||||
|
className="w-full max-w-[260px] overflow-hidden rounded-xl border border-slate-100 bg-white p-5 text-center shadow-2xl"
|
||||||
|
>
|
||||||
|
<div className="relative mx-auto flex h-12 w-12 items-center justify-center">
|
||||||
|
<motion.span
|
||||||
|
className="absolute inset-0 rounded-full border-2 border-blue-100"
|
||||||
|
animate={{ scale: [0.86, 1.15], opacity: [0.9, 0] }}
|
||||||
|
transition={{ duration: 1.2, repeat: Infinity, ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
<span className="relative flex h-10 w-10 items-center justify-center rounded-full bg-blue-50 text-blue-600">
|
||||||
|
<RefreshCw size={19} className="animate-spin" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-sm font-black text-slate-900">正在加载日报</div>
|
||||||
|
<div className="mt-1 text-[11px] font-bold text-slate-400">正在汇总 {fmtDate(reportDate)} 的里程数据</div>
|
||||||
|
<div className="mt-4 h-1 overflow-hidden rounded-full bg-slate-100">
|
||||||
|
<motion.div
|
||||||
|
className="h-full w-1/2 rounded-full bg-blue-500"
|
||||||
|
animate={{ x: ['-100%', '220%'] }}
|
||||||
|
transition={{ duration: 1.15, repeat: Infinity, ease: 'easeInOut' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
CircleGauge,
|
||||||
|
MapPin,
|
||||||
|
Truck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { MileageReportGroup } from '../api';
|
||||||
|
import VehicleTable from './VehicleTable';
|
||||||
|
import {
|
||||||
|
currentAssessmentYearLabel,
|
||||||
|
dailyMileageStatus,
|
||||||
|
fmtKm,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
|
interface GroupRowProps {
|
||||||
|
group: MileageReportGroup;
|
||||||
|
reportDate: string;
|
||||||
|
expanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreakdownList({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
items,
|
||||||
|
showAverage = true,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
items: MileageReportGroup['departments'];
|
||||||
|
showAverage?: boolean;
|
||||||
|
}) {
|
||||||
|
const max = Math.max(1, ...items.map(item => item.vehicleCount));
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-3 flex items-center gap-2 text-xs font-black text-slate-700">
|
||||||
|
<span className="text-slate-400">{icon}</span>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="rounded-lg bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400">暂无数据</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{items.map(item => (
|
||||||
|
<div key={item.name}>
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-3 text-[11px] font-bold">
|
||||||
|
<span className="truncate text-slate-600">{item.name}</span>
|
||||||
|
<span className="shrink-0 tabular-nums text-slate-500">
|
||||||
|
{item.vehicleCount} 台{showAverage ? ` · 均 ${fmtKm(item.averageMileage)} km` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 overflow-hidden rounded-full bg-slate-100">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-blue-500"
|
||||||
|
style={{ width: `${Math.max(4, (item.vehicleCount / max) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GroupRow({
|
||||||
|
group,
|
||||||
|
reportDate,
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
}: GroupRowProps) {
|
||||||
|
const highRate = group.operatingCount > 0 ? (group.highMileageCount / group.operatingCount) * 100 : 0;
|
||||||
|
const qualifiedRate = group.vehicleCount > 0 ? (group.qualifiedCount / group.vehicleCount) * 100 : 0;
|
||||||
|
const assessmentYearLabel = currentAssessmentYearLabel(group.assessmentPeriods, reportDate);
|
||||||
|
const dailyStatus = dailyMileageStatus(group);
|
||||||
|
const dailyStatusClass = dailyStatus.tone === 'positive'
|
||||||
|
? 'border-emerald-100 bg-emerald-50/70 text-emerald-700'
|
||||||
|
: dailyStatus.tone === 'warning'
|
||||||
|
? 'border-amber-100 bg-amber-50/70 text-amber-700'
|
||||||
|
: 'border-slate-100 bg-slate-50 text-slate-600';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-slate-100 last:border-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="grid w-full grid-cols-[minmax(170px,1.3fr)_82px_100px_88px_100px_96px_24px] items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-slate-50 max-lg:grid-cols-[minmax(150px,1fr)_82px_96px_24px]"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<span className="truncate text-xs font-black text-slate-900">{group.displayName}</span>
|
||||||
|
{assessmentYearLabel ? (
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded-md bg-blue-50 px-1.5 py-0.5 text-[9px] font-black text-blue-700 ring-1 ring-blue-100"
|
||||||
|
title={`当前考核年度:${assessmentYearLabel}`}
|
||||||
|
>
|
||||||
|
{assessmentYearLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 flex flex-wrap items-center gap-x-1 text-[10px] font-bold text-slate-400">
|
||||||
|
<span>{group.vehicleCount} 台 · 运营 {group.operatingCount} · 库存 {group.inventoryCount}</span>
|
||||||
|
<span className="text-emerald-600">· 达标 {group.qualifiedCount} 台</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-right">
|
||||||
|
<span className="block text-xs font-black tabular-nums text-blue-600">{group.dailyMileage}</span>
|
||||||
|
<span className="text-[9px] font-bold text-slate-400">km</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-right max-lg:hidden">
|
||||||
|
<span className={`block text-xs font-black tabular-nums ${group.dayOverDayDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||||
|
{group.dayOverDayDelta >= 0 ? '+' : ''}{fmtKm(group.dayOverDayDelta)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] font-bold text-slate-400">环比变化</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-right max-lg:hidden">
|
||||||
|
<span className="block text-xs font-black tabular-nums text-slate-700">{group.lowMileageCount}/{group.highMileageCount}</span>
|
||||||
|
<span className="text-[9px] font-bold text-slate-400">低/高里程</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-right">
|
||||||
|
<span className="block text-xs font-black tabular-nums text-slate-700">{highRate.toFixed(1)}%</span>
|
||||||
|
<span className="text-[9px] font-bold text-slate-400">高里程占比</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-right max-lg:hidden">
|
||||||
|
<span className="block text-xs font-black tabular-nums text-slate-700">{group.qualifiedCount}</span>
|
||||||
|
<span className="text-[9px] font-bold text-slate-400">年度达标</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDown size={16} className={`text-slate-400 transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{expanded ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="bg-slate-50/70 px-4 py-4">
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[1fr_1fr_1.1fr]">
|
||||||
|
<BreakdownList title="业务部门" icon={<Truck size={15} />} items={group.departments} />
|
||||||
|
<BreakdownList title="库存地区" icon={<MapPin size={15} />} items={group.inventoryRegions} showAverage={false} />
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex items-center gap-2 text-xs font-black text-slate-700">
|
||||||
|
<CircleGauge size={15} className="text-slate-400" />考核进度
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="rounded-lg border border-slate-100 bg-white p-3">
|
||||||
|
<div className="text-[10px] font-bold text-slate-400">累计完成率</div>
|
||||||
|
<div className="mt-1 text-base font-black tabular-nums text-slate-900">{group.completionRate.toFixed(1)}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-slate-100 bg-white p-3">
|
||||||
|
<div className="text-[10px] font-bold text-slate-400">达标50%</div>
|
||||||
|
<div className="mt-1 text-base font-black tabular-nums text-slate-900">{group.halfQualifiedCount} 台</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-slate-100 bg-white p-3">
|
||||||
|
<div className="text-[10px] font-bold text-slate-400">剩余任务</div>
|
||||||
|
<div className="mt-1 text-base font-black tabular-nums text-slate-900">{fmtKm(group.remainingMileage)} km</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-slate-100 bg-white p-3">
|
||||||
|
<div className="text-[10px] font-bold text-slate-400">每日应完成</div>
|
||||||
|
<div className="mt-1 text-base font-black tabular-nums text-blue-600">{fmtKm(group.dailyRequiredMileage)} km</div>
|
||||||
|
</div>
|
||||||
|
<div className={`col-span-2 flex flex-wrap items-end justify-between gap-x-4 gap-y-2 rounded-lg border p-3 ${dailyStatusClass}`}>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-black">当日目标执行 · {dailyStatus.label}</div>
|
||||||
|
<div className="mt-1 text-[10px] font-bold opacity-80">当日实际 {fmtKm(group.dailyMileage)} km / 应完成 {fmtKm(group.dailyRequiredMileage)} km</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-[260px] text-[10px] font-bold leading-relaxed opacity-80">{dailyStatus.detail}</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 flex items-end justify-between gap-3 rounded-lg border border-blue-100 bg-blue-50/50 p-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-bold text-slate-500">累计达标率</div>
|
||||||
|
<div className="mt-1 text-[10px] font-bold text-slate-500">已达标 {group.qualifiedCount} / {group.vehicleCount} 台</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-black tabular-nums text-blue-700">{qualifiedRate.toFixed(1)}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[10px] font-bold leading-relaxed text-slate-400">{group.assessmentPeriods.join(';')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<VehicleTable group={group} />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user