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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:06:56 +08:00
208 changed files with 6555 additions and 21333 deletions
-9
View File
@@ -1,9 +0,0 @@
node_modules
dist
.env
.git
.codex
outputs
reports
data/geo
data/vehicle-heatmap/source.csv
-1
View File
@@ -1,4 +1,3 @@
node_modules node_modules
dist dist
.env .env
.worktrees
+4 -27
View File
@@ -5,37 +5,14 @@ services:
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0 image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
network_mode: host network_mode: host
environment: environment:
DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com" DB_HOST: "47.101.148.99"
DB_PORT: "3306" DB_PORT: "3306"
DB_USER: "oneos_db_prod" DB_USER: "root"
DB_PASSWORD: "adASHJcviqwjkbn23ngt1" DB_PASSWORD: "LN#Passw0rd@2026"
DB_NAME: "ln_asset_management" DB_NAME: "lingniu_prod"
HYDROGEN_DB_HOST: "47.99.185.173"
HYDROGEN_DB_PORT: "3306"
HYDROGEN_DB_USER: "root"
HYDROGEN_DB_PASSWORD: "lnMysql."
HYDROGEN_DB_NAME: "ln_asset_management"
MILEAGE_DB_HOST: "101.133.130.65"
MILEAGE_DB_PORT: "3306"
MILEAGE_DB_USER: "bi_reader_02"
MILEAGE_DB_PASSWORD: "bi_reader_02_Pass"
MILEAGE_DB_NAME: "hydrogen_energy"
# ECS production accesses OneOS over the private network. Configure the key
# as a Portainer stack environment variable; never commit it to this file.
ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}"
ONEOS_MILEAGE_API_KEY: "${ONEOS_MILEAGE_API_KEY}"
ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}"
SERVER_PORT: "8111" SERVER_PORT: "8111"
EXTERNAL_API_BASE: "https://lnh2e.com" EXTERNAL_API_BASE: "https://lnh2e.com"
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7" JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
AMAP_WEB_KEY: "${AMAP_WEB_KEY}"
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}"
HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}"
HEATMAP_DB_PORT: "${HEATMAP_DB_PORT:-36453}"
HEATMAP_DB_USER: "${HEATMAP_DB_USER}"
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}"
HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}"
HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}"
deploy: deploy:
replicas: 1 replicas: 1
restart_policy: restart_policy:
-155
View File
@@ -1,155 +0,0 @@
# OneOS 车辆里程接口需求
当前协议选源联调阻塞项和 OneOS 修改清单见:
[OneOS 车辆里程数据源协议改造清单](./oneos-mileage-source-protocol-gaps.md)。
## 开发原则
1. 新增区间查询接口;车辆里程汇总能力在现有
`POST /api/v1/vehicles/mileage/query` 上兼容扩展。
2. 现有接口的路径、鉴权、已有请求参数、已有响应字段、字段语义和默认行为必须完全兼容。
3. 公网 `https://open.d.lnoneos.com` 与 ECS 内网 `http://172.17.111.55:20310` 使用相同路径、鉴权和数据口径。
4. 鉴权继续使用 `Authorization: Bearer APP_KEY`
5. 日期采用 `YYYY-MM-DD`,时区统一为 `Asia/Shanghai`,里程单位统一为 km。
## 1. 车辆区间日里程批量查询
```http
POST /api/v1/vehicles/mileage/range/query
```
请求:
```json
{
"startDate": "2026-07-01",
"endDate": "2026-07-23",
"plateNumbers": ["沪A00001", "沪A00002"],
"protocolPriority": ["GB32960", "MQTT", "JT808"],
"cursor": null,
"pageSize": 5000
}
```
规则:
- `startDate``endDate` 必填,最长支持 366 天;
- `plateNumbers` 可省略,省略时返回授权范围内全部车辆;
- `protocolPriority` 可省略;提供时只允许 `GB32960``MQTT``JT808`
数组顺序表示逐车逐日的数据源优先级,未列出的协议视为禁用;
- BI 只会使用以下四种组合:
- 仪表数据优先:`["GB32960", "MQTT", "JT808"]`
- GPS 数据优先:`["JT808", "GB32960", "MQTT"]`
- 仅仪表数据:`["GB32960", "MQTT"]`
- 仅 GPS 数据:`["JT808"]`
- BI 页面默认只启用“仪表数据”,即默认发送
`["GB32960", "MQTT"]`;用户主动启用 GPS 后才发送包含 `JT808` 的组合;
- 仪表数据内部优先级固定为 `GB32960 > MQTT`,不提供反向切换;
- 数据量过大时使用 `cursor` 分页,同一次查询使用相同 `snapshotId`
- 每辆车每天返回一条记录;
- 无数据返回 `status: "NO_DATA"``dailyMileageKm: null`,真实零里程返回 `status: "NORMAL"``dailyMileageKm: 0`
响应:
```json
{
"code": "SUCCESS",
"message": "success",
"data": [
{
"vin": "LMRK...",
"plateNumber": "沪A00001",
"date": "2026-07-01",
"dailyMileageKm": 182.437,
"sourceProtocol": "GB32960",
"dataTime": "2026-07-01T23:58:45+08:00",
"updatedAt": "2026-07-02T05:10:00+08:00",
"status": "NORMAL"
}
],
"snapshotId": "mileage-20260723-001",
"nextCursor": null,
"traceId": "..."
}
```
## 2. 兼容扩展现有单日车辆里程接口
用于一次获取指定日期每辆车的日里程、累计里程和数据时间。
```http
POST /api/v1/vehicles/mileage/query
```
请求:
```json
{
"date": "2026-07-23",
"plateNumbers": ["沪A00001", "沪A00002"],
"protocolPriority": ["GB32960", "MQTT", "JT808"]
}
```
规则:
- `date``plateNumbers` 沿用现有接口规则;
- `protocolPriority` 的枚举、顺序和禁用规则与区间接口完全一致;
- `plateNumbers` 省略时返回授权范围内全部车辆;
- 接口默认在现有车辆结果中返回 `totalMileageKm``dataTime``updatedAt`
- 只增加响应字段,不删除或修改任何现有字段及其语义;
- 历史日期返回该自然日最终有效结果;
- 查询当天返回当前最新结果;
- `dataTime` 必须是本次统计实际采用的最后一条车辆源数据时间,不能使用接口请求时间或响应时间代替;
- 无数据时相关里程字段返回 `null`,不能使用 0 代替;
- 接口应支持当前应用授权范围内的全部车辆。
响应:
```json
{
"code": "SUCCESS",
"message": "success",
"data": [
{
"vin": "LMRK...",
"plateNumber": "沪A00001",
"date": "2026-07-23",
"dailyMileageKm": 182.437,
"totalMileageKm": 12345.678,
"sourceProtocol": "GB32960",
"dataTime": "2026-07-23T10:35:42+08:00",
"updatedAt": "2026-07-23T10:35:46+08:00",
"status": "NORMAL"
}
],
"traceId": "..."
}
```
现有响应外层结构及已有字段保持不变,调用方无需增加任何请求参数。
## 通用要求
- `dataTime``updatedAt` 使用带 `+08:00` 的 ISO 8601 格式;
- `sourceProtocol` 必须返回本条结果实际采用的协议:
`GB32960``MQTT``JT808`;无数据时返回 `null`
- OneOS 必须按 `protocolPriority` 选择第一份有效数据,不得返回已禁用协议的数据;
- `sourceProtocol=GB32960/MQTT` 在 BI 显示为“仪表数据”,
`sourceProtocol=JT808` 显示为“GPS数据”;
- 每个响应返回 `traceId`
- 接口返回值保留原始精度;
- 负里程不得作为正常数据返回;
- 公网和 ECS 内网使用同一 AppKey 时,授权范围和查询结果保持一致;
- 区间接口及现有接口的汇总扩展不得影响现有调用方和默认行为。
## 验收
1. 现有 `/api/v1/vehicles/mileage/query` 请求方式保持不变,回归测试全部通过。
2. 区间接口不传车牌可完整查询全部授权车辆,分页无重复、无漏行。
3. 现有接口不传车牌时,默认返回全部授权车辆的日里程、累计里程和数据时间。
4. 正确区分无数据与真实零里程。
5. `dataTime` 能反映每辆车实际数据的新鲜度。
6. 四种 `protocolPriority` 组合均能按顺序选源,且响应中的
`sourceProtocol` 与实际采用协议一致。
7. 禁用某类协议后,响应不得回退到该协议。
@@ -1,198 +0,0 @@
# OneOS 日里程与累计里程差额排查报告
- 排查日期:2026-09-16Asia/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 粤AGQ83779 月 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 粤AGR68399 月 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 粤AGP36099 月 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 原始报文、计算服务源码或内部存储,不能确定其内部算法根因。
- 本次未修改业务代码、未变更里程口径、未触发历史数据重算。
-183
View File
@@ -1,183 +0,0 @@
# OneOS 车辆里程数据源协议改造清单
## 0. 发布后复测结论
复测日期:2026-07-23
开放平台发布后,BI 已重新连接 `https://open.d.lnoneos.com` 并完成复测:
- 单日接口和区间接口均接受 `protocolPriority`,不再返回
`INVALID_REQUEST`
- 成功响应已返回实际使用的 `sourceProtocol`
- 仪表优先、GPS 优先、仅仪表、仅 GPS 四种模式均返回 HTTP 200
- “仅仪表数据”结果未出现 `JT808`
- “仅 GPS 数据”结果未出现 `GB32960``MQTT`
- 同一车辆四天区间查询能够随优先级在 `GB32960``JT808`
之间正确切换;
- BI 页面已显示真实“仪表数据”或“GPS数据”,不再显示“来源待接口”。
复测时的单日协议分布如下,数据量会随当天车辆上报变化:
| BI 模式 | GB32960 | MQTT | JT808 | 无数据 | 禁用协议混入 |
| --- | ---: | ---: | ---: | ---: | --- |
| 仪表数据优先 | 387 | 49 | 134 | 454 | 无 |
| GPS 数据优先 | 69 | 30 | 471 | 454 | 无 |
| 仅仪表数据 | 387 | 49 | 0 | 588 | 无 |
| 仅 GPS 数据 | 0 | 0 | 471 | 553 | 无 |
结论:本清单中的协议选源阻塞项已关闭,当前接口满足 BI 对接要求。
以下内容保留为接口契约和后续回归验收依据。
## 1. 历史联调结论(发布前)
联调地址:`https://open.d.lnoneos.com`
涉及接口:
- `POST /api/v1/vehicles/mileage/query`
- `POST /api/v1/vehicles/mileage/range/query`
发布前接口不满足 BI 对车辆里程数据源切换和来源标注的要求,阻塞项如下:
| 序号 | 当前表现 | 期望表现 | 影响 |
| --- | --- | --- | --- |
| 1 | 请求增加 `protocolPriority` 后返回 HTTP 400,业务码为 `INVALID_REQUEST` | 两个里程接口均接受可选字段 `protocolPriority` | BI 无法切换仪表数据和 GPS 数据的优先级,也无法单独禁用某类数据 |
| 2 | 成功响应中没有 `sourceProtocol` | 每辆车、每天的结果返回实际采用的协议 | BI 无法准确标注“仪表数据”或“GPS数据” |
| 3 | 无法验证接口是否排除了已禁用协议 | 未列入 `protocolPriority` 的协议不得参与选源 | “仅仪表数据”和“仅 GPS 数据”无法生效 |
| 4 | 无法验证仪表数据内部的降级顺序 | 仪表数据固定按 `GB32960 > MQTT` 选源 | 32960 缺失时无法确认是否正确降级到 MQTT |
BI 保留旧请求格式的兼容回退,用于开放平台异常或版本回滚时保证现有里程查询可用。回退结果不会伪造数据来源,页面会显示“来源待接口”。正常情况下新接口已不触发该回退。
## 2. OneOS 必须修改的请求字段
两个接口都需要增加以下可选字段:
```json
{
"protocolPriority": ["GB32960", "MQTT", "JT808"]
}
```
字段规则:
1. 类型为非空字符串数组。
2. 仅允许 `GB32960``MQTT``JT808`,协议名区分以此处定义为准。
3. 数组顺序表示逐车、逐自然日的数据源优先级。
4. 未出现在数组中的协议视为禁用,不得参与选源或作为兜底数据返回。
5. `protocolPriority` 省略时保持现有接口的默认行为,确保旧调用方兼容。
6. 非法枚举、空数组或重复协议应返回 HTTP 400,并在响应中给出明确的错误信息和 `traceId`
BI 只会发送以下四种组合:
| BI 模式 | `protocolPriority` | 选源规则 |
| --- | --- | --- |
| 仪表数据优先 | `["GB32960", "MQTT", "JT808"]` | 先查 32960,再查 MQTT,最后查 JT808 |
| GPS 数据优先 | `["JT808", "GB32960", "MQTT"]` | 先查 JT808,再查 32960,最后查 MQTT |
| 仅仪表数据 | `["GB32960", "MQTT"]` | 只允许 32960 和 MQTT,禁止使用 JT808 |
| 仅 GPS 数据 | `["JT808"]` | 只允许 JT808,禁止使用 32960 和 MQTT |
BI 页面默认只启用“仪表数据”,即默认使用
`["GB32960", "MQTT"]`GPS 数据需要用户主动启用。
仪表数据内部优先级固定为 `GB32960 > MQTT`BI 不会发送 `MQTT > GB32960`
## 3. OneOS 必须增加的响应字段
两个接口的每条车辆日里程结果都需要增加:
```json
{
"sourceProtocol": "GB32960"
}
```
字段规则:
1. 有有效里程数据时,只能返回 `GB32960``MQTT``JT808`
2. 返回值必须是本条结果实际采用的协议,不能返回车辆支持的协议列表,也不能返回请求中的第一项作为固定值。
3. 无有效数据、`status="NO_DATA"` 时返回 `null`
4. `sourceProtocol` 对应关系:
- `GB32960``MQTT`BI 显示为“仪表数据”;
- `JT808`BI 显示为“GPS数据”。
5. 如果 `sourceProtocol` 不在本次请求的 `protocolPriority` 中,应视为接口错误。
响应示例:
```json
{
"code": "SUCCESS",
"message": "success",
"data": [
{
"vin": "LMRK...",
"plateNumber": "粤A00001F",
"date": "2026-07-23",
"dailyMileageKm": 182.437,
"totalMileageKm": 12345.678,
"sourceProtocol": "GB32960",
"dataTime": "2026-07-23T10:35:42+08:00",
"updatedAt": "2026-07-23T10:35:46+08:00",
"status": "NORMAL"
}
],
"traceId": "..."
}
```
## 4. 逐车逐日选源规则
OneOS 需要针对每辆车、每个自然日独立执行以下逻辑:
1.`protocolPriority` 从前到后检查数据源。
2. 找到第一份有效数据后立即使用,不再混用低优先级协议的数据。
3. 有效数据必须满足现有里程质量规则,包括里程非空、非负且数据状态有效。
4. 高优先级协议无有效数据时,才能降级到下一个已启用协议。
5. 所有已启用协议均无有效数据时,返回:
```json
{
"dailyMileageKm": null,
"sourceProtocol": null,
"status": "NO_DATA"
}
```
6. 真实零里程必须返回:
```json
{
"dailyMileageKm": 0,
"sourceProtocol": "实际采用的协议",
"status": "NORMAL"
}
```
区间查询必须逐日选源,不能先按一个协议汇总整个区间后再切换协议。
## 5. 验收用例
OneOS 修改完成后,至少提供一辆在同一天同时存在 32960、MQTT、JT808 数据的测试车辆,以及一辆只有 MQTT 数据的测试车辆。
| 用例 | 请求优先级 | 必须满足的结果 |
| --- | --- | --- |
| 仪表优先 | `GB32960, MQTT, JT808` | 同时有三种数据时采用 GB32960,`sourceProtocol="GB32960"` |
| GPS 优先 | `JT808, GB32960, MQTT` | 同时有三种数据时采用 JT808,`sourceProtocol="JT808"` |
| 仅仪表 | `GB32960, MQTT` | 不得返回 JT80832960 缺失时允许降级到 MQTT |
| 仅 GPS | `JT808` | 不得返回 GB32960 或 MQTT |
| 32960 降级 | `GB32960, MQTT` | 只有 MQTT 有效时返回 MQTT`sourceProtocol="MQTT"` |
| 禁用验证 | `JT808` | 即使仪表数据存在,也不能回退到仪表数据 |
| 无数据 | 任一合法组合 | 返回 `dailyMileageKm=null``sourceProtocol=null``status="NO_DATA"` |
| 真实零里程 | 任一合法组合 | 返回 `dailyMileageKm=0`、实际 `sourceProtocol``status="NORMAL"` |
| 单日接口 | 四种组合分别测试 | 请求成功且选源、来源字段符合规则 |
| 区间接口 | 四种组合分别测试 | 每辆车每天独立选源,分页无重复、无漏行 |
| 兼容性 | 不传 `protocolPriority` | 原有调用方式和响应语义保持不变 |
## 6. 交付和联调要求
1. 公网 `https://open.d.lnoneos.com` 和 ECS 内网地址同时发布相同能力。
2. 发布后提供接口版本或发布日期、测试车辆、测试日期及对应协议数据情况。
3. 提供四种合法组合的请求和响应样例。
4. 提供一组禁用协议的验证结果,证明未启用协议不会被回退使用。
5. 每个成功和失败响应均返回 `traceId`,便于双方定位问题。
完成标准:两个接口均支持 `protocolPriority`,每条结果准确返回 `sourceProtocol`,四种 BI 模式全部通过上述验收用例。
@@ -1,97 +0,0 @@
# 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
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>羚牛氢能</title> <title>羚牛氢能车辆资产</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+2 -160
View File
@@ -1,14 +1,13 @@
{ {
"name": "ln-bi", "name": "ln-bi",
"version": "1.1.14", "version": "1.1.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ln-bi", "name": "ln-bi",
"version": "1.1.14", "version": "1.1.5",
"dependencies": { "dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0", "@hono/node-server": "^1.13.0",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"ali-oss": "^6.23.0", "ali-oss": "^6.23.0",
@@ -18,7 +17,6 @@
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"mysql2": "^3.11.0", "mysql2": "^3.11.0",
"pg": "^8.22.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"recharts": "^3.8.1", "recharts": "^3.8.1",
@@ -29,7 +27,6 @@
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@types/ali-oss": "^6.23.3", "@types/ali-oss": "^6.23.3",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/pg": "^8.20.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
@@ -39,12 +36,6 @@
"vite": "^6.2.0" "vite": "^6.2.0"
} }
}, },
"node_modules/@amap/amap-jsapi-loader": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@amap/amap-jsapi-loader/-/amap-jsapi-loader-1.0.1.tgz",
"integrity": "sha512-nPyLKt7Ow/ThHLkSvn2etQlUzqxmTVgK7bIgwdBRTg2HK5668oN7xVxkaiRe3YZEzGzfV2XgH5Jmu2T73ljejw==",
"license": "MIT"
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -1629,18 +1620,6 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -3542,95 +3521,6 @@
"through": "~2.3" "through": "~2.3"
} }
}, },
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3686,45 +3576,6 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/process-nextick-args": { "node_modules/process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -4118,15 +3969,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/sql-escaper": { "node_modules/sql-escaper": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
+2 -7
View File
@@ -1,7 +1,7 @@
{ {
"name": "ln-bi", "name": "ln-bi",
"private": true, "private": true,
"version": "1.1.14", "version": "1.1.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", "dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
@@ -9,12 +9,9 @@
"dev:client": "vite --port=3000 --host=0.0.0.0", "dev:client": "vite --port=3000 --host=0.0.0.0",
"build": "vite build", "build": "vite build",
"start": "node --import tsx src/server/index.ts", "start": "node --import tsx src/server/index.ts",
"lint": "tsc --noEmit", "lint": "tsc --noEmit"
"test": "node --import tsx --test $(find src -type f -name '*.test.ts' -print | sort)",
"test:mileage-report": "node --import tsx --test src/server/routes/mileage/daily-report-model.test.ts"
}, },
"dependencies": { "dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0", "@hono/node-server": "^1.13.0",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"ali-oss": "^6.23.0", "ali-oss": "^6.23.0",
@@ -24,7 +21,6 @@
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"mysql2": "^3.11.0", "mysql2": "^3.11.0",
"pg": "^8.22.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"recharts": "^3.8.1", "recharts": "^3.8.1",
@@ -35,7 +31,6 @@
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@types/ali-oss": "^6.23.3", "@types/ali-oss": "^6.23.3",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/pg": "^8.20.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
@@ -1,78 +0,0 @@
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();
}
-223
View File
@@ -1,223 +0,0 @@
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();
}
+54 -69
View File
@@ -1,64 +1,67 @@
import { lazy, Suspense, useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from 'react';
import { Shell } from "./components/Shell"; import { Truck, Route, Activity, Zap } from 'lucide-react';
import AuthProvider from "./auth/AuthProvider"; import { Shell, type ModuleConfig } from './components/Shell';
import { useAuth } from "./auth/useAuth"; import AssetsModule from './modules/assets/AssetsModule';
import UnauthorizedPage from "./auth/UnauthorizedPage"; import MileageModule from './modules/mileage/MileageModule';
import { canAccessEnergy } from "./shared/auth/roles"; import SchedulingModule from './modules/scheduling/SchedulingModule';
import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface"; import EnergyModule from './modules/energy/EnergyModule';
import { buildModules } from "./app/modules"; import EleImportPage from './modules/ele/EleImportPage';
import { import FeedbackAdminPage from './modules/admin/FeedbackAdminPage';
normalizeBrowserPath, import AuthProvider from './auth/AuthProvider';
readBrowserRoute, import { useAuth } from './auth/useAuth';
} from "./app/routing"; import UnauthorizedPage from './auth/UnauthorizedPage';
import { canAccessScheduling, canAccessEnergy } from './shared/auth/roles';
const EleImportPage = lazy(() => import("./modules/ele/EleImportPage")); const BASE_MODULES: ModuleConfig[] = [
const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage")); { id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
normalizeBrowserPath(); { 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 '';
}
function AuthGate() { function AuthGate() {
const { isLoading, isAuthenticated, error, user } = useAuth(); const { isLoading, isAuthenticated, error, user } = useAuth();
const [route, setRoute] = useState(readBrowserRoute); const [routeKey, setRouteKey] = useState(getRouteKey);
const { routeKey, pathSet } = route;
// 监听 hashchange / popstate,让 a href="#/..." 跳转与浏览器前进后退能即时生效 // 监听 hashchange / popstate,让 a href="#/..." 跳转能即时生效
useEffect(() => { useEffect(() => {
const update = () => { const update = () => setRouteKey(getRouteKey());
setRoute(readBrowserRoute()); window.addEventListener('hashchange', update);
}; window.addEventListener('popstate', update);
window.addEventListener("hashchange", update);
window.addEventListener("popstate", update);
return () => { return () => {
window.removeEventListener("hashchange", update); window.removeEventListener('hashchange', update);
window.removeEventListener("popstate", update); window.removeEventListener('popstate', update);
}; };
}, []); }, []);
useEffect(() => { const modules = useMemo(() => {
document.title = pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI"; const result = [...BASE_MODULES];
}, [pathSet]); if (canAccessEnergy(user?.roles)) result.push(ENERGY_MODULE);
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
const modules = useMemo( return result;
() => buildModules(pathSet, user?.roles), }, [user?.roles]);
[pathSet, user?.roles],
);
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen bg-[var(--app-bg)] p-4 text-slate-800 md:p-8"> <div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
<div className="mx-auto flex max-w-5xl flex-col gap-4"> <div className="text-center">
<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="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
<div className="mb-3 text-[11px] font-black text-blue-600">LN BI ACCESS</div> <p className="text-xs text-slate-400 font-bold">...</p>
<div className="text-xl font-black text-slate-950"> BI</div>
<div className="mt-2 text-xs font-bold text-slate-400"></div>
</div>
<SurfaceCard className="p-4">
<div className="grid gap-3 md:grid-cols-4">
{[0, 1, 2, 3].map(item => <SkeletonBlock key={item} className="h-24" />)}
</div>
<div className="mt-4">
<LoadingState label="正在验证身份" />
</div>
</SurfaceCard>
</div> </div>
</div> </div>
); );
@@ -69,28 +72,10 @@ function AuthGate() {
} }
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现 // 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
if (routeKey === "ele/import") { if (routeKey === 'ele/import') return <EleImportPage />;
return ( if (routeKey === 'admin/feedback') return <FeedbackAdminPage />;
<Suspense fallback={<LoadingState label="正在加载导入工作台" />}>
<EleImportPage />
</Suspense>
);
}
if (routeKey === "admin/feedback") {
return (
<Suspense fallback={<LoadingState label="正在加载反馈后台" />}>
<FeedbackAdminPage />
</Suspense>
);
}
// /energy 整组按能源权限控制 return <Shell modules={modules} />;
if (pathSet === "energy" && !canAccessEnergy(user?.roles)) {
return <UnauthorizedPage message="无能源管理模块访问权限" />;
}
// key={pathSet} 让两套底栏切换时 Shell 内部 state 重置,避免残留旧 activeModule
return <Shell key={pathSet} modules={modules} />;
} }
export default function App() { export default function App() {
-38
View File
@@ -1,38 +0,0 @@
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'],
);
});
-72
View File
@@ -1,72 +0,0 @@
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;
}
-20
View File
@@ -1,20 +0,0 @@
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'), '');
});
-56
View File
@@ -1,56 +0,0 @@
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),
};
}
+13 -1
View File
@@ -82,7 +82,19 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
const jumpToken = params.get('jumpToken'); const jumpToken = params.get('jumpToken');
if (!jumpToken) { if (!jumpToken) {
setState({ isLoading: false, isAuthenticated: false, user: null, error: '请从业务系统跳转访问' }); // 临时:本地开发免登录,含智能调度权限
setState({
isLoading: false,
isAuthenticated: true,
user: {
userId: '1105261382487539712',
userName: '本地调试',
permissionLevel: 'full',
depName: '',
roles: ['BI-SCHEDULE-OPT'],
},
error: null,
});
return; return;
} }
+13 -18
View File
@@ -1,24 +1,19 @@
import { ShieldX, Monitor, Smartphone } from 'lucide-react'; import { ShieldX, Monitor, Smartphone } from 'lucide-react';
import { PageFrame, SurfaceCard } from '../components/ui/surface';
export default function UnauthorizedPage({ message }: { message?: string }) { export default function UnauthorizedPage({ message }: { message?: string }) {
return ( return (
<PageFrame <div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center p-6">
title="未授权访问" <div className="text-center max-w-sm">
subtitle={message || '获取用户认证信息失败,可能是跳转令牌已过期或无效。'} <div className="w-20 h-20 mx-auto mb-6 rounded-full bg-slate-100 flex items-center justify-center">
icon={ShieldX} <ShieldX size={36} className="text-slate-400" />
eyebrow="ACCESS CONTROL"
meta="请从授权入口重新进入"
maxWidth="max-w-xl"
>
<SurfaceCard className="p-4">
<div className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<ShieldX size={30} />
</div>
<p className="mb-4 text-[10px] font-bold uppercase tracking-wider text-slate-400"></p>
</div> </div>
<div className="space-y-3"> <h1 className="text-lg font-black text-slate-800 mb-2">访</h1>
<p className="text-xs text-slate-400 mb-4">
{message || '获取用户认证信息失败,可能是跳转令牌已过期或无效'}
</p>
<div className="bg-white rounded-2xl border border-slate-100 p-4 text-left space-y-3">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider text-center"></p>
<div className="flex items-start gap-3 p-2.5 rounded-xl bg-slate-50"> <div className="flex items-start gap-3 p-2.5 rounded-xl bg-slate-50">
<Monitor size={16} className="text-blue-500 flex-shrink-0 mt-0.5" /> <Monitor size={16} className="text-blue-500 flex-shrink-0 mt-0.5" />
@@ -36,7 +31,7 @@ export default function UnauthorizedPage({ message }: { message?: string }) {
</div> </div>
</div> </div>
</div> </div>
</SurfaceCard> </div>
</PageFrame> </div>
); );
} }
+352 -38
View File
@@ -1,15 +1,24 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
MessageCircleHeart, X, ChevronRight, ChevronLeft, Check, Sparkles,
ImagePlus, Loader2, Inbox, Lightbulb, Bug, Palette, NotebookPen, Settings2,
type LucideIcon,
} from 'lucide-react';
import { fetchJson } from '../auth/api-client'; import { fetchJson } from '../auth/api-client';
import { useAuth } from '../auth/useAuth'; import { useAuth } from '../auth/useAuth';
import { canManageFeedback } from '../shared/auth/roles'; import { canManageFeedback } from '../shared/auth/roles';
import FeedbackHistoryDrawer from './FeedbackHistoryDrawer'; import FeedbackHistoryDrawer from './FeedbackHistoryDrawer';
import FeedbackLauncher from './feedback/FeedbackLauncher'; import RotatingFooterHint from './RotatingFooterHint';
import FeedbackModal from './feedback/FeedbackModal';
import type { FeedbackStep, FeedbackType, UploadedImg } from './feedback/types';
const MAX_SCREENSHOTS = 6; const MAX_SCREENSHOTS = 6;
const MAX_IMG_SIZE_MB = 5; const MAX_IMG_SIZE_MB = 5;
interface UploadedImg {
url: string;
thumbDataUrl: string;
}
async function uploadImage(file: File): Promise<string> { async function uploadImage(file: File): Promise<string> {
const fd = new FormData(); const fd = new FormData();
fd.append('file', file); fd.append('file', file);
@@ -33,6 +42,38 @@ function readAsDataUrl(file: File): Promise<string> {
}); });
} }
type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other';
interface TypeOption {
key: FeedbackType;
icon: LucideIcon;
label: string;
sub: string;
iconBg: string;
iconFg: string;
ring: string;
}
const TYPE_OPTIONS: TypeOption[] = [
{ key: 'dimension', icon: Lightbulb, label: '想看新的统计维度', sub: '比如按 XX 维度切片',
iconBg: 'bg-amber-50', iconFg: 'text-amber-500', ring: 'ring-amber-200' },
{ key: 'bug', icon: Bug, label: '报告一个 Bug', sub: '哪里看着不对劲',
iconBg: 'bg-rose-50', iconFg: 'text-rose-500', ring: 'ring-rose-200' },
{ key: 'ux', icon: Palette, label: '界面 / 体验建议', sub: '哪里能更顺手',
iconBg: 'bg-violet-50', iconFg: 'text-violet-500', ring: 'ring-violet-200' },
{ key: 'other', icon: NotebookPen, label: '其他想法', sub: '欢迎随便聊聊',
iconBg: 'bg-blue-50', iconFg: 'text-blue-500', ring: 'ring-blue-200' },
];
const MODULE_LABELS: Record<string, string> = {
assets: '资产管理',
mileage: '里程管理',
energy: '能源管理',
scheduling: '智能调度',
ele: '充电导入',
'': '通用',
};
function detectModule(): string { function detectModule(): string {
const hash = (window.location.hash || '').slice(1); const hash = (window.location.hash || '').slice(1);
const path = window.location.pathname; const path = window.location.pathname;
@@ -53,7 +94,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [step, setStep] = useState<FeedbackStep>(1); const [step, setStep] = useState<1 | 2 | 3>(1); // 1=选类型, 2=写内容, 3=成功页
const [type, setType] = useState<FeedbackType | null>(null); const [type, setType] = useState<FeedbackType | null>(null);
const [mod, setMod] = useState<string>(''); const [mod, setMod] = useState<string>('');
const [content, setContent] = useState(''); const [content, setContent] = useState('');
@@ -87,12 +128,14 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
} }
}; };
// Open: detect current module
useEffect(() => { useEffect(() => {
if (open && step === 1) { if (open && step === 1) {
setMod(moduleProp ?? detectModule()); setMod(moduleProp ?? detectModule());
} }
}, [open, step, moduleProp]); }, [open, step, moduleProp]);
// Lock scroll when open
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const prev = document.body.style.overflow; const prev = document.body.style.overflow;
@@ -110,7 +153,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
const close = () => { const close = () => {
setOpen(false); setOpen(false);
setTimeout(reset, 300); setTimeout(reset, 300); // 等动画完
}; };
const submit = async () => { const submit = async () => {
@@ -150,48 +193,319 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
} }
}; };
const back = () => setStep((Math.max(1, step - 1)) as FeedbackStep); const back = () => setStep((Math.max(1, step - 1)) as typeof step);
const canNext = step === 1 ? !!type : step === 2 ? content.trim().length > 0 : true; const canNext = step === 1 ? !!type : step === 2 ? content.trim().length > 0 : true;
const progress = step >= 3 ? 100 : (step / 2) * 100; const progress = step >= 3 ? 100 : (step / 2) * 100;
return ( return (
<> <>
<FeedbackLauncher {/* Floating Action Button */}
isAdmin={isAdmin} <div className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-[60]">
menuOpen={menuOpen} <motion.button
onCloseMenu={() => setMenuOpen(false)} initial={{ scale: 0, opacity: 0 }}
onOpenFeedback={() => { setMenuOpen(false); setOpen(true); }} animate={{ scale: 1, opacity: 1 }}
onOpenHistory={() => { setMenuOpen(false); setHistoryOpen(true); }} transition={{ delay: 0.4, type: 'spring', stiffness: 300, damping: 20 }}
onToggleMenu={() => setMenuOpen(current => !current)} 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)}
/>
)}
</div>
<FeedbackHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} /> <FeedbackHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} />
<FeedbackModal {/* Modal */}
canNext={canNext} <AnimatePresence>
content={content} {open && (
error={error} <motion.div
fileRef={fileRef} initial={{ opacity: 0 }}
maxScreenshots={MAX_SCREENSHOTS} animate={{ opacity: 1 }}
module={mod} exit={{ opacity: 0 }}
open={open} className="fixed inset-0 z-[90] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
progress={progress} // 不允许点击背景关闭:避免用户输入到一半误触遮罩丢失内容
screenshots={shots} >
step={step} <motion.div
submitting={submitting} initial={{ y: '100%', opacity: 0 }}
textareaRef={taRef} animate={{ y: 0, opacity: 1 }}
type={type} exit={{ y: '100%', opacity: 0 }}
uploading={uploading} transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
onAddFiles={addFiles} 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"
onBack={back} onClick={(e) => e.stopPropagation()}
onChangeContent={setContent} >
onChangeModule={setMod} {/* Drag handle */}
onClose={close} <div className="flex justify-center pt-2.5 pb-1">
onNext={next} <div className="w-10 h-1 rounded-full bg-slate-300" />
onRemoveScreenshot={(index) => setShots(prev => prev.filter((_, current) => current !== index))} </div>
onSelectType={(selectedType) => { setType(selectedType); setStep(2); }}
/> {/* Header + progress */}
<div className="px-4 pb-3 border-b border-slate-100">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center">
<Sparkles size={14} className="text-white" />
</div>
<div>
<div className="text-sm font-black text-slate-800 leading-tight">
{step === 3 ? '收到啦~' : '提个建议'}
</div>
<div className="text-[10px] text-slate-400 font-bold">
{step === 1 && '第一步 / 共 2 步'}
{step === 2 && '第二步 / 共 2 步'}
{step === 3 && '感谢你的反馈'}
</div>
</div>
</div>
<button onClick={close} className="p-1.5 -mr-1 text-slate-400 hover:text-slate-700">
<X size={18} />
</button>
</div>
<div className="h-1 bg-slate-100 rounded-full overflow-hidden">
<motion.div
initial={false}
animate={{ width: `${progress}%` }}
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 rounded-full"
/>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-4 py-4">
<AnimatePresence mode="wait">
{step === 1 && (
<motion.div key="s1" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }}>
<p className="text-[13px] font-bold text-slate-700 mb-1.5"></p>
<RotatingFooterHint className="justify-start mb-4" />
<div className="grid grid-cols-1 gap-2">
{TYPE_OPTIONS.map((opt, i) => {
const Icon = opt.icon;
const selected = type === opt.key;
return (
<motion.button
key={opt.key}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.04, duration: 0.2 }}
whileHover={{ y: -1 }}
whileTap={{ scale: 0.99 }}
onClick={() => { setType(opt.key); setStep(2); }}
className={`text-left p-3.5 rounded-2xl border bg-white transition-all flex items-center gap-3 group ${selected ? `ring-2 ${opt.ring} border-transparent shadow-sm` : 'border-slate-100 hover:border-slate-200 hover:shadow-sm'}`}
>
<div className={`w-10 h-10 rounded-xl ${opt.iconBg} flex items-center justify-center flex-shrink-0`}>
<Icon size={18} className={opt.iconFg} strokeWidth={2.2} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-bold text-slate-800 leading-tight">{opt.label}</div>
<div className="text-[11px] text-slate-400 font-medium mt-0.5">{opt.sub}</div>
</div>
<ChevronRight size={15} className="text-slate-300 flex-shrink-0 group-hover:text-slate-500 group-hover:translate-x-0.5 transition-all" />
</motion.button>
);
})}
</div>
</motion.div>
)}
{step === 2 && (
<motion.div key="s2" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }} className="space-y-3">
<div>
<p className="text-[12px] font-bold text-slate-600 mb-2"></p>
<textarea
ref={taRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onPaste={(e) => {
const items = e.clipboardData?.items;
if (!items) return;
const imgs: File[] = [];
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.kind === 'file' && it.type.startsWith('image/')) {
const f = it.getAsFile();
if (f) imgs.push(f);
}
}
if (imgs.length > 0) {
e.preventDefault();
addFiles(imgs);
}
}}
autoFocus
rows={5}
maxLength={1000}
placeholder={
type === 'dimension' ? '比如:希望按客户/区域/日期范围 等等切片看里程数据…'
: type === 'bug' ? '比如:氢能页面 04-28 嘉燃经开站显示 153.81,但…(可粘贴截图)'
: type === 'ux' ? '比如:能不能把外部 tab 默认收起,加载快一点…'
: '随便聊聊你的想法'
}
className="w-full bg-slate-50 border-none rounded-xl p-3 text-[12px] text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20 resize-none"
/>
<div className="text-right text-[10px] text-slate-300 font-bold mt-1">{content.length} / 1000</div>
</div>
{/* 截图上传 */}
<div>
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] font-bold text-slate-400 uppercase"></p>
<span className="text-[10px] text-slate-300 font-bold">{shots.length}/{MAX_SCREENSHOTS}</span>
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }}
/>
<div className="flex flex-wrap gap-1.5">
{shots.map((s, i) => (
<div key={i} className="relative w-16 h-16 rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
<img src={s.thumbDataUrl} alt="" className="w-full h-full object-cover" />
<button
onClick={() => setShots(prev => prev.filter((_, idx) => idx !== i))}
className="absolute top-0.5 right-0.5 w-4 h-4 rounded-full bg-slate-900/70 text-white flex items-center justify-center hover:bg-slate-900"
>
<X size={10} />
</button>
</div>
))}
{shots.length < MAX_SCREENSHOTS && (
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="w-16 h-16 rounded-lg border border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 flex flex-col items-center justify-center gap-0.5 text-slate-400"
>
{uploading ? <Loader2 size={16} className="animate-spin" /> : <ImagePlus size={16} />}
<span className="text-[9px] font-bold">{uploading ? '上传中' : '加截图'}</span>
</button>
)}
</div>
</div>
<div>
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5"></p>
<div className="flex gap-1 flex-wrap">
{Object.entries(MODULE_LABELS).map(([k, label]) => (
<button
key={k}
onClick={() => setMod(k)}
className={`px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all ${mod === k ? 'bg-blue-50 border-blue-200 text-blue-600' : 'bg-white border-slate-200 text-slate-500 hover:border-slate-300'}`}
>
{label}
</button>
))}
</div>
</div>
{error && <div className="text-[11px] text-rose-500 font-bold">{error}</div>}
</motion.div>
)}
{step === 3 && (
<motion.div key="s3" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.3 }} className="text-center py-6">
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: 'spring', damping: 12, stiffness: 200, delay: 0.1 }}
className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-400 to-cyan-400 mx-auto flex items-center justify-center mb-3"
>
<Check size={28} strokeWidth={3} className="text-white" />
</motion.div>
<div className="text-base font-black text-slate-800 mb-1"> </div>
<div className="text-[12px] text-slate-500 font-bold leading-relaxed"><br /></div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Footer */}
{step < 3 && (
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
{step > 1 && (
<button
onClick={back}
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50 flex items-center gap-1"
>
<ChevronLeft size={14} />
</button>
)}
<div className="flex-1" />
<button
onClick={next}
disabled={!canNext || submitting}
className="px-4 py-2 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100 disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none flex items-center gap-1"
>
{submitting ? '提交中…' : step === 2 ? '提交' : '下一步'}
{!submitting && step !== 2 && <ChevronRight size={14} />}
</button>
</div>
)}
{step === 3 && (
<div className="px-4 py-3 border-t border-slate-100">
<button
onClick={close}
className="w-full py-2.5 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100"
>
</button>
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
</> </>
); );
} }
+59 -141
View File
@@ -1,88 +1,71 @@
import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react'; import { useState, useEffect, useMemo, type ComponentType } from 'react';
import { motion } from 'motion/react';
import { Building2, ChevronRight, ShieldCheck } from 'lucide-react';
import { useAuth } from '../auth/useAuth'; import { useAuth } from '../auth/useAuth';
import { DemoModeProvider } from './Blur'; import { DemoModeProvider } from './Blur';
import FeedbackFab from './FeedbackFab'; import FeedbackFab from './FeedbackFab';
import { cn } from '../lib/cn';
import { LoadingState } from './ui/surface';
export interface ModuleConfig { export interface ModuleConfig {
id: string; id: string;
label: string; label: string;
icon: ComponentType<{ size?: number; className?: string }>; icon: ComponentType<{ size?: number; className?: string }>;
component?: ElementType; component: ComponentType;
children?: ModuleConfig[];
} }
function flattenModules(modules: ModuleConfig[]): ModuleConfig[] { /** path 到模块 id 的映射 */
return modules.flatMap((module) => module.children ?? [module]); const PATH_MAP: Record<string, string> = {
} '/vehicle': 'assets',
'/assets': 'assets',
/** hash 一级段(`#<id>` 或 `#<id>/<sub>` 都只取 id */ '/mileage': 'mileage',
function getHashHead(): string { '/scheduling': 'scheduling',
return window.location.hash.slice(1).split('/')[0]; '/energy': 'energy',
} };
function getInitialModule(modules: ModuleConfig[]): string { function getInitialModule(modules: ModuleConfig[]): string {
const head = getHashHead(); // 优先看 hash
if (modules.some((m) => m.id === head)) return head; const hash = window.location.hash.slice(1);
if (modules.some((m) => m.id === hash)) return hash;
// 再看 pathname
const pathModule = PATH_MAP[window.location.pathname];
if (pathModule && modules.some((m) => m.id === pathModule)) return pathModule;
// 默认第一个
return modules[0]?.id ?? ''; return modules[0]?.id ?? '';
} }
function getHashModule(modules: ModuleConfig[]): string { function getHashModule(modules: ModuleConfig[]): string {
const head = getHashHead(); const hash = window.location.hash.slice(1);
return modules.some((m) => m.id === head) ? head : ''; return modules.some((m) => m.id === hash) ? hash : '';
} }
export function Shell({ modules }: { modules: ModuleConfig[] }) { export function Shell({ modules }: { modules: ModuleConfig[] }) {
const flatModules = useMemo(() => flattenModules(modules), [modules]); const [activeModule, setActiveModule] = useState(() => getInitialModule(modules));
const [activeModule, setActiveModule] = useState(() => getInitialModule(flattenModules(modules)));
const [openGroupId, setOpenGroupId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const onHashChange = () => { const onHashChange = () => {
const h = getHashModule(flatModules); const h = getHashModule(modules);
if (h) setActiveModule(h); if (h) setActiveModule(h);
}; };
window.addEventListener('hashchange', onHashChange); window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange); return () => window.removeEventListener('hashchange', onHashChange);
}, [flatModules]); }, [modules]);
useEffect(() => { useEffect(() => {
if (!openGroupId) return undefined; // 同步 hash 到当前模块:使用 replaceState 避免产生多余的 history 记录,
const handleKeyDown = (event: KeyboardEvent) => { // 否则在小程序/webview 环境下首次进入需要点两次返回才能退出
if (event.key === 'Escape') setOpenGroupId(null); if (window.location.hash.slice(1) !== activeModule) {
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [openGroupId]);
useEffect(() => {
// 同步 hash 一段到当前模块:使用 replaceState 避免产生多余的 history 记录,
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出。
// 注意只比对一级段,避免把子模块写入的 `#<id>/<sub>` 二级段抹掉。
if (getHashHead() !== activeModule) {
const { pathname, search } = window.location; const { pathname, search } = window.location;
window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`); window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`);
} }
}, [activeModule]); }, [activeModule]);
const switchModule = (id: string) => { const switchModule = (id: string) => {
setOpenGroupId(null); if (window.location.hash.slice(1) === id) return;
if (getHashHead() === id) {
setActiveModule(id);
return;
}
const { pathname, search } = window.location; const { pathname, search } = window.location;
window.history.replaceState(null, '', `${pathname}${search}#${id}`); window.history.replaceState(null, '', `${pathname}${search}#${id}`);
setActiveModule(id); setActiveModule(id);
}; };
const ActiveComponent = flatModules.find((module) => module.id === activeModule)?.component ?? flatModules[0]?.component; const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component;
const { user } = useAuth(); const { user } = useAuth();
const activeLabel = flatModules.find((module) => module.id === activeModule)?.label ?? '业务看板';
const watermarkText = useMemo(() => { const watermarkText = useMemo(() => {
const name = user?.userName || '未登录'; const name = user?.userName || '未登录';
const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-'); const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-');
@@ -91,7 +74,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
return ( return (
<DemoModeProvider enabled={false}> <DemoModeProvider enabled={false}>
<div className="enterprise-grid-bg flex min-h-screen"> <div className="flex min-h-screen">
{/* 全局水印 */} {/* 全局水印 */}
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}> <div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
<div className="absolute inset-0" style={{ <div className="absolute inset-0" style={{
@@ -100,114 +83,49 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
}} /> }} />
</div> </div>
{/* Web 侧边栏 (md 及以上) */} {/* Web 侧边栏 (md 及以上) */}
<nav className="fixed left-0 top-0 z-50 hidden h-full w-20 flex-col items-center border-r border-white/10 bg-slate-950 px-2 py-4 text-white shadow-[12px_0_40px_rgba(15,23,42,0.16)] md:flex"> <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">
<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"> {modules.map((m) => {
<Building2 size={22} /> const Icon = m.icon;
</div> const isActive = m.id === activeModule;
<div className="flex w-full flex-1 flex-col items-center gap-2"> return (
{modules.map((m) => { <button
const Icon = m.icon; key={m.id}
const isGroup = Boolean(m.children?.length); onClick={() => switchModule(m.id)}
const isActive = m.id === activeModule || Boolean(m.children?.some((child) => child.id === activeModule)); className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${
const isOpen = openGroupId === m.id; isActive
return ( ? 'bg-blue-50 text-blue-600'
<div key={m.id} className="relative"> : 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
<button }`}
type="button" >
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)} <Icon size={22} />
aria-expanded={isGroup ? isOpen : undefined} <span className="text-[10px] mt-1 leading-tight">{m.label}</span>
aria-haspopup={isGroup ? 'menu' : undefined} </button>
className={cn( );
'group relative flex h-16 w-16 flex-col items-center justify-center rounded-2xl text-[10px] font-black transition-all', })}
isActive ? 'text-white' : 'text-slate-400 hover:bg-white/8 hover:text-white',
)}
title={m.label}
>
{isActive ? (
<motion.span layoutId="desktop-shell-active" className="absolute inset-0 rounded-2xl bg-blue-600 shadow-lg shadow-blue-950/30" transition={{ type: 'spring', stiffness: 430, damping: 34 }} />
) : null}
<Icon size={21} className="relative mb-1" />
<span className="relative leading-tight">{m.label}</span>
{isGroup ? <ChevronRight size={11} className={cn('absolute right-1 top-1/2 -translate-y-1/2 transition-transform', isOpen && 'rotate-180')} /> : null}
</button>
{isGroup && isOpen ? (
<div role="menu" aria-label={`${m.label}二级菜单`} className="absolute left-[calc(100%+12px)] top-1/2 w-36 -translate-y-1/2 rounded-2xl border border-slate-700 bg-slate-900 p-2 shadow-2xl shadow-slate-950/30">
{m.children!.map((child) => {
const ChildIcon = child.icon;
const childActive = child.id === activeModule;
return (
<button key={child.id} type="button" role="menuitem" onClick={() => switchModule(child.id)} className={cn('flex h-11 w-full items-center gap-2 rounded-xl px-3 text-xs font-black transition-colors', childActive ? 'bg-blue-600 text-white' : 'text-slate-300 hover:bg-white/8 hover:text-white')}>
<ChildIcon size={16} />{child.label}
</button>
);
})}
</div>
) : null}
</div>
);
})}
</div>
<div className="flex w-full flex-col items-center gap-2 border-t border-white/10 pt-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-400/10 text-emerald-300 ring-1 ring-emerald-300/20" title={user?.userName || '当前用户'}>
<ShieldCheck size={18} />
</div>
<div className="max-w-16 truncate text-center text-[9px] font-bold text-slate-400">{user?.userName || '未登录'}</div>
</div>
</nav> </nav>
{/* 内容区 */} {/* 内容区 */}
<main className="min-w-0 flex-1 pb-16 md:ml-20 md:pb-0" style={{ overflowX: 'clip' }}> <main className="flex-1 md:ml-16 pb-16 md:pb-0 min-w-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"> {ActiveComponent && <ActiveComponent />}
<span className="text-slate-600"> BI</span>
<span className="mx-2 text-slate-300">/</span>
<span>{activeLabel}</span>
</div>
<Suspense fallback={<div className="p-3 md:p-6"><LoadingState label="正在加载业务模块" /></div>}>
{ActiveComponent && <ActiveComponent />}
</Suspense>
<FeedbackFab module={activeModule} /> <FeedbackFab module={activeModule} />
</main> </main>
{/* 移动端底部导航 (md 以下) */} {/* 移动端底部导航 (md 以下) */}
<nav className="fixed 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"> <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">
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${modules.length}, minmax(0, 1fr))` }}>
{modules.map((m) => { {modules.map((m) => {
const Icon = m.icon; const Icon = m.icon;
const isGroup = Boolean(m.children?.length); const isActive = m.id === activeModule;
const isActive = m.id === activeModule || Boolean(m.children?.some((child) => child.id === activeModule));
const isOpen = openGroupId === m.id;
return ( return (
<div key={m.id} className="relative flex justify-center"> <button
<button key={m.id}
type="button" onClick={() => switchModule(m.id)}
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)} className={`flex flex-col items-center ${isActive ? 'text-blue-600' : 'text-gray-400'}`}
aria-expanded={isGroup ? isOpen : undefined} >
aria-haspopup={isGroup ? 'menu' : undefined} <Icon size={20} />
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')} <span className="text-[10px] mt-1">{m.label}</span>
> </button>
{isActive ? (
<motion.span layoutId="mobile-shell-active" className="absolute inset-0 rounded-2xl bg-blue-50 ring-1 ring-blue-100" transition={{ type: 'spring', stiffness: 430, damping: 34 }} />
) : null}
<Icon size={20} className="relative" />
<span className="relative">{m.label}</span>
</button>
{isGroup && isOpen ? (
<div role="menu" aria-label={`${m.label}二级菜单`} className="absolute bottom-[calc(100%+14px)] left-1/2 w-36 -translate-x-1/2 rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl shadow-slate-900/15">
{m.children!.map((child) => {
const ChildIcon = child.icon;
const childActive = child.id === activeModule;
return (
<button key={child.id} type="button" role="menuitem" onClick={() => switchModule(child.id)} className={cn('flex h-11 w-full items-center gap-2 rounded-xl px-3 text-xs font-medium transition-colors', childActive ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-50')}>
<ChildIcon size={16} />{child.label}
</button>
);
})}
</div>
) : null}
</div>
); );
})} })}
</div>
</nav> </nav>
</div> </div>
</DemoModeProvider> </DemoModeProvider>
@@ -1,148 +0,0 @@
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>
);
}
@@ -1,88 +0,0 @@
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>
);
}
-177
View File
@@ -1,177 +0,0 @@
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>
);
}
@@ -1,24 +0,0 @@
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>
);
}
@@ -1,78 +0,0 @@
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>
);
}
-8
View File
@@ -1,8 +0,0 @@
export type FeedbackStep = 1 | 2 | 3;
export type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other';
export interface UploadedImg {
url: string;
thumbDataUrl: string;
}
-300
View File
@@ -1,300 +0,0 @@
import { useState, type ComponentType, type ReactNode } from 'react';
import { AlertCircle, Info, Loader2, SearchX, X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { cn } from '../../lib/cn';
export type SurfaceIcon = ComponentType<{ size?: number; className?: string }>;
export function PageFrame({
title,
subtitle,
icon: Icon,
eyebrow,
meta,
actions,
children,
maxWidth = 'max-w-6xl',
compactInfo = false,
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>
);
}
-82
View File
@@ -1,15 +1,8 @@
@import "tailwindcss"; @import "tailwindcss";
:root {
--app-bg: #f4f7fb;
--panel-bg: rgba(255, 255, 255, 0.9);
--hairline: rgba(148, 163, 184, 0.18);
}
html, body { html, body {
overscroll-behavior: none; overscroll-behavior: none;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
background: var(--app-bg);
} }
html { html {
@@ -20,79 +13,4 @@ html {
body { body {
overflow: auto; overflow: auto;
height: 100%; height: 100%;
color: #0f172a;
background: #fff;
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button, input, select {
font-family: inherit;
}
.amap-logo,
.amap-copyright {
opacity: 0.82;
}
.amap-toolbar {
border: 1px solid #e2e8f0 !important;
border-radius: 10px !important;
box-shadow: 0 8px 24px rgb(15 23 42 / 10%) !important;
overflow: hidden;
}
@media (max-width: 767px) {
.amap-logo,
.amap-copyright {
bottom: 60px !important;
}
}
@keyframes marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@keyframes floatUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@utility animate-marquee {
animation: marquee 30s linear infinite;
}
@utility no-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar { display: none; }
}
.shimmer {
position: relative;
}
.shimmer::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.72), transparent);
animation: shimmer 1.4s infinite;
}
.enterprise-grid-bg {
background:
radial-gradient(circle at 16% 0%, rgba(59, 130, 246, 0.08), transparent 28%),
radial-gradient(circle at 90% 12%, rgba(20, 184, 166, 0.08), transparent 26%),
linear-gradient(180deg, #f8fbff 0%, var(--app-bg) 42%, #f7f9fc 100%);
}
.asset-date-input::-webkit-calendar-picker-indicator {
cursor: pointer;
opacity: 0;
} }
-3
View File
@@ -1,3 +0,0 @@
export function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ');
}
+26 -26
View File
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react'; import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react';
import { fetchJson } from '../../auth/api-client'; import { fetchJson } from '../../auth/api-client';
import { EmptyState, LoadingState, PageFrame, SurfaceCard } from '../../components/ui/surface';
interface FeedbackItem { interface FeedbackItem {
id: number; id: number;
@@ -21,10 +20,10 @@ interface FeedbackItem {
} }
const TYPE_LABEL: Record<string, string> = { const TYPE_LABEL: Record<string, string> = {
dimension: '新维度', dimension: '💡 新维度',
bug: 'Bug', bug: '🐛 Bug',
ux: '体验', ux: '🎨 体验',
other: '其他', other: '📝 其他',
}; };
const STATUS_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [ const STATUS_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [
@@ -127,36 +126,36 @@ export default function FeedbackAdminPage() {
}, {}); }, {});
return ( return (
<PageFrame <div className="min-h-screen bg-[#F8F9FB] p-4 md:p-8">
title="用户反馈管理" <div className="max-w-5xl mx-auto space-y-4">
subtitle="查看、回复、跟进用户提交的建议,形成从问题发现到处理闭环的运营后台。" <header className="flex items-center justify-between">
icon={Inbox} <div className="flex items-center gap-3 min-w-0">
eyebrow="FEEDBACK OPS"
meta={`当前 ${items.length} 条反馈`}
actions={(
<div className="flex items-center gap-2">
<button <button
onClick={() => { onClick={() => {
// 优先 history.back(来自 SPA 内部跳转);否则回到主页 // 优先 history.back(来自 SPA 内部跳转);否则回到主页
if (window.history.length > 1) window.history.back(); if (window.history.length > 1) window.history.back();
else { window.location.hash = '#mileage'; } else { window.location.hash = '#mileage'; }
}} }}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-100 bg-white text-slate-500 transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600" className="w-9 h-9 rounded-xl bg-white border border-slate-100 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 text-slate-500 flex items-center justify-center transition-colors flex-shrink-0"
title="返回" title="返回"
> >
<ArrowLeft size={16} /> <ArrowLeft size={16} />
</button> </button>
<button onClick={reload} className="flex h-9 w-9 items-center justify-center rounded-xl bg-slate-900 text-white shadow-sm transition-colors hover:bg-slate-800" title="刷新"> <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center flex-shrink-0">
<Inbox size={18} className="text-white" />
</div>
<div className="min-w-0">
<h1 className="text-lg font-black text-slate-900 leading-tight"></h1>
<p className="text-[11px] font-bold text-slate-400"></p>
</div>
</div>
<button onClick={reload} className="p-2 text-slate-400 hover:text-blue-500 flex-shrink-0" title="刷新">
<RotateCcw size={16} className={loading ? 'animate-spin' : ''} /> <RotateCcw size={16} className={loading ? 'animate-spin' : ''} />
</button> </button>
</div> </header>
)}
maxWidth="max-w-5xl"
>
{/* 状态过滤 */} {/* 状态过滤 */}
<SurfaceCard className="p-2"> <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-2 flex items-center gap-1 overflow-x-auto">
<div className="flex items-center gap-1 overflow-x-auto">
<button <button
onClick={() => setStatusFilter('')} onClick={() => setStatusFilter('')}
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === '' ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`} className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === '' ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
@@ -171,7 +170,6 @@ export default function FeedbackAdminPage() {
</button> </button>
))} ))}
</div> </div>
</SurfaceCard>
{error && ( {error && (
<div className="bg-rose-50 border border-rose-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-rose-600"> <div className="bg-rose-50 border border-rose-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-rose-600">
@@ -194,9 +192,9 @@ export default function FeedbackAdminPage() {
{/* 列表 */} {/* 列表 */}
<div className="space-y-2"> <div className="space-y-2">
{loading && items.length === 0 ? ( {loading && items.length === 0 ? (
<LoadingState label="正在加载用户反馈" /> <div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold"></div>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<EmptyState title="还没有反馈" description="新的用户反馈会出现在这里" /> <div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold"></div>
) : items.map(it => { ) : items.map(it => {
const shots = parseScreenshots(it.screenshots); const shots = parseScreenshots(it.screenshots);
const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status); const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status);
@@ -220,7 +218,7 @@ export default function FeedbackAdminPage() {
{(shots.length > 0 || it.contact) && ( {(shots.length > 0 || it.contact) && (
<div className="flex items-center gap-3 mt-2 text-[10px] text-slate-400 font-bold"> <div className="flex items-center gap-3 mt-2 text-[10px] text-slate-400 font-bold">
{shots.length > 0 && <span className="flex items-center gap-0.5"><ImageIcon size={11} />{shots.length} </span>} {shots.length > 0 && <span className="flex items-center gap-0.5"><ImageIcon size={11} />{shots.length} </span>}
{it.contact && <span> {it.contact}</span>} {it.contact && <span>📞 {it.contact}</span>}
</div> </div>
)} )}
{it.reply_content && ( {it.reply_content && (
@@ -238,6 +236,8 @@ export default function FeedbackAdminPage() {
); );
})} })}
</div> </div>
</div>
{/* 详情 / 回复弹窗 */} {/* 详情 / 回复弹窗 */}
<AnimatePresence> <AnimatePresence>
{active && ( {active && (
@@ -336,6 +336,6 @@ export default function FeedbackAdminPage() {
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
</PageFrame> </div>
); );
} }
File diff suppressed because it is too large Load Diff
-47
View File
@@ -78,43 +78,6 @@ export interface WeeklyDetailItem {
customer_name: string | null; customer_name: string | null;
} }
export type FlowType = 'delivered' | 'returned' | 'replaced';
export interface FlowDailyPoint {
date: string;
delivered: number;
returned: number;
replaced: number;
total: number;
}
export interface FlowDetailItem {
id: string;
type: FlowType;
typeLabel: string;
date: string;
truckId: string;
plateNumber: string;
eventTime: string | null;
submitTime: string | null;
department: string;
manager: string;
customerName: string | null;
}
export interface FlowStatsResponse {
start: string;
end: string;
daily: FlowDailyPoint[];
totals: {
delivered: number;
returned: number;
replaced: number;
total: number;
};
details: FlowDetailItem[];
}
export async function fetchDeptStats(subject?: string | null): Promise<DeptGroup[]> { export async function fetchDeptStats(subject?: string | null): Promise<DeptGroup[]> {
return fetchJson<DeptGroup[]>(withSubject(`${BASE}/dept-stats`, subject)); return fetchJson<DeptGroup[]>(withSubject(`${BASE}/dept-stats`, subject));
} }
@@ -162,13 +125,3 @@ export async function fetchWeeklyDetail(
if (filters?.source) params.set('source', filters.source); if (filters?.source) params.set('source', filters.source);
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?${params.toString()}`); return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?${params.toString()}`);
} }
export async function fetchFlowStats(params: {
start: string;
end: string;
subject?: string | null;
}): Promise<FlowStatsResponse> {
const query = new URLSearchParams({ start: params.start, end: params.end });
if (params.subject) query.set('subject', params.subject);
return fetchJson<FlowStatsResponse>(`${BASE}/flow-stats?${query.toString()}`);
}
@@ -1,261 +0,0 @@
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 />
</>
);
}
@@ -1,149 +0,0 @@
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>
</>
);
}
@@ -1,64 +0,0 @@
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}
/>
</>
);
}
@@ -1,282 +0,0 @@
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>
</>
);
}
@@ -1,203 +0,0 @@
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>
);
}
@@ -1,129 +0,0 @@
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>
);
}
@@ -1,26 +0,0 @@
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>
);
}
@@ -1,118 +0,0 @@
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>
</>;
}
@@ -1,129 +0,0 @@
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>
);
}
@@ -1,153 +0,0 @@
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>
);
}
@@ -1,117 +0,0 @@
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>
);
}
@@ -1,203 +0,0 @@
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>
);
}
@@ -1,105 +0,0 @@
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>
</>
);
}
@@ -1,72 +0,0 @@
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;
};
-324
View File
@@ -1,324 +0,0 @@
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);
});
-416
View File
@@ -1,416 +0,0 @@
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);
}
-7
View File
@@ -24,7 +24,6 @@ export interface BatchSummary {
batch: string; batch: string;
total: number; total: number;
inventory: number; inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>; inventoryRegions: Record<string, number>;
pending: number; pending: number;
operating: number; operating: number;
@@ -37,7 +36,6 @@ export interface ModelSummary {
model: string; model: string;
total: number; total: number;
inventory: number; inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>; inventoryRegions: Record<string, number>;
pending: number; pending: number;
operating: number; operating: number;
@@ -51,7 +49,6 @@ export interface TypeSummary {
type: string; type: string;
totalAssets: number; totalAssets: number;
totalInventory: number; totalInventory: number;
totalAbnormal: number;
totalOperating: number; totalOperating: number;
inventoryRegions: Record<string, number>; inventoryRegions: Record<string, number>;
pending: number; pending: number;
@@ -65,7 +62,6 @@ export interface BatchGroup {
batch: string; batch: string;
total: number; total: number;
inventory: number; inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>; inventoryRegions: Record<string, number>;
pending: number; pending: number;
operating: number; operating: number;
@@ -77,7 +73,6 @@ export interface BatchGroup {
type: string; type: string;
total: number; total: number;
inventory: number; inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>; inventoryRegions: Record<string, number>;
pending: number; pending: number;
operating: number; operating: number;
@@ -111,7 +106,6 @@ export interface VehicleListItem {
city: string | null; city: string | null;
status: string; status: string;
ownership: string; ownership: string;
rentCompany?: string | null;
contractNo: string | null; contractNo: string | null;
customerName: string | null; customerName: string | null;
subjectOrg: string | null; subjectOrg: string | null;
@@ -158,7 +152,6 @@ export interface RegionTypeBreakdown {
total: number; total: number;
operating: number; operating: number;
inventory: number; inventory: number;
abnormal: number;
pending: number; pending: number;
customers: string[]; customers: string[];
} }
+310 -93
View File
@@ -1,28 +1,63 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowLeft, Upload, Zap } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react';
import {
Upload, FileSpreadsheet, RotateCcw, CheckCircle2, AlertCircle,
Truck, ExternalLink, Layers, Zap, ArrowLeft,
} from 'lucide-react';
import { fetchJson } from '../../auth/api-client'; import { fetchJson } from '../../auth/api-client';
import { useAuth } from '../../auth/useAuth'; import { useAuth } from '../../auth/useAuth';
import FeedbackFab from '../../components/FeedbackFab';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { PageFrame } from '../../components/ui/surface'; import FeedbackFab from '../../components/FeedbackFab';
import { BatchTable } from './import-page/BatchTable';
import { RecordFilters } from './import-page/RecordFilters';
import { RecordTable } from './import-page/RecordTable';
import { SummaryKpis } from './import-page/SummaryKpis';
import { UploadDropzone } from './import-page/UploadDropzone';
import { UploadFeedback } from './import-page/UploadFeedback';
import type {
BatchRow,
ListItem,
OverallRow,
UploadResult,
VehicleKindFilter,
} from './import-page/types';
function getJwt(): string | null { function getJwt(): string | null {
return sessionStorage.getItem('bi_jwt'); return sessionStorage.getItem('bi_jwt');
} }
interface UploadResult {
ok: boolean;
filename: string;
batchId: string;
parsed: number;
fileDuplicates: number;
inserted: number;
dbDuplicates: number;
breakdown: { internal: number; external: number };
}
interface ListItem {
id: number;
order_no: string;
station_name: string | null;
terminal_name: string | null;
region: string | null;
city: string | null;
start_time: string | null;
end_time: string | null;
duration_min: number | null;
kwh: number | null;
fee: number | null;
e_fee: number | null;
service_fee: number | null;
plate: string | null;
judged_plate: string | null;
customer_name: string | null;
vehicle_kind: 'internal' | 'external' | 'unknown';
batch_id: string;
imported_at: string;
}
interface OverallRow { vehicle_kind: 'internal' | 'external'; records: number; total_kwh: number; total_fee: number; }
interface BatchRow { batch_id: string; imported_at: string; records: number; internal_count: number; external_count: number; total_kwh: number; total_fee: number; }
const KIND_LABEL: Record<string, string> = {
internal: '内部',
external: '外部',
};
const KIND_STYLE: Record<string, string> = {
internal: 'bg-blue-50 text-blue-600 border-blue-200',
external: 'bg-amber-50 text-amber-600 border-amber-200',
};
async function uploadFile(file: File): Promise<UploadResult> { async function uploadFile(file: File): Promise<UploadResult> {
const fd = new FormData(); const fd = new FormData();
fd.append('file', file); fd.append('file', file);
@@ -47,7 +82,7 @@ export default function EleImportPage() {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [overall, setOverall] = useState<OverallRow[]>([]); const [overall, setOverall] = useState<OverallRow[]>([]);
const [batches, setBatches] = useState<BatchRow[]>([]); const [batches, setBatches] = useState<BatchRow[]>([]);
const [filter, setFilter] = useState<VehicleKindFilter>(''); const [filter, setFilter] = useState<'' | 'internal' | 'external'>('');
const [batchFilter, setBatchFilter] = useState(''); const [batchFilter, setBatchFilter] = useState('');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState(''); const [searchInput, setSearchInput] = useState('');
@@ -74,13 +109,13 @@ export default function EleImportPage() {
reload().catch(e => console.error(e)); reload().catch(e => console.error(e));
}, [reload]); }, [reload]);
const handleUpload = async (selectedFile: File) => { const handleUpload = async (f: File) => {
setUploading(true); setUploading(true);
setError(null); setError(null);
setResult(null); setResult(null);
try { try {
const nextResult = await uploadFile(selectedFile); const r = await uploadFile(f);
setResult(nextResult); setResult(r);
await reload(); await reload();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : String(e)); setError(e instanceof Error ? e.message : String(e));
@@ -89,87 +124,269 @@ export default function EleImportPage() {
} }
}; };
const onPick = (selectedFile: File | null) => { const onPick = (f: File | null) => {
setFile(selectedFile); setFile(f);
if (selectedFile) handleUpload(selectedFile); if (f) handleUpload(f);
}; };
const overallMap = new Map(overall.map(o => [o.vehicle_kind, o]));
const totalRecords = overall.reduce((s, o) => s + Number(o.records || 0), 0);
const totalKwh = overall.reduce((s, o) => s + Number(o.total_kwh || 0), 0);
const totalFee = overall.reduce((s, o) => s + Number(o.total_fee || 0), 0);
return ( return (
<PageFrame <div className="min-h-screen bg-[#F8F9FB] text-gray-800 p-4 md:p-8">
title="充电记录导入" <div className="max-w-6xl mx-auto space-y-4">
subtitle="每日上传 xlsx,按订单编号去重,并自动匹配内部/外部车辆归属。" <header className="flex items-center justify-between">
icon={Zap} <div className="flex items-center gap-3 min-w-0">
eyebrow="ELECTRIC IMPORT" <button
meta={user?.userName || '导入工作台'} onClick={() => {
actions={( if (window.history.length > 1) window.history.back();
<div className="flex items-center gap-2"> else { window.location.hash = '#mileage'; }
<button }}
onClick={() => { 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"
if (window.history.length > 1) window.history.back(); title="返回"
else { window.location.hash = '#mileage'; } >
}} <ArrowLeft size={16} />
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" </button>
title="返回" <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" />
<ArrowLeft size={16} /> </div>
</button> <div className="min-w-0">
<button <h1 className="text-lg font-black text-slate-900 leading-tight"></h1>
onClick={() => inputRef.current?.click()} <p className="text-[11px] font-bold text-slate-400"> xlsx · · </p>
className="inline-flex h-9 items-center gap-1.5 rounded-xl bg-slate-900 px-3 text-xs font-black text-white shadow-sm transition-colors hover:bg-slate-800" </div>
> </div>
<Upload size={14} /> <span className="text-[10px] font-bold text-slate-400 flex-shrink-0">{user?.userName || ''}</span>
</header>
</button>
</div>
)}
>
<UploadDropzone
inputRef={inputRef}
file={file}
uploading={uploading}
dragOver={dragOver}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
const droppedFile = e.dataTransfer.files?.[0];
if (droppedFile) onPick(droppedFile);
}}
onClick={() => inputRef.current?.click()}
onInputChange={(e) => onPick(e.target.files?.[0] || null)}
/>
<UploadFeedback {/* 上传区 */}
result={result} <section
error={error} onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onCloseResult={() => setResult(null)} onDragLeave={() => setDragOver(false)}
onCloseError={() => setError(null)} onDrop={(e) => {
/> e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files?.[0];
if (f) onPick(f);
}}
onClick={() => inputRef.current?.click()}
className={`bg-white rounded-2xl border-2 border-dashed shadow-sm cursor-pointer transition-all ${
dragOver ? 'border-blue-400 bg-blue-50/40' : uploading ? 'border-slate-200' : 'border-slate-200 hover:border-blue-300'
}`}
>
<input
ref={inputRef}
type="file"
accept=".xlsx,.xls"
className="hidden"
onChange={(e) => onPick(e.target.files?.[0] || null)}
/>
<div className="px-6 py-10 flex flex-col items-center text-center">
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3">
{uploading ? <RotateCcw size={22} className="text-blue-500 animate-spin" /> : <Upload size={22} className="text-blue-500" />}
</div>
<div className="text-sm font-bold text-slate-700 mb-1">
{uploading ? '正在解析...' : file ? file.name : '点击或拖拽 xlsx 文件到此处'}
</div>
<div className="text-[11px] text-slate-400 max-w-md leading-relaxed">
</div>
</div>
</section>
<SummaryKpis overall={overall} /> {/* 上传结果提示 */}
<AnimatePresence>
{result && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="bg-white rounded-2xl border border-emerald-100 shadow-sm p-4 flex items-start gap-3"
>
<CheckCircle2 size={18} className="text-emerald-500 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="text-[12px] font-bold text-slate-700 mb-1">
<span className="text-slate-500 font-mono">{result.filename}</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 text-[11px]">
<Stat label="解析" value={result.parsed} color="text-slate-700" />
<Stat label="新增" value={result.inserted} color="text-blue-600" />
<Stat label="重复跳过" value={result.fileDuplicates + result.dbDuplicates} color="text-slate-500" />
<Stat label="内部" value={result.breakdown.internal} color="text-blue-600" />
<Stat label="外部(含无车牌)" value={result.breakdown.external} color="text-amber-600" />
</div>
</div>
<button onClick={() => setResult(null)} className="text-slate-300 hover:text-slate-600 text-[10px] font-bold"></button>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="bg-red-50 rounded-2xl border border-red-200 shadow-sm p-4 flex items-start gap-3"
>
<AlertCircle size={18} className="text-red-500 flex-shrink-0 mt-0.5" />
<div className="flex-1 text-[12px] font-bold text-red-700">{error}</div>
<button onClick={() => setError(null)} className="text-red-300 hover:text-red-600 text-[10px] font-bold"></button>
</motion.div>
)}
</AnimatePresence>
<BatchTable {/* 聚合卡 */}
batches={batches} <section className="grid grid-cols-2 md:grid-cols-3 gap-2">
batchFilter={batchFilter} <KpiCard icon={<Layers size={14} />} label="总记录" value={totalRecords.toLocaleString()} />
onClearFilter={() => setBatchFilter('')} <KpiCard icon={<Truck size={14} />} label="内部记录" value={(overallMap.get('internal')?.records ?? 0).toLocaleString()} accent="blue" />
onSelectBatch={(batchId) => setBatchFilter(batchFilter === batchId ? '' : batchId)} <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>
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden"> {/* 批次 */}
<RecordFilters {batches.length > 0 && (
total={total} <section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
searchInput={searchInput} <div className="px-3 py-2 bg-slate-50 flex items-center justify-between">
filter={filter} <span className="text-[11px] font-bold text-slate-500"></span>
onSearchInputChange={(e) => setSearchInput(e.target.value)} {batchFilter && (
onSearchKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput); }} <button onClick={() => setBatchFilter('')} className="text-[10px] font-bold text-blue-500"></button>
onFilterChange={setFilter} )}
/> </div>
<RecordTable items={items} /> <div className="overflow-x-auto">
</section> <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>
)}
<RotatingFooterHint className="pb-4" /> {/* 列表 */}
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<div className="px-3 py-2 bg-slate-50 flex items-center gap-2 flex-wrap">
<span className="text-[11px] font-bold text-slate-500"></span>
<span className="text-[10px] font-bold text-slate-400"> {total.toLocaleString()} </span>
<div className="flex-1" />
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput); }}
placeholder="搜索订单/车牌/电站"
className="bg-white border border-slate-200 rounded-lg px-2 py-1 text-[11px] outline-none focus:ring-1 focus:ring-blue-500/20 w-44"
/>
<div className="flex gap-1 bg-white p-0.5 rounded-lg border border-slate-200">
{([['', '全部'], ['internal', '内部'], ['external', '外部']] as const).map(([k, label]) => (
<button
key={k}
onClick={() => setFilter(k as typeof filter)}
className={`px-2 py-0.5 rounded text-[10px] font-bold transition-colors ${filter === k ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
>{label}</button>
))}
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-[11px]">
<thead className="bg-slate-50/40 text-slate-400 font-bold">
<tr>
<th className="px-3 py-2 text-left whitespace-nowrap"></th>
<th className="px-3 py-2 text-left whitespace-nowrap"></th>
<th className="px-3 py-2 text-center whitespace-nowrap"></th>
<th className="px-3 py-2 text-left"> / </th>
<th className="px-3 py-2 text-right whitespace-nowrap">()</th>
<th className="px-3 py-2 text-right whitespace-nowrap">()</th>
<th className="px-3 py-2 text-right whitespace-nowrap">()</th>
<th className="px-3 py-2 text-left whitespace-nowrap"></th>
</tr>
</thead>
<tbody>
{items.map(it => (
<tr key={it.id} className="border-t border-slate-100 hover:bg-slate-50/60">
<td className="px-3 py-2 text-slate-600 whitespace-nowrap font-mono">{(it.start_time || '').replace('T', ' ').slice(0, 16)}</td>
<td className="px-3 py-2 font-bold text-slate-700 font-mono whitespace-nowrap">{it.plate || it.judged_plate || <span className="text-slate-300"></span>}</td>
<td className="px-3 py-2 text-center">
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold border ${KIND_STYLE[it.vehicle_kind]}`}>
{KIND_LABEL[it.vehicle_kind]}
</span>
</td>
<td className="px-3 py-2 text-slate-600 truncate max-w-xs">{it.station_name || '—'}{it.terminal_name ? ` · ${it.terminal_name}` : ''}</td>
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{Number(it.kwh ?? 0).toFixed(2)}</td>
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{Number(it.fee ?? 0).toFixed(2)}</td>
<td className="px-3 py-2 text-right text-slate-500 tabular-nums">{it.duration_min ?? '—'}</td>
<td className="px-3 py-2 text-slate-400 font-mono text-[10px]">{it.order_no}</td>
</tr>
))}
{items.length === 0 && (
<tr>
<td colSpan={8} className="px-3 py-8 text-center text-slate-300 text-[11px] font-bold">
<FileSpreadsheet size={18} className="mx-auto mb-2 text-slate-200" />
xlsx
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
<RotatingFooterHint className="pb-4" />
</div>
<FeedbackFab module="ele" /> <FeedbackFab module="ele" />
</PageFrame> </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>
); );
} }
@@ -1,56 +0,0 @@
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>
);
}
@@ -1,51 +0,0 @@
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>
);
}
@@ -1,54 +0,0 @@
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>
);
}
@@ -1,41 +0,0 @@
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>
);
}
@@ -1,62 +0,0 @@
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>
);
}
@@ -1,65 +0,0 @@
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>
);
}
-44
View File
@@ -1,44 +0,0 @@
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');
});
-41
View File
@@ -1,41 +0,0 @@
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);
}
-52
View File
@@ -1,52 +0,0 @@
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;
}
+44 -65
View File
@@ -1,46 +1,37 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Wallet } from 'lucide-react'; import { ChevronRight, Plug } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import TrendBadge from './TrendBadge'; import TrendBadge from './TrendBadge';
import { fetchElectricMonthly } from './api'; import { fetchElectricMonthly } from './api';
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types'; import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile } from '../../components/ui/surface';
import DailyRangeControls from './daily-range/DailyRangeControls'; const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
import { { id: 'thisWeek', label: '本周' },
getQuickRange, { id: 'thisMonth', label: '本月' },
getRangeModeLabel, { id: 'last15', label: '近 15 天' },
normalizeRange, ];
type RangeMode,
} from './daily-range/model';
import { summarizeElectricMonths } from './electric-daily/model';
export default function ElectricDaily() { export default function ElectricDaily() {
const [customer, setCustomer] = useState<CustomerType>('lingniu'); const [customer, setCustomer] = useState<CustomerType>('lingniu');
const [pick, setPick] = useState<RangeMode>('last15'); const [pick, setPick] = useState<DateQuickPick>('last15');
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null); const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set()); const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setError(null); setError(null);
const query = pick === 'custom' fetchElectricMonthly(customer, pick)
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick };
fetchElectricMonthly(customer, query)
.then(m => { .then(m => {
if (cancelled) return; if (cancelled) return;
setMonths(m); setMonths(m);
// 默认展开最新一个月 // 默认展开最新一个月
if (m.length > 0) setOpenMonths(new Set([m[0].month])); if (m.length > 0) setOpenMonths(prev => prev.size > 0 ? prev : new Set([m[0].month]));
}) })
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); }); .catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [customer, pick, effectiveRange.start, effectiveRange.end]); }, [customer, pick]);
const toggleMonth = (m: string) => setOpenMonths(prev => { const toggleMonth = (m: string) => setOpenMonths(prev => {
const next = new Set(prev); const next = new Set(prev);
@@ -48,53 +39,41 @@ export default function ElectricDaily() {
return next; return next;
}); });
const { const totalKwh = useMemo(() => (months ?? []).reduce((s, m) => s + (m.kwh || 0), 0), [months]);
totalKwh,
totalFee,
activeDays,
abnormalDays,
avgKwh,
avgPrice,
hasFeeDetail,
} = useMemo(() => summarizeElectricMonths(months), [months]);
const scopeLabel = getRangeModeLabel(pick);
const rangeText = `${effectiveRange.start}${effectiveRange.end}`;
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0; const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
const applyQuickPick = (nextPick: DateQuickPick) => {
setPick(nextPick);
setDateRange(getQuickRange(nextPick));
};
const updateDateRange = (field: 'start' | 'end', value: string) => {
if (!value) return;
setPick('custom');
setDateRange(prev => ({ ...prev, [field]: value }));
};
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<DailyRangeControls {/* 日期速选 */}
pick={pick} <div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
dateRange={dateRange} {QUICK_PICK_OPTIONS.map(opt => (
customer={customer} <button
onQuickPick={applyQuickPick} key={opt.id}
onCustomPick={() => setPick('custom')} onClick={() => setPick(opt.id)}
onDateRangeChange={updateDateRange} className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
onCustomerChange={setCustomer} pick === opt.id
/> ? 'bg-blue-50 text-blue-600 border-blue-200'
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
</div>
<div 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} /> <div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
<MetricTile {(['lingniu', 'external'] as const).map(c => (
icon={Wallet} <button
label="充电费用" key={c}
value={hasFeeDetail ? `¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '待同步'} onClick={() => setCustomer(c)}
helper={hasFeeDetail ? `均价 ${avgPrice.toFixed(2)} 元/度` : '当前明细仅返回充电量'} className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
tone={hasFeeDetail ? 'emerald' : 'slate'} customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
/> }`}
<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'} /> {c === 'external' ? '外部车辆' : '羚牛车辆'}
</button>
))}
</div> </div>
{/* 外部车辆 数据未就绪 */} {/* 外部车辆 数据未就绪 */}
@@ -127,11 +106,11 @@ export default function ElectricDaily() {
<span className="text-right"></span> <span className="text-right"></span>
</div> </div>
{error ? ( {error ? (
<div className="p-3"><ErrorState message={error} /></div> <div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">{error}</div>
) : months === null ? ( ) : months === null ? (
<div className="p-3"><LoadingState label="正在加载充电明细" /></div> <div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div>
) : months.length === 0 ? ( ) : months.length === 0 ? (
<div className="p-3"><EmptyState title="暂无充电数据" description="请切换时间范围或车辆归属" /></div> <div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div>
) : months.map(m => { ) : months.map(m => {
const open = openMonths.has(m.month); const open = openMonths.has(m.month);
return ( return (
-33
View File
@@ -1,33 +0,0 @@
import { LayoutDashboard, CalendarDays } from 'lucide-react';
import { AnimatePresence } from 'motion/react';
import ElectricView, { type ElectricSubTab } from './ElectricView';
import SubTabs from './SubTabs';
import { useHashSubTab } from './useHashSubTab';
import { FadeIn, PageFrame } from '../../components/ui/surface';
const SUB_TABS = [
{ id: 'daily', label: '每日', icon: CalendarDays },
{ id: 'overview', label: '总览', icon: LayoutDashboard },
] as const satisfies readonly { id: ElectricSubTab; label: string; icon: typeof CalendarDays }[];
const SUB_IDS: readonly ElectricSubTab[] = ['daily', 'overview'];
export default function ElectricModule() {
const [sub, setSub] = useHashSubTab<ElectricSubTab>('electric', SUB_IDS);
return (
<PageFrame
title="电能成本看板"
subtitle="围绕充电量、费用、日趋势和车辆归属展示电能支出结构,辅助识别费用波动。"
icon={CalendarDays}
eyebrow="ELECTRIC BI"
meta="时间单位清晰标注 · 支持日/总览切换"
>
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<AnimatePresence mode="wait">
<FadeIn key={sub}>
<ElectricView sub={sub} />
</FadeIn>
</AnimatePresence>
</PageFrame>
);
}
+22 -45
View File
@@ -1,9 +1,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { BatteryCharging, Gauge, Wallet, CalendarClock } from 'lucide-react'; import { Wallet, CalendarClock } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts'; import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
import { fetchElectricOverview, type ElectricOverviewResponse } from './api'; import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
function fmtYuan(yuan: number) { function fmtYuan(yuan: number) {
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`; return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
@@ -25,10 +24,10 @@ export default function ElectricOverview() {
}, []); }, []);
if (error) { if (error) {
return <ErrorState message={error} />; return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">{error}</div>;
} }
if (!data) { if (!data) {
return <LoadingState label="正在加载电能总览" />; return <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6 text-center text-slate-400 text-sm"></div>;
} }
const k = data.kpi; const k = data.kpi;
const trendData = data.trend; const trendData = data.trend;
@@ -38,12 +37,6 @@ export default function ElectricOverview() {
const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
? `${trendMonthLabel} 每日充电` ? `${trendMonthLabel} 每日充电`
: '本月每日充电'; : '本月每日充电';
const activeDays = trendData.filter(item => item.kwh > 0).length;
const avgDailyKwh = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.kwh, 0) / activeDays : 0;
const avgDailyFee = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.fee, 0) / activeDays : 0;
const peakDay = trendData.reduce<typeof trendData[number] | null>((best, item) => (!best || item.kwh > best.kwh ? item : best), null);
const avgPrice = k.totalKwh > 0 ? k.totalFee / k.totalKwh : 0;
const monthPrice = k.monthKwh > 0 ? k.monthFee / k.monthKwh : 0;
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -51,36 +44,28 @@ export default function ElectricOverview() {
2025-01-01 2025-01-01
</div> </div>
{/* 横向 mini KPI 头 */} {/* 横向 mini KPI 头 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4"> <div className="grid grid-cols-2 gap-2 md:gap-3">
<MetricTile icon={Wallet} label="累计充电费" value={fmtYuan(k.totalFee)} helper={fmtKwh(k.totalKwh)} /> <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<MetricTile icon={CalendarClock} label="本月充电费" value={fmtYuan(k.monthFee)} helper={fmtKwh(k.monthKwh)} tone="emerald" /> <div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
<MetricTile icon={Gauge} label="累计均价" value={avgPrice.toFixed(2)} unit="元/度" helper={`本月 ${monthPrice.toFixed(2)} 元/度`} tone="amber" /> <Wallet size={11} className="text-blue-600" />
<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> <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 className="grid grid-cols-1 gap-3 md:grid-cols-3"> </div>
<SurfaceCard className="p-3"> <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="text-[11px] font-black text-slate-400"></div> <div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
<div className="mt-1 text-xl font-black text-slate-900">{activeDays}<span className="ml-1 text-[11px] text-slate-400"></span></div> <CalendarClock size={11} className="text-blue-600" />
<div className="mt-1 text-[11px] font-bold text-slate-500"> {avgDailyKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} </div> </div>
</SurfaceCard> <div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.monthFee)}</div>
<SurfaceCard className="p-3"> <div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.monthKwh)}</div>
<div className="text-[11px] font-black text-slate-400"></div> </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>
{/* 本月每日充电柱图 */} {/* 本月每日充电柱图 */}
<SurfaceCard className="p-4"> <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700">{chartTitle}</span> <span className="text-sm font-bold text-slate-700">{chartTitle}</span>
<span className="text-[11px] text-slate-400 font-bold"> · · 线</span> <span className="text-[11px] text-slate-400 font-bold"> </span>
</div> </div>
<ResponsiveContainer width="100%" height={160}> <ResponsiveContainer width="100%" height={160}>
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}> <BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
@@ -100,14 +85,6 @@ export default function ElectricOverview() {
contentStyle={{ borderRadius: 12, fontSize: 12 }} contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }} cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
/> />
{avgDailyFee > 0 && (
<ReferenceLine
y={avgDailyFee}
stroke="#f59e0b"
strokeDasharray="4 4"
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
/>
)}
<Bar dataKey="fee" radius={[4, 4, 0, 0]}> <Bar dataKey="fee" radius={[4, 4, 0, 0]}>
{trendData.map((_, i) => ( {trendData.map((_, i) => (
<Cell key={i} fill="url(#electricBarGrad)" /> <Cell key={i} fill="url(#electricBarGrad)" />
@@ -121,7 +98,7 @@ export default function ElectricOverview() {
</defs> </defs>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</SurfaceCard> </div>
<RotatingFooterHint /> <RotatingFooterHint />
</div> </div>
); );
+86
View File
@@ -0,0 +1,86 @@
import { useState } from 'react';
import { Fuel, BatteryCharging, Receipt, LayoutDashboard, CalendarDays } from 'lucide-react';
import { motion } from 'motion/react';
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
import ElectricView, { type ElectricSubTab } from './ElectricView';
import ETCView from './ETCView';
type TopTab = 'hydrogen' | 'electric' | 'etc';
type SubTabId = HydrogenSubTab | ElectricSubTab; // 'daily' | 'overview'
const TABS: { key: TopTab; label: string; icon: typeof Fuel }[] = [
{ key: 'hydrogen', label: '氢能', icon: Fuel },
{ key: 'electric', label: '电能', icon: BatteryCharging },
{ key: 'etc', label: 'ETC', icon: Receipt },
];
const SUB_TABS: { id: SubTabId; label: string; icon: typeof LayoutDashboard }[] = [
{ id: 'daily', label: '每日', icon: CalendarDays },
{ id: 'overview', label: '总览', icon: LayoutDashboard },
];
export default function EnergyModule() {
const [activeTab, setActiveTab] = useState<TopTab>('hydrogen');
const [hydroSub, setHydroSub] = useState<HydrogenSubTab>('daily');
const [electricSub, setElectricSub] = useState<ElectricSubTab>('daily');
const showSubTabs = activeTab === 'hydrogen' || activeTab === 'electric';
const currentSub: SubTabId = activeTab === 'electric' ? electricSub : hydroSub;
const setSub = (id: SubTabId) => activeTab === 'electric' ? setElectricSub(id) : setHydroSub(id);
return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
{/* 统一 sticky 头部:top tab + (氢能时) 子 tab;同一张卡片,无间隙 */}
{/* pb-4 留一点底部缓冲,避免下方快捷选按钮在滚动时贴着 sticky 半截露脸 */}
<div className="sticky top-0 z-30 -mx-3 md:-mx-6 px-3 md:px-6 -mt-3 md:-mt-6 pt-3 md:pt-6 pb-4 bg-[#F8F9FB] shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)]">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
{/* 顶部 tab:氢能 / 电能 / ETC */}
<div className={`px-4 py-2 flex items-center gap-6 ${showSubTabs ? 'border-b border-slate-50' : ''}`}>
{TABS.map(tab => {
const Icon = tab.icon;
const active = activeTab === tab.key;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-2 py-1 transition-colors relative ${active ? 'text-blue-600' : 'text-slate-400 hover:text-slate-600'}`}
>
<Icon size={14} />
<span className="text-[11px] font-bold">{tab.label}</span>
{active && (
<motion.div layoutId="activeEnergyTopTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
);
})}
</div>
{/* 子 tab:氢能 / 电能 都显示 每日 / 总览 */}
{showSubTabs && (
<div className="p-1 flex gap-1">
{SUB_TABS.map(({ id, label, icon: Icon }) => {
const active = currentSub === id;
return (
<button
key={id}
onClick={() => setSub(id)}
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
active ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
}`}
>
<Icon size={14} />
<span>{label}</span>
</button>
);
})}
</div>
)}
</div>
</div>
{activeTab === 'hydrogen' && <HydrogenView sub={hydroSub} />}
{activeTab === 'electric' && <ElectricView sub={electricSub} />}
{activeTab === 'etc' && <ETCView />}
</div>
</div>
);
}
-17
View File
@@ -1,17 +0,0 @@
import { Receipt } from 'lucide-react';
import ETCView from './ETCView';
import { PageFrame } from '../../components/ui/surface';
export default function EtcModule() {
return (
<PageFrame
title="ETC 通行费看板"
subtitle="规划按车、按月、按线路拆分通行费,让车辆运营成本口径逐步完整。"
icon={Receipt}
eyebrow="ETC BI"
meta="数据对接中 · 页面能力预留"
>
<ETCView />
</PageFrame>
);
}
+53 -103
View File
@@ -1,55 +1,37 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-react'; import { ChevronRight, Plug } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts'; import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
import TrendBadge from './TrendBadge'; import TrendBadge from './TrendBadge';
import { fetchHydrogenDaily } from './api'; import { fetchHydrogenDaily } from './api';
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types'; import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
import {
getQuickRange,
getRangeModeLabel,
normalizeRange,
summarizeHydrogenRows,
type RangeMode,
} from './hydrogen-daily/model';
import DailyRangeControls from './daily-range/DailyRangeControls';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'thisWeek', label: '本周' },
{ id: 'thisMonth', label: '本月' },
{ id: 'last15', label: '近 15 天' },
];
export default function HydrogenDaily() { export default function HydrogenDaily() {
const [pick, setPick] = useState<RangeMode>('last15'); const [pick, setPick] = useState<DateQuickPick>('last15');
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
const [customer, setCustomer] = useState<CustomerType>('lingniu'); const [customer, setCustomer] = useState<CustomerType>('lingniu');
const [expanded, setExpanded] = useState<Set<string>>(new Set()); const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null); const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setError(null); setError(null);
const query = pick === 'custom' fetchHydrogenDaily(pick, customer)
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick };
fetchHydrogenDaily(query, customer)
.then(r => { if (!cancelled) setRows(r); }) .then(r => { if (!cancelled) setRows(r); })
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); }); .catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [pick, customer, effectiveRange.start, effectiveRange.end]); }, [pick, customer]);
const { // 柱图:按日期升序,用于"从左到右时间流"
trendData, const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
totalKg, const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0);
activeDays,
stationCount,
avgKg,
peakDay,
lowDay,
zeroDays,
} = useMemo(() => summarizeHydrogenRows(rows), [rows]);
const scopeLabel = getRangeModeLabel(pick);
const rangeText = `${effectiveRange.start}${effectiveRange.end}`;
const toggle = (date: string) => setExpanded(prev => { const toggle = (date: string) => setExpanded(prev => {
const next = new Set(prev); const next = new Set(prev);
@@ -57,34 +39,38 @@ export default function HydrogenDaily() {
return next; return next;
}); });
const applyQuickPick = (nextPick: DateQuickPick) => {
setPick(nextPick);
setDateRange(getQuickRange(nextPick));
};
const updateDateRange = (field: 'start' | 'end', value: string) => {
if (!value) return;
setPick('custom');
setDateRange(prev => ({ ...prev, [field]: value }));
};
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<DailyRangeControls {/* 日期速选 */}
pick={pick} <div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
dateRange={dateRange} {QUICK_PICK_OPTIONS.map(opt => (
customer={customer} <button
onQuickPick={applyQuickPick} key={opt.id}
onCustomPick={() => setPick('custom')} onClick={() => setPick(opt.id)}
onDateRangeChange={updateDateRange} className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
onCustomerChange={setCustomer} pick === opt.id
/> ? 'bg-blue-50 text-blue-600 border-blue-200'
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4"> {/* 客户类型 segmented */}
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} /> <div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" /> {(['lingniu', 'external'] as const).map(c => (
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" /> <button
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" /> 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> </div>
{/* 外部车辆:新系统数据还没准备好 */} {/* 外部车辆:新系统数据还没准备好 */}
@@ -110,34 +96,13 @@ export default function HydrogenDaily() {
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */} {/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && ( {!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
<SurfaceCard> <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="flex items-center justify-between px-4 pt-4 mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700"></span> <span className="text-sm font-bold text-slate-700"></span>
<span className="text-[11px] text-slate-400 font-bold"> · Kg</span> <span className="text-[11px] text-slate-400 font-bold"> Kg</span>
</div> </div>
<div className="mx-4 mb-2 grid grid-cols-3 gap-2 rounded-xl bg-slate-50 p-2"> <ResponsiveContainer width="100%" height={160}>
<div> <BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
{peakDay ? `${peakDay.date.slice(5)} · ${peakDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
</div>
</div>
<div>
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
{lowDay ? `${lowDay.date.slice(5)} · ${lowDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
</div>
</div>
<div>
<div className="text-[10px] font-black text-slate-400"></div>
<div className={`mt-0.5 text-[11px] font-black ${zeroDays > 0 ? 'text-amber-600' : 'text-emerald-600'}`}>
{zeroDays}
</div>
</div>
</div>
<div className="h-[180px] min-w-0 px-2 pb-2">
<ResponsiveContainer width="100%" height={180} minWidth={0}>
<BarChart data={trendData} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<XAxis <XAxis
dataKey="date" dataKey="date"
tickFormatter={(v: string) => v.slice(5)} tickFormatter={(v: string) => v.slice(5)}
@@ -147,27 +112,13 @@ export default function HydrogenDaily() {
interval="preserveStartEnd" interval="preserveStartEnd"
minTickGap={8} minTickGap={8}
/> />
<YAxis <YAxis hide />
width={42}
axisLine={false}
tickLine={false}
tick={{ fontSize: 9, fill: '#94a3b8' }}
tickFormatter={(v: number) => v >= 1000 ? `${Math.round(v / 1000)}k` : `${Math.round(v)}`}
/>
<Tooltip <Tooltip
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']} formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']}
labelFormatter={(d) => `日期 ${d}`} labelFormatter={(d) => `日期 ${d}`}
contentStyle={{ borderRadius: 12, fontSize: 12 }} contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }} cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
/> />
{avgKg > 0 && (
<ReferenceLine
y={avgKg}
stroke="#f59e0b"
strokeDasharray="4 4"
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
/>
)}
<Bar dataKey="totalKg" radius={[4, 4, 0, 0]}> <Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
{trendData.map((_, i) => ( {trendData.map((_, i) => (
<Cell key={i} fill="url(#hydrogenBarGrad)" /> <Cell key={i} fill="url(#hydrogenBarGrad)" />
@@ -181,8 +132,7 @@ export default function HydrogenDaily() {
</defs> </defs>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</SurfaceCard>
)} )}
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */} {/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
@@ -204,11 +154,11 @@ export default function HydrogenDaily() {
</div> </div>
{/* 主行 + 子行 */} {/* 主行 + 子行 */}
{error ? ( {error ? (
<div className="p-3"><ErrorState message={error} /></div> <div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">{error}</div>
) : rows === null ? ( ) : rows === null ? (
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div> <div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div>
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div> <div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div>
) : rows.map(r => { ) : rows.map(r => {
const open = expanded.has(r.date); const open = expanded.has(r.date);
const isAbnormal = Math.abs(r.chainPct) >= 0.3; const isAbnormal = Math.abs(r.chainPct) >= 0.3;
-33
View File
@@ -1,33 +0,0 @@
import { LayoutDashboard, CalendarDays } from 'lucide-react';
import { AnimatePresence } from 'motion/react';
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
import SubTabs from './SubTabs';
import { useHashSubTab } from './useHashSubTab';
import { FadeIn, PageFrame } from '../../components/ui/surface';
const SUB_TABS = [
{ id: 'daily', label: '每日', icon: CalendarDays },
{ id: 'overview', label: '总览', icon: LayoutDashboard },
] as const satisfies readonly { id: HydrogenSubTab; label: string; icon: typeof CalendarDays }[];
const SUB_IDS: readonly HydrogenSubTab[] = ['daily', 'overview'];
export default function HydrogenModule() {
const [sub, setSub] = useHashSubTab<HydrogenSubTab>('hydrogen', SUB_IDS);
return (
<PageFrame
title="氢能经营看板"
subtitle="按时间、车辆归属、加氢站和区域统一展示加氢量、费用、收入与异常波动。"
icon={CalendarDays}
eyebrow="ENERGY BI"
meta="数据单位清晰标注 · 支持日/总览切换"
>
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<AnimatePresence mode="wait">
<FadeIn key={sub}>
<HydrogenView sub={sub} />
</FadeIn>
</AnimatePresence>
</PageFrame>
);
}
+586 -51
View File
@@ -1,16 +1,89 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import RotatingFooterHint from '../../components/RotatingFooterHint'; 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 { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
import { DistributionCharts } from './hydrogen-overview/components/DistributionCharts'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { HydrogenOverviewSkeleton } from './hydrogen-overview/components/HydrogenOverviewSkeleton';
import { InsightCards } from './hydrogen-overview/components/InsightCards';
import { KpiSection } from './hydrogen-overview/components/KpiSection';
import { MonthlyCharts } from './hydrogen-overview/components/MonthlyCharts';
import { OverviewHeader } from './hydrogen-overview/components/OverviewHeader';
import { RefreshOverlay } from './hydrogen-overview/components/RefreshOverlay';
import { CustomerSummaryTable, StationSummaryTable } from './hydrogen-overview/components/SummaryTables';
import { deriveOverviewMetrics, formatYuan as fmtYuan } from './hydrogen-overview/model';
const REGION_COLORS = [
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
'#94a3b8',
];
interface YAxisTickProps {
x?: number;
y?: number;
index?: number;
payload?: { value: string };
}
function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
return (
<g transform={`translate(${x},${y})`}>
<circle cx={-172} cy={0} r={9} fill="#3b82f6" />
<text x={-172} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
{index + 1}
</text>
<text x={-154} y={4} textAnchor="start" fontSize={11} fill="#475569">
{payload?.value}
</text>
</g>
);
}
// ---------- 数字格式化 ----------
function fmtKg(kg: number): { value: string; unit: string } {
if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' };
return { value: kg.toFixed(2), unit: 'Kg' };
}
function fmtYuan(yuan: number): { value: string; unit: string } {
const abs = Math.abs(yuan);
if (abs >= 100_000_000) return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' };
if (abs >= 10_000) {
const w = yuan / 10_000;
return { value: w.toLocaleString('zh-CN', { maximumFractionDigits: 2 }), unit: '万元' };
}
return { value: yuan.toLocaleString('zh-CN', { maximumFractionDigits: 0 }), unit: '元' };
}
// ---------- KPI 卡 ----------
interface KpiCardProps {
icon: React.ReactNode;
label: string;
hero: { value: string; unit: string };
rows: { label: string; value: string; valueClass?: string }[];
accentClass: string;
iconBg: string;
}
function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) {
return (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className={`w-7 h-7 rounded-xl flex items-center justify-center ${iconBg}`}>
{icon}
</div>
<span className="text-[11px] font-bold text-slate-500">{label}</span>
</div>
<div className="flex items-baseline gap-1">
<span className={`text-xl md:text-2xl font-black tabular-nums leading-none ${accentClass}`}>{hero.value}</span>
<span className="text-[11px] text-slate-400 font-bold">{hero.unit}</span>
</div>
<div className="space-y-0.5 pt-1 border-t border-slate-50">
{rows.map((r, i) => (
<div key={i} className="flex items-center justify-between text-[11px] font-bold">
<span className="text-slate-400">{r.label}</span>
<span className={`tabular-nums ${r.valueClass ?? 'text-slate-700'}`}>{r.value}</span>
</div>
))}
</div>
</div>
);
}
// ============================================================
export default function HydrogenOverview() { export default function HydrogenOverview() {
const [data, setData] = useState<HydrogenOverviewResponse | null>(null); const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -51,56 +124,518 @@ export default function HydrogenOverview() {
if (!data) { if (!data) {
return <HydrogenOverviewSkeleton />; return <HydrogenOverviewSkeleton />;
} }
const k = data.kpi;
const top5 = data.top5;
const regions = data.regions;
const monthly = data.monthly;
const customers = data.customers;
const stations = data.stations;
const availableYears = data.availableYears;
const activeYear = data.year;
const { kpi, top5, regions, monthly, customers, stations, availableYears, year: activeYear } = data; const yearKgFmt = fmtKg(k.yearKg);
const { const yearFeeFmt = fmtYuan(k.yearFee);
monthAvgKg, const yearProfitFmt = fmtYuan(k.yearProfit);
bestMonth, const ourYearKgFmt = fmtKg(k.ourYearKg);
latestMonth, const customerYearKgFmt = fmtKg(k.customerYearKg);
monthMomentum, const monthKgFmt = fmtKg(k.monthKg);
top5Share, const monthFeeFmt = fmtYuan(k.monthFee);
profitYield, const todayKgFmt = fmtKg(k.todayKg);
stationAvgKg, const todayFeeFmt = fmtYuan(k.todayFee);
monthlyDual, const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee);
} = deriveOverviewMetrics(data); const customerYearFeeFmt = fmtYuan(customerYearFee);
const yearProfitFmt = fmtYuan(kpi.yearProfit); const yearRevenueFmt = fmtYuan(k.yearRevenue);
const yearRevenueFmt = fmtYuan(kpi.yearRevenue);
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
// 月度收支组合数据(推算"年内每月"图)
const monthlyDual = monthly.map(m => ({
...m,
monthLabel: m.month.slice(5).replace(/^0/, '') + '月',
}));
return ( return (
<div className="flex flex-col gap-3 relative"> <div className="flex flex-col gap-3 relative">
<OverviewHeader {/* 顶部说明条 + 年份切换 + 刷新按钮 */}
activeYear={activeYear} <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">
availableYears={availableYears} <span className="truncate">{lastRefreshAt ? `更新于 ${formatRelative(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
lastRefreshAt={lastRefreshAt} <div className="flex items-center gap-2 flex-shrink-0">
refreshing={refreshing} <div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
onSelectYear={setYear} {availableYears.map(y => {
onRefresh={() => void load(year, true)} 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>
<KpiSection kpi={kpi} /> {/* 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>
<InsightCards {/* 月度趋势:年内每月加氢量 */}
monthAvgKg={monthAvgKg} {monthly.length > 0 && (
bestMonth={bestMonth} <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
latestMonth={latestMonth} <div className="flex items-center justify-between mb-2">
monthMomentum={monthMomentum} <span className="text-sm font-bold text-slate-700">{activeYear} </span>
top5Share={top5Share} <span className="text-[11px] text-slate-400 font-bold"> Kg</span>
profitYield={profitYield} </div>
stationAvgKg={stationAvgKg} <ResponsiveContainer width="100%" height={140}>
stationCount={stations.length} <BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
yearProfitValue={yearProfitFmt.value} <XAxis
yearProfitUnit={yearProfitFmt.unit} dataKey="monthLabel"
yearRevenueValue={yearRevenueFmt.value} tick={{ fontSize: 10, fill: '#94a3b8' }}
yearRevenueUnit={yearRevenueFmt.unit} 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>
)}
<MonthlyCharts activeYear={activeYear} monthly={monthly} monthlyDual={monthlyDual} /> {/* 月度收支对比 */}
<DistributionCharts top5={top5} regions={regions} yearKg={kpi.yearKg} /> {monthly.length > 0 && (
<StationSummaryTable stations={stations} /> <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<CustomerSummaryTable customers={customers} /> <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>
)}
<RotatingFooterHint /> <RotatingFooterHint />
<RefreshOverlay refreshing={refreshing} hasData={Boolean(data)} />
{/* 刷新中:透明遮罩 + 顶部进度条(不替换内容,避免闪烁) */}
<AnimatePresence>
{refreshing && data && (
<motion.div
key="refresh-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed top-0 left-0 right-0 h-0.5 z-50 pointer-events-none overflow-hidden"
>
<motion.div
className="h-full bg-gradient-to-r from-blue-400 via-cyan-400 to-blue-400"
initial={{ x: '-100%' }}
animate={{ x: '100%' }}
transition={{ duration: 1.2, repeat: Infinity, ease: 'linear' }}
style={{ width: '40%' }}
/>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function formatRelative(ts: number): string {
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
if (s < 5) return '刚刚';
if (s < 60) return `${s} 秒前`;
const m = Math.floor(s / 60);
if (m < 60) return `${m} 分钟前`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} 小时前`;
return new Date(ts).toLocaleString('zh-CN', { hour12: false });
}
function HydrogenOverviewSkeleton() {
return (
<div className="flex flex-col gap-3 animate-pulse">
<div className="bg-white rounded-xl border border-slate-100 px-3 py-2">
<div className="h-3 w-44 bg-slate-100 rounded" />
</div>
{/* 5 卡占位 */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 space-y-2">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-xl bg-slate-100" />
<div className="h-3 w-16 bg-slate-100 rounded" />
</div>
<div className="h-7 w-24 bg-slate-200 rounded" />
<div className="space-y-1.5 pt-1 border-t border-slate-50">
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
</div>
</div>
))}
</div>
{/* 月度柱图占位 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="flex items-center justify-between mb-3">
<div className="h-4 w-32 bg-slate-100 rounded" />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
<div className="flex items-end gap-2 h-[120px]">
{[60, 75, 50, 80, 35, 90, 45].map((h, i) => (
<div key={i} className="flex-1 bg-slate-100 rounded-t" style={{ height: `${h}%` }} />
))}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="flex items-center justify-between mb-3">
<div className="h-4 w-32 bg-slate-100 rounded" />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
<div className="space-y-3">
{[100, 78, 56, 40, 28].map((w, i) => (
<div key={i} className="flex items-center gap-3">
<div className="w-5 h-5 rounded-full bg-slate-200" />
<div className="h-3 w-32 bg-slate-100 rounded" />
<div className="flex-1 h-4 rounded-md bg-gradient-to-r from-slate-200 to-slate-100" style={{ maxWidth: `${w}%` }} />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
))}
</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-3">
<div className="h-4 w-28 bg-slate-100 rounded" />
<div className="flex items-center gap-3">
<div className="w-1/2 h-[200px] flex items-center justify-center">
<div className="w-32 h-32 rounded-full border-[18px] border-slate-100" />
</div>
<div className="flex-1 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-slate-200" />
<div className="h-3 w-16 bg-slate-100 rounded" />
<div className="h-3 w-10 bg-slate-100 rounded ml-auto" />
</div>
))}
</div>
</div>
</div>
</div>
<div className="text-center text-[11px] text-slate-400 font-bold flex items-center justify-center gap-1.5">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
</div>
</div> </div>
); );
} }
-22
View File
@@ -1,22 +0,0 @@
import type { ComponentType } from 'react';
import { SegmentedNav } from '../../components/ui/surface';
interface SubTab<T extends string> {
id: T;
label: string;
icon: ComponentType<{ size?: number; className?: string }>;
}
interface Props<T extends string> {
tabs: readonly SubTab<T>[];
active: T;
onChange: (id: T) => void;
}
export default function SubTabs<T extends string>({ tabs, active, onChange }: Props<T>) {
return (
<div className="sticky top-0 z-30 -mx-3 bg-[var(--app-bg)] px-3 pb-2 pt-1 shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)] md:-mx-6 md:top-12 md:px-6">
<SegmentedNav tabs={tabs} active={active} onChange={onChange} />
</div>
);
}
+4 -16
View File
@@ -27,17 +27,8 @@ export function fetchHydrogenOverview(year?: number, force = false): Promise<Hyd
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`); return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`);
} }
export interface HydrogenDailyQuery { export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise<HydrogenDailyRow[]> {
range?: DateQuickPick; const q = new URLSearchParams({ range, customer });
startDate?: string;
endDate?: string;
}
export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise<HydrogenDailyRow[]> {
const q = new URLSearchParams({ customer });
if (query.range) q.set('range', query.range);
if (query.startDate) q.set('startDate', query.startDate);
if (query.endDate) q.set('endDate', query.endDate);
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`); return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
} }
@@ -50,10 +41,7 @@ export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`); return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
} }
export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> { export function fetchElectricMonthly(customer: CustomerType, range: DateQuickPick = 'last15'): Promise<ElectricMonthGroup[]> {
const q = new URLSearchParams({ customer }); const q = new URLSearchParams({ customer, range });
if (query.range) q.set('range', query.range);
if (query.startDate) q.set('startDate', query.startDate);
if (query.endDate) q.set('endDate', query.endDate);
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`); return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
} }
@@ -1,92 +0,0 @@
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>
);
}
-48
View File
@@ -1,48 +0,0 @@
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 ?? '当前时段';
}
@@ -1,41 +0,0 @@
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,
});
});
@@ -1,25 +0,0 @@
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,
};
}
@@ -1,84 +0,0 @@
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,
});
});
@@ -1,41 +0,0 @@
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,
};
}
@@ -1,133 +0,0 @@
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>
);
}
@@ -1,80 +0,0 @@
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>
);
}
@@ -1,88 +0,0 @@
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>
);
}
@@ -1,114 +0,0 @@
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>
);
}
@@ -1,101 +0,0 @@
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>
)}
</>
);
}
@@ -1,53 +0,0 @@
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>
);
}
@@ -1,31 +0,0 @@
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>
);
}
@@ -1,123 +0,0 @@
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>
)}
</>
);
}
@@ -1,72 +0,0 @@
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月']);
});
@@ -1,75 +0,0 @@
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/, '')}`,
})),
};
}
-38
View File
@@ -1,38 +0,0 @@
import { useEffect, useState } from 'react';
/**
* tab URL hash
* hash `#<moduleId>`= sub `#<moduleId>/<sub>`
* hash
*/
export function useHashSubTab<T extends string>(
moduleId: string,
subs: readonly T[],
): [T, (sub: T) => void] {
const defaultSub = subs[0];
const parse = (): T => {
const hash = window.location.hash.slice(1);
const [first, second] = hash.split('/');
if (first !== moduleId) return defaultSub;
if (second && (subs as readonly string[]).includes(second)) return second as T;
return defaultSub;
};
const [sub, setSubState] = useState<T>(parse);
useEffect(() => {
const onChange = () => setSubState(parse());
window.addEventListener('hashchange', onChange);
return () => window.removeEventListener('hashchange', onChange);
}, [moduleId]);
const setSub = (next: T) => {
const { pathname, search } = window.location;
const newHash = next === defaultSub ? `#${moduleId}` : `#${moduleId}/${next}`;
window.history.replaceState(null, '', `${pathname}${search}${newHash}`);
setSubState(next);
};
return [sub, setSub];
}
@@ -1,148 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { AmapConfig, HydrogenHeatmapMetric, HydrogenHeatmapPoint } from './types';
type Props = {
config: AmapConfig;
points: HydrogenHeatmapPoint[];
max: number;
metric: HydrogenHeatmapMetric;
focusQuery: string;
onMapClick: (longitude: number, latitude: number) => void;
};
type AmapInstance = {
Map: new (container: HTMLElement, options: Record<string, unknown>) => any;
HeatMap: new (map: any, options: Record<string, unknown>) => any;
ToolBar: new (options?: Record<string, unknown>) => any;
Scale: new (options?: Record<string, unknown>) => any;
Bounds: new (southWest: [number, number], northEast: [number, number]) => any;
};
declare global {
interface Window {
_AMapSecurityConfig?: { securityJsCode: string };
}
}
function getBounds(points: HydrogenHeatmapPoint[]) {
if (!points.length) return null;
return points.reduce((bounds, point) => ({
minLng: Math.min(bounds.minLng, point.lng),
maxLng: Math.max(bounds.maxLng, point.lng),
minLat: Math.min(bounds.minLat, point.lat),
maxLat: Math.max(bounds.maxLat, point.lat),
}), { minLng: points[0].lng, maxLng: points[0].lng, minLat: points[0].lat, maxLat: points[0].lat });
}
export default function HydrogenAmapCanvas({ config, points, max, metric, focusQuery, onMapClick }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<any>(null);
const heatmapRef = useRef<any>(null);
const amapRef = useRef<AmapInstance | null>(null);
const clickHandlerRef = useRef(onMapClick);
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const intensity = useMemo(() => {
const transform = metric === 'kg'
? (value: number) => Math.log1p(value)
: (value: number) => Math.sqrt(value);
return {
points: points.map((point) => ({ ...point, count: transform(point.count) })),
max: transform(Math.max(1, max)),
};
}, [max, metric, points]);
useEffect(() => {
clickHandlerRef.current = onMapClick;
}, [onMapClick]);
useEffect(() => {
let cancelled = false;
const container = containerRef.current;
if (!container) return undefined;
async function initialize() {
try {
window._AMapSecurityConfig = { securityJsCode: config.securityCode };
const loaderModule = await import('@amap/amap-jsapi-loader');
const AMap = await loaderModule.default.load({
key: config.key,
version: '2.0',
plugins: ['AMap.HeatMap', 'AMap.ToolBar', 'AMap.Scale'],
}) as unknown as AmapInstance;
if (cancelled || !container) return;
const map = new AMap.Map(container, {
viewMode: '2D',
zoom: 5,
center: [105.4, 34.4],
mapStyle: 'amap://styles/whitesmoke',
resizeEnable: true,
showLabel: true,
});
map.addControl(new AMap.ToolBar({ position: { right: '20px', bottom: '76px' } }));
map.addControl(new AMap.Scale({ position: { right: '18px', bottom: '24px' } }));
const heatmap = new AMap.HeatMap(map, {
radius: 34,
opacity: [0.14, 0.84],
gradient: {
0.1: '#2563eb',
0.3: '#0891b2',
0.5: '#16a34a',
0.68: '#eab308',
0.84: '#f97316',
1: '#dc2626',
},
});
map.on('click', (event: any) => clickHandlerRef.current(event.lnglat.getLng(), event.lnglat.getLat()));
mapRef.current = map;
heatmapRef.current = heatmap;
amapRef.current = AMap;
setStatus('ready');
} catch (error) {
console.error('Hydrogen heatmap AMap initialization failed', error);
if (!cancelled) setStatus('error');
}
}
initialize();
return () => {
cancelled = true;
heatmapRef.current?.setMap?.(null);
mapRef.current?.destroy?.();
heatmapRef.current = null;
mapRef.current = null;
amapRef.current = null;
};
}, [config.key, config.securityCode]);
useEffect(() => {
if (status !== 'ready' || !heatmapRef.current) return;
heatmapRef.current.setDataSet({ data: intensity.points, max: intensity.max });
if (!focusQuery || !points.length || !mapRef.current || !amapRef.current) return;
const bounds = getBounds(points);
if (!bounds) return;
if (points.length === 1) {
mapRef.current.setZoomAndCenter(12, [points[0].lng, points[0].lat]);
return;
}
mapRef.current.setBounds(new amapRef.current.Bounds(
[bounds.minLng, bounds.minLat],
[bounds.maxLng, bounds.maxLat],
), false, [80, 80, 80, 80]);
}, [focusQuery, intensity, points, status]);
return (
<div className="absolute inset-0 bg-[#eef3f7]">
<div ref={containerRef} className="h-full w-full" aria-label="加氢站加氢热力图地图" />
{status === 'loading' ? (
<div className="absolute inset-0 grid place-items-center bg-white/76 text-sm font-medium text-slate-500 backdrop-blur-[2px]">
<span className="flex items-center gap-2.5"><i className="h-4 w-4 animate-spin rounded-full border-2 border-cyan-600 border-t-transparent" /></span>
</div>
) : null}
{status === 'error' ? (
<div className="absolute inset-0 grid place-items-center bg-slate-50 text-center">
<div><p className="text-sm font-semibold text-slate-700"></p><p className="mt-1 text-xs text-slate-400"> Key</p></div>
</div>
) : null}
</div>
);
}
@@ -1,85 +0,0 @@
import { ChevronRight, Fuel, X } from 'lucide-react';
import type { HydrogenHeatmapMetric, HydrogenStationRank } from './types';
type Props = {
title: string;
subtitle: string;
kg: number;
refuelCount: number;
vehicleCount: number;
stationCount: number;
stations: HydrogenStationRank[];
metric: HydrogenHeatmapMetric;
selected: boolean;
loading: boolean;
coverageText: string;
onClose: () => void;
onHide: () => void;
};
const integerFormat = new Intl.NumberFormat('zh-CN');
const kgFormat = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 });
function metricLabel(metric: HydrogenHeatmapMetric) {
if (metric === 'refuels') return '按加氢次数排序';
if (metric === 'vehicles') return '按车辆数排序';
return '按加氢量排序';
}
function metricValue(station: HydrogenStationRank, metric: HydrogenHeatmapMetric) {
if (metric === 'kg') return `${kgFormat.format(station.kg)} kg`;
if (metric === 'refuels') return `${integerFormat.format(station.refuelCount)}`;
return `${integerFormat.format(station.vehicleCount)}`;
}
export default function HydrogenHeatmapDetailPanel(props: Props) {
return (
<aside className="relative z-20 flex h-full w-[310px] shrink-0 flex-col border-l border-slate-200 bg-white max-lg:absolute max-lg:bottom-0 max-lg:left-0 max-lg:right-0 max-lg:h-[43vh] max-lg:w-full max-lg:rounded-t-2xl max-lg:border-l-0 max-lg:border-t max-lg:shadow-[0_-10px_30px_rgba(15,23,42,0.14)] max-md:h-[50dvh]">
<div className="border-b border-slate-100 px-5 pb-4 pt-5 max-md:px-4 max-md:pb-3 max-md:pt-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate text-[17px] font-semibold tracking-tight text-slate-900">{props.title}</h2>
{props.loading ? <i className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-cyan-600 border-t-transparent" /> : null}
</div>
<p className="mt-1 text-[11px] text-slate-400">{props.subtitle}</p>
<p className="mt-1 text-[10px] font-medium text-emerald-600">{props.coverageText}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{props.selected ? (
<button type="button" onClick={props.onClose} aria-label="关闭加氢区域详情" className="grid h-8 w-8 place-items-center rounded-lg text-slate-400 hover:bg-slate-50 hover:text-slate-700"><X size={18} strokeWidth={1.8} /></button>
) : null}
<button type="button" onClick={props.onHide} aria-label="隐藏加氢详情" title="隐藏详情" className="grid h-8 w-8 place-items-center rounded-lg text-slate-400 transition hover:bg-cyan-50 hover:text-cyan-700 max-md:h-10 max-md:w-10"><ChevronRight size={18} strokeWidth={1.8} /></button>
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-x-4 gap-y-3 border-t border-slate-100 pt-4 max-md:mt-3 max-md:gap-y-2 max-md:pt-3">
<div><p className="text-[10px] text-slate-400"></p><p className="mt-0.5 text-[19px] font-semibold tracking-tight text-cyan-700">{kgFormat.format(props.kg)} <small className="text-[10px] font-medium">kg</small></p></div>
<div><p className="text-[10px] text-slate-400"></p><p className="mt-0.5 text-[19px] font-semibold tracking-tight text-cyan-700">{integerFormat.format(props.refuelCount)}</p></div>
<div><p className="text-[10px] text-slate-400"></p><p className="mt-0.5 text-[16px] font-semibold text-slate-700">{integerFormat.format(props.vehicleCount)}</p></div>
<div><p className="text-[10px] text-slate-400"></p><p className="mt-0.5 text-[16px] font-semibold text-slate-700">{integerFormat.format(props.stationCount)}</p></div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto px-5 py-4 max-md:px-4 max-md:py-3">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[13px] font-semibold text-slate-800"> TOP10</h3>
<span className="text-[10px] text-slate-400">{metricLabel(props.metric)}</span>
</div>
{props.stations.length ? (
<ol className="divide-y divide-slate-100">
{props.stations.map((station, index) => (
<li key={station.stationId} className="grid grid-cols-[24px_minmax(0,1fr)_68px] items-center gap-2 py-2.5">
<span className={`text-[11px] font-medium ${index < 3 ? 'text-cyan-700' : 'text-slate-400'}`}>{index + 1}</span>
<span className="min-w-0"><span className="block truncate text-[12px] font-medium text-slate-700" title={station.stationName}>{station.stationName}</span><span className="mt-0.5 block truncate text-[9px] text-slate-400" title={station.address}>{station.address || '地址未维护'}</span></span>
<span className="text-right text-[10px] tabular-nums text-slate-600">{metricValue(station, props.metric)}</span>
</li>
))}
</ol>
) : (
<div className="grid h-44 place-items-center text-center text-xs text-slate-400"><span><Fuel className="mx-auto mb-2 text-slate-300" size={22} /></span></div>
)}
</div>
</aside>
);
}
@@ -1,85 +0,0 @@
import { CalendarDays, ChevronUp, RotateCcw, Search } from 'lucide-react';
import type { HydrogenHeatmapMetric, HydrogenPayer, HydrogenStationOption } from './types';
type Props = {
startDate: string;
endDate: string;
minDate: string;
maxDate: string;
query: string;
payer: HydrogenPayer;
metric: HydrogenHeatmapMetric;
stations: HydrogenStationOption[];
onStartDateChange: (value: string) => void;
onEndDateChange: (value: string) => void;
onQueryChange: (value: string) => void;
onPayerChange: (value: HydrogenPayer) => void;
onMetricChange: (value: HydrogenHeatmapMetric) => void;
onReset: () => void;
onHide: () => void;
};
const FIELD = 'relative h-11 min-w-0 rounded-lg border border-slate-200 bg-white transition focus-within:border-cyan-400 focus-within:ring-2 focus-within:ring-cyan-100';
const LABEL = 'pointer-events-none absolute left-3 top-1 z-10 text-[9px] font-medium leading-none text-slate-400';
const CONTROL = 'h-full w-full min-w-0 rounded-lg border-0 bg-transparent px-3 pt-3 text-[12px] text-slate-700 outline-none';
export default function HydrogenHeatmapFilters(props: Props) {
return (
<div className="pointer-events-auto grid max-h-[calc(100dvh-180px)] w-full grid-cols-2 gap-2 overflow-y-auto rounded-xl border border-slate-200/80 bg-white/96 p-2 shadow-[0_8px_28px_rgba(15,23,42,0.12)] backdrop-blur-md md:flex md:w-auto md:min-w-max md:items-center md:overflow-visible">
<label className={`${FIELD} col-span-2 md:w-[320px]`}>
<span className={`${LABEL} left-9`}></span>
<span className="flex h-full min-w-0 items-center pl-8 pt-2">
<CalendarDays className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={15} strokeWidth={1.8} />
<input aria-label="加氢开始日期" type="date" min={props.minDate} max={props.endDate} value={props.startDate} onChange={(event) => props.onStartDateChange(event.target.value)} className="h-full w-[124px] min-w-0 flex-1 border-0 bg-transparent px-1 text-[12px] text-slate-700 outline-none" />
<span className="shrink-0 px-0.5 text-slate-300"></span>
<input aria-label="加氢结束日期" type="date" min={props.startDate} max={props.maxDate} value={props.endDate} onChange={(event) => props.onEndDateChange(event.target.value)} className="h-full w-[124px] min-w-0 flex-1 border-0 bg-transparent px-1 text-[12px] text-slate-700 outline-none" />
</span>
</label>
<label className={`${FIELD} col-span-2 md:w-[145px]`}>
<span className={LABEL}></span>
<span className="relative block h-full">
<input
aria-label="搜索加氢站"
value={props.query}
list="hydrogen-heatmap-stations"
onChange={(event) => props.onQueryChange(event.target.value)}
placeholder="输入站点名称"
className={`${CONTROL} pr-9`}
/>
<Search className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} strokeWidth={1.8} />
<datalist id="hydrogen-heatmap-stations">
{props.stations.map((station) => <option key={station.stationId} value={station.stationName} />)}
</datalist>
</span>
</label>
<label className={`${FIELD} md:w-[100px]`}>
<span className={LABEL}></span>
<select aria-label="费用承担" value={props.payer} onChange={(event) => props.onPayerChange(event.target.value as HydrogenPayer)} className={CONTROL}>
<option value="all"></option>
<option value="lingniu"></option>
<option value="customer"></option>
</select>
</label>
<label className={`${FIELD} md:w-[100px]`}>
<span className={LABEL}></span>
<select aria-label="加氢统计维度" value={props.metric} onChange={(event) => props.onMetricChange(event.target.value as HydrogenHeatmapMetric)} className={CONTROL}>
<option value="kg"></option>
<option value="refuels"></option>
<option value="vehicles"></option>
</select>
</label>
<div className="col-span-2 flex min-w-0 justify-end gap-2 md:w-auto">
<button type="button" onClick={props.onReset} className="flex h-11 min-w-[72px] items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 text-[12px] font-medium text-slate-600 transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700">
<RotateCcw size={14} strokeWidth={1.8} />
</button>
<button type="button" onClick={props.onHide} aria-label="隐藏加氢筛选" title="隐藏筛选" className="grid h-11 w-11 shrink-0 place-items-center rounded-lg border border-slate-200 bg-white text-slate-400 transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700 md:w-10">
<ChevronUp size={16} strokeWidth={1.8} />
</button>
</div>
</div>
);
}
@@ -1,222 +0,0 @@
import { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, ChevronLeft, Clock3, Fuel, Maximize2, Minimize2, SlidersHorizontal } from 'lucide-react';
import HydrogenAmapCanvas from './HydrogenAmapCanvas';
import HydrogenHeatmapDetailPanel from './HydrogenHeatmapDetailPanel';
import HydrogenHeatmapFilters from './HydrogenHeatmapFilters';
import { fetchAmapConfig, fetchHydrogenHeatmapMeta, fetchHydrogenHeatmapPoints, fetchNearbyHydrogenStations } from './api';
import type { AmapConfig, HydrogenHeatmapMeta, HydrogenHeatmapMetric, HydrogenHeatmapResponse, HydrogenNearbyResponse, HydrogenPayer } from './types';
const DEFAULT_START = '2026-01-01';
const DEFAULT_END = '2026-07-13';
const integerFormat = new Intl.NumberFormat('zh-CN');
const kgFormat = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 });
function Metric({ value, label }: { value: string; label: string }) {
return (
<div className="min-w-[88px] text-center max-md:min-w-[68px]">
<p className="text-[21px] font-semibold leading-none tracking-tight text-cyan-700 tabular-nums max-md:text-[16px]">{value}</p>
<p className="mt-1.5 text-[11px] text-slate-500 max-md:mt-1 max-md:text-[9px]">{label}</p>
</div>
);
}
function metricName(metric: HydrogenHeatmapMetric) {
if (metric === 'refuels') return '加氢频次';
if (metric === 'vehicles') return '车辆覆盖';
return '加氢量强度';
}
function metricDescription(metric: HydrogenHeatmapMetric) {
if (metric === 'refuels') return '每个站点累计有效加氢订单数;平方根平滑强度。';
if (metric === 'vehicles') return '每个站点按车牌去重统计车辆数;平方根平滑强度。';
return '每个站点累计有效加氢量(kg);对数平滑强度。';
}
export default function HydrogenHeatmapModule() {
const [meta, setMeta] = useState<HydrogenHeatmapMeta | null>(null);
const [config, setConfig] = useState<AmapConfig | null>(null);
const [data, setData] = useState<HydrogenHeatmapResponse | null>(null);
const [nearby, setNearby] = useState<HydrogenNearbyResponse | null>(null);
const [startDate, setStartDate] = useState(DEFAULT_START);
const [endDate, setEndDate] = useState(DEFAULT_END);
const [query, setQuery] = useState('');
const [payer, setPayer] = useState<HydrogenPayer>('all');
const [metric, setMetric] = useState<HydrogenHeatmapMetric>('kg');
const [isFullscreen, setIsFullscreen] = useState(false);
const [showFilters, setShowFilters] = useState(() => typeof window === 'undefined' || !window.matchMedia('(max-width: 767px)').matches);
const [showDetails, setShowDetails] = useState(() => typeof window === 'undefined' || !window.matchMedia('(max-width: 767px)').matches);
const [loading, setLoading] = useState(true);
const [nearbyLoading, setNearbyLoading] = useState(false);
const [error, setError] = useState('');
const deferredQuery = useDeferredValue(query.trim());
useEffect(() => {
if (!isFullscreen) return undefined;
const previousOverflow = document.body.style.overflow;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsFullscreen(false);
};
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
};
}, [isFullscreen]);
useEffect(() => {
const controller = new AbortController();
Promise.all([fetchHydrogenHeatmapMeta(controller.signal), fetchAmapConfig(controller.signal)])
.then(([nextMeta, nextConfig]) => {
setMeta(nextMeta);
setConfig(nextConfig);
setStartDate(nextMeta.startDate);
setEndDate(nextMeta.endDate);
})
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('加氢热力图基础配置加载失败');
});
return () => controller.abort();
}, []);
useEffect(() => {
const controller = new AbortController();
const timer = window.setTimeout(() => {
setLoading(true);
setError('');
fetchHydrogenHeatmapPoints({ startDate, endDate, query: deferredQuery, payer, metric }, controller.signal)
.then((response) => {
setData(response);
setNearby(null);
})
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('加氢热力图数据加载失败,请稍后重试');
})
.finally(() => setLoading(false));
}, 220);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [deferredQuery, endDate, metric, payer, startDate]);
const handleMapClick = useCallback((longitude: number, latitude: number) => {
const controller = new AbortController();
setNearbyLoading(true);
fetchNearbyHydrogenStations({ lng: longitude, lat: latitude, startDate, endDate, query: deferredQuery, payer, metric }, controller.signal)
.then(setNearby)
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('区域加氢站明细加载失败');
})
.finally(() => setNearbyLoading(false));
}, [deferredQuery, endDate, metric, payer, startDate]);
const reset = () => {
setStartDate(meta?.startDate || DEFAULT_START);
setEndDate(meta?.endDate || DEFAULT_END);
setQuery('');
setPayer('all');
setMetric('kg');
setNearby(null);
};
const coverageText = meta
? `GPS覆盖 ${(meta.gpsCoverageRate * 100).toFixed(3)}% · 已排除 ${integerFormat.format(meta.excludedRefuelCount)} 笔缺坐标数据`
: '正在核对站点 GPS 覆盖率';
const detail = useMemo(() => {
if (nearby) {
return {
title: '选中区域',
subtitle: `${nearby.center.lng.toFixed(3)}, ${nearby.center.lat.toFixed(3)} · 半径 ${nearby.radiusKm} 公里`,
kg: nearby.kg,
refuelCount: nearby.refuelCount,
vehicleCount: nearby.vehicleCount,
stationCount: nearby.stationCount,
stations: nearby.topStations,
selected: true,
};
}
return {
title: deferredQuery ? '站点筛选结果' : '全国 · 当前范围',
subtitle: `${payer === 'all' ? '' : `${payer === 'lingniu' ? '羚牛承担' : '客户承担'} · `}${startDate} ${endDate}`,
kg: data?.kg || 0,
refuelCount: data?.refuelCount || 0,
vehicleCount: data?.vehicleCount || 0,
stationCount: data?.stationCount || 0,
stations: data?.topStations || [],
selected: false,
};
}, [data, deferredQuery, endDate, nearby, payer, startDate]);
return (
<section className={`flex flex-col overflow-hidden bg-white ${isFullscreen ? 'fixed inset-0 z-[100] h-screen w-screen min-h-0' : 'h-[100dvh] min-h-[620px] max-md:h-[calc(100dvh-4rem)] max-md:min-h-[520px]'}`}>
<header className="relative z-30 flex min-h-[98px] shrink-0 items-center justify-between border-b border-slate-200 bg-white px-8 max-md:min-h-[104px] max-md:flex-wrap max-md:gap-y-2 max-md:px-3 max-md:py-3">
<div className="min-w-0">
<h1 className="truncate text-[25px] font-semibold tracking-[-0.025em] text-slate-950 max-md:text-[18px]"></h1>
<p className="mt-1.5 flex items-center gap-1.5 text-[11px] text-slate-400 max-md:mt-1 max-md:text-[9px]"><Fuel size={13} /> × GPS · </p>
</div>
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-7 max-md:order-3 max-md:static max-md:w-full max-md:translate-x-0 max-md:translate-y-0 max-md:justify-center max-md:gap-2">
<Metric value={kgFormat.format(data?.kg ?? meta?.eligibleKg ?? 0)} label="加氢量 kg" />
<span className="h-8 w-px bg-slate-200" />
<Metric value={integerFormat.format(data?.refuelCount ?? meta?.eligibleRefuelCount ?? 0)} label="加氢次数" />
<span className="h-8 w-px bg-slate-200" />
<Metric value={integerFormat.format(data?.stationCount ?? meta?.eligibleStationCount ?? 0)} label="站点" />
</div>
<div className="flex items-center gap-3">
<p className="flex items-center gap-2 text-[12px] text-slate-500 max-xl:hidden"><Clock3 size={15} strokeWidth={1.7} /> {meta?.endDate || DEFAULT_END}</p>
<button type="button" onClick={() => setIsFullscreen((current) => !current)} className="grid h-9 w-9 place-items-center rounded-lg border border-slate-200 text-slate-500 transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700" title={isFullscreen ? '退出全屏' : '地图全屏'} aria-label={isFullscreen ? '退出加氢地图全屏' : '加氢地图全屏'} aria-pressed={isFullscreen}>
{isFullscreen ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
</button>
</div>
</header>
<div className="relative flex min-h-0 flex-1">
<div className="relative z-0 min-w-0 flex-1 overflow-hidden">
{config ? <HydrogenAmapCanvas config={config} points={data?.points || []} max={data?.max || 1} metric={metric} focusQuery={deferredQuery} onMapClick={handleMapClick} /> : <div className="absolute inset-0 bg-slate-100" />}
{showFilters ? (
<div className="pointer-events-none absolute left-3 right-3 top-3 z-10 md:left-5 md:right-auto md:top-5">
<HydrogenHeatmapFilters
startDate={startDate}
endDate={endDate}
minDate={meta?.startDate || DEFAULT_START}
maxDate={meta?.endDate || DEFAULT_END}
query={query}
payer={payer}
metric={metric}
stations={meta?.stations || []}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onQueryChange={setQuery}
onPayerChange={setPayer}
onMetricChange={setMetric}
onReset={reset}
onHide={() => setShowFilters(false)}
/>
</div>
) : (
<button type="button" onClick={() => setShowFilters(true)} aria-label="显示加氢筛选" className="absolute left-3 top-3 z-20 flex h-11 items-center gap-2 rounded-lg border border-slate-200 bg-white/95 px-3 text-xs font-medium text-slate-600 shadow-lg backdrop-blur-sm transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700 md:left-5 md:top-5 md:h-10"><SlidersHorizontal size={15} strokeWidth={1.8} /></button>
)}
{!showDetails ? (
<button type="button" onClick={() => setShowDetails(true)} aria-label="显示加氢详情" className={`absolute right-3 top-3 z-20 flex h-11 items-center gap-2 rounded-lg border border-slate-200 bg-white/95 px-3 text-xs font-medium text-slate-600 shadow-lg backdrop-blur-sm transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700 md:right-5 md:top-5 md:h-10 ${showFilters ? 'max-md:hidden' : ''}`}><ChevronLeft size={16} strokeWidth={1.8} /></button>
) : null}
<div className="pointer-events-none absolute bottom-5 left-5 z-10 w-[300px] rounded-xl border border-slate-200/80 bg-white/95 p-3.5 shadow-[0_8px_30px_rgba(15,23,42,0.11)] backdrop-blur-sm max-lg:bottom-[calc(43vh+16px)] max-md:hidden">
<div className="flex items-center justify-between text-[11px] font-medium text-slate-700"><span>{metricName(metric)}</span>{loading ? <i className="h-3 w-3 animate-spin rounded-full border-2 border-cyan-600 border-t-transparent" /> : null}</div>
<p className="mt-1 text-[10px] leading-4 text-slate-500">{metricDescription(metric)}</p>
<div className="mt-2.5 h-2.5 rounded-full bg-[linear-gradient(90deg,#2563eb_0%,#0891b2_25%,#16a34a_45%,#eab308_65%,#f97316_82%,#dc2626_100%)]" />
<div className="mt-1.5 flex justify-between text-[10px] text-slate-400"><span></span><span></span></div>
</div>
{error ? <div className="absolute bottom-5 left-1/2 z-30 flex -translate-x-1/2 items-center gap-2 rounded-lg bg-slate-900 px-4 py-2.5 text-xs text-white shadow-xl"><AlertTriangle size={15} className="text-amber-300" />{error}</div> : null}
</div>
{showDetails ? (
<HydrogenHeatmapDetailPanel {...detail} metric={metric} loading={nearbyLoading} coverageText={coverageText} onClose={() => setNearby(null)} onHide={() => setShowDetails(false)} />
) : null}
</div>
</section>
);
}
-51
View File
@@ -1,51 +0,0 @@
import { fetchJson } from '../../auth/api-client';
import type {
AmapConfig,
HydrogenHeatmapMeta,
HydrogenHeatmapMetric,
HydrogenHeatmapResponse,
HydrogenNearbyResponse,
HydrogenPayer,
} from './types';
const BASE = '/api/hydrogen-heatmap';
export function fetchAmapConfig(signal?: AbortSignal) {
return fetchJson<AmapConfig>(`${BASE}/config`, { signal });
}
export function fetchHydrogenHeatmapMeta(signal?: AbortSignal) {
return fetchJson<HydrogenHeatmapMeta>(`${BASE}/meta`, { signal });
}
export function fetchHydrogenHeatmapPoints(
params: { startDate: string; endDate: string; query: string; payer: HydrogenPayer; metric: HydrogenHeatmapMetric },
signal?: AbortSignal,
) {
return fetchJson<HydrogenHeatmapResponse>(`${BASE}/points?${new URLSearchParams(params)}`, { signal });
}
export function fetchNearbyHydrogenStations(
params: {
lng: number;
lat: number;
startDate: string;
endDate: string;
query: string;
payer: HydrogenPayer;
metric: HydrogenHeatmapMetric;
},
signal?: AbortSignal,
) {
const search = new URLSearchParams({
lng: String(params.lng),
lat: String(params.lat),
startDate: params.startDate,
endDate: params.endDate,
query: params.query,
payer: params.payer,
metric: params.metric,
radiusKm: '50',
});
return fetchJson<HydrogenNearbyResponse>(`${BASE}/nearby?${search}`, { signal });
}
-72
View File
@@ -1,72 +0,0 @@
export type HydrogenHeatmapMetric = 'kg' | 'refuels' | 'vehicles';
export type HydrogenPayer = 'all' | 'lingniu' | 'customer';
export type HydrogenHeatmapPoint = {
lng: number;
lat: number;
count: number;
};
export type HydrogenStationRank = {
stationId: string;
stationName: string;
address: string;
lng: number;
lat: number;
kg: number;
refuelCount: number;
vehicleCount: number;
firstRefuel: string;
lastRefuel: string;
metricValue: number;
};
export type HydrogenStationOption = {
stationId: string;
stationName: string;
};
export type HydrogenHeatmapMeta = {
startDate: string;
endDate: string;
totalRefuelCount: number;
eligibleRefuelCount: number;
excludedRefuelCount: number;
gpsCoverageRate: number;
totalKg: number;
eligibleKg: number;
vehicleCount: number;
totalStationCount: number;
eligibleStationCount: number;
stations: HydrogenStationOption[];
};
export type HydrogenHeatmapResponse = {
startDate: string;
endDate: string;
metric: HydrogenHeatmapMetric;
payer: HydrogenPayer;
kg: number;
refuelCount: number;
vehicleCount: number;
stationCount: number;
dayCount: number;
points: HydrogenHeatmapPoint[];
max: number;
topStations: HydrogenStationRank[];
};
export type HydrogenNearbyResponse = {
center: { lng: number; lat: number };
radiusKm: number;
kg: number;
refuelCount: number;
vehicleCount: number;
stationCount: number;
topStations: HydrogenStationRank[];
};
export type AmapConfig = {
key: string;
securityCode: string;
};
+7 -134
View File
@@ -1,140 +1,13 @@
import { useEffect, useState } from 'react'; import { FileText } from 'lucide-react';
import { AnimatePresence } from 'motion/react';
import { AlertTriangle, CheckCircle2 } from 'lucide-react';
import { ErrorState, SurfaceCard } from '../../components/ui/surface';
import {
fetchDailyMileageReport,
type DailyMileageReport,
} from './api';
import DailyReportLoadingOverlay from './daily-report/DailyReportLoadingOverlay';
import GroupRow from './daily-report/GroupRow';
import MileageBandDefinition from './daily-report/MileageBandDefinition';
import ReportCharts from './daily-report/ReportCharts';
import ReportHeader from './daily-report/ReportHeader';
import ReportInsights from './daily-report/ReportInsights';
import ReportOverview from './daily-report/ReportOverview';
import {
fmtDate,
initialReportDate,
isVisibleQualityNote,
shanghaiYmd,
} from './daily-report/utils';
// Fast responses still keep the global loading state visible long enough to be perceived.
const MIN_REPORT_LOADING_MS = 280;
function updateReportDateInUrl(date: string): void {
const url = new URL(window.location.href);
url.searchParams.set('mileageReportDate', date);
window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`);
}
export default function DailyReportView() { export default function DailyReportView() {
const maxDate = shanghaiYmd(-1);
const [selectedDate, setSelectedDate] = useState(() => initialReportDate(window.location.search));
const [report, setReport] = useState<DailyMileageReport | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [expandedTargetId, setExpandedTargetId] = useState<number | null>(null);
const [trendTargetId, setTrendTargetId] = useState<number | null>(null);
useEffect(() => {
let active = true;
let finishTimer: number | undefined;
const loadingStartedAt = performance.now();
setLoading(true);
setError('');
updateReportDateInUrl(selectedDate);
fetchDailyMileageReport(selectedDate)
.then(data => {
if (!active) return;
setReport(data);
setExpandedTargetId(current => current != null && data.groups.some(group => group.targetId === current) ? current : null);
setTrendTargetId(current => current != null && data.groups.some(group => group.targetId === current) ? current : null);
})
.catch(cause => {
if (!active) return;
setError(cause instanceof Error ? cause.message : '日报加载失败');
})
.finally(() => {
if (!active) return;
const delay = Math.max(0, MIN_REPORT_LOADING_MS - (performance.now() - loadingStartedAt));
finishTimer = window.setTimeout(() => {
if (active) setLoading(false);
}, delay);
});
return () => {
active = false;
if (finishTimer != null) window.clearTimeout(finishTimer);
};
}, [selectedDate]);
const highMileageRate = report && report.totals.operatingCount > 0
? (report.totals.highMileageCount / report.totals.operatingCount) * 100
: 0;
const visibleQualityNotes = report?.qualityNotes.filter(note => isVisibleQualityNote(note.message)) || [];
if (loading && !report) return <DailyReportLoadingOverlay reportDate={selectedDate} />;
if (!report) return <ErrorState message={error || '日报接口未返回有效数据'} />;
return ( return (
<div className="relative space-y-3 pb-5" aria-busy={loading}> <div className="flex items-center justify-center py-20">
<ReportHeader <div className="text-center">
report={report} <FileText size={48} className="mx-auto text-gray-300 mb-4" />
selectedDate={selectedDate} <h2 className="text-lg font-semibold text-gray-500"></h2>
maxDate={maxDate} <p className="text-sm text-gray-400 mt-2">...</p>
onDateChange={setSelectedDate} </div>
/>
{visibleQualityNotes.length > 0 ? (
<div className="space-y-1.5">
{visibleQualityNotes.map(note => (
<div key={note.message} className={`flex items-start gap-2 rounded-lg px-3 py-2 text-[10px] font-bold ${
note.level === 'warning' ? 'bg-amber-50 text-amber-700' : 'bg-blue-50 text-blue-700'
}`}>
{note.level === 'warning' ? <AlertTriangle size={13} className="mt-0.5 shrink-0" /> : <CheckCircle2 size={13} className="mt-0.5 shrink-0" />}
<span>{note.message}</span>
</div>
))}
</div>
) : null}
<ReportOverview report={report} highMileageRate={highMileageRate} />
<ReportInsights insights={report.insights} />
<ReportCharts
report={report}
trendTargetId={trendTargetId}
onTrendTargetChange={setTrendTargetId}
/>
<MileageBandDefinition />
<SurfaceCard
title={(
<span className="flex flex-wrap items-center gap-2">
<span></span>
<span className="rounded-md bg-blue-50 px-1.5 py-0.5 text-[10px] font-black text-blue-600 ring-1 ring-blue-100"> {fmtDate(report.reportDate)}</span>
</span>
)}
subtitle="点击车型查看部门、库存地区、考核进度及车辆近7日趋势"
>
<div className="hidden grid-cols-[minmax(170px,1.3fr)_82px_100px_88px_100px_96px_24px] gap-3 border-b border-slate-100 bg-slate-50 px-4 py-2 text-[10px] font-black text-slate-400 lg:grid">
<span></span><span className="text-right"></span><span className="text-right"></span><span className="text-right">/</span><span className="text-right"></span><span className="text-right"></span><span />
</div>
{report.groups.map(group => (
<GroupRow
key={group.targetId}
group={group}
reportDate={report.reportDate}
expanded={expandedTargetId === group.targetId}
onToggle={() => setExpandedTargetId(current => current === group.targetId ? null : group.targetId)}
/>
))}
</SurfaceCard>
<AnimatePresence initial={false}>
{loading ? <DailyReportLoadingOverlay reportDate={selectedDate} /> : null}
</AnimatePresence>
{error ? <ErrorState message={error} /> : null}
</div> </div>
); );
} }
+48 -39
View File
@@ -1,51 +1,60 @@
import { useState } from 'react';
import { LayoutDashboard, BarChart3, FileText } from 'lucide-react'; import { LayoutDashboard, BarChart3, FileText } from 'lucide-react';
import { AnimatePresence } from 'motion/react'; import { motion } from 'motion/react';
import MonitoringView from './MonitoringView'; import MonitoringView from './MonitoringView';
import StatisticsView from './StatisticsView'; import StatisticsView from './StatisticsView';
import DailyReportView from './DailyReportView'; import DailyReportView from './DailyReportView';
import { useHashSubTab } from '../energy/useHashSubTab';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { FadeIn, PageFrame, SegmentedNav } from '../../components/ui/surface';
type MileageSubTab = 'monitoring' | 'statistics' | 'report';
const MILEAGE_TABS = [
{ id: 'monitoring', label: '实时监控', icon: LayoutDashboard },
{ id: 'statistics', label: '统计报表', icon: BarChart3 },
{ id: 'report', label: '每日汇报', icon: FileText },
] as const satisfies readonly { id: MileageSubTab; label: string; icon: typeof LayoutDashboard }[];
const MILEAGE_SUB_IDS: readonly MileageSubTab[] = ['monitoring', 'statistics', 'report'];
export default function MileageModule() { export default function MileageModule() {
const [activeSubTab, setActiveSubTab] = useHashSubTab<MileageSubTab>('mileage', MILEAGE_SUB_IDS); const [activeSubTab, setActiveSubTab] = useState<'monitoring' | 'statistics' | 'report'>('monitoring');
return ( return (
<PageFrame <div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
title="车辆里程中心" <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">
subtitle="统一监控车辆日里程、累计里程、考核进度与日报经营口径,突出异常车辆和任务压力。" {/* Sub-navigation — sticky */}
icon={LayoutDashboard} <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">
eyebrow="MILEAGE BI" <button
meta="实时监控 · 统计报表 · 每日汇报" onClick={() => setActiveSubTab('monitoring')}
compactInfo className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'monitoring' ? 'text-blue-600' : 'text-slate-400'}`}
hideHeader >
> <LayoutDashboard size={14} />
<div className="sticky top-0 z-30 -mx-3 bg-[var(--app-bg)] px-3 pb-2 pt-1 shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)] md:-mx-6 md:top-12 md:px-6"> <span className="text-[11px] font-bold"></span>
<SegmentedNav tabs={MILEAGE_TABS} active={activeSubTab} onChange={setActiveSubTab} /> {activeSubTab === 'monitoring' && (
</div> <motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
<button
onClick={() => setActiveSubTab('statistics')}
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'statistics' ? 'text-blue-600' : 'text-slate-400'}`}
>
<BarChart3 size={14} />
<span className="text-[11px] font-bold"></span>
{activeSubTab === 'statistics' && (
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
<button
onClick={() => setActiveSubTab('report')}
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'report' ? 'text-blue-600' : 'text-slate-400'}`}
>
<FileText size={14} />
<span className="text-[11px] font-bold"></span>
{activeSubTab === 'report' && (
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
</div>
<AnimatePresence mode="wait"> {activeSubTab === 'monitoring' ? (
<FadeIn key={activeSubTab}> <MonitoringView />
{activeSubTab === 'monitoring' ? ( ) : activeSubTab === 'statistics' ? (
<MonitoringView /> <StatisticsView />
) : activeSubTab === 'statistics' ? ( ) : (
<StatisticsView /> <DailyReportView />
) : ( )}
<DailyReportView /> <RotatingFooterHint />
)} </div>
</FadeIn> </div>
</AnimatePresence>
<RotatingFooterHint />
</PageFrame>
); );
} }
File diff suppressed because it is too large Load Diff
+556 -110
View File
@@ -1,53 +1,65 @@
import { useEffect, useState } from 'react'; 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 type { TargetSummary, TargetVehicle, TrendPoint } from './types'; import type { TargetSummary, TargetVehicle, TrendPoint } from './types';
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api'; import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
import AllVehiclesPanel from './statistics/AllVehiclesPanel'; import Blur from '../../components/Blur';
import FullscreenTargetTable from './statistics/FullscreenTargetTable';
import StatisticsOverview from './statistics/StatisticsOverview'; function getDefaultDate(): string {
import TargetDetailPanel from './statistics/TargetDetailPanel'; const now = new Date();
import TrendPanel from './statistics/TrendPanel'; if (now.getHours() < 5) now.setDate(now.getDate() - 1);
import { return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
deriveSelectedTargetMetrics, }
getCurrentDateLabel,
getDefaultDate, function fmtKm(value: number): string {
initialAssessmentYears, if (value >= 10000) return (value / 10000).toFixed(2) + '万';
orderTargets, return value.toLocaleString();
} from './statistics/model'; }
function shortTargetName(name: string): string {
// Extract the number and a short description
const match = name.match(/(\d+)[辆台](.+)/);
if (!match) return name;
const count = match[1];
let desc = match[2];
// Simplify common patterns
desc = desc.replace('4.5T普货', '普货');
desc = desc.replace('4.5T冷链车', '冷藏车');
desc = desc.replace('4.5T冷链', '冷藏车');
desc = desc.replace('18T', '18T');
return `${count}${desc}`;
}
export default function StatisticsView() { export default function StatisticsView() {
const currentDateLabel = getCurrentDateLabel();
const [targets, setTargets] = useState<TargetSummary[]>([]); const [targets, setTargets] = useState<TargetSummary[]>([]);
const [trendData, setTrendData] = useState<TrendPoint[]>([]); const [trendData, setTrendData] = useState<TrendPoint[]>([]);
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({}); const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(null); const [selectedTargetId, setSelectedTargetId] = useState<number | null>(null);
const [chartType, setChartType] = useState<'bar' | 'line' | 'area'>('bar');
const [isTableFullscreen, setIsTableFullscreen] = useState(false); const [isTableFullscreen, setIsTableFullscreen] = useState(false);
const [expandedTargetId, setExpandedTargetId] = useState<number | null>(null); const [expandedModel, setExpandedModel] = useState<string | null>(null);
const [assessmentYearMap, setAssessmentYearMap] = useState<Record<number, number>>({});
const [viewAllTargetId, setViewAllTargetId] = useState<number | null>(null); const [viewAllTargetId, setViewAllTargetId] = useState<number | null>(null);
const [viewAllTargetName, setViewAllTargetName] = useState<string>(''); const [viewAllTargetName, setViewAllTargetName] = useState<string>('');
const [viewAllSearch, setViewAllSearch] = useState('');
const [viewAllSort, setViewAllSort] = useState<'asc' | 'desc'>('desc');
const [viewAllDate, setViewAllDate] = useState(getDefaultDate); const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
const [viewAllLoading, setViewAllLoading] = useState(false); const [viewAllLoading, setViewAllLoading] = useState(false);
const selectedTarget = targets.find(target => target.id === selectedTargetId);
const selectedMetrics = deriveSelectedTargetMetrics(
selectedTarget,
selectedTarget ? assessmentYearMap[selectedTarget.id] : undefined,
trendData,
);
// Load targets on mount // Load targets on mount
useEffect(() => { useEffect(() => {
fetchTargets().then(data => { fetchTargets().then(data => {
const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0]; setTargets(data);
const ordered = orderTargets(data); if (data.length > 0 && !selectedTargetId) {
setTargets(ordered); setSelectedTargetId(data[0].id);
if (ordered.length > 0 && !selectedTargetId) {
setSelectedTargetId(focused.id);
setExpandedTargetId(focused.id);
setAssessmentYearMap(initialAssessmentYears(ordered));
fetchTargetVehicles(focused.id).then(vehicles => {
setTargetVehiclesMap(previous => ({ ...previous, [focused.id]: vehicles }));
}).catch(() => {});
} }
}).catch(() => {}); }).catch(() => {});
}, []); }, []);
@@ -55,12 +67,6 @@ export default function StatisticsView() {
// Load trend when selectedTargetId changes // Load trend when selectedTargetId changes
useEffect(() => { useEffect(() => {
if (selectedTargetId === null) return; if (selectedTargetId === null) return;
setExpandedTargetId(selectedTargetId);
if (!targetVehiclesMap[selectedTargetId]) {
fetchTargetVehicles(selectedTargetId).then(vehicles => {
setTargetVehiclesMap(previous => ({ ...previous, [selectedTargetId]: vehicles }));
}).catch(() => {});
}
fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([])); fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
}, [selectedTargetId]); }, [selectedTargetId]);
@@ -69,84 +75,524 @@ export default function StatisticsView() {
if (viewAllTargetId === null) return; if (viewAllTargetId === null) return;
setViewAllLoading(true); setViewAllLoading(true);
fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => { fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => {
setTargetVehiclesMap(previous => ({ ...previous, [viewAllTargetId]: data })); setTargetVehiclesMap(prev => ({ ...prev, [viewAllTargetId]: data }));
}).catch(() => {}).finally(() => setViewAllLoading(false)); }).catch(() => {}).finally(() => setViewAllLoading(false));
}, [viewAllTargetId, viewAllDate]); }, [viewAllTargetId, viewAllDate]);
function selectTarget(targetId: number) {
setSelectedTargetId(targetId);
setExpandedTargetId(targetId);
}
function expandTarget(targetId: number) {
setExpandedTargetId(targetId);
if (!targetVehiclesMap[targetId]) {
fetchTargetVehicles(targetId).then(data => {
setTargetVehiclesMap(previous => ({ ...previous, [targetId]: data }));
}).catch(() => {});
}
}
function openAllVehicles(target: TargetSummary) {
setViewAllTargetId(target.id);
setViewAllTargetName(target.targetName);
setViewAllDate(getDefaultDate());
}
function refreshTargets() {
fetchTargets().then(data => {
setTargets(data);
}).catch(() => {});
}
return ( return (
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1 [overflow-anchor:none]" style={{ overflowX: 'clip' }}> <div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1" style={{ overflowX: 'clip' }}>
<StatisticsOverview {/* Project Selector */}
targets={targets} <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">
selectedTargetId={selectedTargetId} {targets.map(target => (
selectedTarget={selectedTarget} <button
metrics={selectedMetrics} key={target.id}
onSelectTarget={selectTarget} onClick={() => setSelectedTargetId(target.id)}
/> className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
selectedTargetId === target.id
<div className="flex flex-col landscape:flex-row gap-4 flex-1 landscape:overflow-hidden"> ? 'bg-blue-600 text-white shadow-md shadow-blue-200'
<TrendPanel : 'bg-slate-50 text-slate-500 hover:bg-slate-100'
selectedTarget={selectedTarget} }`}
selectedCompletion={selectedMetrics.completion} >
trendData={trendData} {shortTargetName(target.targetName)}
/> </button>
<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 }));
}}
onViewAll={openAllVehicles}
/>
</div> </div>
<FullscreenTargetTable <div className="flex flex-col landscape:flex-row gap-4 flex-1 landscape:overflow-hidden">
isOpen={isTableFullscreen} {/* Left Side: Trend Chart / Dashboard Sidebar */}
targets={targets} <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">
assessmentYearMap={assessmentYearMap} {/* KPI Cards in Landscape — linked to selected target */}
onRefresh={refreshTargets} {(() => {
onClose={() => setIsTableFullscreen(false)} 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>
);
})()}
<AllVehiclesPanel <div className="bg-white p-4 rounded-2xl shadow-sm border border-slate-100 flex-1 flex flex-col min-h-[300px] overflow-hidden">
targetId={viewAllTargetId} <div className="flex items-center justify-between mb-4">
targetName={viewAllTargetName} <div className="flex items-center gap-2">
vehicles={viewAllTargetId !== null ? targetVehiclesMap[viewAllTargetId] || [] : []} <div className="w-1 h-4 bg-blue-600 rounded-full" />
date={viewAllDate} <h3 className="text-sm font-bold text-slate-800">7</h3>
loading={viewAllLoading} </div>
onDateChange={setViewAllDate} <div className="flex bg-slate-50 p-1 rounded-lg">
onClose={() => setViewAllTargetId(null)} {(['bar', 'line', 'area'] as const).map(type => (
/> <button
key={type}
onClick={() => setChartType(type)}
className={`px-2 py-1 rounded-md text-[10px] font-bold transition-all ${
chartType === type ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'
}`}
>
{type === 'bar' ? '柱状' : type === 'line' ? '折线' : '面积'}
</button>
))}
</div>
</div>
<div className="flex-1 w-full min-h-[250px] relative">
<div className="absolute inset-0">
<ResponsiveContainer width="100%" height="100%">
{chartType === 'bar' ? (
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={(val: string) => { const [m, d] = val.split('-'); return `${parseInt(m)}.${parseInt(d)}`; }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
<Tooltip cursor={{ fill: '#f8fafc', fillOpacity: 0.1 }} contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
<Bar dataKey="mileage" fill="#3b82f6" radius={[4, 4, 0, 0]} barSize={20}>
<LabelList dataKey="mileage" position="top" style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
{trendData.map((_entry: TrendPoint, index: number) => (
<Cell key={`cell-${index}`} fill={index === trendData.length - 1 ? '#2563eb' : '#60a5fa'} />
))}
</Bar>
</BarChart>
) : chartType === 'line' ? (
<LineChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={(val: string) => { const [m, d] = val.split('-'); return `${parseInt(m)}.${parseInt(d)}`; }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
<Line type="monotone" dataKey="mileage" stroke="#3b82f6" strokeWidth={3} dot={{ r: 4, fill: '#3b82f6' }}>
<LabelList dataKey="mileage" position="top" offset={10} style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
</Line>
</LineChart>
) : (
<AreaChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorMileage" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={(val: string) => { const [m, d] = val.split('-'); return `${parseInt(m)}.${parseInt(d)}`; }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
<Area type="monotone" dataKey="mileage" stroke="#3b82f6" fillOpacity={1} fill="url(#colorMileage)">
<LabelList dataKey="mileage" position="top" offset={10} style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
</Area>
</AreaChart>
)}
</ResponsiveContainer>
</div>
</div>
</div>
</div>
{/* Right Side: Summary Section */}
<div className="w-full landscape:w-1/3 flex-shrink-0 space-y-2 flex flex-col landscape:overflow-hidden">
<div className="flex items-center justify-between px-2 flex-shrink-0">
<div className="flex items-center gap-2">
<div className="w-1 h-4 bg-blue-600 rounded-full" />
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest"></h3>
</div>
<button
onClick={() => setIsTableFullscreen(true)}
className="p-1.5 bg-white text-slate-400 rounded-lg border border-slate-100 shadow-sm hover:text-blue-600 transition-colors"
>
<Maximize2 size={14} />
</button>
</div>
<div className="grid grid-cols-1 gap-1.5 overflow-y-auto no-scrollbar pb-2">
{targets.map((target, idx) => (
<div
key={idx}
className="bg-white px-3 py-2 rounded-xl border border-slate-100 shadow-sm flex flex-col active:bg-slate-50 transition-all cursor-pointer"
onClick={() => {
const name = target.targetName;
setExpandedModel(expandedModel === name ? null : name);
if (!targetVehiclesMap[target.id]) {
fetchTargetVehicles(target.id).then(data => {
setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data }));
}).catch(() => {});
}
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 overflow-hidden flex-1">
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center flex-shrink-0">
<Truck size={14} className="text-slate-400" />
</div>
<div className="overflow-hidden flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-black text-slate-900">{target.targetName}</span>
<span className="text-[8px] px-1 rounded bg-blue-50 text-blue-600 font-bold">{target.vehicleCount}</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<span className="text-[9px] text-slate-400">:</span>
<span className={`text-[9px] font-bold ${target.avgCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{target.avgCompletion.toFixed(1)}%</span>
</div>
<div className="flex items-center gap-1">
<span className="text-[9px] text-slate-400">:</span>
<span className="text-[9px] font-bold text-slate-600">{target.yearQualifiedCount}</span>
</div>
</div>
</div>
</div>
<div className="text-right flex-shrink-0 ml-2 flex items-center gap-3">
<div className="flex flex-col items-end">
<div className="text-sm font-black text-slate-900 leading-none mb-0.5">
{fmtKm(target.todayTotal)} <span className="text-[8px] text-slate-300 font-bold uppercase">KM</span>
</div>
<div className="text-[8px] font-bold text-slate-300">
: {fmtKm(target.cumulativeTotal)} KM
</div>
</div>
<motion.div
animate={{ rotate: expandedModel === target.targetName ? 180 : 0 }}
className="text-slate-300"
>
<ChevronDown size={14} />
</motion.div>
</div>
</div>
<AnimatePresence>
{expandedModel === target.targetName && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="pt-3 mt-2 border-t border-slate-50 grid grid-cols-2 gap-x-4 gap-y-3">
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
{target.periods.map((p, i) => (
<p key={i} className="text-[10px] font-black text-slate-700">{p}</p>
))}
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">/</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">50%</p>
<p className="text-[10px] font-black text-blue-600">{target.halfQualifiedCount} </p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.currentYearTarget)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">(3.31)</p>
<p className="text-[10px] font-black text-emerald-600">{fmtKm(target.currentYearCompleted)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-rose-500">{fmtKm(target.remaining)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-blue-500">{fmtKm(target.dailyTarget)} km</p>
</div>
<div className="col-span-2 flex items-center justify-between bg-slate-50 p-2 rounded-lg">
<span className="text-[9px] font-bold text-slate-500"></span>
<span className="text-[10px] font-black text-slate-900">{target.daysLeft} </span>
</div>
{/* Vehicle List Detail */}
<div className="col-span-2 space-y-2 mt-2">
<div className="flex items-center justify-between px-1">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest"> (5)</span>
<button
onClick={(e) => {
e.stopPropagation();
setViewAllTargetId(target.id);
setViewAllTargetName(target.targetName);
setViewAllDate(getDefaultDate());
}}
className="text-[8px] text-blue-500 font-bold hover:underline"
>
</button>
</div>
<div className="space-y-1">
{(targetVehiclesMap[target.id] || []).slice(0, 5).map(tv => (
<div key={tv.plateNumber} className="bg-slate-50/50/50 px-2 py-1.5 rounded-lg flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono font-bold text-slate-700"><Blur>{tv.plateNumber}</Blur></span>
<span className="text-[7px] px-1 rounded bg-green-100 text-green-600 font-bold">
线
</span>
</div>
<div className="text-right">
<span className="text-[10px] font-black text-blue-600">{tv.todayMileage}</span>
<span className="text-[8px] text-slate-400 ml-1">KM</span>
</div>
</div>
))}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</div>
</div>
</div>
{/* 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)}%` }}
/>
</div>
<span className="text-[10px] font-black text-white w-10 text-right">{target.avgCompletion.toFixed(1)}%</span>
</div>
<div className="flex justify-between mt-1 text-[9px] text-slate-500">
<span>{fmtKm(target.cumulativeTotal)}</span>
<span>/ {fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</span>
</div>
</td>
<td className="px-3 py-3 text-xs text-slate-300 text-right">{fmtKm(target.annualMileagePerVehicle)} km</td>
<td className="px-3 py-3 text-xs font-black text-emerald-400 text-center">{target.yearQualifiedCount}</td>
<td className="px-3 py-3 text-xs font-black text-blue-400 text-center">{target.halfQualifiedCount}</td>
<td className="px-3 py-3 text-xs font-black text-white text-right">{fmtKm(target.todayTotal)} km</td>
<td className="px-3 py-3 text-xs text-slate-400 text-right">{fmtKm(target.currentYearTarget)} km</td>
<td className="px-3 py-3 text-xs text-emerald-400/80 text-right">{fmtKm(target.currentYearCompleted)} km</td>
<td className="px-3 py-3 text-xs font-bold text-rose-400 text-right">{fmtKm(target.remaining)} km</td>
<td className="px-3 py-3 text-xs text-slate-300 text-center">{target.daysLeft}</td>
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">{fmtKm(target.dailyTarget)} km</td>
</tr>
))}
</tbody>
</table>
</div>
</motion.div>
)}
</AnimatePresence>
{/* View All Vehicles Side Panel */}
<AnimatePresence>
{viewAllTargetId !== null && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setViewAllTargetId(null)}
className="fixed inset-0 z-[110] bg-slate-950/60 backdrop-blur-sm"
/>
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="fixed top-0 right-0 bottom-0 w-full max-w-sm z-[120] bg-white shadow-2xl flex flex-col"
>
<div className="p-6 border-b border-slate-100 flex items-center justify-between">
<div>
<h3 className="text-lg font-black text-slate-900">{viewAllTargetName}</h3>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1"></p>
</div>
<button
onClick={() => {
setViewAllTargetId(null);
setViewAllSearch('');
}}
className="p-2 hover:bg-slate-100 rounded-full transition-colors text-slate-400 hover:text-slate-900"
>
<X size={20} />
</button>
</div>
<div className="px-6 py-3 border-b border-slate-50 space-y-2">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
<input
type="text"
placeholder="搜索车牌号..."
value={viewAllSearch}
onChange={(e) => setViewAllSearch(e.target.value)}
className="w-full pl-9 pr-3 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
/>
</div>
<div className="relative flex-shrink-0">
<Calendar className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" size={13} />
<input
type="date"
value={viewAllDate}
onChange={(e) => setViewAllDate(e.target.value)}
className="pl-8 pr-2 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all w-[130px]"
/>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{viewAllLoading ? '加载中...' : (() => {
const filtered = (viewAllTargetId !== null ? (targetVehiclesMap[viewAllTargetId] || []) : []).filter(tv => tv.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase()));
const totalKm = filtered.reduce((sum, tv) => sum + (tv.todayMileage || 0), 0);
return `${filtered.length} 辆 · 合计 ${fmtKm(totalKm)} km`;
})()}
</span>
<button
onClick={() => setViewAllSort(prev => prev === 'desc' ? 'asc' : 'desc')}
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-600 rounded-lg text-[10px] font-black active:scale-95 transition-all"
>
<ArrowUpDown size={12} />
{viewAllSort === 'desc' ? '降序' : '升序'}
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2 no-scrollbar">
{(viewAllTargetId !== null ? (targetVehiclesMap[viewAllTargetId] || []) : []).filter(tv =>
tv.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase())
).sort((a, b) => {
const valA = a.todayMileage || 0;
const valB = b.todayMileage || 0;
return viewAllSort === 'desc' ? valB - valA : valA - valB;
}).map(tv => (
<div key={tv.plateNumber} className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between hover:border-blue-200 transition-all">
<div className="flex items-center gap-3 overflow-hidden flex-1">
<div className="relative flex-shrink-0">
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
<Truck size={14} className="text-slate-400" />
</div>
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${tv.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} />
</div>
<div className="overflow-hidden flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{tv.plateNumber}</Blur></span>
<span className={`text-[8px] px-1 rounded ${tv.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
{tv.isOnline ? '在线' : '离线'}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-slate-300 font-bold">{tv.rentStatus || ''}{tv.department ? ` · ${tv.department.replace('业务', '')}` : ''}</span>
<span className="text-[9px] font-bold text-slate-600 truncate">{tv.customer || '-'}</span>
</div>
</div>
</div>
<div className="text-right flex-shrink-0 ml-2">
<div className="text-sm font-black text-blue-600">{tv.todayMileage.toLocaleString()} <span className="text-[8px] text-slate-400">KM</span></div>
<div className="text-[9px] font-bold text-slate-300 mt-0.5">: {fmtKm(tv.totalMileage || 0)} km</div>
</div>
</div>
))}
</div>
<div className="p-4 bg-slate-50 border-t border-slate-100">
<button
onClick={() => setViewAllTargetId(null)}
className="w-full py-3 bg-slate-900 text-white rounded-xl text-sm font-bold shadow-lg shadow-slate-200 active:scale-[0.98] transition-all"
>
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div> </div>
); );
} }
+5 -26
View File
@@ -4,14 +4,13 @@ import { X, Truck } from 'lucide-react';
import { import {
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell, BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
} from 'recharts'; } from 'recharts';
import type { MileageSourceGroup, MonitoringVehicle } from './types'; import type { MonitoringVehicle } from './types';
import { fetchVehicleRecent, type VehicleRecentDay } from './api'; import { fetchVehicleRecent, type VehicleRecentDay } from './api';
import Blur from '../../components/Blur'; import Blur from '../../components/Blur';
interface Props { interface Props {
vehicle: MonitoringVehicle | null; vehicle: MonitoringVehicle | null;
onClose: () => void; onClose: () => void;
sourcePriority: MileageSourceGroup[];
} }
type RangeKey = 'last15' | 'month' | 'quarter'; type RangeKey = 'last15' | 'month' | 'quarter';
@@ -57,13 +56,7 @@ function formatLabel(date: string, key: RangeKey): string {
return date.slice(5); return date.slice(5);
} }
function daySourceLabel(day: VehicleRecentDay): string { export default function VehicleDetailModal({ vehicle, onClose }: Props) {
if (day.sourceCategory === 'INSTRUMENT') return '仪表数据';
if (day.sourceCategory === 'GPS') return 'GPS数据';
return day.isDataSynced ? '来源待接口' : '无数据';
}
export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: Props) {
const [days, setDays] = useState<VehicleRecentDay[]>([]); const [days, setDays] = useState<VehicleRecentDay[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [range, setRange] = useState<RangeKey>('last15'); const [range, setRange] = useState<RangeKey>('last15');
@@ -81,12 +74,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
setLoading(true); setLoading(true);
setDays([]); setDays([]);
let cancelled = false; let cancelled = false;
fetchVehicleRecent(vehicle.plate, { start, end, sourcePriority }) fetchVehicleRecent(vehicle.plate, { start, end })
.then(d => { if (!cancelled) setDays(d.days); }) .then(d => { if (!cancelled) setDays(d.days); })
.catch(() => { if (!cancelled) setDays([]); }) .catch(() => { if (!cancelled) setDays([]); })
.finally(() => { if (!cancelled) setLoading(false); }); .finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [vehicle?.plate, range, sourcePriority]); // eslint-disable-line react-hooks/exhaustive-deps }, [vehicle?.plate, range]); // eslint-disable-line react-hooks/exhaustive-deps
// 锁滚动 // 锁滚动
useEffect(() => { useEffect(() => {
@@ -287,21 +280,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
transition={{ delay: Math.min(i * 0.012, 0.4), duration: 0.18 }} transition={{ delay: Math.min(i * 0.012, 0.4), duration: 0.18 }}
className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-slate-50" className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-slate-50"
> >
<div className="w-[88px] flex-shrink-0"> <span className="text-[11px] font-mono font-bold text-slate-600">{d.date}</span>
<div className="text-[11px] font-mono font-bold text-slate-600">{d.date}</div>
<div
className={`text-[8px] font-bold ${
d.sourceCategory === 'INSTRUMENT'
? 'text-violet-500'
: d.sourceCategory === 'GPS'
? 'text-emerald-500'
: 'text-amber-500'
}`}
title={d.sourceProtocol || 'OneOS 当前未返回 sourceProtocol'}
>
{daySourceLabel(d)}
</div>
</div>
<div className="flex items-center gap-2 flex-1 ml-3"> <div className="flex items-center gap-2 flex-1 ml-3">
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden"> <div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
<motion.div <motion.div
+4 -61
View File
@@ -1,33 +1,11 @@
import type { import type { MonitoringData, TargetSummary, TargetVehicle, TrendPoint } from './types';
MileageSourceCategory,
MileageSourceGroup,
MileageSourceProtocol,
MonitoringData,
TargetSummary,
TargetVehicle,
TrendPoint,
} from './types';
import { fetchJson } from '../../auth/api-client'; import { fetchJson } from '../../auth/api-client';
import type {
DailyMileageReport,
MileageReportHistoryResponse,
} from '../../shared/mileage/daily-report';
export type {
DailyMileageReport,
MileageReportBreakdown,
MileageReportGroup,
MileageReportHistoryItem,
MileageReportHistoryResponse,
MileageReportInsight,
MileageReportVehicle,
} from '../../shared/mileage/daily-report';
const BASE = '/api/mileage'; const BASE = '/api/mileage';
export async function fetchMonitoring(params?: { export async function fetchMonitoring(params?: {
sortBy?: 'today' | 'total' | 'statisticTime'; sortBy?: string;
sortOrder?: 'asc' | 'desc'; sortOrder?: string;
limit?: number; limit?: number;
page?: number; page?: number;
search?: string; search?: string;
@@ -38,16 +16,11 @@ export async function fetchMonitoring(params?: {
rentStatus?: string; rentStatus?: string;
platePrefix?: string; platePrefix?: string;
targetName?: string; targetName?: string;
targetNames?: string[];
region?: string; region?: string;
plate?: string; plate?: string;
mileageMin?: string; mileageMin?: string;
mileageMax?: string; mileageMax?: string;
date?: string; date?: string;
startDate?: string;
endDate?: string;
brands?: string[];
sourcePriority?: MileageSourceGroup[];
}): Promise<MonitoringData> { }): Promise<MonitoringData> {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params?.sortBy) query.set('sortBy', params.sortBy); if (params?.sortBy) query.set('sortBy', params.sortBy);
@@ -62,24 +35,11 @@ export async function fetchMonitoring(params?: {
if (params?.rentStatus) query.set('rentStatus', params.rentStatus); if (params?.rentStatus) query.set('rentStatus', params.rentStatus);
if (params?.platePrefix) query.set('platePrefix', params.platePrefix); if (params?.platePrefix) query.set('platePrefix', params.platePrefix);
if (params?.targetName) query.set('targetName', params.targetName); if (params?.targetName) query.set('targetName', params.targetName);
if (params?.targetNames) {
params.targetNames.forEach(name => {
if (name) query.append('targetName', name);
});
}
if (params?.region) query.set('region', params.region); if (params?.region) query.set('region', params.region);
if (params?.brands) {
params.brands.forEach(brand => {
if (brand) query.append('brand', brand);
});
}
if (params?.plate) query.set('plate', params.plate); if (params?.plate) query.set('plate', params.plate);
if (params?.mileageMin) query.set('mileageMin', params.mileageMin); if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
if (params?.mileageMax) query.set('mileageMax', params.mileageMax); if (params?.mileageMax) query.set('mileageMax', params.mileageMax);
if (params?.date) query.set('date', params.date); if (params?.date) query.set('date', params.date);
if (params?.startDate) query.set('startDate', params.startDate);
if (params?.endDate) query.set('endDate', params.endDate);
if (params?.sourcePriority?.length) query.set('sourcePriority', params.sourcePriority.join(','));
const qs = query.toString(); const qs = query.toString();
return fetchJson<MonitoringData>(`${BASE}/monitoring${qs ? `?${qs}` : ''}`); return fetchJson<MonitoringData>(`${BASE}/monitoring${qs ? `?${qs}` : ''}`);
} }
@@ -106,8 +66,6 @@ export interface VehicleRecentDay {
date: string; date: string;
dailyKm: number; dailyKm: number;
isDataSynced: boolean; isDataSynced: boolean;
sourceProtocol: MileageSourceProtocol | null;
sourceCategory: Exclude<MileageSourceCategory, 'MIXED'> | null;
} }
export interface VehicleRecentResponse { export interface VehicleRecentResponse {
@@ -115,32 +73,17 @@ export interface VehicleRecentResponse {
start?: string; start?: string;
end?: string; end?: string;
days: VehicleRecentDay[]; days: VehicleRecentDay[];
sourcePriority?: MileageSourceGroup[];
} }
export async function fetchVehicleRecent( export async function fetchVehicleRecent(
plate: string, plate: string,
range: { range: { days?: number; start?: string; end?: string } = { days: 15 },
days?: number;
start?: string;
end?: string;
sourcePriority?: MileageSourceGroup[];
} = { days: 15 },
): Promise<VehicleRecentResponse> { ): Promise<VehicleRecentResponse> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (range.start) params.set('start', range.start); if (range.start) params.set('start', range.start);
if (range.end) params.set('end', range.end); if (range.end) params.set('end', range.end);
if (range.days != null) params.set('days', String(range.days)); if (range.days != null) params.set('days', String(range.days));
if (range.sourcePriority?.length) params.set('sourcePriority', range.sourcePriority.join(','));
return fetchJson<VehicleRecentResponse>( return fetchJson<VehicleRecentResponse>(
`${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}` `${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}`
); );
} }
export async function fetchDailyMileageReport(date: string): Promise<DailyMileageReport> {
return fetchJson<DailyMileageReport>(`${BASE}/reports?date=${encodeURIComponent(date)}`);
}
export async function fetchDailyMileageReportHistory(limit = 90): Promise<MileageReportHistoryResponse> {
return fetchJson<MileageReportHistoryResponse>(`${BASE}/reports/history?limit=${limit}`);
}
@@ -1,45 +0,0 @@
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>
);
}

Some files were not shown because too many files have changed in this diff Show More