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
|
||||
dist
|
||||
.env
|
||||
.worktrees
|
||||
|
||||
+27
-4
@@ -5,14 +5,37 @@ services:
|
||||
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
|
||||
network_mode: host
|
||||
environment:
|
||||
DB_HOST: "47.101.148.99"
|
||||
DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com"
|
||||
DB_PORT: "3306"
|
||||
DB_USER: "root"
|
||||
DB_PASSWORD: "LN#Passw0rd@2026"
|
||||
DB_NAME: "lingniu_prod"
|
||||
DB_USER: "oneos_db_prod"
|
||||
DB_PASSWORD: "adASHJcviqwjkbn23ngt1"
|
||||
DB_NAME: "ln_asset_management"
|
||||
HYDROGEN_DB_HOST: "47.99.185.173"
|
||||
HYDROGEN_DB_PORT: "3306"
|
||||
HYDROGEN_DB_USER: "root"
|
||||
HYDROGEN_DB_PASSWORD: "lnMysql."
|
||||
HYDROGEN_DB_NAME: "ln_asset_management"
|
||||
MILEAGE_DB_HOST: "101.133.130.65"
|
||||
MILEAGE_DB_PORT: "3306"
|
||||
MILEAGE_DB_USER: "bi_reader_02"
|
||||
MILEAGE_DB_PASSWORD: "bi_reader_02_Pass"
|
||||
MILEAGE_DB_NAME: "hydrogen_energy"
|
||||
# ECS production accesses OneOS over the private network. Configure the key
|
||||
# as a Portainer stack environment variable; never commit it to this file.
|
||||
ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}"
|
||||
ONEOS_MILEAGE_API_KEY: "${ONEOS_MILEAGE_API_KEY}"
|
||||
ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}"
|
||||
SERVER_PORT: "8111"
|
||||
EXTERNAL_API_BASE: "https://lnh2e.com"
|
||||
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
|
||||
AMAP_WEB_KEY: "${AMAP_WEB_KEY}"
|
||||
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}"
|
||||
HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}"
|
||||
HEATMAP_DB_PORT: "${HEATMAP_DB_PORT:-36453}"
|
||||
HEATMAP_DB_USER: "${HEATMAP_DB_USER}"
|
||||
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}"
|
||||
HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}"
|
||||
HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}"
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
|
||||
@@ -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>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>羚牛氢能车辆资产</title>
|
||||
<title>羚牛氢能</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+160
-2
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "ln-bi",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ln-bi",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.14",
|
||||
"dependencies": {
|
||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"ali-oss": "^6.23.0",
|
||||
@@ -17,6 +18,7 @@
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"mysql2": "^3.11.0",
|
||||
"pg": "^8.22.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^3.8.1",
|
||||
@@ -27,6 +29,7 @@
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/ali-oss": "^6.23.3",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
@@ -36,6 +39,12 @@
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@amap/amap-jsapi-loader": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@amap/amap-jsapi-loader/-/amap-jsapi-loader-1.0.1.tgz",
|
||||
"integrity": "sha512-nPyLKt7Ow/ThHLkSvn2etQlUzqxmTVgK7bIgwdBRTg2HK5668oN7xVxkaiRe3YZEzGzfV2XgH5Jmu2T73ljejw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
@@ -1620,6 +1629,18 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
@@ -3521,6 +3542,95 @@
|
||||
"through": "~2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.15.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -3576,6 +3686,45 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
@@ -3969,6 +4118,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/sql-escaper": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
|
||||
|
||||
+7
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ln-bi",
|
||||
"private": true,
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.14",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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",
|
||||
"build": "vite build",
|
||||
"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": {
|
||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"ali-oss": "^6.23.0",
|
||||
@@ -21,6 +24,7 @@
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"mysql2": "^3.11.0",
|
||||
"pg": "^8.22.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^3.8.1",
|
||||
@@ -31,6 +35,7 @@
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/ali-oss": "^6.23.3",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
|
||||
@@ -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 { Truck, Route, Activity, Zap } from 'lucide-react';
|
||||
import { Shell, type ModuleConfig } from './components/Shell';
|
||||
import AssetsModule from './modules/assets/AssetsModule';
|
||||
import MileageModule from './modules/mileage/MileageModule';
|
||||
import SchedulingModule from './modules/scheduling/SchedulingModule';
|
||||
import EnergyModule from './modules/energy/EnergyModule';
|
||||
import EleImportPage from './modules/ele/EleImportPage';
|
||||
import FeedbackAdminPage from './modules/admin/FeedbackAdminPage';
|
||||
import AuthProvider from './auth/AuthProvider';
|
||||
import { useAuth } from './auth/useAuth';
|
||||
import UnauthorizedPage from './auth/UnauthorizedPage';
|
||||
import { canAccessScheduling, canAccessEnergy } from './shared/auth/roles';
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { Shell } from "./components/Shell";
|
||||
import AuthProvider from "./auth/AuthProvider";
|
||||
import { useAuth } from "./auth/useAuth";
|
||||
import UnauthorizedPage from "./auth/UnauthorizedPage";
|
||||
import { canAccessEnergy } from "./shared/auth/roles";
|
||||
import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface";
|
||||
import { buildModules } from "./app/modules";
|
||||
import {
|
||||
normalizeBrowserPath,
|
||||
readBrowserRoute,
|
||||
} from "./app/routing";
|
||||
|
||||
const BASE_MODULES: ModuleConfig[] = [
|
||||
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
||||
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
||||
];
|
||||
|
||||
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 '';
|
||||
}
|
||||
const EleImportPage = lazy(() => import("./modules/ele/EleImportPage"));
|
||||
const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage"));
|
||||
normalizeBrowserPath();
|
||||
|
||||
function AuthGate() {
|
||||
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(() => {
|
||||
const update = () => setRouteKey(getRouteKey());
|
||||
window.addEventListener('hashchange', update);
|
||||
window.addEventListener('popstate', update);
|
||||
const update = () => {
|
||||
setRoute(readBrowserRoute());
|
||||
};
|
||||
window.addEventListener("hashchange", update);
|
||||
window.addEventListener("popstate", update);
|
||||
return () => {
|
||||
window.removeEventListener('hashchange', update);
|
||||
window.removeEventListener('popstate', update);
|
||||
window.removeEventListener("hashchange", update);
|
||||
window.removeEventListener("popstate", update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const modules = useMemo(() => {
|
||||
const result = [...BASE_MODULES];
|
||||
if (canAccessEnergy(user?.roles)) result.push(ENERGY_MODULE);
|
||||
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
|
||||
return result;
|
||||
}, [user?.roles]);
|
||||
useEffect(() => {
|
||||
document.title = pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI";
|
||||
}, [pathSet]);
|
||||
|
||||
const modules = useMemo(
|
||||
() => buildModules(pathSet, user?.roles),
|
||||
[pathSet, user?.roles],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p className="text-xs text-slate-400 font-bold">正在验证身份...</p>
|
||||
<div className="min-h-screen bg-[var(--app-bg)] p-4 text-slate-800 md:p-8">
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-4">
|
||||
<div className="rounded-2xl border border-white/70 bg-white/86 p-5 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
|
||||
<div className="mb-3 text-[11px] font-black text-blue-600">LN BI ACCESS</div>
|
||||
<div className="text-xl font-black text-slate-950">正在进入羚牛氢能 BI</div>
|
||||
<div className="mt-2 text-xs font-bold text-slate-400">校验登录态、权限与本地开发环境配置</div>
|
||||
</div>
|
||||
<SurfaceCard className="p-4">
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
{[0, 1, 2, 3].map(item => <SkeletonBlock key={item} className="h-24" />)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<LoadingState label="正在验证身份" />
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -72,10 +69,28 @@ function AuthGate() {
|
||||
}
|
||||
|
||||
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
|
||||
if (routeKey === 'ele/import') return <EleImportPage />;
|
||||
if (routeKey === 'admin/feedback') return <FeedbackAdminPage />;
|
||||
if (routeKey === "ele/import") {
|
||||
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() {
|
||||
|
||||
@@ -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 { PageFrame, SurfaceCard } from '../components/ui/surface';
|
||||
|
||||
export default function UnauthorizedPage({ message }: { message?: string }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center p-6">
|
||||
<div className="text-center max-w-sm">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-slate-100 flex items-center justify-center">
|
||||
<ShieldX size={36} className="text-slate-400" />
|
||||
<PageFrame
|
||||
title="未授权访问"
|
||||
subtitle={message || '获取用户认证信息失败,可能是跳转令牌已过期或无效。'}
|
||||
icon={ShieldX}
|
||||
eyebrow="ACCESS CONTROL"
|
||||
meta="请从授权入口重新进入"
|
||||
maxWidth="max-w-xl"
|
||||
>
|
||||
<SurfaceCard className="p-4">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
|
||||
<ShieldX size={30} />
|
||||
</div>
|
||||
<h1 className="text-lg font-black text-slate-800 mb-2">未授权访问</h1>
|
||||
<p className="text-xs text-slate-400 mb-4">
|
||||
{message || '获取用户认证信息失败,可能是跳转令牌已过期或无效'}
|
||||
</p>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-slate-100 p-4 text-left space-y-3">
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider text-center">请通过以下方式进入</p>
|
||||
<p className="mb-4 text-[10px] font-bold uppercase tracking-wider text-slate-400">请通过以下方式进入</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
|
||||
<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" />
|
||||
@@ -31,7 +36,7 @@ export default function UnauthorizedPage({ message }: { message?: string }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
+36
-350
@@ -1,24 +1,15 @@
|
||||
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 { useAuth } from '../auth/useAuth';
|
||||
import { canManageFeedback } from '../shared/auth/roles';
|
||||
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_IMG_SIZE_MB = 5;
|
||||
|
||||
interface UploadedImg {
|
||||
url: string;
|
||||
thumbDataUrl: string;
|
||||
}
|
||||
|
||||
async function uploadImage(file: File): Promise<string> {
|
||||
const fd = new FormData();
|
||||
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 {
|
||||
const hash = (window.location.hash || '').slice(1);
|
||||
const path = window.location.pathname;
|
||||
@@ -94,7 +53,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = 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 [mod, setMod] = useState<string>('');
|
||||
const [content, setContent] = useState('');
|
||||
@@ -128,14 +87,12 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
// Open: detect current module
|
||||
useEffect(() => {
|
||||
if (open && step === 1) {
|
||||
setMod(moduleProp ?? detectModule());
|
||||
}
|
||||
}, [open, step, moduleProp]);
|
||||
|
||||
// Lock scroll when open
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
@@ -153,7 +110,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
setTimeout(reset, 300); // 等动画完
|
||||
setTimeout(reset, 300);
|
||||
};
|
||||
|
||||
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 progress = step >= 3 ? 100 : (step / 2) * 100;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating Action Button */}
|
||||
<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={() => 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)}
|
||||
<FeedbackLauncher
|
||||
isAdmin={isAdmin}
|
||||
menuOpen={menuOpen}
|
||||
onCloseMenu={() => setMenuOpen(false)}
|
||||
onOpenFeedback={() => { setMenuOpen(false); setOpen(true); }}
|
||||
onOpenHistory={() => { setMenuOpen(false); setHistoryOpen(true); }}
|
||||
onToggleMenu={() => setMenuOpen(current => !current)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FeedbackHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
||||
|
||||
{/* Modal */}
|
||||
<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()}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="flex justify-center pt-2.5 pb-1">
|
||||
<div className="w-10 h-1 rounded-full bg-slate-300" />
|
||||
</div>
|
||||
|
||||
{/* 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"
|
||||
<FeedbackModal
|
||||
canNext={canNext}
|
||||
content={content}
|
||||
error={error}
|
||||
fileRef={fileRef}
|
||||
maxScreenshots={MAX_SCREENSHOTS}
|
||||
module={mod}
|
||||
open={open}
|
||||
progress={progress}
|
||||
screenshots={shots}
|
||||
step={step}
|
||||
submitting={submitting}
|
||||
textareaRef={taRef}
|
||||
type={type}
|
||||
uploading={uploading}
|
||||
onAddFiles={addFiles}
|
||||
onBack={back}
|
||||
onChangeContent={setContent}
|
||||
onChangeModule={setMod}
|
||||
onClose={close}
|
||||
onNext={next}
|
||||
onRemoveScreenshot={(index) => setShots(prev => prev.filter((_, current) => current !== index))}
|
||||
onSelectType={(selectedType) => { setType(selectedType); setStep(2); }}
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+129
-47
@@ -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 { DemoModeProvider } from './Blur';
|
||||
import FeedbackFab from './FeedbackFab';
|
||||
import { cn } from '../lib/cn';
|
||||
import { LoadingState } from './ui/surface';
|
||||
|
||||
export interface ModuleConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ComponentType<{ size?: number; className?: string }>;
|
||||
component: ComponentType;
|
||||
component?: ElementType;
|
||||
children?: ModuleConfig[];
|
||||
}
|
||||
|
||||
/** path 到模块 id 的映射 */
|
||||
const PATH_MAP: Record<string, string> = {
|
||||
'/vehicle': 'assets',
|
||||
'/assets': 'assets',
|
||||
'/mileage': 'mileage',
|
||||
'/scheduling': 'scheduling',
|
||||
'/energy': 'energy',
|
||||
};
|
||||
function flattenModules(modules: ModuleConfig[]): ModuleConfig[] {
|
||||
return modules.flatMap((module) => module.children ?? [module]);
|
||||
}
|
||||
|
||||
/** hash 一级段(`#<id>` 或 `#<id>/<sub>` 都只取 id) */
|
||||
function getHashHead(): string {
|
||||
return window.location.hash.slice(1).split('/')[0];
|
||||
}
|
||||
|
||||
function getInitialModule(modules: ModuleConfig[]): string {
|
||||
// 优先看 hash
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (modules.some((m) => m.id === hash)) return hash;
|
||||
// 再看 pathname
|
||||
const pathModule = PATH_MAP[window.location.pathname];
|
||||
if (pathModule && modules.some((m) => m.id === pathModule)) return pathModule;
|
||||
// 默认第一个
|
||||
const head = getHashHead();
|
||||
if (modules.some((m) => m.id === head)) return head;
|
||||
return modules[0]?.id ?? '';
|
||||
}
|
||||
|
||||
function getHashModule(modules: ModuleConfig[]): string {
|
||||
const hash = window.location.hash.slice(1);
|
||||
return modules.some((m) => m.id === hash) ? hash : '';
|
||||
const head = getHashHead();
|
||||
return modules.some((m) => m.id === head) ? head : '';
|
||||
}
|
||||
|
||||
export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
const [activeModule, setActiveModule] = useState(() => getInitialModule(modules));
|
||||
const flatModules = useMemo(() => flattenModules(modules), [modules]);
|
||||
const [activeModule, setActiveModule] = useState(() => getInitialModule(flattenModules(modules)));
|
||||
const [openGroupId, setOpenGroupId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => {
|
||||
const h = getHashModule(modules);
|
||||
const h = getHashModule(flatModules);
|
||||
if (h) setActiveModule(h);
|
||||
};
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
return () => window.removeEventListener('hashchange', onHashChange);
|
||||
}, [modules]);
|
||||
}, [flatModules]);
|
||||
|
||||
useEffect(() => {
|
||||
// 同步 hash 到当前模块:使用 replaceState 避免产生多余的 history 记录,
|
||||
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出
|
||||
if (window.location.hash.slice(1) !== activeModule) {
|
||||
if (!openGroupId) return undefined;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpenGroupId(null);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [openGroupId]);
|
||||
|
||||
useEffect(() => {
|
||||
// 同步 hash 一段到当前模块:使用 replaceState 避免产生多余的 history 记录,
|
||||
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出。
|
||||
// 注意只比对一级段,避免把子模块写入的 `#<id>/<sub>` 二级段抹掉。
|
||||
if (getHashHead() !== activeModule) {
|
||||
const { pathname, search } = window.location;
|
||||
window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`);
|
||||
}
|
||||
}, [activeModule]);
|
||||
|
||||
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;
|
||||
window.history.replaceState(null, '', `${pathname}${search}#${id}`);
|
||||
setActiveModule(id);
|
||||
};
|
||||
|
||||
const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component;
|
||||
const ActiveComponent = flatModules.find((module) => module.id === activeModule)?.component ?? flatModules[0]?.component;
|
||||
|
||||
const { user } = useAuth();
|
||||
const activeLabel = flatModules.find((module) => module.id === activeModule)?.label ?? '业务看板';
|
||||
const watermarkText = useMemo(() => {
|
||||
const name = user?.userName || '未登录';
|
||||
const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-');
|
||||
@@ -74,7 +91,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
|
||||
return (
|
||||
<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="absolute inset-0" style={{
|
||||
@@ -83,49 +100,114 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
}} />
|
||||
</div>
|
||||
{/* 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">
|
||||
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-white text-blue-600 shadow-lg shadow-blue-950/20">
|
||||
<Building2 size={22} />
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col items-center gap-2">
|
||||
{modules.map((m) => {
|
||||
const Icon = m.icon;
|
||||
const 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 (
|
||||
<div key={m.id} className="relative">
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => switchModule(m.id)}
|
||||
className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)}
|
||||
aria-expanded={isGroup ? isOpen : undefined}
|
||||
aria-haspopup={isGroup ? 'menu' : undefined}
|
||||
className={cn(
|
||||
'group relative flex h-16 w-16 flex-col items-center justify-center rounded-2xl text-[10px] font-black transition-all',
|
||||
isActive ? 'text-white' : 'text-slate-400 hover:bg-white/8 hover:text-white',
|
||||
)}
|
||||
title={m.label}
|
||||
>
|
||||
<Icon size={22} />
|
||||
<span className="text-[10px] mt-1 leading-tight">{m.label}</span>
|
||||
{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>
|
||||
|
||||
{/* 内容区 */}
|
||||
<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' }}>
|
||||
<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} />
|
||||
</main>
|
||||
|
||||
{/* 移动端底部导航 (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) => {
|
||||
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 (
|
||||
<div key={m.id} className="relative flex justify-center">
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => switchModule(m.id)}
|
||||
className={`flex flex-col items-center ${isActive ? 'text-blue-600' : 'text-gray-400'}`}
|
||||
type="button"
|
||||
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)}
|
||||
aria-expanded={isGroup ? isOpen : undefined}
|
||||
aria-haspopup={isGroup ? 'menu' : undefined}
|
||||
className={cn('relative flex min-h-12 w-full flex-col items-center justify-center gap-0.5 rounded-2xl text-[10px] font-medium transition-colors', isActive ? 'text-blue-700' : 'text-slate-400')}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span className="text-[10px] mt-1">{m.label}</span>
|
||||
{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>
|
||||
</div>
|
||||
</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";
|
||||
|
||||
:root {
|
||||
--app-bg: #f4f7fb;
|
||||
--panel-bg: rgba(255, 255, 255, 0.9);
|
||||
--hairline: rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
html, body {
|
||||
overscroll-behavior: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
background: var(--app-bg);
|
||||
}
|
||||
|
||||
html {
|
||||
@@ -13,4 +20,79 @@ html {
|
||||
body {
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
color: #0f172a;
|
||||
background: #fff;
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
button, input, select {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.amap-logo,
|
||||
.amap-copyright {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.amap-toolbar {
|
||||
border: 1px solid #e2e8f0 !important;
|
||||
border-radius: 10px !important;
|
||||
box-shadow: 0 8px 24px rgb(15 23 42 / 10%) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.amap-logo,
|
||||
.amap-copyright {
|
||||
bottom: 60px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
@keyframes floatUp {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@utility animate-marquee {
|
||||
animation: marquee 30s linear infinite;
|
||||
}
|
||||
|
||||
@utility no-scrollbar {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.shimmer::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.72), transparent);
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
.enterprise-grid-bg {
|
||||
background:
|
||||
radial-gradient(circle at 16% 0%, rgba(59, 130, 246, 0.08), transparent 28%),
|
||||
radial-gradient(circle at 90% 12%, rgba(20, 184, 166, 0.08), transparent 26%),
|
||||
linear-gradient(180deg, #f8fbff 0%, var(--app-bg) 42%, #f7f9fc 100%);
|
||||
}
|
||||
|
||||
.asset-date-input::-webkit-calendar-picker-indicator {
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -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 { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react';
|
||||
import { fetchJson } from '../../auth/api-client';
|
||||
import { EmptyState, LoadingState, PageFrame, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
interface FeedbackItem {
|
||||
id: number;
|
||||
@@ -20,10 +21,10 @@ interface FeedbackItem {
|
||||
}
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
dimension: '💡 新维度',
|
||||
bug: '🐛 Bug',
|
||||
ux: '🎨 体验',
|
||||
other: '📝 其他',
|
||||
dimension: '新维度',
|
||||
bug: 'Bug',
|
||||
ux: '体验',
|
||||
other: '其他',
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [
|
||||
@@ -126,36 +127,36 @@ export default function FeedbackAdminPage() {
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] p-4 md:p-8">
|
||||
<div className="max-w-5xl mx-auto space-y-4">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<PageFrame
|
||||
title="用户反馈管理"
|
||||
subtitle="查看、回复、跟进用户提交的建议,形成从问题发现到处理闭环的运营后台。"
|
||||
icon={Inbox}
|
||||
eyebrow="FEEDBACK OPS"
|
||||
meta={`当前 ${items.length} 条反馈`}
|
||||
actions={(
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
// 优先 history.back(来自 SPA 内部跳转);否则回到主页
|
||||
if (window.history.length > 1) window.history.back();
|
||||
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="返回"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</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">
|
||||
<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="刷新">
|
||||
<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="刷新">
|
||||
<RotateCcw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</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
|
||||
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'}`}
|
||||
@@ -170,6 +171,7 @@ export default function FeedbackAdminPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
|
||||
{error && (
|
||||
<div className="bg-rose-50 border border-rose-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-rose-600">
|
||||
@@ -192,9 +194,9 @@ export default function FeedbackAdminPage() {
|
||||
{/* 列表 */}
|
||||
<div className="space-y-2">
|
||||
{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 ? (
|
||||
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold">还没有反馈</div>
|
||||
<EmptyState title="还没有反馈" description="新的用户反馈会出现在这里" />
|
||||
) : items.map(it => {
|
||||
const shots = parseScreenshots(it.screenshots);
|
||||
const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status);
|
||||
@@ -218,7 +220,7 @@ export default function FeedbackAdminPage() {
|
||||
{(shots.length > 0 || it.contact) && (
|
||||
<div className="flex items-center gap-3 mt-2 text-[10px] text-slate-400 font-bold">
|
||||
{shots.length > 0 && <span className="flex items-center gap-0.5"><ImageIcon size={11} />{shots.length} 张</span>}
|
||||
{it.contact && <span>📞 {it.contact}</span>}
|
||||
{it.contact && <span>联系 {it.contact}</span>}
|
||||
</div>
|
||||
)}
|
||||
{it.reply_content && (
|
||||
@@ -236,8 +238,6 @@ export default function FeedbackAdminPage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 详情 / 回复弹窗 */}
|
||||
<AnimatePresence>
|
||||
{active && (
|
||||
@@ -336,6 +336,6 @@ export default function FeedbackAdminPage() {
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
+248
-1390
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,43 @@ export interface WeeklyDetailItem {
|
||||
customer_name: string | null;
|
||||
}
|
||||
|
||||
export type FlowType = 'delivered' | 'returned' | 'replaced';
|
||||
|
||||
export interface FlowDailyPoint {
|
||||
date: string;
|
||||
delivered: number;
|
||||
returned: number;
|
||||
replaced: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface FlowDetailItem {
|
||||
id: string;
|
||||
type: FlowType;
|
||||
typeLabel: string;
|
||||
date: string;
|
||||
truckId: string;
|
||||
plateNumber: string;
|
||||
eventTime: string | null;
|
||||
submitTime: string | null;
|
||||
department: string;
|
||||
manager: string;
|
||||
customerName: string | null;
|
||||
}
|
||||
|
||||
export interface FlowStatsResponse {
|
||||
start: string;
|
||||
end: string;
|
||||
daily: FlowDailyPoint[];
|
||||
totals: {
|
||||
delivered: number;
|
||||
returned: number;
|
||||
replaced: number;
|
||||
total: number;
|
||||
};
|
||||
details: FlowDetailItem[];
|
||||
}
|
||||
|
||||
export async function fetchDeptStats(subject?: string | null): Promise<DeptGroup[]> {
|
||||
return fetchJson<DeptGroup[]>(withSubject(`${BASE}/dept-stats`, subject));
|
||||
}
|
||||
@@ -125,3 +162,13 @@ export async function fetchWeeklyDetail(
|
||||
if (filters?.source) params.set('source', filters.source);
|
||||
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function fetchFlowStats(params: {
|
||||
start: string;
|
||||
end: string;
|
||||
subject?: string | null;
|
||||
}): Promise<FlowStatsResponse> {
|
||||
const query = new URLSearchParams({ start: params.start, end: params.end });
|
||||
if (params.subject) query.set('subject', params.subject);
|
||||
return fetchJson<FlowStatsResponse>(`${BASE}/flow-stats?${query.toString()}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
total: number;
|
||||
inventory: number;
|
||||
abnormal: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
operating: number;
|
||||
@@ -36,6 +37,7 @@ export interface ModelSummary {
|
||||
model: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
abnormal: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
operating: number;
|
||||
@@ -49,6 +51,7 @@ export interface TypeSummary {
|
||||
type: string;
|
||||
totalAssets: number;
|
||||
totalInventory: number;
|
||||
totalAbnormal: number;
|
||||
totalOperating: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
@@ -62,6 +65,7 @@ export interface BatchGroup {
|
||||
batch: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
abnormal: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
operating: number;
|
||||
@@ -73,6 +77,7 @@ export interface BatchGroup {
|
||||
type: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
abnormal: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
operating: number;
|
||||
@@ -106,6 +111,7 @@ export interface VehicleListItem {
|
||||
city: string | null;
|
||||
status: string;
|
||||
ownership: string;
|
||||
rentCompany?: string | null;
|
||||
contractNo: string | null;
|
||||
customerName: string | null;
|
||||
subjectOrg: string | null;
|
||||
@@ -152,6 +158,7 @@ export interface RegionTypeBreakdown {
|
||||
total: number;
|
||||
operating: number;
|
||||
inventory: number;
|
||||
abnormal: number;
|
||||
pending: number;
|
||||
customers: string[];
|
||||
}
|
||||
|
||||
@@ -1,63 +1,28 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
Upload, FileSpreadsheet, RotateCcw, CheckCircle2, AlertCircle,
|
||||
Truck, ExternalLink, Layers, Zap, ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { ArrowLeft, Upload, Zap } from 'lucide-react';
|
||||
import { fetchJson } from '../../auth/api-client';
|
||||
import { useAuth } from '../../auth/useAuth';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
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 {
|
||||
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> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
@@ -82,7 +47,7 @@ export default function EleImportPage() {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [overall, setOverall] = useState<OverallRow[]>([]);
|
||||
const [batches, setBatches] = useState<BatchRow[]>([]);
|
||||
const [filter, setFilter] = useState<'' | 'internal' | 'external'>('');
|
||||
const [filter, setFilter] = useState<VehicleKindFilter>('');
|
||||
const [batchFilter, setBatchFilter] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
@@ -109,13 +74,13 @@ export default function EleImportPage() {
|
||||
reload().catch(e => console.error(e));
|
||||
}, [reload]);
|
||||
|
||||
const handleUpload = async (f: File) => {
|
||||
const handleUpload = async (selectedFile: File) => {
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await uploadFile(f);
|
||||
setResult(r);
|
||||
const nextResult = await uploadFile(selectedFile);
|
||||
setResult(nextResult);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
@@ -124,269 +89,87 @@ export default function EleImportPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const onPick = (f: File | null) => {
|
||||
setFile(f);
|
||||
if (f) handleUpload(f);
|
||||
const onPick = (selectedFile: File | null) => {
|
||||
setFile(selectedFile);
|
||||
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 (
|
||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 p-4 md:p-8">
|
||||
<div className="max-w-6xl mx-auto space-y-4">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<PageFrame
|
||||
title="充电记录导入"
|
||||
subtitle="每日上传 xlsx,按订单编号去重,并自动匹配内部/外部车辆归属。"
|
||||
icon={Zap}
|
||||
eyebrow="ELECTRIC IMPORT"
|
||||
meta={user?.userName || '导入工作台'}
|
||||
actions={(
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.history.length > 1) window.history.back();
|
||||
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="返回"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center flex-shrink-0">
|
||||
<Zap size={18} className="text-white" />
|
||||
<button
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-xl bg-slate-900 px-3 text-xs font-black text-white shadow-sm transition-colors hover:bg-slate-800"
|
||||
>
|
||||
<Upload size={14} />
|
||||
上传文件
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-black text-slate-900 leading-tight">充电记录导入</h1>
|
||||
<p className="text-[11px] font-bold text-slate-400">每日上传 xlsx · 订单编号去重 · 系统车辆自动匹配</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-slate-400 flex-shrink-0">{user?.userName || ''}</span>
|
||||
</header>
|
||||
|
||||
{/* 上传区 */}
|
||||
<section
|
||||
)}
|
||||
>
|
||||
<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 f = e.dataTransfer.files?.[0];
|
||||
if (f) onPick(f);
|
||||
const droppedFile = e.dataTransfer.files?.[0];
|
||||
if (droppedFile) onPick(droppedFile);
|
||||
}}
|
||||
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)}
|
||||
onInputChange={(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>
|
||||
|
||||
{/* 上传结果提示 */}
|
||||
<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>
|
||||
|
||||
{/* 聚合卡 */}
|
||||
<section className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
<KpiCard icon={<Layers size={14} />} label="总记录" value={totalRecords.toLocaleString()} />
|
||||
<KpiCard icon={<Truck size={14} />} label="内部记录" value={(overallMap.get('internal')?.records ?? 0).toLocaleString()} accent="blue" />
|
||||
<KpiCard icon={<ExternalLink size={14} />} label="外部记录" value={(overallMap.get('external')?.records ?? 0).toLocaleString()} accent="amber" />
|
||||
<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>
|
||||
|
||||
{/* 批次 */}
|
||||
{batches.length > 0 && (
|
||||
<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={() => setBatchFilter('')} 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(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>
|
||||
)}
|
||||
|
||||
{/* 列表 */}
|
||||
<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"
|
||||
<UploadFeedback
|
||||
result={result}
|
||||
error={error}
|
||||
onCloseResult={() => setResult(null)}
|
||||
onCloseError={() => setError(null)}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<SummaryKpis overall={overall} />
|
||||
|
||||
<BatchTable
|
||||
batches={batches}
|
||||
batchFilter={batchFilter}
|
||||
onClearFilter={() => setBatchFilter('')}
|
||||
onSelectBatch={(batchId) => setBatchFilter(batchFilter === batchId ? '' : batchId)}
|
||||
/>
|
||||
|
||||
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
<RecordFilters
|
||||
total={total}
|
||||
searchInput={searchInput}
|
||||
filter={filter}
|
||||
onSearchInputChange={(e) => setSearchInput(e.target.value)}
|
||||
onSearchKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput); }}
|
||||
onFilterChange={setFilter}
|
||||
/>
|
||||
<RecordTable items={items} />
|
||||
</section>
|
||||
|
||||
<RotatingFooterHint className="pb-4" />
|
||||
</div>
|
||||
<FeedbackFab module="ele" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { ChevronRight, Plug } from 'lucide-react';
|
||||
import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Wallet } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import TrendBadge from './TrendBadge';
|
||||
import { fetchElectricMonthly } from './api';
|
||||
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
|
||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||
{ id: 'thisWeek', label: '本周' },
|
||||
{ id: 'thisMonth', label: '本月' },
|
||||
{ id: 'last15', label: '近 15 天' },
|
||||
];
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile } from '../../components/ui/surface';
|
||||
import DailyRangeControls from './daily-range/DailyRangeControls';
|
||||
import {
|
||||
getQuickRange,
|
||||
getRangeModeLabel,
|
||||
normalizeRange,
|
||||
type RangeMode,
|
||||
} from './daily-range/model';
|
||||
import { summarizeElectricMonths } from './electric-daily/model';
|
||||
|
||||
export default function ElectricDaily() {
|
||||
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 [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
fetchElectricMonthly(customer, pick)
|
||||
const query = pick === 'custom'
|
||||
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
||||
: { range: pick };
|
||||
fetchElectricMonthly(customer, query)
|
||||
.then(m => {
|
||||
if (cancelled) return;
|
||||
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)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [customer, pick]);
|
||||
}, [customer, pick, effectiveRange.start, effectiveRange.end]);
|
||||
|
||||
const toggleMonth = (m: string) => setOpenMonths(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -39,41 +48,53 @@ export default function ElectricDaily() {
|
||||
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 applyQuickPick = (nextPick: DateQuickPick) => {
|
||||
setPick(nextPick);
|
||||
setDateRange(getQuickRange(nextPick));
|
||||
};
|
||||
|
||||
const updateDateRange = (field: 'start' | 'end', value: string) => {
|
||||
if (!value) return;
|
||||
setPick('custom');
|
||||
setDateRange(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* 日期速选 */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
|
||||
{QUICK_PICK_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => setPick(opt.id)}
|
||||
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
|
||||
pick === opt.id
|
||||
? 'bg-blue-50 text-blue-600 border-blue-200'
|
||||
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<DailyRangeControls
|
||||
pick={pick}
|
||||
dateRange={dateRange}
|
||||
customer={customer}
|
||||
onQuickPick={applyQuickPick}
|
||||
onCustomPick={() => setPick('custom')}
|
||||
onDateRangeChange={updateDateRange}
|
||||
onCustomerChange={setCustomer}
|
||||
/>
|
||||
|
||||
{/* 客户类型 */}
|
||||
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
|
||||
{(['lingniu', 'external'] as const).map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCustomer(c)}
|
||||
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
|
||||
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{c === 'external' ? '外部车辆' : '羚牛车辆'}
|
||||
</button>
|
||||
))}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile icon={BatteryCharging} label={`${scopeLabel}充电量`} value={totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={rangeText} />
|
||||
<MetricTile
|
||||
icon={Wallet}
|
||||
label="充电费用"
|
||||
value={hasFeeDetail ? `¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '待同步'}
|
||||
helper={hasFeeDetail ? `均价 ${avgPrice.toFixed(2)} 元/度` : '当前明细仅返回充电量'}
|
||||
tone={hasFeeDetail ? 'emerald' : 'slate'}
|
||||
/>
|
||||
<MetricTile icon={CalendarDays} label="有效天数" value={`${activeDays}`} unit="天" helper={`日均 ${avgKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} tone="amber" />
|
||||
<MetricTile icon={TrendingUp} label="波动提醒" value={abnormalDays} unit="天" helper="环比超过 30% 标记" tone={abnormalDays > 0 ? 'rose' : 'slate'} />
|
||||
</div>
|
||||
|
||||
{/* 外部车辆 数据未就绪 */}
|
||||
@@ -106,11 +127,11 @@ export default function ElectricDaily() {
|
||||
<span className="text-right">环比</span>
|
||||
</div>
|
||||
{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 ? (
|
||||
<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 ? (
|
||||
<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 => {
|
||||
const open = openMonths.has(m.month);
|
||||
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 { Wallet, CalendarClock } from 'lucide-react';
|
||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
|
||||
import { BatteryCharging, Gauge, Wallet, CalendarClock } from 'lucide-react';
|
||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
|
||||
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
function fmtYuan(yuan: number) {
|
||||
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
||||
@@ -24,10 +25,10 @@ export default function ElectricOverview() {
|
||||
}, []);
|
||||
|
||||
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) {
|
||||
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 trendData = data.trend;
|
||||
@@ -37,6 +38,12 @@ export default function ElectricOverview() {
|
||||
const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
|
||||
? `${trendMonthLabel} 每日充电`
|
||||
: '本月每日充电';
|
||||
const activeDays = trendData.filter(item => item.kwh > 0).length;
|
||||
const avgDailyKwh = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.kwh, 0) / activeDays : 0;
|
||||
const avgDailyFee = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.fee, 0) / activeDays : 0;
|
||||
const peakDay = trendData.reduce<typeof trendData[number] | null>((best, item) => (!best || item.kwh > best.kwh ? item : best), null);
|
||||
const avgPrice = k.totalKwh > 0 ? k.totalFee / k.totalKwh : 0;
|
||||
const monthPrice = k.monthKwh > 0 ? k.monthFee / k.monthKwh : 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -44,28 +51,36 @@ export default function ElectricOverview() {
|
||||
龙王路停车场充电站,期初 2025-01-01,手工导入每日更新
|
||||
</div>
|
||||
{/* 横向 mini KPI 头 */}
|
||||
<div className="grid grid-cols-2 gap-2 md:gap-3">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
|
||||
<Wallet size={11} className="text-blue-600" />累计
|
||||
</div>
|
||||
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.totalFee)}</div>
|
||||
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.totalKwh)}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
|
||||
<CalendarClock size={11} className="text-blue-600" />本月
|
||||
</div>
|
||||
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.monthFee)}</div>
|
||||
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.monthKwh)}</div>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile icon={Wallet} label="累计充电费" value={fmtYuan(k.totalFee)} helper={fmtKwh(k.totalKwh)} />
|
||||
<MetricTile icon={CalendarClock} label="本月充电费" value={fmtYuan(k.monthFee)} helper={fmtKwh(k.monthKwh)} tone="emerald" />
|
||||
<MetricTile icon={Gauge} label="累计均价" value={avgPrice.toFixed(2)} unit="元/度" helper={`本月 ${monthPrice.toFixed(2)} 元/度`} tone="amber" />
|
||||
<MetricTile icon={BatteryCharging} label="今日充电" value={k.todayKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={`${fmtYuan(k.todayFee)} · ${k.todayChainPct >= 0 ? '+' : ''}${(k.todayChainPct * 100).toFixed(1)}%`} tone={Math.abs(k.todayChainPct) >= 0.3 ? 'rose' : 'slate'} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<SurfaceCard className="p-3">
|
||||
<div className="text-[11px] font-black text-slate-400">有效充电日</div>
|
||||
<div className="mt-1 text-xl font-black text-slate-900">{activeDays}<span className="ml-1 text-[11px] text-slate-400">天</span></div>
|
||||
<div className="mt-1 text-[11px] font-bold text-slate-500">日均 {avgDailyKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度</div>
|
||||
</SurfaceCard>
|
||||
<SurfaceCard className="p-3">
|
||||
<div className="text-[11px] font-black text-slate-400">峰值日</div>
|
||||
<div className="mt-1 text-xl font-black text-blue-600">{peakDay ? peakDay.date.slice(5) : '—'}</div>
|
||||
<div className="mt-1 text-[11px] font-bold text-slate-500">{peakDay ? `${peakDay.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度 · ${fmtYuan(peakDay.fee)}` : '暂无数据'}</div>
|
||||
</SurfaceCard>
|
||||
<SurfaceCard className="p-3">
|
||||
<div className="text-[11px] font-black text-slate-400">月度占比</div>
|
||||
<div className="mt-1 text-xl font-black text-emerald-600">{k.totalFee > 0 ? (k.monthFee / k.totalFee * 100).toFixed(1) : '0.0'}%</div>
|
||||
<div className="mt-1 text-[11px] font-bold text-slate-500">本月费用 / 累计费用</div>
|
||||
</SurfaceCard>
|
||||
</div>
|
||||
|
||||
{/* 本月每日充电柱图 */}
|
||||
<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">
|
||||
<span className="text-sm font-bold text-slate-700">{chartTitle}</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 元</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">时间单位:日 · 单位 元 · 均线为日均费用</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||
@@ -85,6 +100,14 @@ export default function ElectricOverview() {
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
|
||||
/>
|
||||
{avgDailyFee > 0 && (
|
||||
<ReferenceLine
|
||||
y={avgDailyFee}
|
||||
stroke="#f59e0b"
|
||||
strokeDasharray="4 4"
|
||||
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
|
||||
/>
|
||||
)}
|
||||
<Bar dataKey="fee" radius={[4, 4, 0, 0]}>
|
||||
{trendData.map((_, i) => (
|
||||
<Cell key={i} fill="url(#electricBarGrad)" />
|
||||
@@ -98,7 +121,7 @@ export default function ElectricOverview() {
|
||||
</defs>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
<RotatingFooterHint />
|
||||
</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 { ChevronRight, Plug } from 'lucide-react';
|
||||
import { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-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 { fetchHydrogenDaily } from './api';
|
||||
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';
|
||||
|
||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||
{ id: 'thisWeek', label: '本周' },
|
||||
{ id: 'thisMonth', label: '本月' },
|
||||
{ id: 'last15', label: '近 15 天' },
|
||||
];
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
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 [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
fetchHydrogenDaily(pick, customer)
|
||||
const query = pick === 'custom'
|
||||
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
||||
: { range: pick };
|
||||
fetchHydrogenDaily(query, customer)
|
||||
.then(r => { if (!cancelled) setRows(r); })
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [pick, customer]);
|
||||
}, [pick, customer, effectiveRange.start, effectiveRange.end]);
|
||||
|
||||
// 柱图:按日期升序,用于"从左到右时间流"
|
||||
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
|
||||
const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0);
|
||||
const {
|
||||
trendData,
|
||||
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 next = new Set(prev);
|
||||
@@ -39,38 +57,34 @@ export default function HydrogenDaily() {
|
||||
return next;
|
||||
});
|
||||
|
||||
const applyQuickPick = (nextPick: DateQuickPick) => {
|
||||
setPick(nextPick);
|
||||
setDateRange(getQuickRange(nextPick));
|
||||
};
|
||||
|
||||
const updateDateRange = (field: 'start' | 'end', value: string) => {
|
||||
if (!value) return;
|
||||
setPick('custom');
|
||||
setDateRange(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* 日期速选 */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
|
||||
{QUICK_PICK_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => setPick(opt.id)}
|
||||
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
|
||||
pick === opt.id
|
||||
? 'bg-blue-50 text-blue-600 border-blue-200'
|
||||
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<DailyRangeControls
|
||||
pick={pick}
|
||||
dateRange={dateRange}
|
||||
customer={customer}
|
||||
onQuickPick={applyQuickPick}
|
||||
onCustomPick={() => setPick('custom')}
|
||||
onDateRangeChange={updateDateRange}
|
||||
onCustomerChange={setCustomer}
|
||||
/>
|
||||
|
||||
{/* 客户类型 segmented */}
|
||||
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
|
||||
{(['lingniu', 'external'] as const).map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCustomer(c)}
|
||||
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
|
||||
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{c === 'external' ? '外部车辆' : '羚牛车辆'}
|
||||
</button>
|
||||
))}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
|
||||
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
|
||||
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
|
||||
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
|
||||
</div>
|
||||
|
||||
{/* 外部车辆:新系统数据还没准备好 */}
|
||||
@@ -96,13 +110,34 @@ export default function HydrogenDaily() {
|
||||
|
||||
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
|
||||
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<SurfaceCard>
|
||||
<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-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">时间单位:日 · 单位 Kg</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||
<div className="mx-4 mb-2 grid grid-cols-3 gap-2 rounded-xl bg-slate-50 p-2">
|
||||
<div>
|
||||
<div className="text-[10px] font-black text-slate-400">峰值日</div>
|
||||
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
|
||||
{peakDay ? `${peakDay.date.slice(5)} · ${peakDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-black text-slate-400">低谷日</div>
|
||||
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
|
||||
{lowDay ? `${lowDay.date.slice(5)} · ${lowDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-black text-slate-400">零数据日</div>
|
||||
<div className={`mt-0.5 text-[11px] font-black ${zeroDays > 0 ? 'text-amber-600' : 'text-emerald-600'}`}>
|
||||
{zeroDays} 天
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[180px] min-w-0 px-2 pb-2">
|
||||
<ResponsiveContainer width="100%" height={180} minWidth={0}>
|
||||
<BarChart data={trendData} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(v: string) => v.slice(5)}
|
||||
@@ -112,13 +147,27 @@ export default function HydrogenDaily() {
|
||||
interval="preserveStartEnd"
|
||||
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
|
||||
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']}
|
||||
labelFormatter={(d) => `日期 ${d}`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
|
||||
/>
|
||||
{avgKg > 0 && (
|
||||
<ReferenceLine
|
||||
y={avgKg}
|
||||
stroke="#f59e0b"
|
||||
strokeDasharray="4 4"
|
||||
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
|
||||
/>
|
||||
)}
|
||||
<Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
|
||||
{trendData.map((_, i) => (
|
||||
<Cell key={i} fill="url(#hydrogenBarGrad)" />
|
||||
@@ -133,6 +182,7 @@ export default function HydrogenDaily() {
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
)}
|
||||
|
||||
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
|
||||
@@ -154,11 +204,11 @@ export default function HydrogenDaily() {
|
||||
</div>
|
||||
{/* 主行 + 子行 */}
|
||||
{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 ? (
|
||||
<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 ? (
|
||||
<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 => {
|
||||
const open = expanded.has(r.date);
|
||||
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 {
|
||||
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 { 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() {
|
||||
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -124,518 +51,56 @@ export default function HydrogenOverview() {
|
||||
if (!data) {
|
||||
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 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';
|
||||
|
||||
// 月度收支组合数据(推算"年内每月"图)
|
||||
const monthlyDual = monthly.map(m => ({
|
||||
...m,
|
||||
monthLabel: m.month.slice(5).replace(/^0/, '') + '月',
|
||||
}));
|
||||
const { kpi, top5, regions, monthly, customers, stations, availableYears, year: activeYear } = data;
|
||||
const {
|
||||
monthAvgKg,
|
||||
bestMonth,
|
||||
latestMonth,
|
||||
monthMomentum,
|
||||
top5Share,
|
||||
profitYield,
|
||||
stationAvgKg,
|
||||
monthlyDual,
|
||||
} = deriveOverviewMetrics(data);
|
||||
const yearProfitFmt = fmtYuan(kpi.yearProfit);
|
||||
const yearRevenueFmt = fmtYuan(kpi.yearRevenue);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 relative">
|
||||
{/* 顶部说明条 + 年份切换 + 刷新按钮 */}
|
||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
|
||||
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRelative(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
|
||||
{availableYears.map(y => {
|
||||
const active = y === activeYear;
|
||||
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>
|
||||
<OverviewHeader
|
||||
activeYear={activeYear}
|
||||
availableYears={availableYears}
|
||||
lastRefreshAt={lastRefreshAt}
|
||||
refreshing={refreshing}
|
||||
onSelectYear={setYear}
|
||||
onRefresh={() => void load(year, true)}
|
||||
/>
|
||||
|
||||
{/* KPI 5 卡 */}
|
||||
<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>
|
||||
<KpiSection kpi={kpi} />
|
||||
|
||||
{/* 月度趋势:年内每月加氢量 */}
|
||||
{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}
|
||||
<InsightCards
|
||||
monthAvgKg={monthAvgKg}
|
||||
bestMonth={bestMonth}
|
||||
latestMonth={latestMonth}
|
||||
monthMomentum={monthMomentum}
|
||||
top5Share={top5Share}
|
||||
profitYield={profitYield}
|
||||
stationAvgKg={stationAvgKg}
|
||||
stationCount={stations.length}
|
||||
yearProfitValue={yearProfitFmt.value}
|
||||
yearProfitUnit={yearProfitFmt.unit}
|
||||
yearRevenueValue={yearRevenueFmt.value}
|
||||
yearRevenueUnit={yearRevenueFmt.unit}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
<MonthlyCharts activeYear={activeYear} monthly={monthly} monthlyDual={monthlyDual} />
|
||||
<DistributionCharts top5={top5} regions={regions} yearKg={kpi.yearKg} />
|
||||
<StationSummaryTable stations={stations} />
|
||||
<CustomerSummaryTable customers={customers} />
|
||||
|
||||
<RotatingFooterHint />
|
||||
|
||||
{/* 刷新中:透明遮罩 + 顶部进度条(不替换内容,避免闪烁) */}
|
||||
<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>
|
||||
<RefreshOverlay refreshing={refreshing} hasData={Boolean(data)} />
|
||||
</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}` : ''}`);
|
||||
}
|
||||
|
||||
export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise<HydrogenDailyRow[]> {
|
||||
const q = new URLSearchParams({ range, customer });
|
||||
export interface HydrogenDailyQuery {
|
||||
range?: DateQuickPick;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise<HydrogenDailyRow[]> {
|
||||
const q = new URLSearchParams({ customer });
|
||||
if (query.range) q.set('range', query.range);
|
||||
if (query.startDate) q.set('startDate', query.startDate);
|
||||
if (query.endDate) q.set('endDate', query.endDate);
|
||||
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
|
||||
}
|
||||
|
||||
@@ -41,7 +50,10 @@ export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
|
||||
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
|
||||
}
|
||||
|
||||
export function fetchElectricMonthly(customer: CustomerType, range: DateQuickPick = 'last15'): Promise<ElectricMonthGroup[]> {
|
||||
const q = new URLSearchParams({ customer, range });
|
||||
export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> {
|
||||
const q = new URLSearchParams({ customer });
|
||||
if (query.range) q.set('range', query.range);
|
||||
if (query.startDate) q.set('startDate', query.startDate);
|
||||
if (query.endDate) q.set('endDate', query.endDate);
|
||||
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() {
|
||||
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 (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<FileText size={48} className="mx-auto text-gray-300 mb-4" />
|
||||
<h2 className="text-lg font-semibold text-gray-500">每日汇报</h2>
|
||||
<p className="text-sm text-gray-400 mt-2">开发中...</p>
|
||||
<div className="relative space-y-3 pb-5" aria-busy={loading}>
|
||||
<ReportHeader
|
||||
report={report}
|
||||
selectedDate={selectedDate}
|
||||
maxDate={maxDate}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,51 +1,41 @@
|
||||
import { useState } from 'react';
|
||||
import { LayoutDashboard, BarChart3, FileText } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import MonitoringView from './MonitoringView';
|
||||
import StatisticsView from './StatisticsView';
|
||||
import DailyReportView from './DailyReportView';
|
||||
import { useHashSubTab } from '../energy/useHashSubTab';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { FadeIn, PageFrame, SegmentedNav } from '../../components/ui/surface';
|
||||
|
||||
type MileageSubTab = 'monitoring' | 'statistics' | 'report';
|
||||
|
||||
const MILEAGE_TABS = [
|
||||
{ id: 'monitoring', label: '实时监控', icon: LayoutDashboard },
|
||||
{ id: 'statistics', label: '统计报表', icon: BarChart3 },
|
||||
{ id: 'report', label: '每日汇报', icon: FileText },
|
||||
] as const satisfies readonly { id: MileageSubTab; label: string; icon: typeof LayoutDashboard }[];
|
||||
|
||||
const MILEAGE_SUB_IDS: readonly MileageSubTab[] = ['monitoring', 'statistics', 'report'];
|
||||
|
||||
export default function MileageModule() {
|
||||
const [activeSubTab, setActiveSubTab] = useState<'monitoring' | 'statistics' | 'report'>('monitoring');
|
||||
const [activeSubTab, setActiveSubTab] = useHashSubTab<MileageSubTab>('mileage', MILEAGE_SUB_IDS);
|
||||
|
||||
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 landscape:pb-0 landscape:h-full landscape:flex-1 landscape:overflow-hidden">
|
||||
{/* Sub-navigation — sticky */}
|
||||
<div className="bg-white px-4 py-2 rounded-2xl border border-slate-100 shadow-sm flex items-center gap-6 sticky top-0 z-30">
|
||||
<button
|
||||
onClick={() => setActiveSubTab('monitoring')}
|
||||
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'monitoring' ? 'text-blue-600' : 'text-slate-400'}`}
|
||||
<PageFrame
|
||||
title="车辆里程中心"
|
||||
subtitle="统一监控车辆日里程、累计里程、考核进度与日报经营口径,突出异常车辆和任务压力。"
|
||||
icon={LayoutDashboard}
|
||||
eyebrow="MILEAGE BI"
|
||||
meta="实时监控 · 统计报表 · 每日汇报"
|
||||
compactInfo
|
||||
hideHeader
|
||||
>
|
||||
<LayoutDashboard size={14} />
|
||||
<span className="text-[11px] font-bold">实时监控</span>
|
||||
{activeSubTab === 'monitoring' && (
|
||||
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSubTab('statistics')}
|
||||
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'statistics' ? 'text-blue-600' : 'text-slate-400'}`}
|
||||
>
|
||||
<BarChart3 size={14} />
|
||||
<span className="text-[11px] font-bold">统计报表</span>
|
||||
{activeSubTab === 'statistics' && (
|
||||
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSubTab('report')}
|
||||
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'report' ? 'text-blue-600' : 'text-slate-400'}`}
|
||||
>
|
||||
<FileText size={14} />
|
||||
<span className="text-[11px] font-bold">每日汇报</span>
|
||||
{activeSubTab === 'report' && (
|
||||
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
<div className="sticky top-0 z-30 -mx-3 bg-[var(--app-bg)] px-3 pb-2 pt-1 shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)] md:-mx-6 md:top-12 md:px-6">
|
||||
<SegmentedNav tabs={MILEAGE_TABS} active={activeSubTab} onChange={setActiveSubTab} />
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<FadeIn key={activeSubTab}>
|
||||
{activeSubTab === 'monitoring' ? (
|
||||
<MonitoringView />
|
||||
) : activeSubTab === 'statistics' ? (
|
||||
@@ -53,8 +43,9 @@ export default function MileageModule() {
|
||||
) : (
|
||||
<DailyReportView />
|
||||
)}
|
||||
</FadeIn>
|
||||
</AnimatePresence>
|
||||
<RotatingFooterHint />
|
||||
</div>
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,65 +1,53 @@
|
||||
import { useState, useEffect } 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 { useEffect, useState } from 'react';
|
||||
import type { TargetSummary, TargetVehicle, TrendPoint } from './types';
|
||||
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
||||
import Blur from '../../components/Blur';
|
||||
|
||||
function getDefaultDate(): string {
|
||||
const now = new Date();
|
||||
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function fmtKm(value: number): string {
|
||||
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
import AllVehiclesPanel from './statistics/AllVehiclesPanel';
|
||||
import FullscreenTargetTable from './statistics/FullscreenTargetTable';
|
||||
import StatisticsOverview from './statistics/StatisticsOverview';
|
||||
import TargetDetailPanel from './statistics/TargetDetailPanel';
|
||||
import TrendPanel from './statistics/TrendPanel';
|
||||
import {
|
||||
deriveSelectedTargetMetrics,
|
||||
getCurrentDateLabel,
|
||||
getDefaultDate,
|
||||
initialAssessmentYears,
|
||||
orderTargets,
|
||||
} from './statistics/model';
|
||||
|
||||
export default function StatisticsView() {
|
||||
const currentDateLabel = getCurrentDateLabel();
|
||||
const [targets, setTargets] = useState<TargetSummary[]>([]);
|
||||
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
||||
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
|
||||
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(null);
|
||||
|
||||
const [chartType, setChartType] = useState<'bar' | 'line' | 'area'>('bar');
|
||||
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 [viewAllTargetName, setViewAllTargetName] = useState<string>('');
|
||||
const [viewAllSearch, setViewAllSearch] = useState('');
|
||||
const [viewAllSort, setViewAllSort] = useState<'asc' | 'desc'>('desc');
|
||||
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
|
||||
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
|
||||
useEffect(() => {
|
||||
fetchTargets().then(data => {
|
||||
setTargets(data);
|
||||
if (data.length > 0 && !selectedTargetId) {
|
||||
setSelectedTargetId(data[0].id);
|
||||
const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0];
|
||||
const ordered = orderTargets(data);
|
||||
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(() => {});
|
||||
}, []);
|
||||
@@ -67,6 +55,12 @@ export default function StatisticsView() {
|
||||
// Load trend when selectedTargetId changes
|
||||
useEffect(() => {
|
||||
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([]));
|
||||
}, [selectedTargetId]);
|
||||
|
||||
@@ -75,524 +69,84 @@ export default function StatisticsView() {
|
||||
if (viewAllTargetId === null) return;
|
||||
setViewAllLoading(true);
|
||||
fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => {
|
||||
setTargetVehiclesMap(prev => ({ ...prev, [viewAllTargetId]: data }));
|
||||
setTargetVehiclesMap(previous => ({ ...previous, [viewAllTargetId]: data }));
|
||||
}).catch(() => {}).finally(() => setViewAllLoading(false));
|
||||
}, [viewAllTargetId, viewAllDate]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1" style={{ overflowX: 'clip' }}>
|
||||
{/* Project Selector */}
|
||||
<div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-100 flex gap-1 overflow-x-auto no-scrollbar flex-shrink-0">
|
||||
{targets.map(target => (
|
||||
<button
|
||||
key={target.id}
|
||||
onClick={() => setSelectedTargetId(target.id)}
|
||||
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>
|
||||
function selectTarget(targetId: number) {
|
||||
setSelectedTargetId(targetId);
|
||||
setExpandedTargetId(targetId);
|
||||
}
|
||||
|
||||
<div className="flex flex-col landscape:flex-row gap-4 flex-1 landscape:overflow-hidden">
|
||||
{/* Left Side: Trend Chart / Dashboard Sidebar */}
|
||||
<div className="flex-none landscape:flex-1 landscape:w-2/3 space-y-4 flex flex-col overflow-y-auto no-scrollbar min-w-0">
|
||||
{/* KPI Cards in Landscape — linked to selected target */}
|
||||
{(() => {
|
||||
const sel = targets.find(t => t.id === selectedTargetId);
|
||||
return (
|
||||
<div className="hidden landscape:grid grid-cols-4 gap-3 flex-shrink-0">
|
||||
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">今日总里程</div>
|
||||
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
||||
{fmtKm(sel?.todayTotal ?? 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">
|
||||
{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 }));
|
||||
function expandTarget(targetId: number) {
|
||||
setExpandedTargetId(targetId);
|
||||
if (!targetVehiclesMap[targetId]) {
|
||||
fetchTargetVehicles(targetId).then(data => {
|
||||
setTargetVehiclesMap(previous => ({ ...previous, [targetId]: 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();
|
||||
function openAllVehicles(target: TargetSummary) {
|
||||
setViewAllTargetId(target.id);
|
||||
setViewAllTargetName(target.targetName);
|
||||
setViewAllDate(getDefaultDate());
|
||||
}
|
||||
|
||||
function refreshTargets() {
|
||||
fetchTargets().then(data => {
|
||||
setTargets(data);
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1 [overflow-anchor:none]" style={{ overflowX: 'clip' }}>
|
||||
<StatisticsOverview
|
||||
targets={targets}
|
||||
selectedTargetId={selectedTargetId}
|
||||
selectedTarget={selectedTarget}
|
||||
metrics={selectedMetrics}
|
||||
onSelectTarget={selectTarget}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col landscape:flex-row gap-4 flex-1 landscape:overflow-hidden">
|
||||
<TrendPanel
|
||||
selectedTarget={selectedTarget}
|
||||
selectedCompletion={selectedMetrics.completion}
|
||||
trendData={trendData}
|
||||
/>
|
||||
<TargetDetailPanel
|
||||
target={selectedTarget}
|
||||
expandedTargetId={expandedTargetId}
|
||||
selectedYear={selectedTarget ? assessmentYearMap[selectedTarget.id] : undefined}
|
||||
vehicles={selectedTarget ? targetVehiclesMap[selectedTarget.id] || [] : []}
|
||||
currentDateLabel={currentDateLabel}
|
||||
onOpenFullscreen={() => setIsTableFullscreen(true)}
|
||||
onExpandTarget={expandTarget}
|
||||
onAssessmentYearChange={(targetId, year) => {
|
||||
setAssessmentYearMap(previous => ({ ...previous, [targetId]: year }));
|
||||
}}
|
||||
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>
|
||||
|
||||
{/* Fullscreen Table Overlay */}
|
||||
<AnimatePresence>
|
||||
{isTableFullscreen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
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 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="sticky top-0 bg-slate-900 z-10">
|
||||
<tr className="border-b border-slate-800/60">
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase sticky left-0 bg-slate-900 z-20 min-w-[100px]">车型</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-12">台数</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase min-w-[140px]">完成进度</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">年任务/辆</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-emerald-500 uppercase text-center">达标</th>
|
||||
<th className="px-3 py-2 text-[10px] font-bold text-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)}%` }}
|
||||
onViewAll={openAllVehicles}
|
||||
/>
|
||||
</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"
|
||||
<FullscreenTargetTable
|
||||
isOpen={isTableFullscreen}
|
||||
targets={targets}
|
||||
assessmentYearMap={assessmentYearMap}
|
||||
onRefresh={refreshTargets}
|
||||
onClose={() => setIsTableFullscreen(false)}
|
||||
/>
|
||||
<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"
|
||||
<AllVehiclesPanel
|
||||
targetId={viewAllTargetId}
|
||||
targetName={viewAllTargetName}
|
||||
vehicles={viewAllTargetId !== null ? targetVehiclesMap[viewAllTargetId] || [] : []}
|
||||
date={viewAllDate}
|
||||
loading={viewAllLoading}
|
||||
onDateChange={setViewAllDate}
|
||||
onClose={() => setViewAllTargetId(null)}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,14 @@ import { X, Truck } from 'lucide-react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
|
||||
} from 'recharts';
|
||||
import type { MonitoringVehicle } from './types';
|
||||
import type { MileageSourceGroup, MonitoringVehicle } from './types';
|
||||
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
|
||||
import Blur from '../../components/Blur';
|
||||
|
||||
interface Props {
|
||||
vehicle: MonitoringVehicle | null;
|
||||
onClose: () => void;
|
||||
sourcePriority: MileageSourceGroup[];
|
||||
}
|
||||
|
||||
type RangeKey = 'last15' | 'month' | 'quarter';
|
||||
@@ -56,7 +57,13 @@ function formatLabel(date: string, key: RangeKey): string {
|
||||
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 [loading, setLoading] = useState(false);
|
||||
const [range, setRange] = useState<RangeKey>('last15');
|
||||
@@ -74,12 +81,12 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) {
|
||||
setLoading(true);
|
||||
setDays([]);
|
||||
let cancelled = false;
|
||||
fetchVehicleRecent(vehicle.plate, { start, end })
|
||||
fetchVehicleRecent(vehicle.plate, { start, end, sourcePriority })
|
||||
.then(d => { if (!cancelled) setDays(d.days); })
|
||||
.catch(() => { if (!cancelled) setDays([]); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
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(() => {
|
||||
@@ -280,7 +287,21 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) {
|
||||
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"
|
||||
>
|
||||
<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-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||
<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 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';
|
||||
|
||||
export async function fetchMonitoring(params?: {
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
sortBy?: 'today' | 'total' | 'statisticTime';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
limit?: number;
|
||||
page?: number;
|
||||
search?: string;
|
||||
@@ -16,11 +38,16 @@ export async function fetchMonitoring(params?: {
|
||||
rentStatus?: string;
|
||||
platePrefix?: string;
|
||||
targetName?: string;
|
||||
targetNames?: string[];
|
||||
region?: string;
|
||||
plate?: string;
|
||||
mileageMin?: string;
|
||||
mileageMax?: string;
|
||||
date?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
brands?: string[];
|
||||
sourcePriority?: MileageSourceGroup[];
|
||||
}): Promise<MonitoringData> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.sortBy) query.set('sortBy', params.sortBy);
|
||||
@@ -35,11 +62,24 @@ export async function fetchMonitoring(params?: {
|
||||
if (params?.rentStatus) query.set('rentStatus', params.rentStatus);
|
||||
if (params?.platePrefix) query.set('platePrefix', params.platePrefix);
|
||||
if (params?.targetName) query.set('targetName', params.targetName);
|
||||
if (params?.targetNames) {
|
||||
params.targetNames.forEach(name => {
|
||||
if (name) query.append('targetName', name);
|
||||
});
|
||||
}
|
||||
if (params?.region) query.set('region', params.region);
|
||||
if (params?.brands) {
|
||||
params.brands.forEach(brand => {
|
||||
if (brand) query.append('brand', brand);
|
||||
});
|
||||
}
|
||||
if (params?.plate) query.set('plate', params.plate);
|
||||
if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
|
||||
if (params?.mileageMax) query.set('mileageMax', params.mileageMax);
|
||||
if (params?.date) query.set('date', params.date);
|
||||
if (params?.startDate) query.set('startDate', params.startDate);
|
||||
if (params?.endDate) query.set('endDate', params.endDate);
|
||||
if (params?.sourcePriority?.length) query.set('sourcePriority', params.sourcePriority.join(','));
|
||||
const qs = query.toString();
|
||||
return fetchJson<MonitoringData>(`${BASE}/monitoring${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
@@ -66,6 +106,8 @@ export interface VehicleRecentDay {
|
||||
date: string;
|
||||
dailyKm: number;
|
||||
isDataSynced: boolean;
|
||||
sourceProtocol: MileageSourceProtocol | null;
|
||||
sourceCategory: Exclude<MileageSourceCategory, 'MIXED'> | null;
|
||||
}
|
||||
|
||||
export interface VehicleRecentResponse {
|
||||
@@ -73,17 +115,32 @@ export interface VehicleRecentResponse {
|
||||
start?: string;
|
||||
end?: string;
|
||||
days: VehicleRecentDay[];
|
||||
sourcePriority?: MileageSourceGroup[];
|
||||
}
|
||||
|
||||
export async function fetchVehicleRecent(
|
||||
plate: string,
|
||||
range: { days?: number; start?: string; end?: string } = { days: 15 },
|
||||
range: {
|
||||
days?: number;
|
||||
start?: string;
|
||||
end?: string;
|
||||
sourcePriority?: MileageSourceGroup[];
|
||||
} = { days: 15 },
|
||||
): Promise<VehicleRecentResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (range.start) params.set('start', range.start);
|
||||
if (range.end) params.set('end', range.end);
|
||||
if (range.days != null) params.set('days', String(range.days));
|
||||
if (range.sourcePriority?.length) params.set('sourcePriority', range.sourcePriority.join(','));
|
||||
return fetchJson<VehicleRecentResponse>(
|
||||
`${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
export 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