Author SHA1 Message Date
kkfluous 6b75437423 Merge branch 'main' into demo
ci/woodpecker/push/woodpecker Pipeline was successful
- 解决 3 处冲突,保留 demo 的演示行为:
  - AuthProvider: 无 jumpToken 时放行
  - auth middleware: BYPASS_AUTH=true
  - Shell: DemoModeProvider enabled=true
- 引入 main 上的智能调度模块等改动
2026-04-24 10:37:54 +08:00
kkfluousandClaude Opus 4.6 cf8f7cf969 feat(demo): 演示模式脱敏 + 临时跳过认证
ci/woodpecker/push/woodpecker Pipeline was successful
- 新增 Blur 组件与 DemoModeProvider,全局包裹 Shell
- 资产/里程模块的客户名、业务经理、车牌、VIN 等敏感字段使用 <Blur> 包裹
- 前端 AuthProvider 无 jumpToken 时放行
- 后端 authMiddleware 开头直接 next(),跳过所有认证

仅用于演示分支,勿合并至 main。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:40:58 +08:00
166 changed files with 1246 additions and 23489 deletions
-9
View File
@@ -1,9 +0,0 @@
node_modules
dist
.env
.git
.codex
outputs
reports
data/geo
data/vehicle-heatmap/source.csv
-51
View File
@@ -1,51 +0,0 @@
# Core asset database
DB_HOST=
DB_PORT=3306
DB_USER=
DB_PASSWORD=
DB_NAME=
# Hydrogen ledger database
HYDROGEN_DB_HOST=
HYDROGEN_DB_PORT=3306
HYDROGEN_DB_USER=
HYDROGEN_DB_PASSWORD=
HYDROGEN_DB_NAME=
# Mileage reference database
MILEAGE_DB_HOST=
MILEAGE_DB_PORT=3306
MILEAGE_DB_USER=
MILEAGE_DB_PASSWORD=
MILEAGE_DB_NAME=
# OneOS mileage API
ONEOS_MILEAGE_API_BASE_URL=http://172.17.111.55:20310
ONEOS_MILEAGE_API_KEY=
ONEOS_MILEAGE_API_TIMEOUT_MS=20000
# Authentication and external API
EXTERNAL_API_BASE=https://lnh2e.com
JWT_SECRET=
DEV_BYPASS_AUTH=0
# Vehicle heatmap database and map SDK
HEATMAP_DB_HOST=
HEATMAP_DB_PORT=36453
HEATMAP_DB_USER=
HEATMAP_DB_PASSWORD=
HEATMAP_DB_NAME=lingniu_vehicle_data
HEATMAP_DB_SSL=false
AMAP_WEB_KEY=
AMAP_SECURITY_JS_CODE=
# Feedback attachment object storage
OSS_ENDPOINT=
OSS_REGION=oss-cn-shanghai
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
OSS_BUCKET=
OSS_BASE_DIR=/dos
OSS_HOST=
SERVER_PORT=3001
-1
View File
@@ -1,4 +1,3 @@
node_modules
dist
.env
.worktrees
+8 -36
View File
@@ -5,42 +5,14 @@ services:
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
network_mode: host
environment:
DB_HOST: "${DB_HOST:?Set DB_HOST in the deployment environment}"
DB_PORT: "${DB_PORT:-3306}"
DB_USER: "${DB_USER:?Set DB_USER in the deployment environment}"
DB_PASSWORD: "${DB_PASSWORD:?Set DB_PASSWORD in the deployment environment}"
DB_NAME: "${DB_NAME:?Set DB_NAME in the deployment environment}"
HYDROGEN_DB_HOST: "${HYDROGEN_DB_HOST:?Set HYDROGEN_DB_HOST in the deployment environment}"
HYDROGEN_DB_PORT: "${HYDROGEN_DB_PORT:-3306}"
HYDROGEN_DB_USER: "${HYDROGEN_DB_USER:?Set HYDROGEN_DB_USER in the deployment environment}"
HYDROGEN_DB_PASSWORD: "${HYDROGEN_DB_PASSWORD:?Set HYDROGEN_DB_PASSWORD in the deployment environment}"
HYDROGEN_DB_NAME: "${HYDROGEN_DB_NAME:?Set HYDROGEN_DB_NAME in the deployment environment}"
MILEAGE_DB_HOST: "${MILEAGE_DB_HOST:?Set MILEAGE_DB_HOST in the deployment environment}"
MILEAGE_DB_PORT: "${MILEAGE_DB_PORT:-3306}"
MILEAGE_DB_USER: "${MILEAGE_DB_USER:?Set MILEAGE_DB_USER in the deployment environment}"
MILEAGE_DB_PASSWORD: "${MILEAGE_DB_PASSWORD:?Set MILEAGE_DB_PASSWORD in the deployment environment}"
MILEAGE_DB_NAME: "${MILEAGE_DB_NAME:?Set MILEAGE_DB_NAME in the deployment environment}"
ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}"
ONEOS_MILEAGE_API_KEY: "${ONEOS_MILEAGE_API_KEY:?Set ONEOS_MILEAGE_API_KEY in the deployment environment}"
ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}"
SERVER_PORT: "${SERVER_PORT:-8111}"
EXTERNAL_API_BASE: "${EXTERNAL_API_BASE:-https://lnh2e.com}"
JWT_SECRET: "${JWT_SECRET:?Set JWT_SECRET in the deployment environment}"
AMAP_WEB_KEY: "${AMAP_WEB_KEY:?Set AMAP_WEB_KEY in the deployment environment}"
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE:?Set AMAP_SECURITY_JS_CODE in the deployment environment}"
OSS_ENDPOINT: "${OSS_ENDPOINT:?Set OSS_ENDPOINT in the deployment environment}"
OSS_REGION: "${OSS_REGION:-oss-cn-shanghai}"
OSS_ACCESS_KEY_ID: "${OSS_ACCESS_KEY_ID:?Set OSS_ACCESS_KEY_ID in the deployment environment}"
OSS_ACCESS_KEY_SECRET: "${OSS_ACCESS_KEY_SECRET:?Set OSS_ACCESS_KEY_SECRET in the deployment environment}"
OSS_BUCKET: "${OSS_BUCKET:?Set OSS_BUCKET in the deployment environment}"
OSS_BASE_DIR: "${OSS_BASE_DIR:-/dos}"
OSS_HOST: "${OSS_HOST:-}"
HEATMAP_DB_HOST: "${HEATMAP_DB_HOST:?Set HEATMAP_DB_HOST in the deployment environment}"
HEATMAP_DB_PORT: "${HEATMAP_DB_PORT:-36453}"
HEATMAP_DB_USER: "${HEATMAP_DB_USER:?Set HEATMAP_DB_USER in the deployment environment}"
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD:?Set HEATMAP_DB_PASSWORD in the deployment environment}"
HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}"
HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}"
DB_HOST: "47.101.148.99"
DB_PORT: "3306"
DB_USER: "root"
DB_PASSWORD: "LN#Passw0rd@2026"
DB_NAME: "lingniu_prod"
SERVER_PORT: "8111"
EXTERNAL_API_BASE: "https://lnh2e.com"
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
deploy:
replicas: 1
restart_policy:
-216
View File
@@ -1,216 +0,0 @@
# BI 渐进式重构方案
更新日期:2026-08-07
## 1. 目标
本次重构不重做整套系统,采用可回滚的小批次交付,逐步解决三个核心问题:
1. UI/UX:统一信息层级、筛选方式、状态反馈和移动端行为。
2. 下钻:任何汇总指标都能沿同一统计口径进入可核对的明细。
3. 统计逻辑:指标公式、时间语义、数据来源和数据截至时间均可查询、可测试。
不在当前阶段引入新的 BI 引擎或替换现有 React、Hono、MySQL 技术栈。
## 2. 设计原则
- 先口径,后图表:没有明确公式和数据源的指标不得进入主看板。
- 父子一致:下钻前后的时间、车辆范围、组织范围和数据源必须一致。
- URL 即分析状态:日期、范围、实体和数据源写入 URL,支持刷新、返回和分享。
- 空值不等于零值:接口失败、尚未接入、没有权限和真实零业务使用不同状态。
- 汇总可对账:页面汇总应等于明细全量聚合,明细截断不能影响顶部合计。
- 数据新鲜度显式展示:业务时间与页面刷新时间分开表达。
- 渐进发布:每批只修改一个口径或一条下钻链路,通过测试和真实数据核对后提交。
## 3. 目标信息架构
每个 BI 模块统一为三级信息结构:
| 层级 | 用途 | 页面内容 |
| --- | --- | --- |
| 总览 | 判断经营状态 | 核心 KPI、趋势、结构、异常、数据截至时间 |
| 分析 | 定位变化来源 | 时间筛选、组织/车辆范围、分组排行、归因 |
| 明细 | 核对原始业务 | 车辆、订单、站点、账单或通行记录 |
页面头部统一提供模块名称、简短业务说明和“指标口径”入口。筛选条件位于内容上方,实体下钻状态紧邻筛选区展示,避免用户忘记当前分析范围。
## 4. 模块与下钻矩阵
### 4.1 里程
| 父级 | 下钻目标 | 必须继承的上下文 | 当前状态 |
| --- | --- | --- | --- |
| 实时监控汇总 | 车辆详情 | 日期区间、协议优先级、车牌 | 已完成 |
| 考核目标 | 部门/客户归因 | 考核年份、目标、归因维度 | 已完成 |
| 归因分组 | 车辆清单 | 目标、部门或客户值 | 已完成 |
| 每日汇报 | 实时监控/考核目标 | 汇报日期、目标或车牌 | 已完成 |
考核完成率使用目标里程加权:
```text
SUM(completed_mileage_km) / NULLIF(SUM(target_mileage_km), 0) * 100
```
禁止直接平均车辆完成率或考核组完成率。
### 4.2 氢能
| 父级 | 下钻目标 | 必须继承的上下文 | 当前状态 |
| --- | --- | --- | --- |
| 站点排行 | 站点每日明细 | 年份、站点、日期区间、车辆范围 | 已完成 |
| 客户排行 | 客户每日明细 | 年份、客户、日期区间、车辆范围 | 已完成 |
| 每日趋势 | 加氢订单 | 日期、车辆范围、站点或客户 | 下钻代码已完成,待数据源恢复后验收 |
成本、收入和毛利必须分开:
```text
成本 = SUM(cost_total)
客户收入 = SUM(fee_total)
客户单毛利 = 客户收入 - 客户单对应成本
```
数据库不可用时返回可识别的 503 状态,页面不得显示派生零值。
### 4.3 电能
| 父级 | 下钻目标 | 必须继承的上下文 | 当前状态 |
| --- | --- | --- | --- |
| 总览日柱 | 当日订单 | 日期、全部车辆范围 | 已完成 |
| 月份 | 日期 | 车辆范围、日期区间 | 已完成 |
| 日期 | 充电订单 | 日期、车辆范围 | 已完成 |
电能范围包括“全部车辆、羚牛车辆、外部车辆”。总览使用全部车辆口径,因此总览下钻必须进入 `all`,不能默认切换为羚牛车辆。
综合费用强度使用加权公式:
```text
SUM(fee) / NULLIF(SUM(kwh), 0)
```
零费用订单参与电量和订单数统计。当前月无数据时可以展示最近数据月,但所有月度标签必须显示实际月份,并展示 `MAX(start_time)` 数据截至时间。
### 4.4 ETC
| 父级 | 下钻目标 | 必须继承的上下文 | 当前状态 |
| --- | --- | --- | --- |
| 通行费用 | 通行记录 | 日期、车牌、客户、费用承担类型 | 下钻代码已完成,待首次真实同步验收 |
| 账单应收 | ETC 账单 | 账期、客户、支付状态 | 下钻代码已完成,待首次真实同步验收 |
供应商未配置或从未同步时展示接入状态,不使用模拟业务数据填充图表。
## 5. 指标治理
指标目录由 `/api/analytics/metrics` 发布,当前采用版本化合同。每个指标必须包含:
- 唯一且带领域前缀的指标 ID。
- 中文名称和业务解释。
- 单位、聚合方式和时间语义。
- 可审计公式与真实数据源。
- 支持的筛选维度和下钻实体。
指标变更规则:
1. 修改公式、来源或时间语义时提升目录版本。
2. 后端查询、指标目录、页面标签和测试在同一批次修改。
3. 比率类指标必须由分子、分母汇总后计算,禁止平均子级比率。
4. 金额同时标明成本、收入、应收、实收或总费用,不使用模糊的“费用”。
5. 快照、区间流量和数据新鲜度不得混在同一时间标签下。
## 6. URL 与交互约定
- 模块和子页使用 hash,例如 `#electric/overview`
- 分析上下文使用领域前缀查询参数,例如 `electricDate``hydrogenStationId``mileagePlate`
- 不删除其他领域的查询参数。
- 总览进入明细使用 `pushState`,同页筛选调整使用 `replaceState`
- 浏览器返回、前进和刷新必须恢复筛选与展开状态。
- 图表下钻同时支持鼠标和键盘,交互图形提供可读名称。
## 7. 接口与性能边界
| 类型 | 目标 |
| --- | --- |
| 热缓存汇总接口 | P95 小于 500 ms |
| 普通数据库聚合 | P95 小于 1.5 s |
| 明细查询 | 首屏小于 2 s,超过上限时分页或截断 |
| 并发同参 GET | 前端和服务端均只执行一次 |
| 外部数据源失败 | 返回结构化 503,不返回连接信息 |
查询要求:
- 日期条件使用范围比较,避免对索引列套 `DATE()``DATE_FORMAT()` 后再过滤。
- 汇总与明细可以并行查询,但必须使用相同过滤条件。
- 明细展示上限与全量汇总分离,截断时明确提示。
- 缓存键必须包含所有影响统计结果的筛选维度。
- 失败请求不得驻留在并发请求缓存中。
## 8. 分阶段路线
### 阶段 A:口径与导航基线
- 已完成版本化指标目录和四模块入口。
- 已完成里程、电能、氢能主要 URL 状态。
- 已完成 ETC 记录/账单视图、日期、搜索和分页 URL 状态,以及超范围页码自动纠正。
- 已统一共享加载、空数据和故障组件的顶层卡片与明细内联边界,消除能源明细中的嵌套状态卡片。
- 已统一电能、氢能和 ETC 日期范围输入;电能与氢能共用快捷区间算法和单一输入事件路径。
- 电能与氢能可根据 URL 日期恢复快捷区间选中态;过期区间自动归为自定义,避免标签与统计日期不一致。
- 已统一电能和氢能车辆范围 segmented control,并区分前端车辆归属状态与后端 `customer` 查询参数语义。
- 已完成并发同参 GET 去重。
### 阶段 B:核心下钻闭环
- 已完成里程考核归因、车辆详情和每日汇报跳转。
- 里程考核年度已纳入 `mileageYear` URL 状态;目标、部门/客户归因和车辆清单下钻继承同一年度,无效年度会按目标真实年度归一。
- 车辆级里程详情继承 `mileageStart``mileageEnd` 和数据源优先级;显式下钻日期严格展示所选日,预设区间继续排除尚未完整的当天数据,请求故障显示为不可用并支持重试。
- 里程实时监控的日期、来源、批次、区域、车牌、归属、高级筛选和排序已纳入独立 URL 状态;分享或刷新链接可恢复完整查询,车辆详情前进后退保留列表上下文且不把下钻车牌误写为筛选车牌。
- 已完成氢能站点、客户下钻代码。
- 已完成氢能每日趋势到加氢订单的 URL 下钻、分页和结构化故障状态;待数据源恢复后验收真实明细。
- 已为电能总览与氢能每日趋势柱图统一 Enter/Space 键盘下钻;氢能有效日期柱提供可读名称、焦点和选中反馈。
- 已完成电能全部车辆、日期和订单下钻。
- 电能每日列表打开新日期订单使用浏览器历史 `pushState`,返回/前进可恢复进入前后的分析状态;筛选调整仍使用 `replaceState`
- 已完成 ETC 通行记录与结算账单下钻代码、筛选、分页和未同步空状态;待首次真实同步后验收真实数据。
### 阶段 C:数据可信度
- 里程年度归因使用考核台账 `current_mileage` 快照,按车辆目标封顶后加总完成量与缺口,并自动核对归因合计和目标汇总;带日期的 OneOS 里程只用于车辆实时明细。
- 里程考核目标接口发布车辆台账 `MAX(update_time)`,统计页使用真实日期和时间展示数据截至点,不再用页面访问日或考核结束日代替快照时间。
- 里程考核接口区分目标声明台数与当前有效车辆数;两者不一致时页面明确标注缺少或超配数量,并说明完成率、缺口和归因的实际计算范围。
- 里程考核统计主数据已区分加载、真实空数据、首次请求故障和保留旧数据的刷新故障,并提供统一重试入口,接口失败不再显示为零值或空页面。
- 里程考核归因车辆与 7 天趋势已提供独立加载、故障、空数据和重试状态;子请求失败时停止展示“0 台未达标”和“最新日变化 +0”等伪业务结果。
- 里程考核车辆日期侧栏仅展示带目标和日期标识的成功快照;切换日期失败时保留并标注上一成功日期,首次失败不再回退为当前日数据或伪造“0 辆”。
- 里程实时监控的首页、分页、全屏和导出已共用同一组筛选参数;全屏首次故障不再显示零值,刷新故障会保留并标注上一成功快照,导出成功或失败均提供可见结果和重试入口。
- 里程实时监控主列表快照已绑定完整筛选与排序口径;新口径请求失败时隐藏旧 KPI、趋势和车辆,原口径刷新失败时保留并标注旧快照,快速筛选与分页的过期响应不会覆盖当前结果。
- 里程实时监控已将排序维度与 KPI 公式解耦:主值和平均单车始终使用当日/区间流量里程,累计仪表合计保持独立快照语义;平均单车里程已纳入版本 7 指标目录。
- 里程历史区间监控已复用权限过滤前的分钟级基础快照;筛选、排序、分页和并发同参请求不再重复拉取 OneOS 区间数据及车辆元数据,强制刷新仍可绕过已完成快照,失败请求不进入缓存。
- 已完成电能数据截至时间和实际趋势月展示。
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
- 已完成氢能结构化故障状态与重试体验。
- 已完成里程今日仪表快照复用:监控查询和考核车辆当前日明细命中同一分钟级快照,手动刷新生成一致的分页快照;历史日仍使用独立日期缓存。
- 已在各 BI 模块头部提供当前数据源的真实请求状态、失败率和最后成功/失败时间。
- 待恢复氢能数据库后进行总览、站点、客户三层对账。
- 待 ETC 同步后进行通行记录、账单和收款三层对账。
### 阶段 D:部署与安全
- 已将部署清单、氢能和里程连接中的数据库敏感值迁移为必填环境变量。
- 轮换已经进入版本历史的氢能数据库凭据。
- 已提供无敏感值的 `.env.example`;测试、生产值由各环境密钥管理。
- 已拆分进程存活检查与无敏感值的配置就绪检查。
- 已增加基于真实业务请求的数据源最后结果、最后成功时间和滚动失败率监控。
## 9. 每批验收门槛
每次提交至少满足:
1. TypeScript 检查通过。
2. 受影响的口径、URL 或查询测试通过。
3. 生产前端构建通过,服务端入口可加载。
4. `git diff --check` 通过。
5. 桌面和移动端检查无溢出、遮挡或不可操作控件。
6. 使用真实接口核对至少一个父级汇总与子级明细。
7. 不提交本地数据、临时脚本、凭据或无关工作区修改。
## 10. 当前外部依赖
- 氢能数据库连接尚未恢复,真实数据对账未完成。
- ETC 供应商未配置、未执行首次同步;记录与账单下钻代码已完成,但真实数据仍不可验收。
- 氢能连接信息已从当前版本迁出,但历史凭据仍必须在数据库侧轮换;文档不记录任何连接值。
-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. 禁用某类协议后,响应不得回退到该协议。
-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 模式全部通过上述验收用例。
File diff suppressed because it is too large Load Diff
@@ -1,264 +0,0 @@
# 能源管理模块设计
在底部导航增加「能源管理」入口,集中展示加氢/充电的成本与用量数据。当前阶段**只做前端原型,数据全部走前端 mock**,后端接入留作下一阶段。
## 业务背景
参考 FineBI 既有的三张大屏:
| BI 链接 | 标题 | 内容 |
|---------|------|------|
| `link/0iqP` | 氢气管理(氢费统计) | 年/月/日加氢量与加氢费 KPI、加氢业务区域分布、Top5 加氢站、各区域占比 |
| `link/GBSp` | 加氢站氢量每日汇总 | 每日加氢量与环比,下钻到站点级带单价 |
| `link/TPqB` | 龙王路停车场充电站每日充电汇总 | 每日充电量(度) + 充电费用(元) |
这些大屏目前只在桌面浏览器里好看,移动端体验较差,且与现有应用风格割裂。新模块的目标是把核心信息按「移动优先 + 双端响应」重新组织进当前 BI App 的底部导航,让一线运营在手机上也能秒级抓取关键能耗指标。
## 用户故事
- 作为运营人员,进 App 底部 Tab 「能源管理」,第一屏立刻能看到本年/本月/今日的加氢量与加氢费。
- 作为运营人员,能切换查看「氢能」和「电能」两类业务。
- 作为氢能业务方,能看到每日加氢明细,并下钻到站点级别(站名 + 单价)。
- 作为电能业务方,能看到龙王路充电站每日充电量与充电费。
## 范围
### 在范围内
- 新模块 `src/modules/energy/`,遵循 mileage 模块的目录骨架
- `EnergyModule` 顶级组件 + 二级 Tab(氢能 / 电能)
- 氢能下:总览(KPI + Top5 横柱 + 区域占比环)+ 每日(明细表带下钻)
- 电能下:mini KPI 头 + 每日明细表
- 全前端 mock 数据,数据值从 BI 截图取真实样本,文件 `mock.ts`
- 双端响应(mobile + web
-`App.tsx``BASE_MODULES` 注册(登录即可见)
### 不在范围内
- 后端接入、真实数据库表设计(下一期)
- 中国地图(加氢业务区域分布)— 工作量与依赖(高德/echarts-geo)大,后续单独立项
- iframe 嵌入 FineBI(已否决)
- 角色权限门禁(暂不需要)
- 数据导出 / CSV
- 站点详情页 / 区域详情页
## 模块注册(App.tsx
```ts
import { Truck, Route, Activity, Zap } from 'lucide-react';
import EnergyModule from './modules/energy/EnergyModule';
const BASE_MODULES: ModuleConfig[] = [
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
{ id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule },
];
```
并在 `Shell.tsx``PATH_MAP``'/energy': 'energy'``#energy` hash 由 Shell 已有逻辑兜底。
## 页面结构
```
EnergyModule
├── 顶部 sticky sub-nav: [氢能] [电能] ← motion layoutId 滑块动画
├── 氢能 view (HydrogenView)
│ ├── 内层 sticky tab: [总览] [每日]
│ ├── HydrogenOverview
│ │ ├── 数据时间提示条:「数据自 2025-01-01 起,每 5 分钟更新」
│ │ ├── KPI 网格(移动 2×2 / 桌面 1×4)
│ │ ├── Top5 加氢站横柱图
│ │ └── 各区域加氢占比环形图
│ └── HydrogenDaily
│ ├── 日期速选 6 选 1:当天 / 本周 / 本月 / 本季度 / 最近7天 / 最近30天
│ ├── 客户类型 2 选 1:外部 / 羚牛
│ ├── 合计行(pin 在表头下)
│ └── 表格:日期 → 加氢量(Kg) → 环比%(日期行可展开为站点级)
└── 电能 view (ElectricView)
├── 数据时间提示条:「龙王路停车场充电站,期初 2025-01-01,手工导入每日更新」
├── 横向 mini KPI 头(3 列:累计 / 本月 / 今日)
├── 客户类型 2 选 1:外部 / 羚牛
└── 表格:月份/日期 → 充电电量(度) → 充电费用(元)(月份组可展开为日级)
```
## 文件结构
```
src/modules/energy/
├── EnergyModule.tsx # 顶级容器 + 氢能/电能 切换
├── HydrogenView.tsx # 含「总览/每日」二级 Tab
├── HydrogenOverview.tsx # KPI + Top5 + 区域占比
├── HydrogenDaily.tsx # 加氢量明细表
├── ElectricView.tsx # 充电汇总表
├── mock.ts # 所有 mock 数据
└── types.ts # 共享类型
```
参照 `src/modules/mileage/` 风格。**没有 api.ts**(后端先不接)。
## 数据形状(mock.ts / types.ts
```ts
// types.ts
export type CustomerType = 'external' | 'lingniu'; // 外部 / 羚牛
export type DateQuickPick = 'today' | 'thisWeek' | 'thisMonth' | 'thisQuarter' | 'last7' | 'last30';
export interface HydrogenKpi {
yearKg: number; // 年加氢量 Kg
yearFee: number; // 年加氢费 元
ourYearKg: number; // 我方年加氢量
ourYearFee: number; // 我方年加氢费
customerYearKg: number; // 客户产生年加氢量
monthKg: number;
monthFee: number;
todayKg: number;
todayFee: number;
lingniuBornKg: number; // 累计羚牛承担量
lingniuBornFee: number; // 累计羚牛承担费
}
export interface HydrogenStation {
name: string;
kg: number;
fee: number;
}
export interface HydrogenRegionShare {
region: string;
kg: number;
share: number; // 0-1
}
export interface HydrogenStationRow {
name: string;
pricePerKg: number; // 单价 元/Kg
kg: number;
chainPct: number; // 环比 -1..+1
}
export interface HydrogenDailyRow {
date: string; // 'YYYY-MM-DD'
totalKg: number;
chainPct: number;
stations: HydrogenStationRow[];
}
export interface ElectricKpi {
totalKwh: number;
totalFee: number;
monthKwh: number;
monthFee: number;
todayKwh: number;
todayFee: number;
todayChainPct: number;
}
export interface ElectricDailyRow {
date: string; // 'YYYY-MM-DD'
kwh: number;
fee: number;
chainPct: number; // 环比,用于趋势箭头
}
export interface ElectricMonthGroup {
month: string; // 'YYYY-MM'
kwh: number;
fee: number;
rows: ElectricDailyRow[];
}
```
`mock.ts` 提供:
- `hydrogenKpi`:取自 0iqP 的 362.43T / ¥1066.46 万 / 10.03 万 等真实样本
- `hydrogenStationsTop5`5 家站,名字取自 GBSp 截图
- `hydrogenRegionShare`:约 8-12 个区域条目
- `hydrogenDaily`:约 30 天数据,前 7 天每天 3-4 个站点;剩余天只有汇总 + 1-2 站点
- `electricKpi`:取自 TPqB 合计 817,632.24 度 / ¥151,542.92
- `electricMonthly`:以月为顶级 group,含每日明细,至少覆盖 2026-04 起若干天
数字均做轻微抖动,但保留量级与百分比,避免与 BI 大屏数字"完全一致"误导。
## 视觉规范
### 通用
- 容器:`min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6` + `max-w-6xl mx-auto`
- 横屏:保留 `landscape:pb-0 landscape:h-full landscape:flex-1 landscape:overflow-hidden`
- 卡片:`bg-white rounded-2xl border border-slate-100 shadow-sm`
- sub-nav`bg-white px-4 py-2 rounded-2xl border border-slate-100 shadow-sm` + sticky top 0
- Tab active 用 `motion.div layoutId="..."` 下划线动画(沿用 mileage 模式)
- 主色:`text-blue-600 / bg-blue-50`
- 正向:`text-emerald-500 / bg-emerald-50`
- 负向:`text-red-500 / bg-red-50`
- 警示:`text-amber-500 / bg-amber-50`
### 氢能总览
- KPI 卡:4 张主角卡,顺序固定
1. 年加氢量(青蓝渐变 `from-cyan-50 to-blue-50`+ 主数 `362.43T` + 分解两行(我方 / 客户产生)
2. 年加氢费(蓝紫渐变 `from-blue-50 to-violet-50`+ 主数 `¥1066.46万` + 分解(我方)
3. 累计羚牛承担(蜜橙渐变 `from-amber-50 to-orange-50`+ 主数 `¥10.03万` + 分解(量 / 费)
4. 本月/今日合并卡(浅灰)+ 左半月度 + 右半今日,文字主数 `text-2xl md:text-3xl`
- 卡左上 lucide icon`Fuel` / `Wallet` / `Coins` / `CalendarClock`
- KPI 主数移动端 `text-2xl font-bold`,桌面 `md:text-3xl`,分解行 `text-[11px] text-slate-500 font-bold`
- Top5 加氢站:横向柱状(recharts BarChart `layout="vertical"`),柱子蓝→青渐变,柱左侧带数字徽章 1-5,柱右端贴 `XX,XXX Kg · XX%``<ResponsiveContainer width="100%" height={240}>`
- 区域占比环形:recharts PieChart,外环切片,中心圆心放年合计 `362.43T`,下方两列图例(移动单列、桌面双列)
### 氢能每日表
- 日期速选:6 个 pill 一行,可横向滚动,激活态 `bg-blue-50 text-blue-600 border-blue-200`
- 客户类型:2 列等宽 segmented control(背景 `bg-slate-100 rounded-xl`,激活态白底+阴影)
- 合计行:`bg-blue-50/50 text-blue-600` 粗体
- 主行 = 日期:左侧 `▶` 折叠图标 + 日期;展开后子行内缩进 `pl-6`,子行内容 = 站名 - 单价 元/Kg
- 环比 pill 双端共用样式:
-`↑` 绿底 `bg-emerald-50 text-emerald-600`
-`↓` 红底 `bg-red-50 text-red-600`
- 持平 `` 灰底 `bg-slate-100 text-slate-500`
- 圆角 `rounded-full px-2 py-0.5 text-[11px] font-bold`
- 移动 3 列(日期 / 量 / 环比),桌面 4 列加站价列(站点级行用网格而非缩进)
### 电能
- mini KPI 头:3 卡横排(移动也保持横排,不堆叠),每卡上排 `¥金额` 主数(`text-xl md:text-2xl`),下排 `XXX 度` 副数 + 今日卡再加 pill 环比
- 月份组:`▶` + `2026-04` + 该月合计 `度 / 元`;展开后是日级行
- 月份组 active 时背景 `bg-blue-50/30`
- 行的趋势图标 → 与氢能页用同一 pill 组件(保证视觉一致)
## 响应式行为
| 区域 | 移动 (<md) | 桌面 (≥md) |
|------|-----------|-----------|
| 顶部 sub-nav (氢/电) | sticky 满宽 | sticky 满宽,左对齐 |
| 氢能内层 sub-tab | 紧贴 sub-nav 下 | 同 |
| 氢能总览 KPI 网格 | `grid-cols-2` | `md:grid-cols-4` |
| Top5 横柱 + 区域占比 | 上下叠 `grid-cols-1` | `md:grid-cols-2 gap-4` |
| 每日氢能表 | 3 列:日期/量/环比 | 4 列:日期/站价/量/环比,站点级行同样 4 列 |
| 电能 mini KPI 头 | 横排 3 卡(gap 紧凑) | 3 卡(gap 宽松) |
| 电能表格 | 3 列 | 4-5 列(可加客户类型 / 趋势火花线,留作后期) |
| 容器宽度上限 | 100% | `max-w-6xl mx-auto` |
| recharts 图 | `<ResponsiveContainer width="100%">` | 同 |
## 组件复用
- 内层 sub-nav 抄 `MileageModule.tsx` 的 sticky tab 实现(含 motion 滑块)
- segmented control(客户类型 / 日期速选)抄 mileage 已有的实现
- 表格行 chevron 折叠抄 assets 模块里现有展开行的写法
- 环比 pill 单独抽 `<TrendBadge value={number} />` 共用组件,放在 `src/modules/energy/HydrogenDaily.tsx` 顶端 export
## 边界与开放点
1. **`氢费`字面口径**:用户原始描述「每日氢费」,但 BI GBSp 实际只有加氢量+环比,单价在站点级。本期采纳 BI 的口径——表格只展示量+环比+单价(嵌入站名),费汇总放氢能总览的 KPI 卡。
2. **数据时间**mock 用 2026-04-28 为「今天」(`currentDate` 取自 user 自动 memory)。`今日加氢` 在 0Kg 时显示 `0` 而非 `--`,避免误判为缺失。
3. **空态**:mock 数据已写满,UI 仍要支持 `rows.length === 0` 时显示「暂无数据」灰文 + 图标。
4. **未来后端接入**mock 文件命名 `mock.ts`,不放在 `api.ts`;后期添加 `api.ts` 时同名 exportUI 切到 `useEffect + fetch` 即可。
5. **icon 选型**`Zap` 作为模块底栏 icon。备选 `Fuel` / `Battery`
6. **lucide 大小**:底部 nav 沿用 `Icon size={20}`
## 验收标准
- [ ] 底部 nav 多出「能源管理」Tab,icon Zap,登录后可见
- [ ] 进入后默认在「氢能 → 总览」
- [ ] 氢能总览 4 张 KPI 卡数据正确、移动 2×2 / 桌面 1×4
- [ ] Top5 横柱 + 区域占比环 双端可见,柱图站名不被截断
- [ ] 切换到「氢能 → 每日」,日期速选/客户类型 toggle 工作(前端筛选 mock 即可)
- [ ] 点开任意日期行能展开站点级行,环比 pill 颜色正确
- [ ] 切到电能 Tab3 张 mini KPI + 月份分组表,月份能展开日级行
- [ ] 横屏不出现底部 nav 遮挡内容
- [ ] `npm run lint` 通过
- [ ] 不引入新依赖(recharts / lucide-react / motion 已有)
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>羚牛氢能</title>
<title>羚牛氢能车辆资产</title>
</head>
<body>
<div id="root"></div>
+4 -1202
View File
File diff suppressed because it is too large Load Diff
+2 -8
View File
@@ -1,7 +1,7 @@
{
"name": "ln-bi",
"private": true,
"version": "1.1.13",
"version": "1.1.5",
"type": "module",
"scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
@@ -12,28 +12,22 @@
"lint": "tsc --noEmit"
},
"dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0",
"@types/jsonwebtoken": "^9.0.10",
"ali-oss": "^6.23.0",
"dotenv": "^16.4.0",
"hono": "^4.7.0",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"mysql2": "^3.11.0",
"pg": "^8.22.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.8.1",
"tsx": "^4.21.0",
"xlsx": "^0.18.5"
"tsx": "^4.21.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.14",
"@types/ali-oss": "^6.23.3",
"@types/node": "^22.14.0",
"@types/pg": "^8.20.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.4",
+25 -198
View File
@@ -1,190 +1,39 @@
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { Truck, Route, Activity, Fuel, BatteryCharging, Receipt, MapPinned } from "lucide-react";
import { Shell, type ModuleConfig } from "./components/Shell";
import AuthProvider from "./auth/AuthProvider";
import { useAuth } from "./auth/useAuth";
import UnauthorizedPage from "./auth/UnauthorizedPage";
import { canAccessScheduling, canAccessEnergy } from "./shared/auth/roles";
import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface";
import { useMemo } from 'react';
import { Truck, Route, Activity } from 'lucide-react';
import { Shell, type ModuleConfig } from './components/Shell';
import AssetsModule from './modules/assets/AssetsModule';
import MileageModule from './modules/mileage/MileageModule';
import SchedulingModule from './modules/scheduling/SchedulingModule';
import AuthProvider from './auth/AuthProvider';
import { useAuth } from './auth/useAuth';
import UnauthorizedPage from './auth/UnauthorizedPage';
import { canAccessScheduling } from './shared/auth/roles';
const AssetsModule = lazy(() => import("./modules/assets/AssetsModule"));
const MileageModule = lazy(() => import("./modules/mileage/MileageModule"));
const SchedulingModule = lazy(() => import("./modules/scheduling/SchedulingModule"));
const HydrogenModule = lazy(() => import("./modules/energy/HydrogenModule"));
const ElectricModule = lazy(() => import("./modules/energy/ElectricModule"));
const EtcModule = lazy(() => import("./modules/energy/EtcModule"));
const EleImportPage = lazy(() => import("./modules/ele/EleImportPage"));
const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage"));
const VehicleHeatmapModule = lazy(() => import("./modules/vehicle-heatmap/VehicleHeatmapModule"));
const HydrogenHeatmapModule = lazy(() => import("./modules/hydrogen-heatmap/HydrogenHeatmapModule"));
const ASSETS_MODULE: ModuleConfig = {
id: "assets",
label: "资产管理",
icon: Truck,
component: AssetsModule,
};
const MILEAGE_MODULE: ModuleConfig = {
id: "mileage",
label: "里程管理",
icon: Route,
component: MileageModule,
};
const VEHICLE_HEATMAP_MODULE: ModuleConfig = {
id: "vehicle-heatmap",
label: "车辆",
icon: MapPinned,
component: VehicleHeatmapModule,
};
const BASE_MODULES: ModuleConfig[] = [
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
];
const SCHEDULING_MODULE: ModuleConfig = {
id: "scheduling",
label: "智能调度",
icon: Activity,
component: SchedulingModule,
id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule,
};
const HYDROGEN_MODULE: ModuleConfig = {
id: "hydrogen",
label: "氢能",
icon: Fuel,
component: HydrogenModule,
};
const HYDROGEN_HEATMAP_MODULE: ModuleConfig = {
id: "hydrogen-heatmap",
label: "加氢",
icon: MapPinned,
component: HydrogenHeatmapModule,
};
const ELECTRIC_MODULE: ModuleConfig = {
id: "electric",
label: "电能",
icon: BatteryCharging,
component: ElectricModule,
};
const ETC_MODULE: ModuleConfig = {
id: "etc",
label: "ETC",
icon: Receipt,
component: EtcModule,
};
/**
* 把旧路径 / 根路径归一化到 `/asset` 或 `/energy` 主路径,
* 必要时携带 hash 一段定位到具体模块。已是主路径或后台管理页则不动。
*/
function normalizePath() {
if (typeof window === "undefined") return;
const { pathname, hash, search } = window.location;
// 主路径 & 隐藏后台页保持不变
if (pathname === "/asset" || pathname === "/energy") return;
if (pathname === "/ele/import" || pathname === "/admin/feedback") return;
const legacyMap: 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" },
};
// 未知路径兜底到 /asset(保留原 hash 让 Shell 内部继续解析)
const target = legacyMap[pathname] ?? { path: "/asset" };
const finalHash = target.hash ? `#${target.hash}` : hash || "";
window.history.replaceState(null, "", `${target.path}${search}${finalHash}`);
}
normalizePath();
type PathSet = "asset" | "energy";
function getPathSet(): PathSet {
return window.location.pathname === "/energy" ? "energy" : "asset";
}
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() {
const { isLoading, isAuthenticated, error, user } = useAuth();
const [routeKey, setRouteKey] = useState(getRouteKey);
const [pathSet, setPathSet] = useState<PathSet>(getPathSet);
// 监听 hashchange / popstate,让 a href="#/..." 跳转与浏览器前进后退能即时生效
useEffect(() => {
const update = () => {
setRouteKey(getRouteKey());
setPathSet(getPathSet());
};
window.addEventListener("hashchange", update);
window.addEventListener("popstate", update);
return () => {
window.removeEventListener("hashchange", update);
window.removeEventListener("popstate", update);
};
}, []);
useEffect(() => {
document.title = pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI";
}, [pathSet]);
const modules = useMemo<ModuleConfig[]>(() => {
if (pathSet === "energy") {
return [HYDROGEN_MODULE, ELECTRIC_MODULE, ETC_MODULE];
const modules = useMemo(() => {
if (canAccessScheduling(user?.roles)) {
return [...BASE_MODULES, SCHEDULING_MODULE];
}
const heatmapChildren = canAccessEnergy(user?.roles)
? [HYDROGEN_HEATMAP_MODULE, VEHICLE_HEATMAP_MODULE]
: [VEHICLE_HEATMAP_MODULE];
const heatmapModule: ModuleConfig = {
id: "heatmap",
label: "热力图",
icon: MapPinned,
children: heatmapChildren,
};
const result: ModuleConfig[] = [ASSETS_MODULE, MILEAGE_MODULE, heatmapModule];
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
return result;
}, [pathSet, user?.roles]);
return BASE_MODULES;
}, [user?.roles]);
if (isLoading) {
return (
<div className="min-h-screen bg-[var(--app-bg)] p-4 text-slate-800 md:p-8">
<div className="mx-auto flex max-w-5xl flex-col gap-4">
<div className="rounded-2xl border border-white/70 bg-white/86 p-5 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
<div className="mb-3 text-[11px] font-black text-blue-600">LN BI ACCESS</div>
<div className="text-xl font-black text-slate-950"> BI</div>
<div className="mt-2 text-xs font-bold text-slate-400"></div>
</div>
<SurfaceCard className="p-4">
<div className="grid gap-3 md:grid-cols-4">
{[0, 1, 2, 3].map(item => <SkeletonBlock key={item} className="h-24" />)}
</div>
<div className="mt-4">
<LoadingState label="正在验证身份" />
</div>
</SurfaceCard>
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
<div className="text-center">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
<p className="text-xs text-slate-400 font-bold">...</p>
</div>
</div>
);
@@ -194,29 +43,7 @@ function AuthGate() {
return <UnauthorizedPage message={error || undefined} />;
}
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
if (routeKey === "ele/import") {
return (
<Suspense fallback={<LoadingState label="正在加载导入工作台" />}>
<EleImportPage />
</Suspense>
);
}
if (routeKey === "admin/feedback") {
return (
<Suspense fallback={<LoadingState label="正在加载反馈后台" />}>
<FeedbackAdminPage />
</Suspense>
);
}
// /energy 整组按能源权限控制
if (pathSet === "energy" && !canAccessEnergy(user?.roles)) {
return <UnauthorizedPage message="无能源管理模块访问权限" />;
}
// key={pathSet} 让两套底栏切换时 Shell 内部 state 重置,避免残留旧 activeModule
return <Shell key={pathSet} modules={modules} />;
return <Shell modules={modules} />;
}
export default function App() {
+2 -18
View File
@@ -36,23 +36,6 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
}, []);
async function authenticate() {
// 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效
if (import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
setState({
isLoading: false,
isAuthenticated: true,
user: {
userId: 'dev-local',
userName: '本地开发',
permissionLevel: 'full',
depName: '',
roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'],
},
error: null,
});
return;
}
// 1. 检查 sessionStorage 中是否有 JWT
const savedToken = sessionStorage.getItem('bi_jwt');
if (savedToken) {
@@ -82,7 +65,8 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
const jumpToken = params.get('jumpToken');
if (!jumpToken) {
setState({ isLoading: false, isAuthenticated: false, user: null, error: '请从业务系统跳转访问' });
// 演示模式:无 token 时直接放行
setState({ isLoading: false, isAuthenticated: true, user: null, error: null });
return;
}
+13 -18
View File
@@ -1,24 +1,19 @@
import { ShieldX, Monitor, Smartphone } from 'lucide-react';
import { PageFrame, SurfaceCard } from '../components/ui/surface';
export default function UnauthorizedPage({ message }: { message?: string }) {
return (
<PageFrame
title="未授权访问"
subtitle={message || '获取用户认证信息失败,可能是跳转令牌已过期或无效。'}
icon={ShieldX}
eyebrow="ACCESS CONTROL"
meta="请从授权入口重新进入"
maxWidth="max-w-xl"
>
<SurfaceCard className="p-4">
<div className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<ShieldX size={30} />
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center p-6">
<div className="text-center max-w-sm">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-slate-100 flex items-center justify-center">
<ShieldX size={36} className="text-slate-400" />
</div>
<p className="mb-4 text-[10px] font-bold uppercase tracking-wider text-slate-400"></p>
</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">
<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>
</SurfaceCard>
</PageFrame>
</div>
</div>
);
}
-99
View File
@@ -1,99 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
ApiRequestError,
_clearApiClientInflightForTests,
fetchJson,
setTokenGetter,
} from './api-client.js';
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
test('deduplicates concurrent GET requests for the same URL and token', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
let resolveFetch: ((response: Response) => void) | undefined;
globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => {
calls += 1;
assert.equal(new Headers(init?.headers).get('authorization'), 'Bearer test-token');
return new Promise<Response>(resolve => { resolveFetch = resolve; });
}) as typeof fetch;
setTokenGetter(() => 'test-token');
_clearApiClientInflightForTests();
try {
const first = fetchJson<{ value: number }>('/api/test');
const second = fetchJson<{ value: number }>('/api/test');
assert.equal(calls, 1);
resolveFetch?.(jsonResponse({ value: 42 }));
assert.deepEqual(await first, { value: 42 });
assert.deepEqual(await second, { value: 42 });
} finally {
globalThis.fetch = originalFetch;
setTokenGetter(() => null);
_clearApiClientInflightForTests();
}
});
test('does not retain a failed GET request', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = (async () => {
calls += 1;
if (calls === 1) throw new Error('network down');
return jsonResponse({ ok: true });
}) as typeof fetch;
setTokenGetter(() => null);
_clearApiClientInflightForTests();
try {
await assert.rejects(fetchJson('/api/retry'), /network down/);
assert.deepEqual(await fetchJson('/api/retry'), { ok: true });
assert.equal(calls, 2);
} finally {
globalThis.fetch = originalFetch;
_clearApiClientInflightForTests();
}
});
test('surfaces structured API errors without retaining the failed request', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = (async () => {
calls += 1;
if (calls > 1) return jsonResponse({ ok: true });
return new Response(JSON.stringify({
error: '当前无法读取氢能业务数据,请稍后重试',
code: 'HYDROGEN_SOURCE_UNAVAILABLE',
retryable: true,
}), {
status: 503,
headers: { 'content-type': 'application/json' },
});
}) as typeof fetch;
_clearApiClientInflightForTests();
try {
await assert.rejects(
fetchJson('/api/energy/hydrogen/overview'),
(error: unknown) => {
assert.ok(error instanceof ApiRequestError);
assert.equal(error.message, '当前无法读取氢能业务数据,请稍后重试');
assert.equal(error.status, 503);
assert.equal(error.code, 'HYDROGEN_SOURCE_UNAVAILABLE');
assert.equal(error.retryable, true);
return true;
},
);
assert.deepEqual(await fetchJson('/api/energy/hydrogen/overview'), { ok: true });
assert.equal(calls, 2);
} finally {
globalThis.fetch = originalFetch;
_clearApiClientInflightForTests();
}
});
+3 -62
View File
@@ -1,34 +1,13 @@
/** 全局认证 fetch 客户端 */
let tokenGetter: () => string | null = () => null;
const inflightGetRequests = new Map<string, Promise<unknown>>();
export class ApiRequestError extends Error {
readonly status: number;
readonly code: string | null;
readonly retryable: boolean;
constructor(message: string, status: number, code: string | null, retryable: boolean) {
super(message);
this.name = 'ApiRequestError';
this.status = status;
this.code = code;
this.retryable = retryable;
}
}
interface ApiErrorPayload {
error?: unknown;
message?: unknown;
code?: unknown;
retryable?: unknown;
}
export function setTokenGetter(fn: () => string | null) {
tokenGetter = fn;
}
async function executeJsonRequest<T>(url: string, options: RequestInit | undefined, token: string | null): Promise<T> {
export async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const token = tokenGetter();
const res = await fetch(url, {
...options,
headers: {
@@ -40,44 +19,6 @@ async function executeJsonRequest<T>(url: string, options: RequestInit | undefin
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
throw new Error('Unauthorized');
}
if (!res.ok) {
let payload: ApiErrorPayload = {};
try {
const parsed: unknown = await res.json();
if (typeof parsed === 'object' && parsed !== null) payload = parsed as ApiErrorPayload;
} catch {
payload = {};
}
const message = typeof payload?.error === 'string'
? payload.error
: typeof payload?.message === 'string'
? payload.message
: `请求失败(${res.status}`;
const code = typeof payload?.code === 'string' ? payload.code : null;
const retryable = typeof payload?.retryable === 'boolean' ? payload.retryable : res.status >= 500;
throw new ApiRequestError(message, res.status, code, retryable);
}
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
return res.json();
}
export async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const token = tokenGetter();
const method = (options?.method || 'GET').toUpperCase();
const canDedupe = method === 'GET' && options?.body == null && options?.signal == null;
if (!canDedupe) return executeJsonRequest<T>(url, options, token);
const key = `${token || 'anonymous'}:${url}`;
const existing = inflightGetRequests.get(key) as Promise<T> | undefined;
if (existing) return existing;
const request = executeJsonRequest<T>(url, options, token)
.finally(() => {
if (inflightGetRequests.get(key) === request) inflightGetRequests.delete(key);
});
inflightGetRequests.set(key, request);
return request;
}
export function _clearApiClientInflightForTests(): void {
inflightGetRequests.clear();
}
-12
View File
@@ -1,12 +0,0 @@
import type { MetricDomain } from '../shared/analytics/catalog';
import DataSourceStatusButton from './DataSourceStatusButton';
import MetricCatalogButton from './MetricCatalogButton';
export default function BiHeaderActions({ domain }: { domain: MetricDomain }) {
return (
<>
<DataSourceStatusButton domain={domain} />
<MetricCatalogButton domain={domain} />
</>
);
}
-228
View File
@@ -1,228 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Activity, AlertTriangle, CheckCircle2, Clock3, Loader2, RefreshCw, X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { fetchJson } from '../auth/api-client';
import type { MetricDomain } from '../shared/analytics/catalog';
import {
BI_SOURCE_BY_DOMAIN,
type DataSourceId,
type DataSourceState,
} from '../shared/analytics/source-status';
interface SourceTelemetry {
source: DataSourceId;
state: DataSourceState;
attempts: number;
failures: number;
failureRate: number | null;
lastSuccessAt: string | null;
lastFailureAt: string | null;
}
interface SourceTelemetryResponse {
semantics: 'observed-requests';
windowSize: number;
processStartedAt: string;
checkedAt: string;
sources: SourceTelemetry[];
}
const STATE_META = {
available: {
label: '最近请求成功',
icon: CheckCircle2,
className: 'border-emerald-200 bg-emerald-50 text-emerald-700',
},
failing: {
label: '最近请求失败',
icon: AlertTriangle,
className: 'border-rose-200 bg-rose-50 text-rose-700',
},
unobserved: {
label: '启动后尚无真实请求',
icon: Clock3,
className: 'border-slate-200 bg-slate-50 text-slate-600',
},
} as const;
function formatObservedAt(value: string | null): string {
if (!value) return '暂无';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(date);
}
export default function DataSourceStatusButton({ domain }: { domain: MetricDomain }) {
const [open, setOpen] = useState(false);
const [data, setData] = useState<SourceTelemetryResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const focusCloseButton = useCallback((node: HTMLButtonElement | null) => node?.focus(), []);
const sourceMeta = BI_SOURCE_BY_DOMAIN[domain];
const source = useMemo(
() => data?.sources.find(item => item.source === sourceMeta.source) || null,
[data, sourceMeta.source],
);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setData(await fetchJson<SourceTelemetryResponse>('/api/analytics/data-sources'));
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : String(loadError));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) void load();
}, [load, open]);
useEffect(() => {
if (!open) return undefined;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
triggerRef.current?.focus();
};
}, [open]);
const stateMeta = source ? STATE_META[source.state] : STATE_META.unobserved;
const StateIcon = stateMeta.icon;
const triggerTone = source?.state === 'failing'
? 'border-rose-200 bg-rose-50 text-rose-600'
: source?.state === 'available'
? 'border-emerald-200 bg-emerald-50 text-emerald-600'
: 'border-slate-200 bg-white text-slate-500';
return (
<>
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(true)}
className={`inline-flex h-9 w-9 items-center justify-center rounded-xl border shadow-sm transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 ${triggerTone}`}
aria-label="查看数据源运行状态"
title="数据源运行状态"
>
<Activity size={16} />
</button>
{createPortal(<AnimatePresence>
{open ? (
<div className="fixed inset-0 z-[10020]">
<motion.button
type="button"
aria-label="点击遮罩关闭数据源状态"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setOpen(false)}
className="absolute inset-0 h-full w-full bg-slate-950/35 backdrop-blur-[2px]"
/>
<motion.section
role="dialog"
aria-modal="true"
aria-labelledby={`${domain}-source-status-title`}
initial={{ opacity: 0, x: 32 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 32 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="absolute inset-y-0 right-0 flex w-full max-w-sm flex-col bg-white shadow-2xl"
>
<header className="flex shrink-0 items-center gap-3 border-b border-slate-100 px-4 py-4">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-blue-50 text-blue-600 ring-1 ring-blue-100">
<Activity size={17} />
</span>
<div className="min-w-0 flex-1">
<h2 id={`${domain}-source-status-title`} className="text-sm font-black text-slate-900"></h2>
<p className="mt-0.5 truncate text-[10px] font-bold text-slate-400">{sourceMeta.label}</p>
</div>
<button
type="button"
onClick={() => void load()}
disabled={loading}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50 disabled:opacity-50"
aria-label="刷新数据源状态"
title="刷新"
>
<RefreshCw size={15} className={loading ? 'animate-spin' : ''} />
</button>
<button
ref={focusCloseButton}
type="button"
onClick={() => setOpen(false)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50"
aria-label="关闭数据源状态"
title="关闭"
>
<X size={16} />
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
{error ? (
<div className="rounded-lg border border-rose-100 bg-rose-50 p-3 text-xs font-bold text-rose-700">{error}</div>
) : loading && !data ? (
<div className="flex items-center justify-center gap-2 py-16 text-xs font-bold text-slate-400">
<Loader2 size={16} className="animate-spin" />
</div>
) : source ? (
<>
<div className={`flex items-center gap-2 rounded-lg border px-3 py-3 ${stateMeta.className}`}>
<StateIcon size={18} />
<span className="text-sm font-black">{stateMeta.label}</span>
</div>
<dl className="mt-5 divide-y divide-slate-100 border-y border-slate-100 text-xs">
<div className="flex items-center justify-between gap-4 py-3">
<dt className="font-bold text-slate-400"></dt>
<dd className="font-black text-slate-700"> {data?.windowSize ?? 100} </dd>
</div>
<div className="flex items-center justify-between gap-4 py-3">
<dt className="font-bold text-slate-400"> / </dt>
<dd className="font-black tabular-nums text-slate-700">{source.attempts} / {source.failures}</dd>
</div>
<div className="flex items-center justify-between gap-4 py-3">
<dt className="font-bold text-slate-400"></dt>
<dd className="font-black tabular-nums text-slate-700">{source.failureRate == null ? '暂无' : `${(source.failureRate * 100).toFixed(1)}%`}</dd>
</div>
<div className="flex items-center justify-between gap-4 py-3">
<dt className="font-bold text-slate-400"></dt>
<dd className="font-black tabular-nums text-slate-700">{formatObservedAt(source.lastSuccessAt)}</dd>
</div>
<div className="flex items-center justify-between gap-4 py-3">
<dt className="font-bold text-slate-400"></dt>
<dd className="font-black tabular-nums text-slate-700">{formatObservedAt(source.lastFailureAt)}</dd>
</div>
</dl>
<p className="mt-5 text-[10px] font-bold leading-relaxed text-slate-400">
</p>
</>
) : null}
</div>
</motion.section>
</div>
) : null}
</AnimatePresence>, document.body)}
</>
);
}
-511
View File
@@ -1,511 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
MessageCircleHeart, X, ChevronRight, ChevronLeft, Check, Sparkles,
ImagePlus, Loader2, Inbox, Lightbulb, Bug, Palette, NotebookPen, Settings2,
type LucideIcon,
} from 'lucide-react';
import { fetchJson } from '../auth/api-client';
import { useAuth } from '../auth/useAuth';
import { canManageFeedback } from '../shared/auth/roles';
import FeedbackHistoryDrawer from './FeedbackHistoryDrawer';
import RotatingFooterHint from './RotatingFooterHint';
const MAX_SCREENSHOTS = 6;
const MAX_IMG_SIZE_MB = 5;
interface UploadedImg {
url: string;
thumbDataUrl: string;
}
async function uploadImage(file: File): Promise<string> {
const fd = new FormData();
fd.append('file', file);
const token = sessionStorage.getItem('bi_jwt');
const res = await fetch('/api/feedback/upload', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: fd,
});
const json = await res.json();
if (!res.ok || !json.ok) throw new Error(json.message || `上传失败 (${res.status})`);
return json.url as string;
}
function readAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(String(r.result || ''));
r.onerror = reject;
r.readAsDataURL(file);
});
}
type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other';
interface TypeOption {
key: FeedbackType;
icon: LucideIcon;
label: string;
sub: string;
iconBg: string;
iconFg: string;
ring: string;
}
const TYPE_OPTIONS: TypeOption[] = [
{ key: 'dimension', icon: Lightbulb, label: '想看新的统计维度', sub: '比如按 XX 维度切片',
iconBg: 'bg-amber-50', iconFg: 'text-amber-500', ring: 'ring-amber-200' },
{ key: 'bug', icon: Bug, label: '报告一个 Bug', sub: '哪里看着不对劲',
iconBg: 'bg-rose-50', iconFg: 'text-rose-500', ring: 'ring-rose-200' },
{ key: 'ux', icon: Palette, label: '界面 / 体验建议', sub: '哪里能更顺手',
iconBg: 'bg-violet-50', iconFg: 'text-violet-500', ring: 'ring-violet-200' },
{ key: 'other', icon: NotebookPen, label: '其他想法', sub: '欢迎随便聊聊',
iconBg: 'bg-blue-50', iconFg: 'text-blue-500', ring: 'ring-blue-200' },
];
const MODULE_LABELS: Record<string, string> = {
assets: '资产管理',
mileage: '里程管理',
energy: '能源管理',
scheduling: '智能调度',
ele: '充电导入',
'': '通用',
};
function detectModule(): string {
const hash = (window.location.hash || '').slice(1);
const path = window.location.pathname;
if (path.includes('/ele/')) return 'ele';
if (hash.includes('ele')) return 'ele';
if (hash.startsWith('/')) return hash.split('/')[1] || '';
return hash || '';
}
interface Props {
/** 显式覆盖当前模块(否则自动从 URL 检测) */
module?: string;
}
export default function FeedbackFab({ module: moduleProp }: Props = {}) {
const { user } = useAuth();
const isAdmin = canManageFeedback(user?.roles);
const [open, setOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [step, setStep] = useState<1 | 2 | 3>(1); // 1=选类型, 2=写内容, 3=成功页
const [type, setType] = useState<FeedbackType | null>(null);
const [mod, setMod] = useState<string>('');
const [content, setContent] = useState('');
const [shots, setShots] = useState<UploadedImg[]>([]);
const [uploading, setUploading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const taRef = useRef<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const addFiles = async (files: FileList | File[]) => {
const list = Array.from(files).filter(f => f.type.startsWith('image/'));
if (list.length === 0) return;
setError(null);
setUploading(true);
try {
for (const f of list) {
if (shots.length >= MAX_SCREENSHOTS) break;
if (f.size > MAX_IMG_SIZE_MB * 1024 * 1024) {
setError(`${f.name}」超过 ${MAX_IMG_SIZE_MB}MB`);
continue;
}
const thumbDataUrl = await readAsDataUrl(f);
const url = await uploadImage(f);
setShots(prev => prev.length >= MAX_SCREENSHOTS ? prev : [...prev, { url, thumbDataUrl }]);
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setUploading(false);
}
};
// Open: detect current module
useEffect(() => {
if (open && step === 1) {
setMod(moduleProp ?? detectModule());
}
}, [open, step, moduleProp]);
// Lock scroll when open
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [open]);
const reset = () => {
setStep(1);
setType(null);
setContent('');
setShots([]);
setError(null);
};
const close = () => {
setOpen(false);
setTimeout(reset, 300); // 等动画完
};
const submit = async () => {
if (!type || !content.trim()) return;
setSubmitting(true);
setError(null);
try {
await fetchJson('/api/feedback/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type,
module: mod || null,
content: content.trim(),
screenshots: shots.map(s => s.url),
userAgent: navigator.userAgent.slice(0, 500),
}),
});
setStep(3);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSubmitting(false);
}
};
const next = () => {
if (step === 1) {
if (!type) return;
setStep(2);
return;
}
if (step === 2) {
if (!content.trim()) { taRef.current?.focus(); return; }
submit();
return;
}
};
const back = () => setStep((Math.max(1, step - 1)) as typeof step);
const canNext = step === 1 ? !!type : step === 2 ? content.trim().length > 0 : true;
const progress = step >= 3 ? 100 : (step / 2) * 100;
return (
<>
{/* Floating Action Button */}
<div className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-[60]">
<motion.button
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.4, type: 'spring', stiffness: 300, damping: 20 }}
whileHover={{ scale: 1.08 }}
whileTap={{ scale: 0.92 }}
onClick={() => setMenuOpen(m => !m)}
className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-cyan-400 text-white shadow-lg shadow-blue-200 flex items-center justify-center group"
aria-label="反馈"
title="提建议 / 我的反馈"
>
<MessageCircleHeart size={20} className="drop-shadow group-hover:scale-110 transition-transform" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-amber-300 ring-2 ring-white animate-pulse" />
</motion.button>
<AnimatePresence>
{menuOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 8 }}
transition={{ duration: 0.15 }}
className="absolute bottom-14 right-0 bg-white rounded-2xl shadow-xl border border-slate-100 p-1.5 min-w-[148px] flex flex-col gap-0.5"
>
<button
onClick={() => { setMenuOpen(false); setOpen(true); }}
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-blue-50 hover:text-blue-600 text-left"
>
<Sparkles size={14} className="text-blue-500" />
</button>
<button
onClick={() => { setMenuOpen(false); setHistoryOpen(true); }}
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-emerald-50 hover:text-emerald-600 text-left"
>
<Inbox size={14} className="text-emerald-500" />
</button>
{isAdmin && (
<>
<div className="h-px bg-slate-100 my-0.5" />
<a
href="#/admin/feedback"
onClick={() => setMenuOpen(false)}
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-violet-50 hover:text-violet-600"
>
<Settings2 size={14} className="text-violet-500" />
</a>
</>
)}
</motion.div>
)}
</AnimatePresence>
{/* 点击外面关菜单 */}
{menuOpen && (
<div
className="fixed inset-0 z-[-1]"
onClick={() => setMenuOpen(false)}
/>
)}
</div>
<FeedbackHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} />
{/* Modal */}
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[90] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
// 不允许点击背景关闭:避免用户输入到一半误触遮罩丢失内容
>
<motion.div
initial={{ y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '100%', opacity: 0 }}
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Drag handle */}
<div className="flex justify-center pt-2.5 pb-1">
<div className="w-10 h-1 rounded-full bg-slate-300" />
</div>
{/* Header + progress */}
<div className="px-4 pb-3 border-b border-slate-100">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center">
<Sparkles size={14} className="text-white" />
</div>
<div>
<div className="text-sm font-black text-slate-800 leading-tight">
{step === 3 ? '收到啦~' : '提个建议'}
</div>
<div className="text-[10px] text-slate-400 font-bold">
{step === 1 && '第一步 / 共 2 步'}
{step === 2 && '第二步 / 共 2 步'}
{step === 3 && '感谢你的反馈'}
</div>
</div>
</div>
<button onClick={close} className="p-1.5 -mr-1 text-slate-400 hover:text-slate-700">
<X size={18} />
</button>
</div>
<div className="h-1 bg-slate-100 rounded-full overflow-hidden">
<motion.div
initial={false}
animate={{ width: `${progress}%` }}
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 rounded-full"
/>
</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>
</>
);
}
-163
View File
@@ -1,163 +0,0 @@
import { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { X, MailOpen, Loader2, ArrowLeft } from 'lucide-react';
import { fetchJson } from '../auth/api-client';
interface FeedbackItem {
id: number;
type: 'dimension' | 'bug' | 'ux' | 'other';
module: string | null;
content: string;
contact: string | null;
screenshots: string[] | string | null;
status: 'open' | 'in_progress' | 'done' | 'rejected';
reply_content: string | null;
reply_user: string | null;
reply_at: string | null;
created_at: string;
}
const TYPE_LABEL: Record<string, string> = {
dimension: '💡 新维度',
bug: '🐛 Bug',
ux: '🎨 体验',
other: '📝 其他',
};
const STATUS_LABEL: Record<string, string> = {
open: '待处理',
in_progress: '处理中',
done: '已完成',
rejected: '已忽略',
};
const STATUS_STYLE: Record<string, string> = {
open: 'bg-slate-100 text-slate-500',
in_progress: 'bg-amber-100 text-amber-600',
done: 'bg-emerald-100 text-emerald-600',
rejected: 'bg-rose-100 text-rose-500',
};
interface Props {
open: boolean;
onClose: () => void;
onBack?: () => void;
}
function parseScreenshots(s: FeedbackItem['screenshots']): string[] {
if (!s) return [];
if (Array.isArray(s)) return s;
try { return JSON.parse(String(s)); } catch { return []; }
}
export default function FeedbackHistoryDrawer({ open, onClose, onBack }: Props) {
const [items, setItems] = useState<FeedbackItem[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setItems(null);
setError(null);
fetchJson<{ items: FeedbackItem[] }>('/api/feedback/mine')
.then(d => setItems(d.items))
.catch(e => setError(e instanceof Error ? e.message : String(e)));
}, [open]);
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
onClick={onClose}
>
<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-lg 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 flex items-center gap-2">
{onBack && (
<button onClick={onBack} className="p-1 -ml-1 text-slate-500 hover:text-slate-700">
<ArrowLeft size={18} />
</button>
)}
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-emerald-400 to-cyan-400 flex items-center justify-center">
<MailOpen size={14} className="text-white" />
</div>
<div className="flex-1">
<div className="text-sm font-black text-slate-800 leading-tight"></div>
<div className="text-[10px] text-slate-400 font-bold"></div>
</div>
<button onClick={onClose} className="p-1.5 text-slate-400 hover:text-slate-700">
<X size={18} />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-3">
{error ? (
<div className="bg-rose-50 text-rose-600 rounded-xl p-3 text-[12px] font-bold">{error}</div>
) : items === null ? (
<div className="py-10 text-center text-slate-400 text-[12px] font-bold flex items-center justify-center gap-1.5">
<Loader2 size={14} className="animate-spin" />
</div>
) : items.length === 0 ? (
<div className="py-10 text-center text-slate-300 text-[12px] font-bold"></div>
) : (
<div className="space-y-2.5">
{items.map(it => {
const shots = parseScreenshots(it.screenshots);
return (
<div key={it.id} className="bg-slate-50 rounded-xl p-3 space-y-2">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[10px] font-bold text-slate-500">{TYPE_LABEL[it.type] || it.type}</span>
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${STATUS_STYLE[it.status]}`}>
{STATUS_LABEL[it.status]}
</span>
<span className="text-[10px] text-slate-400 ml-auto">
{(it.created_at || '').replace('T', ' ').slice(0, 16)}
</span>
</div>
<div className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-wrap break-words">
{it.content}
</div>
{shots.length > 0 && (
<div className="flex flex-wrap gap-1">
{shots.map((url, i) => (
<a key={i} href={url} target="_blank" rel="noreferrer" className="block w-12 h-12 rounded overflow-hidden border border-slate-200">
<img src={url} alt="" className="w-full h-full object-cover" />
</a>
))}
</div>
)}
{it.reply_content && (
<div className="bg-blue-50 border border-blue-100 rounded-lg p-2.5 mt-1">
<div className="text-[10px] font-bold text-blue-500 mb-0.5">
{it.reply_user || '产品同学'}
{it.reply_at && <span className="text-blue-300 ml-1">{(it.reply_at || '').replace('T', ' ').slice(0, 16)}</span>}
</div>
<div className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-wrap break-words">
{it.reply_content}
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
-166
View File
@@ -1,166 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { BookOpen, Database, Loader2, X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { fetchJson } from '../auth/api-client';
import type { MetricDefinition, MetricDomain } from '../shared/analytics/catalog';
interface MetricCatalogResponse {
catalogVersion: number;
domains: MetricDomain[];
metrics: MetricDefinition[];
}
const AGGREGATION_LABEL: Record<MetricDefinition['aggregation'], string> = {
sum: '求和',
count: '计数',
derived: '派生计算',
'weighted-ratio': '加权比率',
'distinct-count': '去重计数',
latest: '取最新值',
};
const TIME_LABEL: Record<MetricDefinition['timeSemantics'], string> = {
flow: '区间流量',
snapshot: '时点快照',
freshness: '数据新鲜度',
};
export default function MetricCatalogButton({ domain }: { domain: MetricDomain }) {
const [open, setOpen] = useState(false);
const [data, setData] = useState<MetricCatalogResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const setCloseRef = useCallback((node: HTMLButtonElement | null) => {
closeRef.current = node;
node?.focus();
}, []);
useEffect(() => {
if (!open || data) return undefined;
let cancelled = false;
setError(null);
fetchJson<MetricCatalogResponse>(`/api/analytics/metrics?domain=${domain}`)
.then(result => { if (!cancelled) setData(result); })
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [open, data, domain]);
useEffect(() => {
if (!open) return undefined;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
triggerRef.current?.focus();
};
}, [open]);
return (
<>
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(true)}
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600"
aria-label="查看指标口径"
title="指标口径"
>
<BookOpen size={16} />
</button>
{createPortal(<AnimatePresence>
{open && (
<div className="fixed inset-0 z-[10020]">
<motion.button
type="button"
aria-label="点击遮罩关闭指标口径"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setOpen(false)}
className="absolute inset-0 h-full w-full bg-slate-950/35 backdrop-blur-[2px]"
/>
<motion.section
role="dialog"
aria-modal="true"
aria-labelledby={`${domain}-metric-catalog-title`}
initial={{ opacity: 0, x: 32 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 32 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="absolute inset-y-0 right-0 flex w-full max-w-lg flex-col bg-white shadow-2xl"
>
<header className="flex shrink-0 items-center gap-3 border-b border-slate-100 px-4 py-4 md:px-5">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-blue-50 text-blue-600 ring-1 ring-blue-100">
<BookOpen size={17} />
</span>
<div className="min-w-0 flex-1">
<h2 id={`${domain}-metric-catalog-title`} className="text-sm font-black text-slate-900"></h2>
<p className="mt-0.5 text-[10px] font-bold text-slate-400">
{data ? `目录版本 v${data.catalogVersion} · ${data.metrics.length} 项指标` : '读取已发布指标目录'}
</p>
</div>
<button
ref={setCloseRef}
type="button"
onClick={() => setOpen(false)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50 hover:text-slate-800"
aria-label="关闭指标口径"
title="关闭"
>
<X size={16} />
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-2 md:px-5">
{error ? (
<div className="my-4 rounded-lg border border-rose-100 bg-rose-50 p-3 text-xs font-bold text-rose-700">{error}</div>
) : !data ? (
<div className="flex items-center justify-center gap-2 py-16 text-xs font-bold text-slate-400">
<Loader2 size={16} className="animate-spin" />
</div>
) : (
data.metrics.map(metric => (
<article key={metric.id} className="border-b border-slate-100 py-4 last:border-b-0">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<h3 className="text-sm font-black text-slate-900">{metric.label}</h3>
<div className="mt-1 font-mono text-[9px] font-bold text-slate-400">{metric.id}</div>
</div>
<div className="flex flex-wrap gap-1 text-[9px] font-black">
<span className="rounded bg-blue-50 px-1.5 py-1 text-blue-600">{AGGREGATION_LABEL[metric.aggregation]}</span>
<span className="rounded bg-slate-100 px-1.5 py-1 text-slate-600">{TIME_LABEL[metric.timeSemantics]}</span>
<span className="rounded bg-emerald-50 px-1.5 py-1 text-emerald-700">{metric.unit}</span>
</div>
</div>
<p className="mt-3 text-[11px] font-bold leading-relaxed text-slate-600">{metric.description}</p>
<div className="mt-3 rounded-lg bg-slate-950 px-3 py-2 font-mono text-[10px] leading-relaxed text-slate-200 break-words">
{metric.formula}
</div>
<div className="mt-3 flex items-start gap-2 text-[10px] font-bold text-slate-500">
<Database size={12} className="mt-0.5 shrink-0 text-slate-400" />
<span className="break-words">{metric.sources.join(' · ')}</span>
</div>
<div className="mt-2 flex flex-wrap gap-1">
{metric.dimensions.map(dimension => (
<span key={dimension} className="rounded border border-slate-200 px-1.5 py-0.5 text-[9px] font-bold text-slate-500">{dimension}</span>
))}
</div>
</article>
))
)}
</div>
</motion.section>
</div>
)}
</AnimatePresence>, document.body)}
</>
);
}
-51
View File
@@ -1,51 +0,0 @@
import { useEffect, useState } from 'react';
const FOOTER_HINTS = [
'想看哪个角度的数据?告诉我们一下嘛',
'更多统计维度接入中,欢迎您的建议 ~',
'下一个图表,可能就是您建议的那个',
'数据科学家正在深夜挖掘新维度…',
'维度灵感正在路上,钉一下产品同学也行',
'数字背后还有故事,等下一次上线揭晓',
];
interface Props {
/** 自定义提示词集合,默认使用通用文案 */
hints?: string[];
/** 切换间隔,默认 4 秒 */
intervalMs?: number;
/** 额外类名 */
className?: string;
/** 点击时回调(一般用来打开反馈弹窗) */
onClick?: () => void;
}
export default function RotatingFooterHint({ hints = FOOTER_HINTS, intervalMs = 4000, className = '', onClick }: Props) {
const [idx, setIdx] = useState(0);
useEffect(() => {
if (hints.length <= 1) return;
const t = setInterval(() => setIdx(i => (i + 1) % hints.length), intervalMs);
return () => clearInterval(t);
}, [hints, intervalMs]);
return (
<div
className={`flex items-center gap-1.5 text-[11px] text-slate-400 font-bold ${onClick ? 'cursor-pointer hover:text-blue-500 transition-colors' : ''} ${className || 'mt-1 justify-center'}`}
onClick={onClick}
>
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
<span
key={idx}
style={{ animation: 'rotatingHintFade 0.5s ease' }}
>
{hints[idx]}
</span>
<style>{`
@keyframes rotatingHintFade {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
</div>
);
}
+48 -132
View File
@@ -1,88 +1,69 @@
import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react';
import { motion } from 'motion/react';
import { Building2, ChevronRight, ShieldCheck } from 'lucide-react';
import { useState, useEffect, useMemo, type ComponentType } from 'react';
import { useAuth } from '../auth/useAuth';
import { DemoModeProvider } from './Blur';
import FeedbackFab from './FeedbackFab';
import { cn } from '../lib/cn';
import { LoadingState } from './ui/surface';
export interface ModuleConfig {
id: string;
label: string;
icon: ComponentType<{ size?: number; className?: string }>;
component?: ElementType;
children?: ModuleConfig[];
component: ComponentType;
}
function flattenModules(modules: ModuleConfig[]): ModuleConfig[] {
return modules.flatMap((module) => module.children ?? [module]);
}
/** hash 一级段(`#<id>` 或 `#<id>/<sub>` 都只取 id */
function getHashHead(): string {
return window.location.hash.slice(1).split('/')[0];
}
/** path 到模块 id 的映射 */
const PATH_MAP: Record<string, string> = {
'/vehicle': 'assets',
'/assets': 'assets',
'/mileage': 'mileage',
'/scheduling': 'scheduling',
};
function getInitialModule(modules: ModuleConfig[]): string {
const head = getHashHead();
if (modules.some((m) => m.id === head)) return head;
// 优先看 hash
const hash = window.location.hash.slice(1);
if (modules.some((m) => m.id === hash)) return hash;
// 再看 pathname
const pathModule = PATH_MAP[window.location.pathname];
if (pathModule && modules.some((m) => m.id === pathModule)) return pathModule;
// 默认第一个
return modules[0]?.id ?? '';
}
function getHashModule(modules: ModuleConfig[]): string {
const head = getHashHead();
return modules.some((m) => m.id === head) ? head : '';
const hash = window.location.hash.slice(1);
return modules.some((m) => m.id === hash) ? hash : '';
}
export function Shell({ modules }: { modules: ModuleConfig[] }) {
const flatModules = useMemo(() => flattenModules(modules), [modules]);
const [activeModule, setActiveModule] = useState(() => getInitialModule(flattenModules(modules)));
const [openGroupId, setOpenGroupId] = useState<string | null>(null);
const [activeModule, setActiveModule] = useState(() => getInitialModule(modules));
useEffect(() => {
const onHashChange = () => {
const h = getHashModule(flatModules);
const h = getHashModule(modules);
if (h) setActiveModule(h);
};
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, [flatModules]);
}, [modules]);
useEffect(() => {
if (!openGroupId) return undefined;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpenGroupId(null);
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [openGroupId]);
useEffect(() => {
// 同步 hash 一段到当前模块:使用 replaceState 避免产生多余的 history 记录,
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出。
// 注意只比对一级段,避免把子模块写入的 `#<id>/<sub>` 二级段抹掉。
if (getHashHead() !== activeModule) {
// 同步 hash 到当前模块:使用 replaceState 避免产生多余的 history 记录,
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出
if (window.location.hash.slice(1) !== activeModule) {
const { pathname, search } = window.location;
window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`);
}
}, [activeModule]);
const switchModule = (id: string) => {
setOpenGroupId(null);
if (getHashHead() === id) {
setActiveModule(id);
return;
}
if (window.location.hash.slice(1) === id) return;
const { pathname, search } = window.location;
window.history.replaceState(null, '', `${pathname}${search}#${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 activeLabel = flatModules.find((module) => module.id === activeModule)?.label ?? '业务看板';
const watermarkText = useMemo(() => {
const name = user?.userName || '未登录';
const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-');
@@ -90,8 +71,9 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
}, [user]);
return (
<DemoModeProvider enabled={false}>
<div className="enterprise-grid-bg flex min-h-screen">
<DemoModeProvider enabled={true}>
<div className="flex min-h-screen">
{/* 全局水印 */}
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
<div className="absolute inset-0" style={{
@@ -100,114 +82,48 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
}} />
</div>
{/* 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">
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-white text-blue-600 shadow-lg shadow-blue-950/20">
<Building2 size={22} />
</div>
<div className="flex w-full flex-1 flex-col items-center gap-2">
<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">
{modules.map((m) => {
const Icon = m.icon;
const isGroup = Boolean(m.children?.length);
const isActive = m.id === activeModule || Boolean(m.children?.some((child) => child.id === activeModule));
const isOpen = openGroupId === m.id;
const isActive = m.id === activeModule;
return (
<div key={m.id} className="relative">
<button
type="button"
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)}
aria-expanded={isGroup ? isOpen : undefined}
aria-haspopup={isGroup ? 'menu' : undefined}
className={cn(
'group relative flex h-16 w-16 flex-col items-center justify-center rounded-2xl text-[10px] font-black transition-all',
isActive ? 'text-white' : 'text-slate-400 hover:bg-white/8 hover:text-white',
)}
title={m.label}
key={m.id}
onClick={() => switchModule(m.id)}
className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${
isActive
? 'bg-blue-50 text-blue-600'
: 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
}`}
>
{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}
<Icon size={22} />
<span className="text-[10px] mt-1 leading-tight">{m.label}</span>
</button>
);
})}
</div>
) : null}
</div>
);
})}
</div>
<div className="flex w-full flex-col items-center gap-2 border-t border-white/10 pt-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-400/10 text-emerald-300 ring-1 ring-emerald-300/20" title={user?.userName || '当前用户'}>
<ShieldCheck size={18} />
</div>
<div className="max-w-16 truncate text-center text-[9px] font-bold text-slate-400">{user?.userName || '未登录'}</div>
</div>
</nav>
{/* 内容区 */}
<main className="min-w-0 flex-1 pb-16 md:ml-20 md:pb-0" style={{ overflowX: 'clip' }}>
<div className="pointer-events-none fixed left-20 right-0 top-0 z-20 hidden h-12 items-center border-b border-white/60 bg-white/55 px-6 text-xs font-bold text-slate-400 backdrop-blur-xl md:flex">
<span className="text-slate-600"> BI</span>
<span className="mx-2 text-slate-300">/</span>
<span>{activeLabel}</span>
</div>
<Suspense fallback={<div className="p-3 md:p-6"><LoadingState label="正在加载业务模块" /></div>}>
<main className="flex-1 md:ml-16 pb-16 md:pb-0 min-w-0" style={{ overflowX: 'clip' }}>
{ActiveComponent && <ActiveComponent />}
</Suspense>
<FeedbackFab module={activeModule} />
</main>
{/* 移动端底部导航 (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">
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${modules.length}, minmax(0, 1fr))` }}>
<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">
{modules.map((m) => {
const Icon = m.icon;
const isGroup = Boolean(m.children?.length);
const isActive = m.id === activeModule || Boolean(m.children?.some((child) => child.id === activeModule));
const isOpen = openGroupId === m.id;
const isActive = m.id === activeModule;
return (
<div key={m.id} className="relative flex justify-center">
<button
type="button"
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)}
aria-expanded={isGroup ? isOpen : undefined}
aria-haspopup={isGroup ? 'menu' : undefined}
className={cn('relative flex min-h-12 w-full flex-col items-center justify-center gap-0.5 rounded-2xl text-[10px] font-medium transition-colors', isActive ? 'text-blue-700' : 'text-slate-400')}
key={m.id}
onClick={() => switchModule(m.id)}
className={`flex flex-col items-center ${isActive ? 'text-blue-600' : 'text-gray-400'}`}
>
{isActive ? (
<motion.span layoutId="mobile-shell-active" className="absolute inset-0 rounded-2xl bg-blue-50 ring-1 ring-blue-100" transition={{ type: 'spring', stiffness: 430, damping: 34 }} />
) : null}
<Icon size={20} className="relative" />
<span className="relative">{m.label}</span>
</button>
{isGroup && isOpen ? (
<div role="menu" aria-label={`${m.label}二级菜单`} className="absolute bottom-[calc(100%+14px)] left-1/2 w-36 -translate-x-1/2 rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl shadow-slate-900/15">
{m.children!.map((child) => {
const ChildIcon = child.icon;
const childActive = child.id === activeModule;
return (
<button key={child.id} type="button" role="menuitem" onClick={() => switchModule(child.id)} className={cn('flex h-11 w-full items-center gap-2 rounded-xl px-3 text-xs font-medium transition-colors', childActive ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-50')}>
<ChildIcon size={16} />{child.label}
<Icon size={20} />
<span className="text-[10px] mt-1">{m.label}</span>
</button>
);
})}
</div>
) : null}
</div>
);
})}
</div>
</nav>
</div>
</DemoModeProvider>
-357
View File
@@ -1,357 +0,0 @@
import { useState, type ComponentType, type ReactNode } from 'react';
import { AlertCircle, Info, Loader2, RefreshCw, SearchX, X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { cn } from '../../lib/cn';
export type SurfaceIcon = ComponentType<{ size?: number; className?: string }>;
export function PageFrame({
title,
subtitle,
icon: Icon,
eyebrow,
meta,
actions,
children,
maxWidth = 'max-w-6xl',
compactInfo = false,
}: {
title: string;
subtitle?: string;
icon?: SurfaceIcon;
eyebrow?: string;
meta?: ReactNode;
actions?: ReactNode;
children: ReactNode;
maxWidth?: string;
compactInfo?: boolean;
}) {
const [infoOpen, setInfoOpen] = useState(false);
return (
<div className="min-h-screen bg-[var(--app-bg)] text-slate-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
<div className={cn(maxWidth, 'mx-auto flex flex-col gap-4 pb-20 md:pb-6')}>
<motion.header
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28, ease: 'easeOut' }}
className={cn(
'relative rounded-2xl border border-white/70 bg-white/86 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl',
compactInfo ? 'overflow-visible' : 'overflow-hidden',
)}
>
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-blue-600 via-cyan-400 to-emerald-400" />
{compactInfo ? (
<>
<div className="flex min-h-[48px] items-center gap-2 px-3 py-1.5 md:min-h-[54px] md:gap-3 md:px-4 md:py-2">
{Icon ? (
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100 md:h-9 md:w-9">
<Icon size={17} />
</span>
) : null}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
{eyebrow ? <span className="shrink-0 text-[11px] font-black text-blue-600">{eyebrow}</span> : null}
{meta ? <span className="hidden truncate text-[11px] font-bold text-slate-400 sm:inline">{meta}</span> : null}
</div>
<h1 className="mt-0.5 truncate text-base font-black tracking-tight text-slate-950 md:text-lg">{title}</h1>
</div>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
{subtitle ? (
<button
type="button"
onClick={() => setInfoOpen(open => !open)}
className={cn(
'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border text-slate-400 transition-colors md:h-9 md:w-9',
infoOpen ? 'border-blue-100 bg-blue-50 text-blue-600' : 'border-slate-100 bg-slate-50 hover:bg-blue-50 hover:text-blue-600',
)}
aria-label={infoOpen ? '收起页面说明' : '展开页面说明'}
title={infoOpen ? '收起说明' : '页面说明'}
>
{infoOpen ? <X size={16} /> : <Info size={16} />}
</button>
) : null}
</div>
<AnimatePresence initial={false}>
{infoOpen && subtitle ? (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="absolute left-0 right-0 top-full z-40 mt-2 overflow-hidden rounded-2xl border border-slate-100 bg-white shadow-xl md:static md:mt-0 md:rounded-none md:border-x-0 md:border-b-0 md:shadow-none"
>
<div className="px-4 py-3 text-xs font-bold leading-relaxed text-slate-500 md:text-sm">
{subtitle}
</div>
</motion.div>
) : null}
</AnimatePresence>
</>
) : (
<div className="flex flex-col gap-4 p-4 md:flex-row md:items-end md:justify-between md:p-5">
<div className="min-w-0">
<div className="mb-2 flex flex-wrap items-center gap-2">
{Icon ? (
<span className="inline-flex h-8 w-8 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
<Icon size={17} />
</span>
) : null}
{eyebrow ? <span className="text-[11px] font-black text-blue-600">{eyebrow}</span> : null}
{meta ? <span className="text-[11px] font-bold text-slate-400">{meta}</span> : null}
</div>
<h1 className="truncate text-xl font-black tracking-tight text-slate-950 md:text-2xl">{title}</h1>
{subtitle ? <p className="mt-2 max-w-3xl text-xs font-bold leading-relaxed text-slate-500 md:text-sm">{subtitle}</p> : null}
</div>
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
</div>
)}
</motion.header>
{children}
</div>
</div>
);
}
export function SurfaceCard({
title,
subtitle,
actions,
children,
className,
}: {
title?: string;
subtitle?: string;
actions?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<section className={cn('rounded-2xl border border-slate-100 bg-white shadow-sm', className)}>
{(title || subtitle || actions) && (
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-slate-100 px-4 py-3">
<div>
{title ? <h2 className="text-sm font-black text-slate-900">{title}</h2> : null}
{subtitle ? <p className="mt-1 text-[11px] font-bold text-slate-400">{subtitle}</p> : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</div>
)}
{children}
</section>
);
}
export function MetricTile({
label,
value,
unit,
helper,
icon: Icon,
tone = 'blue',
}: {
label: string;
value: ReactNode;
unit?: string;
helper?: string;
icon?: SurfaceIcon;
tone?: 'blue' | 'emerald' | 'amber' | 'rose' | 'slate';
}) {
const toneClass = {
blue: 'bg-blue-50 text-blue-600 ring-blue-100',
emerald: 'bg-emerald-50 text-emerald-600 ring-emerald-100',
amber: 'bg-amber-50 text-amber-600 ring-amber-100',
rose: 'bg-rose-50 text-rose-600 ring-rose-100',
slate: 'bg-slate-100 text-slate-600 ring-slate-200',
}[tone];
return (
<div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 text-[11px] font-black text-slate-400">{label}</div>
{Icon ? (
<span className={cn('inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1', toneClass)}>
<Icon size={18} />
</span>
) : null}
</div>
<div className="mt-3 flex min-w-0 items-end gap-1">
<span className="min-w-0 whitespace-nowrap text-[clamp(1.65rem,6vw,2rem)] font-black leading-none tracking-tight text-slate-950 tabular-nums">
{value}
</span>
{unit ? <span className="shrink-0 pb-0.5 text-[11px] font-black text-slate-400">{unit}</span> : null}
</div>
{helper ? <div className="mt-3 text-[11px] font-bold leading-relaxed text-slate-500">{helper}</div> : null}
</div>
);
}
export function SegmentedNav<T extends string>({
tabs,
active,
onChange,
idPrefix,
ariaLabel,
className,
}: {
tabs: readonly { id: T; label: string; icon?: SurfaceIcon }[];
active: T;
onChange: (id: T) => void;
idPrefix: string;
ariaLabel: string;
className?: string;
}) {
const activateFromKeyboard = (event: React.KeyboardEvent<HTMLButtonElement>, currentIndex: number) => {
let nextIndex: number | null = null;
if (event.key === 'ArrowRight') nextIndex = (currentIndex + 1) % tabs.length;
if (event.key === 'ArrowLeft') nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
if (event.key === 'Home') nextIndex = 0;
if (event.key === 'End') nextIndex = tabs.length - 1;
if (nextIndex === null) return;
event.preventDefault();
const next = tabs[nextIndex];
onChange(next.id);
window.requestAnimationFrame(() => document.getElementById(`${idPrefix}-tab-${next.id}`)?.focus());
};
return (
<div className={cn('rounded-2xl border border-white/70 bg-white/90 p-1 shadow-sm backdrop-blur-xl', className)}>
<div
role="tablist"
aria-label={ariaLabel}
className="grid gap-1"
style={{ gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))` }}
>
{tabs.map(({ id, label, icon: Icon }, index) => {
const isActive = active === id;
return (
<button
key={id}
id={`${idPrefix}-tab-${id}`}
type="button"
role="tab"
aria-selected={isActive}
aria-controls={`${idPrefix}-panel-${id}`}
tabIndex={isActive ? 0 : -1}
onClick={() => onChange(id)}
onKeyDown={event => activateFromKeyboard(event, index)}
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)} />;
}
type StateVariant = 'card' | 'inline';
export function LoadingState({
label = '数据加载中',
variant = 'card',
}: {
label?: string;
variant?: StateVariant;
}) {
return (
<div className={cn(
'p-8 text-center',
variant === 'card' && 'rounded-2xl border border-slate-100 bg-white 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 = '换个筛选条件或稍后再试',
variant = 'card',
}: {
title?: string;
description?: string;
variant?: StateVariant;
}) {
return (
<div className={cn(
'p-8 text-center',
variant === 'card' && 'rounded-2xl border border-slate-100 bg-white 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,
title = '加载失败',
onRetry,
retrying = false,
variant = 'card',
}: {
message: string;
title?: string;
onRetry?: () => void;
retrying?: boolean;
variant?: StateVariant;
}) {
return (
<div className={cn(
'bg-rose-50 p-4 text-rose-700',
variant === 'card' && 'rounded-2xl border border-rose-100 shadow-sm',
)}>
<div className="flex items-start gap-2">
<AlertCircle size={18} className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-sm font-black">{title}</div>
<div className="mt-1 text-xs font-bold leading-relaxed">{message}</div>
{onRetry ? (
<button
type="button"
onClick={onRetry}
disabled={retrying}
className="mt-3 inline-flex h-8 items-center gap-1.5 rounded-lg border border-rose-200 bg-white px-3 text-xs font-black text-rose-700 transition-colors hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-60"
>
<RefreshCw size={13} className={retrying ? 'animate-spin' : ''} />
</button>
) : null}
</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";
:root {
--app-bg: #f4f7fb;
--panel-bg: rgba(255, 255, 255, 0.9);
--hairline: rgba(148, 163, 184, 0.18);
}
html, body {
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
background: var(--app-bg);
}
html {
@@ -20,79 +13,4 @@ html {
body {
overflow: auto;
height: 100%;
color: #0f172a;
background: #fff;
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button, input, select {
font-family: inherit;
}
.amap-logo,
.amap-copyright {
opacity: 0.82;
}
.amap-toolbar {
border: 1px solid #e2e8f0 !important;
border-radius: 10px !important;
box-shadow: 0 8px 24px rgb(15 23 42 / 10%) !important;
overflow: hidden;
}
@media (max-width: 767px) {
.amap-logo,
.amap-copyright {
bottom: 60px !important;
}
}
@keyframes marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@keyframes floatUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@utility animate-marquee {
animation: marquee 30s linear infinite;
}
@utility no-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar { display: none; }
}
.shimmer {
position: relative;
}
.shimmer::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.72), transparent);
animation: shimmer 1.4s infinite;
}
.enterprise-grid-bg {
background:
radial-gradient(circle at 16% 0%, rgba(59, 130, 246, 0.08), transparent 28%),
radial-gradient(circle at 90% 12%, rgba(20, 184, 166, 0.08), transparent 26%),
linear-gradient(180deg, #f8fbff 0%, var(--app-bg) 42%, #f7f9fc 100%);
}
.asset-date-input::-webkit-calendar-picker-indicator {
cursor: pointer;
opacity: 0;
}
-3
View File
@@ -1,3 +0,0 @@
export function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ');
}
-341
View File
@@ -1,341 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react';
import { fetchJson } from '../../auth/api-client';
import { EmptyState, LoadingState, PageFrame, SurfaceCard } from '../../components/ui/surface';
interface FeedbackItem {
id: number;
type: 'dimension' | 'bug' | 'ux' | 'other';
module: string | null;
content: string;
contact: string | null;
screenshots: string[] | string | null;
user_id: string | null;
user_name: string | null;
status: 'open' | 'in_progress' | 'done' | 'rejected';
reply_content: string | null;
reply_user: string | null;
reply_at: string | null;
created_at: string;
}
const TYPE_LABEL: Record<string, string> = {
dimension: '新维度',
bug: 'Bug',
ux: '体验',
other: '其他',
};
const STATUS_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [
{ key: 'open', label: '待处理', cls: 'bg-slate-100 text-slate-500 border-slate-200' },
{ key: 'in_progress', label: '处理中', cls: 'bg-amber-100 text-amber-600 border-amber-200' },
{ key: 'done', label: '已完成', cls: 'bg-emerald-100 text-emerald-600 border-emerald-200' },
{ key: 'rejected', label: '已忽略', cls: 'bg-rose-100 text-rose-500 border-rose-200' },
];
const MODULE_LABELS: Record<string, string> = {
assets: '资产管理',
mileage: '里程管理',
energy: '能源管理',
scheduling: '智能调度',
ele: '充电导入',
};
function parseScreenshots(s: FeedbackItem['screenshots']): string[] {
if (!s) return [];
if (Array.isArray(s)) return s;
try { return JSON.parse(String(s)); } catch { return []; }
}
async function patchItem(id: number, data: { status?: string; reply?: string }): Promise<void> {
const token = sessionStorage.getItem('bi_jwt');
const res = await fetch(`/api/feedback/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(data),
});
const json = await res.json();
if (!res.ok || !json.ok) throw new Error(json.message || `更新失败 (${res.status})`);
}
export default function FeedbackAdminPage() {
const [items, setItems] = useState<FeedbackItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<'' | FeedbackItem['status']>('');
const [active, setActive] = useState<FeedbackItem | null>(null);
const [replyDraft, setReplyDraft] = useState('');
const [replyStatus, setReplyStatus] = useState<FeedbackItem['status']>('done');
const [saving, setSaving] = useState(false);
const [hint, setHint] = useState<string | null>(null);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (statusFilter) params.set('status', statusFilter);
params.set('limit', '200');
const d = await fetchJson<{ items: FeedbackItem[] }>(`/api/feedback/list?${params.toString()}`);
setItems(d.items);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [statusFilter]);
useEffect(() => { reload(); }, [reload]);
const open = (it: FeedbackItem) => {
setActive(it);
setReplyDraft(it.reply_content || '');
setReplyStatus(it.status === 'open' ? 'done' : it.status);
};
const save = async () => {
if (!active) return;
setSaving(true);
setHint(null);
try {
await patchItem(active.id, { status: replyStatus, reply: replyDraft });
setHint('已保存');
setActive(null);
await reload();
} catch (e) {
setHint(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
setTimeout(() => setHint(null), 3000);
}
};
const setStatusOnly = async (it: FeedbackItem, status: FeedbackItem['status']) => {
try {
await patchItem(it.id, { status });
await reload();
} catch (e) {
setHint(e instanceof Error ? e.message : String(e));
setTimeout(() => setHint(null), 3000);
}
};
const counters = items.reduce<Record<string, number>>((m, it) => {
m[it.status] = (m[it.status] || 0) + 1;
return m;
}, {});
return (
<PageFrame
title="用户反馈管理"
subtitle="查看、回复、跟进用户提交的建议,形成从问题发现到处理闭环的运营后台。"
icon={Inbox}
eyebrow="FEEDBACK OPS"
meta={`当前 ${items.length} 条反馈`}
actions={(
<div className="flex items-center gap-2">
<button
onClick={() => {
// 优先 history.back(来自 SPA 内部跳转);否则回到主页
if (window.history.length > 1) window.history.back();
else { window.location.hash = '#mileage'; }
}}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-100 bg-white text-slate-500 transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600"
title="返回"
>
<ArrowLeft size={16} />
</button>
<button onClick={reload} className="flex h-9 w-9 items-center justify-center rounded-xl bg-slate-900 text-white shadow-sm transition-colors hover:bg-slate-800" title="刷新">
<RotateCcw size={16} className={loading ? 'animate-spin' : ''} />
</button>
</div>
)}
maxWidth="max-w-5xl"
>
{/* 状态过滤 */}
<SurfaceCard className="p-2">
<div className="flex items-center gap-1 overflow-x-auto">
<button
onClick={() => setStatusFilter('')}
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === '' ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
> {items.length}</button>
{STATUS_OPTIONS.map(o => (
<button
key={o.key}
onClick={() => setStatusFilter(statusFilter === o.key ? '' : o.key)}
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === o.key ? `${o.cls} border` : 'text-slate-500 hover:bg-slate-50'}`}
>
{o.label} {counters[o.key] ?? 0}
</button>
))}
</div>
</SurfaceCard>
{error && (
<div className="bg-rose-50 border border-rose-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-rose-600">
<AlertCircle size={14} /> {error}
</div>
)}
<AnimatePresence>
{hint && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="bg-emerald-50 border border-emerald-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-emerald-600"
>
<CheckCircle2 size={14} /> {hint}
</motion.div>
)}
</AnimatePresence>
{/* 列表 */}
<div className="space-y-2">
{loading && items.length === 0 ? (
<LoadingState label="正在加载用户反馈" />
) : items.length === 0 ? (
<EmptyState title="还没有反馈" description="新的用户反馈会出现在这里" />
) : items.map(it => {
const shots = parseScreenshots(it.screenshots);
const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status);
return (
<div
key={it.id}
className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 cursor-pointer hover:border-blue-200 transition-colors"
onClick={() => open(it)}
>
<div className="flex items-center gap-1.5 flex-wrap mb-1.5">
<span className="text-[11px] font-bold text-slate-500">{TYPE_LABEL[it.type] || it.type}</span>
{it.module && <span className="text-[10px] text-slate-400 font-bold">{MODULE_LABELS[it.module] || it.module}</span>}
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded border ${statusOpt?.cls || 'bg-slate-50 text-slate-400 border-slate-200'}`}>
{statusOpt?.label || it.status}
</span>
<span className="text-[10px] text-slate-400 ml-auto">
{(it.user_name || it.user_id || '匿名')} · {(it.created_at || '').replace('T', ' ').slice(0, 16)}
</span>
</div>
<div className="text-[12px] text-slate-700 leading-relaxed line-clamp-2 break-words">{it.content}</div>
{(shots.length > 0 || it.contact) && (
<div className="flex items-center gap-3 mt-2 text-[10px] text-slate-400 font-bold">
{shots.length > 0 && <span className="flex items-center gap-0.5"><ImageIcon size={11} />{shots.length} </span>}
{it.contact && <span> {it.contact}</span>}
</div>
)}
{it.reply_content && (
<div className="bg-blue-50 border border-blue-100 rounded-lg px-2.5 py-1.5 mt-2 text-[11px] text-slate-600 line-clamp-1">
: {it.reply_content}
</div>
)}
{it.status === 'open' && (
<div className="flex gap-1 mt-2 pt-2 border-t border-slate-50" onClick={(e) => e.stopPropagation()}>
<button onClick={() => setStatusOnly(it, 'in_progress')} className="flex-1 px-2 py-1 rounded text-[10px] font-bold bg-amber-50 text-amber-600 hover:bg-amber-100"></button>
<button onClick={() => setStatusOnly(it, 'rejected')} className="px-2 py-1 rounded text-[10px] font-bold bg-rose-50 text-rose-500 hover:bg-rose-100"></button>
</div>
)}
</div>
);
})}
</div>
{/* 详情 / 回复弹窗 */}
<AnimatePresence>
{active && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[80] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
onClick={() => setActive(null)}
>
<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-xl 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 flex items-center gap-2">
<div className="flex-1">
<div className="text-sm font-black text-slate-800 leading-tight"> #{active.id}</div>
<div className="text-[10px] text-slate-400 font-bold">
{active.user_name || active.user_id || '匿名'} · {(active.created_at || '').replace('T', ' ').slice(0, 16)}
</div>
</div>
<button onClick={() => setActive(null)} className="p-1.5 text-slate-400 hover:text-slate-700">
<X size={18} />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
<div className="bg-slate-50 rounded-xl p-3 space-y-2">
<div className="flex items-center gap-1.5 flex-wrap text-[10px] font-bold">
<span className="text-slate-500">{TYPE_LABEL[active.type] || active.type}</span>
{active.module && <span className="text-slate-400">: {MODULE_LABELS[active.module] || active.module}</span>}
{active.contact && <span className="text-slate-400">: {active.contact}</span>}
</div>
<div className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-wrap break-words">{active.content}</div>
{parseScreenshots(active.screenshots).length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{parseScreenshots(active.screenshots).map((u, i) => (
<a key={i} href={u} target="_blank" rel="noreferrer" className="block w-20 h-20 rounded-lg overflow-hidden border border-slate-200">
<img src={u} alt="" className="w-full h-full object-cover" />
</a>
))}
</div>
)}
</div>
<div>
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5"></p>
<div className="flex gap-1 flex-wrap">
{STATUS_OPTIONS.map(o => (
<button
key={o.key}
onClick={() => setReplyStatus(o.key)}
className={`px-3 py-1 rounded-full text-[11px] font-bold border ${replyStatus === o.key ? o.cls : 'bg-white text-slate-500 border-slate-200 hover:border-slate-300'}`}
>
{o.label}
</button>
))}
</div>
</div>
<div>
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5"></p>
<textarea
value={replyDraft}
onChange={(e) => setReplyDraft(e.target.value)}
rows={4}
maxLength={2000}
placeholder="给用户的回复(用户在「我的反馈」里能看到)"
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">{replyDraft.length} / 2000</div>
</div>
</div>
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
<div className="flex-1" />
<button
onClick={() => setActive(null)}
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50"
></button>
<button
onClick={save}
disabled={saving}
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"
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Send size={13} />}
{saving ? '保存中…' : '保存'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</PageFrame>
);
}
+64 -469
View File
@@ -14,12 +14,8 @@ import {
Filter,
ArrowRightLeft,
MapPin,
Download,
CalendarDays,
X,
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import * as XLSX from 'xlsx';
import {
BarChart,
Bar,
@@ -34,13 +30,11 @@ import {
LabelList,
} from 'recharts';
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types';
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, fetchFlowStats, type SubjectOption } from './api';
import type { FlowDetailItem, FlowStatsResponse, FlowType, WeeklyDetailItem } from './api';
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, type SubjectOption } from './api';
import type { WeeklyDetailItem } from './api';
import { SearchSelect } from '../../components/SearchSelect';
import { MultiSearchSelect } from '../../components/MultiSearchSelect';
import Blur from '../../components/Blur';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { ErrorState, LoadingState, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface';
// --- Constants ---
@@ -51,79 +45,6 @@ const TABS = [
{ id: 'customer', label: '按客户' },
];
function MarqueeBanner() {
const trackRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const [overflow, setOverflow] = useState(false);
useEffect(() => {
const check = () => {
if (!trackRef.current || !innerRef.current) return;
setOverflow(innerRef.current.scrollWidth > trackRef.current.clientWidth);
};
check();
const ro = new ResizeObserver(check);
ro.observe(trackRef.current!);
return () => ro.disconnect();
}, []);
const text = '车辆资产已于 2026 年 6 月 18 日完成“运营状态”与“业务关联”校验';
return (
<div className="relative -mx-6 mb-4 bg-green-50 border-y border-green-200">
<div ref={trackRef} className="overflow-hidden">
<div className={`flex w-max py-2 ${overflow ? 'animate-marquee' : 'w-full justify-center'}`}>
<span ref={innerRef} className="inline-block whitespace-nowrap px-6 text-xs text-green-700 font-medium">
{text}
</span>
{overflow && (
<span className="inline-block whitespace-nowrap px-6 text-xs text-green-700 font-medium">
{text}
</span>
)}
</div>
</div>
</div>
);
}
function formatLocalDateTime(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const hh = String(date.getHours()).padStart(2, '0');
const mm = String(date.getMinutes()).padStart(2, '0');
const ss = String(date.getSeconds()).padStart(2, '0');
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`;
}
function formatLocalDate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function addDays(date: Date, days: number): Date {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
function getWeeklyFlowRange() {
const now = new Date();
const day = now.getDay();
const end = day === 6 ? addDays(now, -1) : day === 0 ? addDays(now, -2) : addDays(now, 5 - day);
const start = addDays(end, -6);
return { start: formatLocalDate(start), end: formatLocalDate(end) };
}
const FLOW_META: Record<FlowType, { label: string; tone: string; chip: string }> = {
delivered: { label: '交车', tone: 'text-blue-600 bg-blue-50 border-blue-100', chip: 'bg-blue-50 text-blue-700 border-blue-100' },
returned: { label: '还车', tone: 'text-orange-600 bg-orange-50 border-orange-100', chip: 'bg-orange-50 text-orange-700 border-orange-100' },
replaced: { label: '替换', tone: 'text-violet-600 bg-violet-50 border-violet-100', chip: 'bg-violet-50 text-violet-700 border-violet-100' },
};
export default function AssetsModule() {
const [activeTab, setActiveTab] = useState<'overview' | 'department' | 'region' | 'customer'>('overview');
const [tabReady, setTabReady] = useState(true);
@@ -170,13 +91,8 @@ export default function AssetsModule() {
const [modalWeeklyDetail, setModalWeeklyDetail] = useState<WeeklyDetailItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdate, setLastUpdate] = useState<string>(() => formatLocalDateTime(new Date()));
const [lastUpdate, setLastUpdate] = useState<string>('');
const [modalLoading, setModalLoading] = useState(false);
const [flowRange, setFlowRange] = useState(() => getWeeklyFlowRange());
const [flowStats, setFlowStats] = useState<FlowStatsResponse | null>(null);
const [flowLoading, setFlowLoading] = useState(false);
const [flowDailyExpanded, setFlowDailyExpanded] = useState(false);
const [selectedFlow, setSelectedFlow] = useState<{ date: string; type: FlowType } | null>(null);
// Dept/Region/Customer data
const [deptData, setDeptData] = useState<DeptGroup[]>([]);
@@ -245,7 +161,7 @@ export default function AssetsModule() {
setRegionData(region);
setCustomerData(cust);
setInventoryData(inv);
setLastUpdate(formatLocalDateTime(new Date()));
setLastUpdate(new Date().toLocaleString('zh-CN'));
} catch (e) {
setError(e instanceof Error ? e.message : '数据加载失败');
} finally {
@@ -259,24 +175,6 @@ export default function AssetsModule() {
return () => clearInterval(interval);
}, [loadData]);
useEffect(() => {
let cancelled = false;
setFlowLoading(true);
fetchFlowStats({ start: flowRange.start, end: flowRange.end, subject: selectedSubject })
.then((data) => {
if (!cancelled) setFlowStats(data);
})
.catch(() => {
if (!cancelled) setFlowStats(null);
})
.finally(() => {
if (!cancelled) setFlowLoading(false);
});
return () => {
cancelled = true;
};
}, [flowRange.start, flowRange.end, selectedSubject]);
// 归属公司列表(仅首次加载,公司集合相对稳定)
useEffect(() => {
fetchSubjects().then(setSubjects).catch(() => setSubjects([]));
@@ -325,18 +223,11 @@ export default function AssetsModule() {
setModalLoading(true);
const cat = showPlateNumbers.category;
// Weekly categories use the dedicated weekly-detail endpoint.
// Pending 不属于 weeklyweekly-detail 不支持 model/batch/location 过滤,
// 走下面的 /list 路径才能按型号/区域等维度过滤。
const weeklyTypes: Record<string, string> = { Delivered: 'delivered', Returned: 'returned', Replaced: 'replaced' };
// Weekly categories use the dedicated weekly-detail endpoint
const weeklyTypes: Record<string, string> = { Delivered: 'delivered', Returned: 'returned', Replaced: 'replaced', Pending: 'pending' };
if (cat && weeklyTypes[cat]) {
setModalVehicles([]);
fetchWeeklyDetail(weeklyTypes[cat], {
model: showPlateNumbers.model,
batch: showPlateNumbers.batch,
location: showPlateNumbers.location,
source: showPlateNumbers.source,
})
fetchWeeklyDetail(weeklyTypes[cat])
.then(setModalWeeklyDetail)
.catch(() => setModalWeeklyDetail([]))
.finally(() => setModalLoading(false));
@@ -350,10 +241,8 @@ export default function AssetsModule() {
if (showPlateNumbers.batch !== 'All') params.batch = showPlateNumbers.batch;
if (showPlateNumbers.model !== 'All') params.model = showPlateNumbers.model;
if (showPlateNumbers.location !== 'All') params.location = showPlateNumbers.location;
if (showPlateNumbers.source) params.source = showPlateNumbers.source;
if (cat === 'Inventory') params.category = 'Inventory';
if (cat === 'Operating') params.category = 'Operating';
if (cat === 'Pending') params.category = 'Pending';
if (showPlateNumbers.manager) params.manager = showPlateNumbers.manager;
if (showPlateNumbers.customer) params.customer = showPlateNumbers.customer;
if (showPlateNumbers.department) params.department = showPlateNumbers.department;
@@ -576,40 +465,6 @@ export default function AssetsModule() {
return mp;
}), [modalWeeklyDetail, modalFilters.plateNumber]);
const selectedFlowDetails = useMemo(() => {
if (!flowStats || !selectedFlow) return [];
return flowStats.details.filter((item) => item.date === selectedFlow.date && item.type === selectedFlow.type);
}, [flowStats, selectedFlow]);
const exportFlowDetails = useCallback((rows?: FlowDetailItem[], title = '资产流转明细') => {
const source = rows ?? flowStats?.details ?? [];
if (source.length === 0) return;
const table = source.map((item) => ({
日期: item.date,
类型: item.typeLabel,
车牌: item.plateNumber,
流转时间: item.eventTime || '',
提交时间: item.submitTime || '',
部门: item.department || '',
业务负责人: item.manager || '',
客户: item.customerName || '',
}));
const ws = XLSX.utils.json_to_sheet(table);
ws['!cols'] = [
{ wch: 14 },
{ wch: 8 },
{ wch: 14 },
{ wch: 20 },
{ wch: 20 },
{ wch: 16 },
{ wch: 14 },
{ wch: 24 },
];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '明细');
XLSX.writeFile(wb, `${title}-${flowRange.start}-${flowRange.end}.xlsx`);
}, [flowRange.end, flowRange.start, flowStats]);
const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]);
useEffect(() => {
if (customerChartView === 'province') {
@@ -630,60 +485,38 @@ export default function AssetsModule() {
if (loading && !summary) {
return (
<PageFrame
title="车辆资产中心"
subtitle="正在同步车辆、部门、区域与客户归属数据,加载完成后可继续穿透查看明细。"
icon={Truck}
eyebrow="ASSET INTELLIGENCE"
meta="数据准备中"
>
<SurfaceCard className="min-h-[360px]">
<div className="grid gap-4 md:grid-cols-4">
{[0, 1, 2, 3].map(item => (
<SkeletonBlock key={item} className="h-28" />
))}
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="animate-spin text-blue-500" size={32} />
<span className="text-sm text-gray-500">...</span>
</div>
<div className="mt-8">
<LoadingState label="正在加载车辆资产数据" />
</div>
</SurfaceCard>
</PageFrame>
);
}
if (error && !summary) {
return (
<PageFrame
title="车辆资产中心"
subtitle="车辆资产数据暂时没有返回,请重试或稍后再看。"
icon={Truck}
eyebrow="ASSET INTELLIGENCE"
meta="加载失败"
>
<SurfaceCard className="min-h-[360px]">
<div className="mt-6 flex flex-col items-center gap-4">
<ErrorState message={error} />
<button onClick={loadData} className="rounded-xl bg-slate-900 px-4 py-2 text-xs font-black text-white shadow-sm transition-colors hover:bg-slate-800">
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
<div className="flex flex-col items-center gap-3 text-center">
<div className="text-red-500 text-lg font-bold"></div>
<div className="text-sm text-gray-500">{error}</div>
<button onClick={loadData} className="mt-2 px-4 py-2 bg-blue-500 text-white rounded text-sm hover:bg-blue-600">
</button>
</div>
</SurfaceCard>
</PageFrame>
</div>
);
}
const SUMMARY = summary!;
const operatingRate = SUMMARY.totalAssets > 0 ? SUMMARY.operating.total / SUMMARY.totalAssets * 100 : 0;
const inventoryRate = SUMMARY.totalAssets > 0 ? SUMMARY.inventory.total / SUMMARY.totalAssets * 100 : 0;
const pendingRate = SUMMARY.totalAssets > 0 ? SUMMARY.pendingDelivery / SUMMARY.totalAssets * 100 : 0;
return (
<div className="min-h-screen bg-[var(--app-bg)] text-gray-800 font-sans p-3 md:p-6 relative">
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-6 relative">
{/* 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">
<div className="sticky top-0 z-40 -mx-6 -mt-6 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80">
{/* 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>
<h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide"></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">
@@ -846,11 +679,12 @@ export default function AssetsModule() {
<span className="w-1 h-1 rounded-full bg-blue-400 inline-block" />
: {lastUpdate}
</div>
<div className="flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-green-400 animate-pulse inline-block" />
</div>
</div>
</div>
{/* OneOS 迁移提示滚动条 */}
<MarqueeBanner />
{/* Main Content Area */}
<div className="flex flex-col gap-6">
@@ -864,11 +698,11 @@ export default function AssetsModule() {
{tabReady && activeTab === 'overview' && (
<>
{/* Header Summary - Ultra Compact */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-2">
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 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"
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-gray-50 transition-colors"
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">
<div className="w-7 h-7 rounded bg-gray-50 flex items-center justify-center text-gray-400">
<Truck size={14} />
</div>
<div>
@@ -878,9 +712,9 @@ export default function AssetsModule() {
</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"
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-blue-50 transition-colors"
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">
<div className="w-7 h-7 rounded bg-blue-50 flex items-center justify-center text-blue-500">
<Activity size={14} />
</div>
<div>
@@ -893,9 +727,9 @@ export default function AssetsModule() {
</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"
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-gray-50 transition-colors"
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">
<div className="w-7 h-7 rounded bg-gray-50 flex items-center justify-center text-gray-500">
<Warehouse size={14} />
</div>
<div>
@@ -908,9 +742,9 @@ export default function AssetsModule() {
</div>
{/* 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"
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-blue-50 transition-colors"
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">
<div className="w-7 h-7 rounded bg-blue-50 flex items-center justify-center text-blue-500">
<PlusCircle size={14} />
</div>
<div>
@@ -919,163 +753,41 @@ export default function AssetsModule() {
</div>
</div>
{/* Dynamics */}
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm col-span-2">
<div className="flex items-center justify-between mb-1.5">
<div className="text-[9px] text-gray-400 font-bold uppercase tracking-tight"></div>
<div className="text-[7px] text-gray-300 font-normal italic">-</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-3 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 className="flex justify-between items-center gap-1">
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-green-50 py-1 rounded transition-all group">
<span className="text-xs font-bold text-gray-800 group-hover:text-green-600">{SUMMARY.weeklyNew}</span>
<span className="text-[8px] text-green-500/80 font-bold mt-0.5"></span>
</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 className="w-[1px] h-3 bg-gray-100"></div>
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-blue-50 py-1 rounded transition-all group"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Delivered', source: 'asset', title: '本周交车' })}>
<span className="text-xs font-bold text-gray-800 group-hover:text-blue-600">{SUMMARY.weeklyDelivered}</span>
<span className="text-[8px] text-blue-500/80 font-bold mt-0.5"></span>
</div>
<div 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 className="w-[1px] h-3 bg-gray-100"></div>
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-orange-50 py-1 rounded transition-all group"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Returned', source: 'asset', title: '本周还车' })}>
<span className="text-xs font-bold text-gray-800 group-hover:text-orange-600">{SUMMARY.weeklyReturned}</span>
<span className="text-[8px] text-orange-500/80 font-bold mt-0.5"></span>
</div>
<div className="w-[1px] h-3 bg-gray-100"></div>
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-purple-50 py-1 rounded transition-all group"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Replaced', source: 'asset', title: '本周替换' })}>
<span className="text-xs font-bold text-gray-800 group-hover:text-purple-600">{SUMMARY.weeklyReplaced}</span>
<span className="text-[8px] text-purple-500/80 font-bold mt-0.5"></span>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-3 mb-4">
<div data-testid="asset-flow-card" className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 text-[13px] font-black text-slate-700">
<CalendarDays size={14} className="text-blue-500" />
<span></span>
{flowLoading && <Loader2 size={12} className="animate-spin text-slate-400" />}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => exportFlowDetails()}
disabled={!flowStats?.details.length}
className="inline-flex h-8 items-center justify-center gap-1 rounded-xl border border-slate-200 bg-slate-50 px-2.5 text-[11px] font-black text-slate-600 transition hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
>
<Download size={13} />
</button>
</div>
</div>
<div className="mt-2 rounded-xl border border-slate-100 bg-slate-50/80 px-2 py-1.5">
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
<span className="block text-[9px] font-black text-slate-400"></span>
<span className="mt-0.5 flex items-center justify-between gap-2">
<span className="text-[12px] font-black text-slate-700">{flowRange.start.replaceAll('-', '/')}</span>
<CalendarDays size={12} className="text-slate-300" />
</span>
<input
type="date"
value={flowRange.start}
onChange={(e) => setFlowRange((prev) => ({ ...prev, start: e.target.value }))}
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</label>
<div className="rounded-full bg-slate-200/70 px-2 py-0.5 text-[9px] font-black text-slate-400"></div>
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
<span className="block text-[9px] font-black text-slate-400"></span>
<span className="mt-0.5 flex items-center justify-between gap-2">
<span className="text-[12px] font-black text-slate-700">{flowRange.end.replaceAll('-', '/')}</span>
<CalendarDays size={12} className="text-slate-300" />
</span>
<input
type="date"
value={flowRange.end}
onChange={(e) => setFlowRange((prev) => ({ ...prev, end: e.target.value }))}
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</label>
</div>
</div>
<div className="mt-2 rounded-2xl border border-slate-100 bg-slate-50/70 px-2 py-2.5">
<div className="grid grid-cols-4 items-center text-center">
<div className="px-2">
<div className="text-lg font-black leading-none text-slate-950">{flowStats?.totals.total ?? 0}</div>
<div className="mt-1 text-[10px] font-black text-slate-400"></div>
</div>
{(['delivered', 'returned', 'replaced'] as FlowType[]).map((type) => (
<div key={type} className="border-l border-slate-200/70 px-2">
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>
{flowStats?.totals[type] ?? 0}
</div>
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
</div>
))}
</div>
</div>
<button
type="button"
data-testid="asset-flow-daily-toggle"
onClick={() => setFlowDailyExpanded((prev) => !prev)}
className="mt-2 flex w-full items-center justify-between rounded-xl border border-slate-100 bg-white px-3 py-1.5 text-[12px] font-black text-slate-500 transition hover:border-blue-100 hover:bg-blue-50/50 hover:text-blue-600"
>
<span>{flowDailyExpanded ? '收起每日明细' : '展开每日明细'}</span>
<span className="flex items-center gap-2 text-[11px] text-slate-400">
{flowStats?.daily.length ?? 0}
<ChevronDown size={15} className={`transition-transform ${flowDailyExpanded ? 'rotate-180' : ''}`} />
</span>
</button>
<AnimatePresence initial={false}>
{flowDailyExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="mt-3 max-h-[280px] space-y-2 overflow-y-auto pr-1">
{flowLoading && (
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400">...</div>
)}
{!flowLoading && flowStats?.daily.map((day) => (
<div key={day.date} className="rounded-2xl border border-slate-100 bg-white px-3 py-3 shadow-sm">
<div className="flex items-center justify-between">
<div className="text-[11px] font-black text-slate-400">{day.date}</div>
<div className="text-[10px] font-bold italic text-slate-300"></div>
</div>
<div className="mt-2 grid grid-cols-4 items-center text-center">
<div className="px-2">
<div className="text-lg font-black leading-none text-slate-900">{day.total}</div>
<div className="mt-1 text-[10px] font-black text-slate-400"></div>
</div>
{(['delivered', 'returned', 'replaced'] as FlowType[]).map((type) => {
const count = day[type];
return (
<button
key={type}
type="button"
data-testid={`asset-flow-cell-${day.date}-${type}`}
disabled={count === 0}
onClick={() => setSelectedFlow({ date: day.date, type })}
className="border-l border-slate-100 px-2 text-center transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-35"
>
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>{count}</div>
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
</button>
);
})}
</div>
</div>
))}
{!flowLoading && !flowStats?.daily.length && (
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400"></div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Asset Summary Table */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden mb-6">
<div className="bg-white rounded-sm border border-gray-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>
@@ -2468,7 +2180,7 @@ export default function AssetsModule() {
</td>
<td className="p-2 text-center text-gray-500 cursor-pointer hover:underline" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type}` })}>{tb.total}</td>
<td className="p-2 text-center text-green-500 cursor-pointer hover:underline" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Operating', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 正在运营` })}>{tb.operating}</td>
<td className="p-2 text-center text-orange-500 cursor-pointer hover:underline" onClick={() => { setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Pending', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 待交车` }); }}>{tb.pending}</td>
<td className="p-2 text-center text-orange-500 cursor-pointer hover:underline" onClick={() => { setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Inventory', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 库存` }); }}>{tb.inventory}</td>
<td className="p-2 text-center text-gray-400 text-[10px] italic">{tb.customers.slice(0, 2).join(', ')}</td>
</tr>
))}
@@ -2530,9 +2242,9 @@ export default function AssetsModule() {
</span>
<span
className="font-bold text-orange-600 cursor-pointer"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, vehicleType: tb.type, category: 'Pending', source: 'region', title: `区域运营统计 - ${r.region} - ${tb.type} - 待交车` })}
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, vehicleType: tb.type, category: 'Inventory', source: 'region', title: `区域运营统计 - ${r.region} - ${tb.type} - 库存` })}
>
:{tb.pending}
:{tb.inventory}
</span>
</div>
</div>
@@ -2880,123 +2592,6 @@ export default function AssetsModule() {
)}
{/* Flow Detail Modal */}
<AnimatePresence>
{selectedFlow && (
<div className="fixed inset-0 z-[1000] flex items-end justify-center bg-slate-950/45 p-0 backdrop-blur-sm sm:items-center sm:p-4">
<motion.div
data-testid="asset-flow-detail-modal"
initial={{ opacity: 0, y: 28, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.98 }}
className="flex max-h-[88vh] w-full flex-col overflow-hidden rounded-t-3xl bg-white shadow-2xl sm:max-w-5xl sm:rounded-3xl"
>
<div className="border-b border-slate-100 bg-slate-950 px-4 py-4 text-white sm:px-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-black uppercase tracking-wide text-slate-400"></div>
<h3 className="mt-1 text-lg font-black">
{selectedFlow.date} · {FLOW_META[selectedFlow.type].label}
</h3>
<div className="mt-1 text-[12px] font-bold text-slate-300">
{selectedFlowDetails.length}
</div>
</div>
<button
type="button"
onClick={() => setSelectedFlow(null)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
>
<X size={18} />
</button>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => exportFlowDetails(selectedFlowDetails, `${selectedFlow.date}-${FLOW_META[selectedFlow.type].label}明细`)}
disabled={selectedFlowDetails.length === 0}
className="inline-flex h-8 items-center gap-1 rounded-xl bg-white px-3 text-[11px] font-black text-slate-900 transition hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-40"
>
<Download size={13} />
</button>
</div>
</div>
<div className="overflow-auto bg-slate-50 p-3 sm:p-4">
<div className="hidden overflow-hidden rounded-2xl border border-slate-100 bg-white lg:block">
<table className="w-full table-fixed text-left">
<thead className="bg-slate-50 text-[11px] font-black text-slate-400">
<tr>
<th className="w-28 px-3 py-3"></th>
<th className="w-40 px-3 py-3">{FLOW_META[selectedFlow.type].label}</th>
<th className="w-40 px-3 py-3"></th>
<th className="w-36 px-3 py-3"></th>
<th className="w-28 px-3 py-3"></th>
<th className="px-3 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 text-[12px] font-bold text-slate-700">
{selectedFlowDetails.map((item) => (
<tr key={item.id} className="hover:bg-blue-50/40">
<td className="px-3 py-3 font-black text-slate-950">{item.plateNumber}</td>
<td className="px-3 py-3 text-slate-500">{item.eventTime || '-'}</td>
<td className="px-3 py-3 text-blue-600">{item.submitTime || '-'}</td>
<td className="px-3 py-3">{item.department || '-'}</td>
<td className="px-3 py-3">{item.manager || '-'}</td>
<td className="px-3 py-3">{item.customerName || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="space-y-2 lg:hidden">
{selectedFlowDetails.map((item) => (
<div key={item.id} className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-base font-black text-slate-950">{item.plateNumber}</div>
<div className={`mt-1 inline-flex rounded-full border px-2 py-0.5 text-[10px] font-black ${FLOW_META[item.type].chip}`}>
{item.typeLabel}
</div>
</div>
<div className="text-right text-[10px] font-bold text-slate-400">
<div></div>
<div className="mt-0.5 text-[11px] text-blue-600">{item.submitTime?.slice(5, 16) || '-'}</div>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px]">
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400">{item.typeLabel}</div>
<div className="mt-1 font-bold text-slate-700">{item.eventTime || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400"></div>
<div className="mt-1 font-bold text-slate-700">{item.manager || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400"></div>
<div className="mt-1 font-bold text-slate-700">{item.department || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400"></div>
<div className="mt-1 line-clamp-2 font-bold text-slate-700">{item.customerName || '-'}</div>
</div>
</div>
</div>
))}
</div>
{selectedFlowDetails.length === 0 && (
<div className="rounded-2xl bg-white px-4 py-10 text-center text-sm font-bold text-slate-400"></div>
)}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
{/* Vehicle Detail Modal */}
<AnimatePresence>
{showPlateNumbers && (
@@ -3234,7 +2829,7 @@ export default function AssetsModule() {
</AnimatePresence>
</div>
<RotatingFooterHint className="pb-4" />
</div>
);
}
+2 -59
View File
@@ -50,7 +50,6 @@ export async function fetchVehicleList(params: {
department?: string;
attendance?: string;
subject?: string | null;
source?: string;
}): Promise<VehicleListItem[]> {
const query = new URLSearchParams();
if (params.batch) query.set('batch', params.batch);
@@ -66,7 +65,6 @@ export async function fetchVehicleList(params: {
if (params.department) query.set('department', params.department);
if (params.attendance) query.set('attendance', params.attendance);
if (params.subject) query.set('subject', params.subject);
if (params.source) query.set('source', params.source);
return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`);
}
@@ -78,43 +76,6 @@ export interface WeeklyDetailItem {
customer_name: string | null;
}
export type FlowType = 'delivered' | 'returned' | 'replaced';
export interface FlowDailyPoint {
date: string;
delivered: number;
returned: number;
replaced: number;
total: number;
}
export interface FlowDetailItem {
id: string;
type: FlowType;
typeLabel: string;
date: string;
truckId: string;
plateNumber: string;
eventTime: string | null;
submitTime: string | null;
department: string;
manager: string;
customerName: string | null;
}
export interface FlowStatsResponse {
start: string;
end: string;
daily: FlowDailyPoint[];
totals: {
delivered: number;
returned: number;
replaced: number;
total: number;
};
details: FlowDetailItem[];
}
export async function fetchDeptStats(subject?: string | null): Promise<DeptGroup[]> {
return fetchJson<DeptGroup[]>(withSubject(`${BASE}/dept-stats`, subject));
}
@@ -151,24 +112,6 @@ export async function fetchRegionChart(
);
}
export async function fetchWeeklyDetail(
type: string,
filters?: { model?: string; batch?: string; location?: string; source?: string },
): Promise<WeeklyDetailItem[]> {
const params = new URLSearchParams({ type });
if (filters?.model && filters.model !== 'All') params.set('model', filters.model);
if (filters?.batch && filters.batch !== 'All') params.set('batch', filters.batch);
if (filters?.location && filters.location !== 'All') params.set('location', filters.location);
if (filters?.source) params.set('source', filters.source);
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?${params.toString()}`);
}
export async function fetchFlowStats(params: {
start: string;
end: string;
subject?: string | null;
}): Promise<FlowStatsResponse> {
const query = new URLSearchParams({ start: params.start, end: params.end });
if (params.subject) query.set('subject', params.subject);
return fetchJson<FlowStatsResponse>(`${BASE}/flow-stats?${query.toString()}`);
export async function fetchWeeklyDetail(type: string): Promise<WeeklyDetailItem[]> {
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?type=${type}`);
}
-2
View File
@@ -106,7 +106,6 @@ export interface VehicleListItem {
city: string | null;
status: string;
ownership: string;
rentCompany?: string | null;
contractNo: string | null;
customerName: string | null;
subjectOrg: string | null;
@@ -153,7 +152,6 @@ export interface RegionTypeBreakdown {
total: number;
operating: number;
inventory: number;
pending: number;
customers: string[];
}
-396
View File
@@ -1,396 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
Upload, FileSpreadsheet, RotateCcw, CheckCircle2, AlertCircle,
Truck, ExternalLink, Layers, Zap, ArrowLeft,
} from 'lucide-react';
import { fetchJson } from '../../auth/api-client';
import { useAuth } from '../../auth/useAuth';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import FeedbackFab from '../../components/FeedbackFab';
import { PageFrame } from '../../components/ui/surface';
function getJwt(): string | null {
return sessionStorage.getItem('bi_jwt');
}
interface UploadResult {
ok: boolean;
filename: string;
batchId: string;
parsed: number;
fileDuplicates: number;
inserted: number;
dbDuplicates: number;
breakdown: { internal: number; external: number };
}
interface ListItem {
id: number;
order_no: string;
station_name: string | null;
terminal_name: string | null;
region: string | null;
city: string | null;
start_time: string | null;
end_time: string | null;
duration_min: number | null;
kwh: number | null;
fee: number | null;
e_fee: number | null;
service_fee: number | null;
plate: string | null;
judged_plate: string | null;
customer_name: string | null;
vehicle_kind: 'internal' | 'external' | 'unknown';
batch_id: string;
imported_at: string;
}
interface OverallRow { vehicle_kind: 'internal' | 'external'; records: number; total_kwh: number; total_fee: number; }
interface BatchRow { batch_id: string; imported_at: string; records: number; internal_count: number; external_count: number; total_kwh: number; total_fee: number; }
const KIND_LABEL: Record<string, string> = {
internal: '内部',
external: '外部',
};
const KIND_STYLE: Record<string, string> = {
internal: 'bg-blue-50 text-blue-600 border-blue-200',
external: 'bg-amber-50 text-amber-600 border-amber-200',
};
async function uploadFile(file: File): Promise<UploadResult> {
const fd = new FormData();
fd.append('file', file);
const token = getJwt();
const res = await fetch('/api/ele/import', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: fd,
});
const json = await res.json();
if (!res.ok || !json.ok) throw new Error(json.message || `上传失败 (${res.status})`);
return json as UploadResult;
}
export default function EleImportPage() {
const { user } = useAuth();
const [file, setFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [result, setResult] = useState<UploadResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [items, setItems] = useState<ListItem[]>([]);
const [total, setTotal] = useState(0);
const [overall, setOverall] = useState<OverallRow[]>([]);
const [batches, setBatches] = useState<BatchRow[]>([]);
const [filter, setFilter] = useState<'' | 'internal' | 'external'>('');
const [batchFilter, setBatchFilter] = useState('');
const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState('');
const [dragOver, setDragOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const reload = useCallback(async () => {
const params = new URLSearchParams({ page: '1', limit: '50' });
if (filter) params.set('kind', filter);
if (batchFilter) params.set('batchId', batchFilter);
if (search) params.set('search', search);
const [list, agg, b] = await Promise.all([
fetchJson<{ items: ListItem[]; total: number }>(`/api/ele/list?${params.toString()}`),
fetchJson<{ overall: OverallRow[] }>(`/api/ele/aggregate`),
fetchJson<{ items: BatchRow[] }>(`/api/ele/batches`),
]);
setItems(list.items);
setTotal(list.total);
setOverall(agg.overall);
setBatches(b.items);
}, [filter, batchFilter, search]);
useEffect(() => {
reload().catch(e => console.error(e));
}, [reload]);
const handleUpload = async (f: File) => {
setUploading(true);
setError(null);
setResult(null);
try {
const r = await uploadFile(f);
setResult(r);
await reload();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setUploading(false);
}
};
const onPick = (f: File | null) => {
setFile(f);
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 (
<PageFrame
title="充电记录导入"
subtitle="每日上传 xlsx,按订单编号去重,并自动匹配内部/外部车辆归属。"
icon={Zap}
eyebrow="ELECTRIC IMPORT"
meta={user?.userName || '导入工作台'}
actions={(
<div className="flex items-center gap-2">
<button
onClick={() => {
if (window.history.length > 1) window.history.back();
else { window.location.hash = '#mileage'; }
}}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-100 bg-white text-slate-500 transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600"
title="返回"
>
<ArrowLeft size={16} />
</button>
<button
onClick={() => inputRef.current?.click()}
className="inline-flex h-9 items-center gap-1.5 rounded-xl bg-slate-900 px-3 text-xs font-black text-white shadow-sm transition-colors hover:bg-slate-800"
>
<Upload size={14} />
</button>
</div>
)}
>
{/* 上传区 */}
<section
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files?.[0];
if (f) onPick(f);
}}
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>
{/* 上传结果提示 */}
<AnimatePresence>
{result && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="bg-white rounded-2xl border border-emerald-100 shadow-sm p-4 flex items-start gap-3"
>
<CheckCircle2 size={18} className="text-emerald-500 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="text-[12px] font-bold text-slate-700 mb-1">
<span className="text-slate-500 font-mono">{result.filename}</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 text-[11px]">
<Stat label="解析" value={result.parsed} color="text-slate-700" />
<Stat label="新增" value={result.inserted} color="text-blue-600" />
<Stat label="重复跳过" value={result.fileDuplicates + result.dbDuplicates} color="text-slate-500" />
<Stat label="内部" value={result.breakdown.internal} color="text-blue-600" />
<Stat label="外部(含无车牌)" value={result.breakdown.external} color="text-amber-600" />
</div>
</div>
<button onClick={() => setResult(null)} className="text-slate-300 hover:text-slate-600 text-[10px] font-bold"></button>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="bg-red-50 rounded-2xl border border-red-200 shadow-sm p-4 flex items-start gap-3"
>
<AlertCircle size={18} className="text-red-500 flex-shrink-0 mt-0.5" />
<div className="flex-1 text-[12px] font-bold text-red-700">{error}</div>
<button onClick={() => setError(null)} className="text-red-300 hover:text-red-600 text-[10px] font-bold"></button>
</motion.div>
)}
</AnimatePresence>
{/* 聚合卡 */}
<section className="grid grid-cols-2 md:grid-cols-3 gap-2">
<KpiCard icon={<Layers size={14} />} label="总记录" value={totalRecords.toLocaleString()} />
<KpiCard icon={<Truck size={14} />} label="内部记录" value={(overallMap.get('internal')?.records ?? 0).toLocaleString()} accent="blue" />
<KpiCard icon={<ExternalLink size={14} />} label="外部记录" value={(overallMap.get('external')?.records ?? 0).toLocaleString()} accent="amber" />
<KpiCard icon={<Zap size={14} />} label="累计电量" value={`${totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} />
<KpiCard icon={<Zap size={14} />} label="内部电量" value={`${(overallMap.get('internal')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="blue" />
<KpiCard icon={<Zap size={14} />} label="外部电量" value={`${(overallMap.get('external')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="amber" />
</section>
{/* 批次 */}
{batches.length > 0 && (
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<div className="px-3 py-2 bg-slate-50 flex items-center justify-between">
<span className="text-[11px] font-bold text-slate-500"></span>
{batchFilter && (
<button onClick={() => setBatchFilter('')} className="text-[10px] font-bold text-blue-500"></button>
)}
</div>
<div className="overflow-x-auto">
<table className="w-full text-[11px]">
<thead className="text-slate-400 font-bold bg-slate-50/40">
<tr>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2 text-right">()</th>
<th className="px-3 py-2 text-right">()</th>
<th className="px-3 py-2 text-right"></th>
</tr>
</thead>
<tbody>
{batches.map(b => (
<tr
key={b.batch_id}
onClick={() => setBatchFilter(batchFilter === b.batch_id ? '' : b.batch_id)}
className={`border-t border-slate-100 cursor-pointer transition-colors ${batchFilter === b.batch_id ? 'bg-blue-50/40' : 'hover:bg-slate-50/60'}`}
>
<td className="px-3 py-2 text-slate-600 whitespace-nowrap">{(b.imported_at || '').replace('T', ' ').slice(0, 19)}</td>
<td className="px-3 py-2 text-right font-bold text-slate-700">{Number(b.records).toLocaleString()}</td>
<td className="px-3 py-2 text-right text-blue-600 font-bold">{Number(b.internal_count).toLocaleString()}</td>
<td className="px-3 py-2 text-right text-amber-600 font-bold">{Number(b.external_count).toLocaleString()}</td>
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">{Number(b.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })}</td>
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">¥{Number(b.total_fee ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</td>
<td className="px-3 py-2 text-right text-slate-300 font-mono text-[10px]">{b.batch_id.slice(0, 12)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{/* 列表 */}
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<div className="px-3 py-2 bg-slate-50 flex items-center gap-2 flex-wrap">
<span className="text-[11px] font-bold text-slate-500"></span>
<span className="text-[10px] font-bold text-slate-400"> {total.toLocaleString()} </span>
<div className="flex-1" />
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput); }}
placeholder="搜索订单/车牌/电站"
className="bg-white border border-slate-200 rounded-lg px-2 py-1 text-[11px] outline-none focus:ring-1 focus:ring-blue-500/20 w-44"
/>
<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" />
<FeedbackFab module="ele" />
</PageFrame>
);
}
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className="bg-slate-50 rounded-lg px-2 py-1.5">
<div className="text-[9px] text-slate-400 uppercase font-bold">{label}</div>
<div className={`text-sm font-black tabular-nums ${color}`}>{value}</div>
</div>
);
}
function KpiCard({ icon, label, value, accent = 'slate' }: { icon: React.ReactNode; label: string; value: string; accent?: 'slate' | 'blue' | 'amber' }) {
const accentMap: Record<string, string> = {
slate: 'text-slate-700',
blue: 'text-blue-600',
amber: 'text-amber-600',
};
return (
<div className="bg-white rounded-xl border border-slate-100 shadow-sm p-3">
<div className="flex items-center gap-1 text-[10px] font-bold text-slate-400 uppercase">
<span className={accentMap[accent]}>{icon}</span>
<span>{label}</span>
</div>
<div className={`text-base font-black tabular-nums leading-tight mt-0.5 ${accentMap[accent]}`}>{value}</div>
</div>
);
}
-110
View File
@@ -1,110 +0,0 @@
import { cn } from '../../lib/cn';
import type { DateQuickPick } from './types';
import { QUICK_DATE_OPTIONS, type DateRangeMode } from './date-range';
export function DateRangeInputs({
idPrefix,
startDate,
endDate,
onChange,
ariaPrefix = '',
className,
}: {
idPrefix: string;
startDate: string;
endDate: string;
onChange: (field: 'start' | 'end', value: string) => void;
ariaPrefix?: string;
className?: string;
}) {
const prefix = ariaPrefix ? `${ariaPrefix} ` : '';
return (
<div className={cn('grid grid-cols-2 gap-2', className)}>
<label className="min-w-0 rounded-lg border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
id={`${idPrefix}-start-date`}
name={`${idPrefix}StartDate`}
type="date"
aria-label={`${prefix}开始日期`}
value={startDate}
onInput={event => onChange('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-lg border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
id={`${idPrefix}-end-date`}
name={`${idPrefix}EndDate`}
type="date"
aria-label={`${prefix}结束日期`}
value={endDate}
onInput={event => onChange('end', event.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
</div>
);
}
export default function AnalysisDateFilters({
idPrefix,
mode,
startDate,
endDate,
onQuickPick,
onCustom,
onDateChange,
}: {
idPrefix: string;
mode: DateRangeMode;
startDate: string;
endDate: string;
onQuickPick: (pick: DateQuickPick) => void;
onCustom: () => void;
onDateChange: (field: 'start' | 'end', value: string) => void;
}) {
return (
<>
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{QUICK_DATE_OPTIONS.map(option => (
<button
key={option.id}
type="button"
onClick={() => onQuickPick(option.id)}
aria-pressed={mode === option.id}
className={cn(
'min-h-9 shrink-0 rounded-lg border px-3 text-[12px] font-black transition-colors',
mode === option.id
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50',
)}
>
{option.label}
</button>
))}
<button
type="button"
onClick={onCustom}
aria-pressed={mode === 'custom'}
className={cn(
'min-h-9 shrink-0 rounded-lg border px-3 text-[12px] font-black transition-colors',
mode === '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>
<DateRangeInputs
idPrefix={idPrefix}
startDate={startDate}
endDate={endDate}
onChange={onDateChange}
className="mt-2"
/>
</>
);
}
@@ -1,50 +0,0 @@
import type { ComponentType } from 'react';
import { cn } from '../../lib/cn';
export interface AnalysisScopeOption<T extends string> {
id: T;
label: string;
}
export default function AnalysisScopeSwitch<T extends string>({
ariaLabel,
value,
options,
onChange,
icon: Icon,
className,
}: {
ariaLabel: string;
value: T;
options: readonly AnalysisScopeOption<T>[];
onChange: (value: T) => void;
icon?: ComponentType<{ size?: number; className?: string }>;
className?: string;
}) {
return (
<div
role="group"
aria-label={ariaLabel}
className={cn('grid gap-1 rounded-lg bg-slate-100 p-1', className)}
style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
>
{options.map(option => (
<button
key={option.id}
type="button"
onClick={() => onChange(option.id)}
aria-pressed={value === option.id}
className={cn(
'flex min-h-9 min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 text-[12px] font-black transition-colors',
value === option.id
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-700',
)}
>
{Icon ? <Icon size={14} className="shrink-0" /> : null}
<span className="min-w-0 truncate">{option.label}</span>
</button>
))}
</div>
);
}
-315
View File
@@ -1,315 +0,0 @@
import { useEffect, useState } from 'react';
import { CheckCircle2, ChevronLeft, ChevronRight, ReceiptText, Route, Search, TriangleAlert } from 'lucide-react';
import Blur from '../../components/Blur';
import { ErrorState, LoadingState } from '../../components/ui/surface';
import { fetchEtcBills, fetchEtcRecords } from './api';
import type { EtcBillResponse, EtcOverviewResponse, EtcTollRecordResponse } from './types';
import type { EtcDetailView, EtcDrillContext } from './etc-drill-context';
import { DateRangeInputs } from './AnalysisDateFilters';
import { reconcileEtcBills, reconcileEtcRecords } from './etc-reconciliation';
function fmtMoney(value: number): string {
return `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function formatSigned(value: number, fractionDigits = 0): string {
const formatted = Math.abs(value).toLocaleString('zh-CN', {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
});
return `${value >= 0 ? '+' : '-'}${formatted}`;
}
function costTypeLabel(value: number): string {
if (value === 1) return '客户承担';
if (value === 2) return '我方承担';
return '承担方待确认';
}
function paymentLabel(receivable: number, paid: number): string {
if (receivable > 0 && paid >= receivable) return '已收';
if (paid > 0) return '部分收款';
return '未收';
}
export default function ETCDetails({
context,
overviewKpi,
onContextChange,
}: {
context: EtcDrillContext;
overviewKpi: EtcOverviewResponse['kpi'];
onContextChange: (context: EtcDrillContext, mode: 'push' | 'replace') => void;
}) {
const { view, startDate, endDate, search, page } = context;
const [draftSearch, setDraftSearch] = useState(search);
const [records, setRecords] = useState<EtcTollRecordResponse | null>(null);
const [bills, setBills] = useState<EtcBillResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => setDraftSearch(search), [search]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
if (view === 'records') setRecords(null);
else setBills(null);
const query = {
page,
limit: 20,
startDate: startDate || undefined,
endDate: endDate || undefined,
search: search || undefined,
};
const request = view === 'records' ? fetchEtcRecords(query) : fetchEtcBills(query);
request
.then(result => {
if (cancelled) return;
if (page > result.totalPages) {
onContextChange({ ...context, page: result.totalPages }, 'replace');
return;
}
if (view === 'records') setRecords(result as EtcTollRecordResponse);
else setBills(result as EtcBillResponse);
})
.catch(loadError => {
if (!cancelled) setError(loadError instanceof Error ? loadError.message : String(loadError));
})
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [context, endDate, onContextChange, page, search, startDate, view]);
const activeData = view === 'records' ? records : bills;
const totalPages = activeData?.totalPages || 1;
const isFullDataset = !startDate && !endDate && !search;
const reconciliation = isFullDataset
? view === 'records' && records
? reconcileEtcRecords(overviewKpi, records)
: view === 'bills' && bills
? reconcileEtcBills(overviewKpi, bills)
: null
: null;
const reconciliationDifferences = reconciliation && !reconciliation.matches
? 'passageCountDifference' in reconciliation
? [
reconciliation.passageCountDifference !== 0 && `通行次数 ${formatSigned(reconciliation.passageCountDifference)}`,
reconciliation.vehicleCountDifference !== 0 && `车辆数 ${formatSigned(reconciliation.vehicleCountDifference)}`,
reconciliation.tollAmountDifference !== 0 && `通行费 ${formatSigned(reconciliation.tollAmountDifference, 2)}`,
reconciliation.serviceFeeDifference !== 0 && `服务费 ${formatSigned(reconciliation.serviceFeeDifference, 2)}`,
reconciliation.totalAmountDifference !== 0 && `总费用 ${formatSigned(reconciliation.totalAmountDifference, 2)}`,
].filter(Boolean).join(' · ')
: [
reconciliation.billCountDifference !== 0 && `账单数 ${formatSigned(reconciliation.billCountDifference)}`,
reconciliation.receivableAmountDifference !== 0 && `应收 ${formatSigned(reconciliation.receivableAmountDifference, 2)}`,
reconciliation.paidAmountDifference !== 0 && `已收 ${formatSigned(reconciliation.paidAmountDifference, 2)}`,
].filter(Boolean).join(' · ')
: '';
const applySearch = () => {
onContextChange({ ...context, search: draftSearch.trim(), page: 1 }, 'replace');
};
const changeView = (next: EtcDetailView) => {
onContextChange(
{ ...context, view: next, page: 1 },
next === view ? 'replace' : 'push',
);
};
const updateDate = (field: 'start' | 'end', value: string) => {
let nextStart = field === 'start' ? value : startDate;
let nextEnd = field === 'end' ? value : endDate;
if (nextStart && nextEnd && nextStart > nextEnd) [nextStart, nextEnd] = [nextEnd, nextStart];
onContextChange({ ...context, startDate: nextStart, endDate: nextEnd, page: 1 }, 'replace');
};
return (
<section
id="etc-details"
tabIndex={-1}
className="scroll-mt-3 overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
>
<header className="flex flex-col gap-3 border-b border-slate-100 px-3 py-3 md:px-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-black text-slate-900">ETC </h2>
<p className="mt-1 text-[10px] font-bold text-slate-400">
{view === 'records' ? '通行流水与费用承担' : '客户账期、应收与收款'}
</p>
</div>
<div className="flex rounded-lg bg-slate-100 p-1">
<button
type="button"
onClick={() => changeView('records')}
aria-pressed={view === 'records'}
className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[10px] font-black ${view === 'records' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500'}`}
>
<Route size={13} />
</button>
<button
type="button"
onClick={() => changeView('bills')}
aria-pressed={view === 'bills'}
className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[10px] font-black ${view === 'bills' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500'}`}
>
<ReceiptText size={13} />
</button>
</div>
</div>
<div className="grid grid-cols-[minmax(0,1fr)_40px] gap-2 sm:grid-cols-[minmax(280px,1fr)_minmax(0,1fr)_40px]">
<DateRangeInputs
idPrefix="etc"
ariaPrefix="ETC"
startDate={startDate}
endDate={endDate}
onChange={updateDate}
className="col-span-2 sm:col-span-1"
/>
<label className="min-w-0 rounded-lg border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
type="search"
aria-label={view === 'records' ? '搜索车牌、客户或流水号' : '搜索客户或账单号'}
placeholder={view === 'records' ? '车牌 / 客户 / 流水号' : '客户 / 账单号'}
value={draftSearch}
maxLength={128}
onChange={event => setDraftSearch(event.target.value)}
onKeyDown={event => { if (event.key === 'Enter') applySearch(); }}
className="mt-1 h-6 w-full min-w-0 bg-transparent text-[11px] font-bold text-slate-700 outline-none placeholder:text-slate-300"
/>
</label>
<button
type="button"
onClick={applySearch}
className="flex h-full min-h-[58px] w-10 items-center justify-center rounded-lg bg-slate-900 text-white hover:bg-blue-600"
aria-label="应用 ETC 明细搜索"
title="搜索"
>
<Search size={14} />
</button>
</div>
</header>
{activeData ? (
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 bg-slate-50/70 px-3 py-2 text-[10px] font-bold text-slate-500 md:px-4">
<span>{activeData.total} </span>
{view === 'records' && records ? (
<span> {fmtMoney(records.summary.tollAmount)} · {fmtMoney(records.summary.serviceFee)} · {fmtMoney(records.summary.totalAmount)}</span>
) : view === 'bills' && bills ? (
<span> {fmtMoney(bills.summary.receivableAmount)} · {fmtMoney(bills.summary.paidAmount)}</span>
) : null}
</div>
) : null}
{reconciliation && (
<div
role={reconciliation.matches ? 'status' : 'alert'}
className={`flex items-start gap-2 border-b px-3 py-2 text-[10px] font-bold md:px-4 ${
reconciliation.matches
? 'border-emerald-100 bg-emerald-50 text-emerald-700'
: 'border-amber-200 bg-amber-50 text-amber-800'
}`}
>
{reconciliation.matches
? <CheckCircle2 size={14} className="mt-0.5 shrink-0" />
: <TriangleAlert size={14} className="mt-0.5 shrink-0" />}
<span>
{reconciliation.matches
? `口径核对通过:总览与 ${activeData?.total ?? 0}${view === 'records' ? '通行记录' : '结算账单'}全量汇总一致`
: `口径存在差异:${reconciliationDifferences}`}
</span>
</div>
)}
{error ? (
<ErrorState message={error} variant="inline" />
) : loading && !activeData ? (
<LoadingState label="正在加载 ETC 明细" variant="inline" />
) : view === 'records' && records ? (
records.items.length === 0 ? (
<div className="py-14 text-center">
<Route size={22} className="mx-auto text-slate-300" />
<div className="mt-3 text-xs font-black text-slate-500"></div>
<div className="mt-1 text-[10px] font-bold text-slate-300"></div>
</div>
) : (
<>
<div className="hidden overflow-x-auto md:block">
<table className="w-full min-w-[820px] text-left text-[11px]">
<thead className="bg-white text-[10px] font-black text-slate-400">
<tr><th className="px-4 py-2"></th><th className="px-3 py-2"> / </th><th className="px-3 py-2"> </th><th className="px-3 py-2"></th><th className="px-4 py-2 text-right"></th></tr>
</thead>
<tbody className="divide-y divide-slate-100">
{records.items.map(item => (
<tr key={item.id}>
<td className="px-4 py-3 font-bold text-slate-600">{item.transTime}</td>
<td className="px-3 py-3"><div className="font-black text-slate-800"><Blur>{item.plate}</Blur></div><div className="mt-1 text-[9px] font-bold text-slate-400"><Blur>{item.customerName || '未关联客户'}</Blur></div></td>
<td className="px-3 py-3 font-bold text-slate-600">{item.entryStation || '-'} {item.exitStation || '-'}</td>
<td className="px-3 py-3 font-bold text-slate-600">{costTypeLabel(item.costType)}</td>
<td className="px-4 py-3 text-right"><div className="font-black text-slate-800">{fmtMoney(item.totalAmount)}</div><div className="mt-1 text-[9px] font-bold text-slate-400"> {fmtMoney(item.tollAmount)} · {fmtMoney(item.serviceFee)}</div></td>
</tr>
))}
</tbody>
</table>
</div>
<div className="divide-y divide-slate-100 md:hidden">
{records.items.map(item => (
<article key={item.id} className="px-3 py-3">
<div className="flex items-start justify-between gap-3"><div><div className="font-mono text-xs font-black text-slate-800"><Blur>{item.plate}</Blur></div><div className="mt-1 text-[9px] font-bold text-slate-400">{item.transTime}</div></div><div className="text-right text-sm font-black text-slate-800">{fmtMoney(item.totalAmount)}</div></div>
<div className="mt-2 text-[10px] font-bold text-slate-500">{item.entryStation || '-'} {item.exitStation || '-'}</div>
<div className="mt-1 flex justify-between gap-2 text-[9px] font-bold text-slate-400"><Blur>{item.customerName || '未关联客户'}</Blur><span>{costTypeLabel(item.costType)}</span></div>
</article>
))}
</div>
</>
)
) : view === 'bills' && bills ? (
bills.items.length === 0 ? (
<div className="py-14 text-center">
<ReceiptText size={22} className="mx-auto text-slate-300" />
<div className="mt-3 text-xs font-black text-slate-500"></div>
<div className="mt-1 text-[10px] font-bold text-slate-300"></div>
</div>
) : (
<>
<div className="hidden overflow-x-auto md:block">
<table className="w-full min-w-[760px] text-left text-[11px]">
<thead className="text-[10px] font-black text-slate-400"><tr><th className="px-4 py-2"> / </th><th className="px-3 py-2"></th><th className="px-3 py-2"></th><th className="px-3 py-2"></th><th className="px-4 py-2 text-right"> / </th></tr></thead>
<tbody className="divide-y divide-slate-100">
{bills.items.map(item => (
<tr key={item.id}>
<td className="px-4 py-3"><div className="font-black text-slate-800">{item.billCode}</div><div className="mt-1 text-[9px] font-bold text-slate-400"><Blur>{item.customerName || '未指定客户'}</Blur></div></td>
<td className="px-3 py-3 font-bold text-slate-600">{item.periodStart} {item.periodEnd}</td>
<td className="px-3 py-3 font-bold text-slate-600">{item.tollCount} · {fmtMoney(item.tollAmount)}</td>
<td className="px-3 py-3 font-bold text-slate-600">{paymentLabel(item.receivableAmount, item.paidAmount)}</td>
<td className="px-4 py-3 text-right"><div className="font-black text-slate-800">{fmtMoney(item.receivableAmount)}</div><div className="mt-1 text-[9px] font-bold text-slate-400"> {fmtMoney(item.paidAmount)}</div></td>
</tr>
))}
</tbody>
</table>
</div>
<div className="divide-y divide-slate-100 md:hidden">
{bills.items.map(item => (
<article key={item.id} className="px-3 py-3">
<div className="flex items-start justify-between gap-3"><div><div className="text-xs font-black text-slate-800">{item.billCode}</div><div className="mt-1 text-[9px] font-bold text-slate-400"><Blur>{item.customerName || '未指定客户'}</Blur></div></div><div className="text-right"><div className="text-sm font-black text-slate-800">{fmtMoney(item.receivableAmount)}</div><div className="mt-1 text-[9px] font-bold text-slate-400"> {fmtMoney(item.paidAmount)}</div></div></div>
<div className="mt-2 text-[10px] font-bold text-slate-500">{item.periodStart} {item.periodEnd}</div>
<div className="mt-1 flex justify-between text-[9px] font-bold text-slate-400"><span>{item.tollCount} </span><span>{paymentLabel(item.receivableAmount, item.paidAmount)}</span></div>
</article>
))}
</div>
</>
)
) : null}
{activeData ? (
<footer className="flex items-center justify-between border-t border-slate-100 px-3 py-3 md:px-4">
<span className="text-[10px] font-bold text-slate-400"> {page} / {totalPages} </span>
<div className="flex gap-2">
<button type="button" onClick={() => onContextChange({ ...context, page: Math.max(1, page - 1) }, 'push')} disabled={page <= 1 || loading} className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 text-slate-500 disabled:opacity-30" aria-label="上一页" title="上一页"><ChevronLeft size={14} /></button>
<button type="button" onClick={() => onContextChange({ ...context, page: Math.min(totalPages, page + 1) }, 'push')} disabled={page >= totalPages || loading} className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 text-slate-500 disabled:opacity-30" aria-label="下一页" title="下一页"><ChevronRight size={14} /></button>
</div>
</footer>
) : null}
</section>
);
}
-184
View File
@@ -1,184 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { CircleCheck, CircleX, Clock3, Database, ReceiptText, RefreshCw, Route, Truck } from 'lucide-react';
import { fetchEtcOverview } from './api';
import type { EtcOverviewResponse } from './types';
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
import ETCDetails from './ETCDetails';
import {
buildEtcDrillUrl,
parseEtcDrillContext,
type EtcDetailView,
type EtcDrillContext,
} from './etc-drill-context';
function fmtMoney(value: number): string {
return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
}
function syncStatusLabel(status: EtcOverviewResponse['integration']['lastSyncStatus']): string {
if (status === 'success') return '同步成功';
if (status === 'failed') return '同步失败';
if (status === 'partial') return '部分成功';
if (status === 'never') return '尚未同步';
return '状态未知';
}
function DrilldownMetricButton({
label,
title,
onClick,
children,
}: {
label: string;
title: string;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-controls="etc-details"
aria-label={label}
className="min-w-0 rounded-2xl text-left outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
title={title}
>
{children}
</button>
);
}
export default function ETCView() {
const [data, setData] = useState<EtcOverviewResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [detailContext, setDetailContext] = useState<EtcDrillContext>(() => (
parseEtcDrillContext(window.location.search)
));
const commitDetailContext = useCallback((next: EtcDrillContext, mode: 'push' | 'replace') => {
window.history[mode === 'push' ? 'pushState' : 'replaceState'](
null,
'',
buildEtcDrillUrl(window.location, next),
);
setDetailContext(next);
}, []);
const selectDetailView = useCallback((next: EtcDetailView) => {
commitDetailContext(
{ ...detailContext, view: next, page: 1 },
next === detailContext.view ? 'replace' : 'push',
);
}, [commitDetailContext, detailContext]);
const openDetailView = useCallback((next: EtcDetailView) => {
selectDetailView(next);
window.requestAnimationFrame(() => {
const details = document.getElementById('etc-details');
details?.focus({ preventScroll: true });
details?.scrollIntoView({
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
block: 'start',
});
});
}, [selectDetailView]);
useEffect(() => {
const handlePopState = () => setDetailContext(parseEtcDrillContext(window.location.search));
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
const load = async (force = false) => {
if (force) setRefreshing(true);
setError(null);
try {
setData(await fetchEtcOverview(force));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setRefreshing(false);
}
};
useEffect(() => {
void load();
}, []);
if (error && !data) return <ErrorState message={error} />;
if (!data) return <LoadingState label="正在检查 ETC 接入状态" />;
const { integration, kpi } = data;
const collectionRate = kpi.receivableAmount > 0 ? kpi.paidAmount / kpi.receivableAmount * 100 : 0;
return (
<div className="flex flex-col gap-3">
<SurfaceCard className="flex items-center gap-3 p-3">
<span className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${integration.providerConfigured ? 'bg-emerald-50 text-emerald-600' : 'bg-amber-50 text-amber-600'}`}>
{integration.providerConfigured ? <CircleCheck size={18} /> : <CircleX size={18} />}
</span>
<div className="min-w-0 flex-1">
<div className="text-xs font-black text-slate-800">
{integration.providerConfigured ? 'ETC 数据提供方已配置' : 'ETC 数据提供方尚未配置'}
</div>
<div className="mt-1 truncate text-[10px] font-bold text-slate-400">
{integration.providerConfigured
? `${integration.providerType || '已配置提供方'} · ${syncStatusLabel(integration.lastSyncStatus)}`
: '数据库结构已准备,等待配置同步账户并完成首次同步'}
</div>
</div>
<button
type="button"
onClick={() => void load(true)}
disabled={refreshing}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 hover:text-blue-600 disabled:opacity-50"
aria-label="刷新 ETC 状态"
title="刷新 ETC 状态"
>
<RefreshCw size={15} className={refreshing ? 'animate-spin' : ''} />
</button>
</SurfaceCard>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<DrilldownMetricButton label={`查看 ETC 通行记录,总费用 ${fmtMoney(kpi.totalAmount)}`} title="查看通行记录" onClick={() => openDetailView('records')}>
<MetricTile icon={ReceiptText} label="ETC 总费用" value={fmtMoney(kpi.totalAmount)} helper={`通行费 ${fmtMoney(kpi.tollAmount)} · 服务费 ${fmtMoney(kpi.serviceFee)}`} />
</DrilldownMetricButton>
<DrilldownMetricButton label={`查看 ETC 通行记录,共 ${kpi.passageCount}`} title="查看通行记录" onClick={() => openDetailView('records')}>
<MetricTile icon={Route} label="通行次数" value={kpi.passageCount} unit="次" helper={kpi.latestTransactionTime ? `最新 ${kpi.latestTransactionTime}` : '暂无通行记录'} tone="emerald" />
</DrilldownMetricButton>
<DrilldownMetricButton label={`查看 ETC 通行记录,共 ${kpi.vehicleCount} 辆车`} title="查看通行记录" onClick={() => openDetailView('records')}>
<MetricTile icon={Truck} label="通行车辆" value={kpi.vehicleCount} unit="辆" helper="按车牌去重" tone="amber" />
</DrilldownMetricButton>
<DrilldownMetricButton label={`查看 ETC 结算账单,应收 ${fmtMoney(kpi.receivableAmount)}`} title="查看结算账单" onClick={() => openDetailView('bills')}>
<MetricTile icon={Database} label="账单应收" value={fmtMoney(kpi.receivableAmount)} helper={`${kpi.billCount} 账单 · 已收 ${fmtMoney(kpi.paidAmount)} · ${collectionRate.toFixed(1)}%`} tone="slate" />
</DrilldownMetricButton>
</div>
<div className="grid gap-3 md:grid-cols-3">
<SurfaceCard className="p-3">
<div className="text-[10px] font-black text-slate-400"></div>
<div className={`mt-1 text-sm font-black ${integration.providerConfigured ? 'text-emerald-600' : 'text-amber-600'}`}>
{integration.providerConfigured ? '已配置' : '未配置'}
</div>
</SurfaceCard>
<SurfaceCard className="p-3">
<div className="text-[10px] font-black text-slate-400"></div>
<div className={`mt-1 text-sm font-black ${integration.syncEnabled ? 'text-emerald-600' : 'text-slate-500'}`}>
{integration.syncEnabled ? '已启用' : '未启用'}
</div>
</SurfaceCard>
<SurfaceCard className="p-3">
<div className="flex items-center gap-1 text-[10px] font-black text-slate-400"><Clock3 size={11} /></div>
<div className="mt-1 text-sm font-black text-slate-700">{syncStatusLabel(integration.lastSyncStatus)}</div>
<div className="mt-1 text-[10px] font-bold text-slate-400">{integration.lastSyncAt || '无同步记录'}</div>
</SurfaceCard>
</div>
<ETCDetails context={detailContext} overviewKpi={kpi} onContextChange={commitDetailContext} />
{error && <ErrorState message={error} />}
</div>
);
}
-434
View File
@@ -1,434 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { BatteryCharging, CalendarDays, CheckCircle2, ChevronRight, MapPin, Plug, TrendingUp, TriangleAlert, Truck, Wallet, X } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import TrendBadge from './TrendBadge';
import { fetchElectricMonthly, fetchElectricOrders } from './api';
import type { DateQuickPick, ElectricChargeOrderResponse, ElectricMonthGroup, ElectricVehicleScope } from './types';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
import {
buildElectricDrillUrl,
parseElectricDrillContext,
type ElectricDrillContext,
} from './electric-drill-context';
import AnalysisDateFilters from './AnalysisDateFilters';
import AnalysisScopeSwitch, { type AnalysisScopeOption } from './AnalysisScopeSwitch';
import {
dateRangeModeLabel,
getQuickDateRange,
inferDateRangeMode,
normalizeDateRange,
type DateRangeMode,
} from './date-range';
import { reconcileElectricDaily } from './electric-reconciliation';
const VEHICLE_SCOPE_OPTIONS: readonly AnalysisScopeOption<ElectricVehicleScope>[] = [
{ id: 'all', label: '全部车辆' },
{ id: 'lingniu', label: '羚牛车辆' },
{ id: 'external', label: '外部车辆' },
];
export default function ElectricDaily() {
const [drillContext, setDrillContext] = useState<ElectricDrillContext>(() => (
parseElectricDrillContext(window.location.search)
));
const [vehicleScope, setVehicleScope] = useState<ElectricVehicleScope>(drillContext.vehicleScope);
const [pick, setPick] = useState<DateRangeMode>(() => {
if (drillContext.startDate && drillContext.endDate) {
return inferDateRangeMode(drillContext.startDate, drillContext.endDate);
}
return 'last15';
});
const [dateRange, setDateRange] = useState(() => {
if (drillContext.startDate && drillContext.endDate) {
return normalizeDateRange(drillContext.startDate, drillContext.endDate);
}
if (drillContext.selectedDate) {
return { start: drillContext.selectedDate, end: drillContext.selectedDate };
}
return getQuickDateRange('last15');
});
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
const [orders, setOrders] = useState<ElectricChargeOrderResponse | null>(null);
const [ordersError, setOrdersError] = useState<string | null>(null);
const orderTriggerRef = useRef<HTMLButtonElement | null>(null);
const effectiveRange = useMemo(() => normalizeDateRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
const selectedDate = drillContext.selectedDate;
const selectedDateRef = useRef(selectedDate);
selectedDateRef.current = selectedDate;
const commitDrillContext = useCallback((next: ElectricDrillContext, mode: 'push' | 'replace' = 'replace') => {
const url = buildElectricDrillUrl(window.location, next);
window.history[mode === 'push' ? 'pushState' : 'replaceState'](null, '', url);
setDrillContext(next);
}, []);
useEffect(() => {
let cancelled = false;
setError(null);
const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick };
fetchElectricMonthly(vehicleScope, query)
.then(m => {
if (cancelled) return;
setMonths(m);
if (m.length > 0) {
setOpenMonths(new Set([selectedDateRef.current?.slice(0, 7) || m[0].month]));
}
})
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [vehicleScope, pick, effectiveRange.start, effectiveRange.end]);
useEffect(() => {
if (!selectedDate) {
setOrders(null);
setOrdersError(null);
return undefined;
}
let cancelled = false;
setOrders(null);
setOrdersError(null);
fetchElectricOrders(selectedDate, vehicleScope)
.then(result => { if (!cancelled) setOrders(result); })
.catch(e => { if (!cancelled) setOrdersError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [selectedDate, vehicleScope]);
const toggleMonth = (m: string) => setOpenMonths(prev => {
const next = new Set(prev);
next.has(m) ? next.delete(m) : next.add(m);
return next;
});
const totalKwh = useMemo(() => (months ?? []).reduce((s, m) => s + (m.kwh || 0), 0), [months]);
const totalFee = useMemo(() => (months ?? []).reduce((s, m) => s + (m.fee || 0), 0), [months]);
const activeDays = useMemo(() => (months ?? []).reduce((sum, m) => sum + m.rows.filter(r => r.kwh > 0).length, 0), [months]);
const abnormalDays = useMemo(() => (months ?? []).reduce((sum, m) => sum + m.rows.filter(r => Math.abs(r.chainPct) >= 0.3).length, 0), [months]);
const avgKwh = activeDays > 0 ? totalKwh / activeDays : 0;
const avgPrice = totalKwh > 0 ? totalFee / totalKwh : 0;
const scopeLabel = dateRangeModeLabel(pick);
const rangeText = `${effectiveRange.start}${effectiveRange.end}`;
const showExternalEmpty = vehicleScope === 'external' && months !== null && totalKwh === 0;
const applyQuickPick = (nextPick: DateQuickPick) => {
const nextRange = getQuickDateRange(nextPick);
setPick(nextPick);
setDateRange(nextRange);
commitDrillContext({
vehicleScope,
startDate: nextRange.start,
endDate: nextRange.end,
});
};
const updateDateRange = (field: 'start' | 'end', value: string) => {
if (!value) return;
const nextRange = { ...dateRange, [field]: value };
const normalized = normalizeDateRange(nextRange.start, nextRange.end);
setPick('custom');
setDateRange(normalized);
commitDrillContext({
vehicleScope,
startDate: normalized.start,
endDate: normalized.end,
});
};
const updateVehicleScope = (next: ElectricVehicleScope) => {
setVehicleScope(next);
commitDrillContext({
...drillContext,
vehicleScope: next,
startDate: effectiveRange.start,
endDate: effectiveRange.end,
});
};
const toggleDate = (date: string) => {
const opening = selectedDate !== date;
if (opening && document.activeElement instanceof HTMLButtonElement) {
orderTriggerRef.current = document.activeElement;
}
commitDrillContext({
...drillContext,
vehicleScope,
startDate: effectiveRange.start,
endDate: effectiveRange.end,
selectedDate: opening ? date : undefined,
}, opening ? 'push' : 'replace');
window.requestAnimationFrame(() => {
if (opening) {
const details = document.getElementById(`electric-orders-${date}`);
details?.focus({ preventScroll: true });
details?.scrollIntoView({
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
block: 'start',
});
} else {
const trigger = orderTriggerRef.current ?? document.getElementById(`electric-date-trigger-${date}`);
if (trigger instanceof HTMLElement) trigger.focus();
}
});
};
useEffect(() => {
const handlePopState = () => {
const next = parseElectricDrillContext(window.location.search);
setDrillContext(next);
setVehicleScope(next.vehicleScope);
if (next.startDate && next.endDate) {
const normalized = normalizeDateRange(next.startDate, next.endDate);
setPick(inferDateRangeMode(normalized.start, normalized.end));
setDateRange(normalized);
} else if (next.selectedDate) {
setPick('custom');
setDateRange({ start: next.selectedDate, end: next.selectedDate });
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
return (
<div className="flex flex-col gap-3">
<SurfaceCard className="p-2 md:p-3">
<AnalysisDateFilters
idPrefix="electric"
mode={pick}
startDate={dateRange.start}
endDate={dateRange.end}
onQuickPick={applyQuickPick}
onCustom={() => setPick('custom')}
onDateChange={updateDateRange}
/>
<AnalysisScopeSwitch
ariaLabel="电能车辆范围"
value={vehicleScope}
options={VEHICLE_SCOPE_OPTIONS}
onChange={updateVehicleScope}
icon={Truck}
className="mt-2"
/>
</SurfaceCard>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={BatteryCharging} label={`${scopeLabel}充电量`} value={totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={rangeText} />
<MetricTile
icon={Wallet}
label="充电费用"
value={`¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
helper={`综合费用强度 ${avgPrice.toFixed(2)} 元/度`}
tone="emerald"
/>
<MetricTile icon={CalendarDays} label="有效天数" value={`${activeDays}`} unit="天" helper={`日均 ${avgKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} tone="amber" />
<MetricTile icon={TrendingUp} label="波动提醒" value={abnormalDays} unit="天" helper="环比超过 30% 标记" tone={abnormalDays > 0 ? 'rose' : 'slate'} />
</div>
{/* 外部车辆 数据未就绪 */}
{showExternalEmpty && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 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 relative">
<Plug size={22} className="text-blue-500" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
</div>
<div className="text-sm font-bold text-slate-700 mb-1"> · </div>
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
<br />
线
</div>
</motion.div>
)}
{/* 月份分组表 */}
{!showExternalEmpty && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<div className="grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
<span> / </span>
<span className="text-right"> ()</span>
<span className="text-right"></span>
</div>
{error ? (
<ErrorState message={error} variant="inline" />
) : months === null ? (
<LoadingState label="正在加载充电明细" variant="inline" />
) : months.length === 0 ? (
<EmptyState title="暂无充电数据" description="请切换时间范围或车辆归属" variant="inline" />
) : months.map(m => {
const open = openMonths.has(m.month);
return (
<div key={m.month} className="border-t border-slate-100 first:border-t-0">
<button
type="button"
onClick={() => toggleMonth(m.month)}
className={`w-full grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2.5 text-left transition-colors ${
open ? 'bg-blue-50/30' : 'hover:bg-slate-50'
}`}
aria-expanded={open}
aria-controls={open ? `electric-month-${m.month}` : undefined}
>
<span className="flex items-center gap-1 text-[12px] text-slate-700 font-bold">
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
{m.month}
</span>
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
{m.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span />
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
id={`electric-month-${m.month}`}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
{m.rows.map(d => {
const isAbnormal = Math.abs(d.chainPct) >= 0.3;
const abnormalBg = isAbnormal
? d.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
: 'bg-slate-50/50';
const reconciliation = orders?.date === d.date
? reconcileElectricDaily(d, orders, vehicleScope)
: null;
return (
<div key={d.date} className="border-t border-slate-100">
<button
id={`electric-date-trigger-${d.date}`}
type="button"
onClick={() => toggleDate(d.date)}
className={`grid w-full grid-cols-[minmax(0,1fr)_120px_88px] gap-3 px-3 py-2 pl-9 text-left md:grid-cols-[minmax(0,1fr)_160px_120px] ${abnormalBg}`}
aria-expanded={selectedDate === d.date}
aria-controls={`electric-orders-${d.date}`}
>
<span className="flex items-center gap-1 text-[12px] font-bold text-slate-600">
<ChevronRight size={12} className={`text-slate-400 transition-transform ${selectedDate === d.date ? 'rotate-90' : ''}`} />
{d.date.slice(5)}
</span>
<span className="text-right text-[12px] font-bold tabular-nums text-slate-700">
{d.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span className="text-right"><TrendBadge value={d.chainPct} /></span>
</button>
{selectedDate === d.date && (
<div
id={`electric-orders-${d.date}`}
tabIndex={-1}
aria-label={`${d.date} 充电订单明细`}
className="scroll-mt-3 border-t border-blue-100 bg-blue-50/40 px-3 py-3 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 md:pl-9"
>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1.5 text-[11px] font-black text-blue-800">
<Plug size={13} className="shrink-0" />
<span className="truncate">{d.date} </span>
</div>
<button
type="button"
onClick={() => toggleDate(d.date)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-white text-blue-500 ring-1 ring-blue-100 hover:text-blue-700"
aria-label={`关闭 ${d.date} 充电订单`}
title="关闭订单明细"
>
<X size={13} />
</button>
</div>
{orders && reconciliation && (
<div
role={reconciliation.matches ? 'status' : 'alert'}
className={`mb-2 flex items-start gap-2 rounded-lg border px-3 py-2 text-[10px] font-bold ${
reconciliation.matches
? 'border-emerald-100 bg-emerald-50 text-emerald-700'
: 'border-amber-200 bg-amber-50 text-amber-800'
}`}
>
{reconciliation.matches
? <CheckCircle2 size={14} className="mt-0.5 shrink-0" />
: <TriangleAlert size={14} className="mt-0.5 shrink-0" />}
<span>
{reconciliation.matches
? `口径核对通过:日汇总与 ${orders.recordCount} 笔订单全量合计一致`
: `口径存在差异:电量 ${reconciliation.kwhDifference >= 0 ? '+' : ''}${reconciliation.kwhDifference.toFixed(2)} 度 · 费用 ${reconciliation.feeDifference >= 0 ? '+' : ''}${reconciliation.feeDifference.toFixed(2)}${!reconciliation.dateMatches || !reconciliation.scopeMatches ? ' · 日期或车辆范围不一致' : ''}`}
</span>
</div>
)}
{ordersError ? (
<ErrorState message={ordersError} variant="inline" />
) : !orders || orders.date !== d.date ? (
<LoadingState label="正在加载充电订单" variant="inline" />
) : orders.items.length === 0 ? (
<EmptyState title="当日无充电订单" description="当前车辆归属下没有订单明细" variant="inline" />
) : (
<div className="overflow-hidden rounded-lg border border-blue-100 bg-white">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-blue-100 bg-blue-50 px-3 py-2 text-[10px] font-bold text-blue-700">
<span>{orders.recordCount} · {orders.totalKwh.toLocaleString('zh-CN')} </span>
<span> ¥{orders.totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</span>
</div>
{orders.truncated && (
<div className="border-b border-amber-100 bg-amber-50 px-3 py-2 text-[10px] font-bold text-amber-700">
500 500
</div>
)}
<div className="grid grid-cols-[minmax(0,1fr)_72px_82px] gap-2 border-b border-slate-100 bg-slate-50 px-3 py-1.5 text-[10px] font-bold text-slate-400 md:grid-cols-[minmax(0,1fr)_100px_120px]">
<span> / / </span>
<span className="text-right"></span>
<span className="text-right"></span>
</div>
{orders.items.map(order => (
<div key={order.id} className="grid grid-cols-[minmax(0,1fr)_72px_82px] gap-2 border-b border-slate-100 px-3 py-2 last:border-b-0 md:grid-cols-[minmax(0,1fr)_100px_120px]">
<div className="min-w-0">
<div className="flex items-center gap-1 text-[11px] font-black text-slate-700">
<span>{order.startTime.slice(11, 16)}</span>
<span className="truncate text-blue-600">{order.plate}</span>
<span className="shrink-0 rounded bg-slate-100 px-1 py-0.5 text-[9px] text-slate-500">{order.orderStatus}</span>
</div>
<div className="mt-1 flex min-w-0 items-center gap-1 text-[10px] font-bold text-slate-400">
<MapPin size={10} className="shrink-0" />
<span className="truncate">{order.stationName}</span>
<span className="hidden shrink-0 md:inline">· {order.orderNo}</span>
</div>
</div>
<div className="text-right text-[11px] font-black tabular-nums text-slate-700">
{order.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
<div className="mt-1 text-[9px] font-bold text-slate-400"></div>
</div>
<div className="text-right text-[11px] font-black tabular-nums text-emerald-600">
¥{order.totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
<div className="mt-1 text-[9px] font-bold text-slate-400">
{order.electricityFee.toLocaleString('zh-CN')} · {order.serviceFee.toLocaleString('zh-CN')}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
)}
<RotatingFooterHint />
</div>
);
}
-41
View File
@@ -1,41 +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';
import BiHeaderActions from '../../components/BiHeaderActions';
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="时间单位清晰标注 · 支持日/总览切换"
actions={<BiHeaderActions domain="electric" />}
>
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} idPrefix="electric-view" ariaLabel="电能分析视图" />
<AnimatePresence mode="wait">
<FadeIn key={sub}>
<div
id={`electric-view-panel-${sub}`}
role="tabpanel"
aria-labelledby={`electric-view-tab-${sub}`}
>
<ElectricView sub={sub} />
</div>
</FadeIn>
</AnimatePresence>
</PageFrame>
);
}
-161
View File
@@ -1,161 +0,0 @@
import { useEffect, useState } from 'react';
import { BatteryCharging, Gauge, Wallet, CalendarClock } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
import { buildElectricDrillUrl } from './electric-drill-context';
import { activateDrillOnKeyDown } from './drill-interaction';
function fmtYuan(yuan: number) {
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
}
function fmtKwh(kwh: number) {
return `${kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} 度`;
}
function fmtMonth(month: string | undefined) {
if (!month) return '当前月';
const [year, monthNumber] = month.split('-');
return `${year}${Number(monthNumber)}`;
}
export default function ElectricOverview() {
const [data, setData] = useState<ElectricOverviewResponse | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetchElectricOverview()
.then(d => { if (!cancelled) setData(d); })
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, []);
if (error) {
return <ErrorState message={error} />;
}
if (!data) {
return <LoadingState label="正在加载电能总览" />;
}
const k = data.kpi;
const trendData = data.trend;
// 当电能数据滞后(本月无数据走 fallback)时,柱图标题显示实际月份
const trendMonthLabel = trendData[0]?.date.slice(0, 7);
const trendMonthText = fmtMonth(trendMonthLabel);
const now = new Date();
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
? `${trendMonthLabel} 每日充电`
: '本月每日充电';
const activeDays = trendData.filter(item => item.kwh > 0).length;
const trendKwh = trendData.reduce((sum, item) => sum + item.kwh, 0);
const trendFee = trendData.reduce((sum, item) => sum + item.fee, 0);
const avgDailyKwh = activeDays > 0 ? trendKwh / activeDays : 0;
const avgDailyFee = activeDays > 0 ? trendFee / 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 trendPrice = trendKwh > 0 ? trendFee / trendKwh : 0;
const openDay = (date: string) => {
const url = buildElectricDrillUrl(
{ pathname: window.location.pathname, search: window.location.search, hash: '#electric' },
{
vehicleScope: 'all',
startDate: date,
endDate: date,
selectedDate: date,
},
);
window.history.pushState(null, '', url);
window.dispatchEvent(new HashChangeEvent('hashchange'));
};
return (
<div className="flex flex-col gap-3">
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400">
{data.latestRecordAt ?? '暂无记录'} · ·
</div>
{/* 横向 mini KPI 头 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={Wallet} label="累计充电费" value={fmtYuan(k.totalFee)} helper={fmtKwh(k.totalKwh)} />
<MetricTile icon={CalendarClock} label={`${trendMonthText}充电费`} value={fmtYuan(trendFee)} helper={fmtKwh(trendKwh)} tone="emerald" />
<MetricTile icon={Gauge} label="综合费用强度" value={avgPrice.toFixed(2)} unit="元/度" helper={`${trendMonthText} ${trendPrice.toFixed(2)} 元/度 · 含零费用订单`} tone="amber" />
<MetricTile icon={BatteryCharging} label="今日充电" value={k.todayKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={`${fmtYuan(k.todayFee)} · ${k.todayChainPct >= 0 ? '+' : ''}${(k.todayChainPct * 100).toFixed(1)}%`} tone={Math.abs(k.todayChainPct) >= 0.3 ? 'rose' : 'slate'} />
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<SurfaceCard className="p-3">
<div className="text-[11px] font-black text-slate-400">{trendMonthText}</div>
<div className="mt-1 text-xl font-black text-slate-900">{activeDays}<span className="ml-1 text-[11px] text-slate-400"></span></div>
<div className="mt-1 text-[11px] font-bold text-slate-500"> {avgDailyKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} </div>
</SurfaceCard>
<SurfaceCard className="p-3">
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-xl font-black text-blue-600">{peakDay ? peakDay.date.slice(5) : '—'}</div>
<div className="mt-1 text-[11px] font-bold text-slate-500">{peakDay ? `${peakDay.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度 · ${fmtYuan(peakDay.fee)}` : '暂无数据'}</div>
</SurfaceCard>
<SurfaceCard className="p-3">
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-xl font-black text-emerald-600">{k.totalFee > 0 ? (trendFee / k.totalFee * 100).toFixed(1) : '0.0'}%</div>
<div className="mt-1 text-[11px] font-bold text-slate-500">{trendMonthText} / </div>
</SurfaceCard>
</div>
{/* 当前月无数据时展示最近一个有数据的自然月 */}
<SurfaceCard className="p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700">{chartTitle}</span>
<span className="text-[11px] text-slate-400 font-bold"> · · 线</span>
</div>
<ResponsiveContainer width="100%" height={160}>
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
<XAxis
dataKey="date"
tickFormatter={(v: string) => v.slice(5)}
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
minTickGap={8}
/>
<YAxis hide />
<Tooltip
formatter={(v) => [`¥${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`, '充电费用']}
labelFormatter={(d) => `日期 ${d}`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
/>
{avgDailyFee > 0 && (
<ReferenceLine
y={avgDailyFee}
stroke="#f59e0b"
strokeDasharray="4 4"
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
/>
)}
<Bar dataKey="fee" radius={[4, 4, 0, 0]}>
{trendData.map(item => (
<Cell
key={item.date}
fill="url(#electricBarGrad)"
className="cursor-pointer outline-none focus:stroke-blue-700 focus:stroke-2"
role="button"
tabIndex={0}
aria-label={`${item.date},充电费用 ${fmtYuan(item.fee)},查看全部车辆订单`}
onClick={() => openDay(item.date)}
onKeyDown={event => activateDrillOnKeyDown(event, () => openDay(item.date))}
/>
))}
</Bar>
<defs>
<linearGradient id="electricBarGrad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="100%" stopColor="#22d3ee" />
</linearGradient>
</defs>
</BarChart>
</ResponsiveContainer>
</SurfaceCard>
<RotatingFooterHint />
</div>
);
}
-12
View File
@@ -1,12 +0,0 @@
import ElectricOverview from './ElectricOverview';
import ElectricDaily from './ElectricDaily';
export type ElectricSubTab = 'daily' | 'overview';
interface Props {
sub: ElectricSubTab;
}
export default function ElectricView({ sub }: Props) {
return sub === 'overview' ? <ElectricOverview /> : <ElectricDaily />;
}
-19
View File
@@ -1,19 +0,0 @@
import { Receipt } from 'lucide-react';
import ETCView from './ETCView';
import { PageFrame } from '../../components/ui/surface';
import BiHeaderActions from '../../components/BiHeaderActions';
export default function EtcModule() {
return (
<PageFrame
title="ETC 通行费看板"
subtitle="区分通行记录与结算账单口径,展示通行费、服务费、车辆规模及数据同步状态。"
icon={Receipt}
eyebrow="ETC BI"
meta="真实接入状态 · 指标口径已发布"
actions={<BiHeaderActions domain="etc" />}
>
<ETCView />
</PageFrame>
);
}
-534
View File
@@ -1,534 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Building2, ChevronRight, Fuel, Plug, ReceiptText, TrendingUp, Truck, UserRound, X } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
import TrendBadge from './TrendBadge';
import { fetchHydrogenDaily } from './api';
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
import {
buildHydrogenDrillUrl,
parseHydrogenDrillContext,
type HydrogenDrillContext,
} from './hydrogen-drill-context';
import HydrogenOrders from './HydrogenOrders';
import AnalysisDateFilters from './AnalysisDateFilters';
import AnalysisScopeSwitch, { type AnalysisScopeOption } from './AnalysisScopeSwitch';
import { activateDrillOnKeyDown } from './drill-interaction';
import {
dateRangeModeLabel,
getQuickDateRange,
inferDateRangeMode,
normalizeDateRange,
type DateRangeMode,
} from './date-range';
const VEHICLE_SCOPE_OPTIONS: readonly AnalysisScopeOption<CustomerType>[] = [
{ id: 'lingniu', label: '羚牛车辆' },
{ id: 'external', label: '外部车辆' },
];
const HYDROGEN_ORDERS_ID = 'hydrogen-orders';
export default function HydrogenDaily() {
const [drillContext, setDrillContext] = useState<HydrogenDrillContext>(() => (
parseHydrogenDrillContext(window.location.search)
));
const [pick, setPick] = useState<DateRangeMode>(() => (
drillContext.startDate && drillContext.endDate
? inferDateRangeMode(drillContext.startDate, drillContext.endDate)
: 'last15'
));
const [dateRange, setDateRange] = useState(() => (
drillContext.startDate && drillContext.endDate
? normalizeDateRange(drillContext.startDate, drillContext.endDate)
: getQuickDateRange('last15')
));
const [vehicleScope, setVehicleScope] = useState<CustomerType>(drillContext.vehicleScope);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [retryKey, setRetryKey] = useState(0);
const orderTriggerRef = useRef<HTMLElement | SVGElement | null>(null);
const effectiveRange = useMemo(() => normalizeDateRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
const selectedStationId = drillContext.level === 'station' ? drillContext.stationId : undefined;
const selectedStationName = drillContext.level === 'station'
? drillContext.stationName || `站点 #${drillContext.stationId}`
: undefined;
const selectedCustomerName = drillContext.level === 'customer'
? drillContext.customerName
: undefined;
const selectedDate = drillContext.selectedDate;
const selectedDailyRow = rows?.find(row => row.date === selectedDate);
const commitDrillContext = useCallback((next: HydrogenDrillContext, mode: 'push' | 'replace') => {
const url = buildHydrogenDrillUrl(window.location, next);
window.history[mode === 'push' ? 'pushState' : 'replaceState'](null, '', url);
setDrillContext(next);
}, []);
useEffect(() => {
let cancelled = false;
setError(null);
const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end, stationId: selectedStationId, customerName: selectedCustomerName }
: { range: pick, stationId: selectedStationId, customerName: selectedCustomerName };
fetchHydrogenDaily(query, vehicleScope)
.then(r => { if (!cancelled) setRows(r); })
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [pick, vehicleScope, effectiveRange.start, effectiveRange.end, selectedStationId, selectedCustomerName, retryKey]);
// 柱图:按日期升序,用于"从左到右时间流"
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0);
const activeDays = (rows ?? []).filter(r => r.totalKg > 0).length;
const stationCount = useMemo(() => {
const ids = new Set<number>();
(rows ?? []).forEach(r => r.stations.forEach(s => ids.add(s.stationId)));
return ids.size;
}, [rows]);
const avgKg = activeDays > 0 ? totalKg / activeDays : 0;
const scopeLabel = dateRangeModeLabel(pick);
const rangeText = `${effectiveRange.start}${effectiveRange.end}`;
const orderScopeLabel = selectedCustomerName
? `客户 ${selectedCustomerName}`
: selectedStationName
? `站点 ${selectedStationName}`
: vehicleScope === 'external' ? '外部车辆' : '羚牛车辆';
const peakDay = trendData.reduce<HydrogenDailyRow | null>((best, item) => (!best || item.totalKg > best.totalKg ? item : best), null);
const lowDay = trendData
.filter(item => item.totalKg > 0)
.reduce<HydrogenDailyRow | null>((low, item) => (!low || item.totalKg < low.totalKg ? item : low), null);
const zeroDays = (rows ?? []).filter(r => r.totalKg === 0).length;
const toggle = (date: string) => setExpanded(prev => {
const next = new Set(prev);
next.has(date) ? next.delete(date) : next.add(date);
return next;
});
const applyQuickPick = (nextPick: DateQuickPick) => {
const nextRange = getQuickDateRange(nextPick);
setPick(nextPick);
setDateRange(nextRange);
commitDrillContext({
...drillContext,
vehicleScope,
startDate: nextRange.start,
endDate: nextRange.end,
selectedDate: undefined,
orderPage: undefined,
}, 'replace');
};
const updateDateRange = (field: 'start' | 'end', value: string) => {
if (!value) return;
const nextRange = { ...dateRange, [field]: value };
setPick('custom');
const normalized = normalizeDateRange(nextRange.start, nextRange.end);
setDateRange(normalized);
commitDrillContext({
...drillContext,
vehicleScope,
startDate: normalized.start,
endDate: normalized.end,
selectedDate: undefined,
orderPage: undefined,
}, 'replace');
};
const updateVehicleScope = (next: CustomerType) => {
setVehicleScope(next);
commitDrillContext({
...drillContext,
vehicleScope: next,
startDate: effectiveRange.start,
endDate: effectiveRange.end,
orderPage: undefined,
}, 'replace');
};
const clearEntity = () => {
commitDrillContext({
level: 'overview',
year: drillContext.year,
vehicleScope,
startDate: effectiveRange.start,
endDate: effectiveRange.end,
}, 'replace');
};
const openOrders = (date: string) => {
const activeElement = document.activeElement;
if (activeElement instanceof HTMLElement || activeElement instanceof SVGElement) {
orderTriggerRef.current = activeElement;
}
commitDrillContext({
...drillContext,
vehicleScope,
startDate: effectiveRange.start,
endDate: effectiveRange.end,
selectedDate: date,
orderPage: undefined,
}, selectedDate === date ? 'replace' : 'push');
window.requestAnimationFrame(() => {
const orders = document.getElementById(HYDROGEN_ORDERS_ID);
orders?.focus({ preventScroll: true });
orders?.scrollIntoView({
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
block: 'start',
});
});
};
const closeOrders = () => {
commitDrillContext({
...drillContext,
vehicleScope,
startDate: effectiveRange.start,
endDate: effectiveRange.end,
selectedDate: undefined,
orderPage: undefined,
}, 'replace');
window.requestAnimationFrame(() => orderTriggerRef.current?.focus());
};
const changeOrderPage = useCallback((page: number, mode: 'push' | 'replace') => {
commitDrillContext({
...drillContext,
orderPage: page > 1 ? page : undefined,
}, mode);
}, [commitDrillContext, drillContext]);
const retryLoad = () => {
setRows(null);
setError(null);
setRetryKey(key => key + 1);
};
useEffect(() => {
const handlePopState = () => {
const next = parseHydrogenDrillContext(window.location.search);
setDrillContext(next);
setVehicleScope(next.vehicleScope);
if (next.startDate && next.endDate) {
const normalized = normalizeDateRange(next.startDate, next.endDate);
setPick(inferDateRangeMode(normalized.start, normalized.end));
setDateRange(normalized);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
return (
<div className="flex flex-col gap-3">
<SurfaceCard className="p-2 md:p-3">
<AnalysisDateFilters
idPrefix="hydrogen"
mode={pick}
startDate={dateRange.start}
endDate={dateRange.end}
onQuickPick={applyQuickPick}
onCustom={() => setPick('custom')}
onDateChange={updateDateRange}
/>
<AnalysisScopeSwitch
ariaLabel="氢能车辆范围"
value={vehicleScope}
options={VEHICLE_SCOPE_OPTIONS}
onChange={updateVehicleScope}
icon={Truck}
className="mt-2"
/>
</SurfaceCard>
{selectedStationName && (
<section className="flex items-center gap-3 rounded-xl border border-blue-100 bg-blue-50 px-4 py-3 text-blue-700 shadow-sm">
<Building2 size={16} className="shrink-0" />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-black">{selectedStationName}</div>
<div className="mt-0.5 text-[10px] font-bold text-blue-500"> · {rangeText}</div>
</div>
<button
type="button"
onClick={clearEntity}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white text-blue-500 shadow-sm ring-1 ring-blue-100 hover:text-blue-700"
aria-label="清除站点筛选"
title="清除站点筛选"
>
<X size={14} />
</button>
</section>
)}
{selectedCustomerName && (
<section className="flex items-center gap-3 rounded-xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-emerald-700 shadow-sm">
<UserRound size={16} className="shrink-0" />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-black">{selectedCustomerName}</div>
<div className="mt-0.5 text-[10px] font-bold text-emerald-600"> · {rangeText}</div>
</div>
<button
type="button"
onClick={clearEntity}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white text-emerald-600 shadow-sm ring-1 ring-emerald-100 hover:text-emerald-800"
aria-label="清除客户筛选"
title="清除客户筛选"
>
<X size={14} />
</button>
</section>
)}
{error && (
<ErrorState
title="氢能数据源暂不可用"
message={error}
onRetry={retryLoad}
/>
)}
{!error && rows !== null && <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
<MetricTile icon={Truck} label="车辆归属" value={vehicleScope === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
</div>}
{/* 外部车辆:新系统数据还没准备好 */}
{!error && vehicleScope === 'external' && rows !== null && totalKg === 0 && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 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 relative">
<Plug size={22} className="text-blue-500" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
</div>
<div className="text-sm font-bold text-slate-700 mb-1"> · </div>
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
<br />
线
</div>
</motion.div>
)}
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
{!error && !(vehicleScope === 'external' && totalKg === 0) && trendData.length > 0 && (
<SurfaceCard>
<div className="flex items-center justify-between px-4 pt-4 mb-2">
<span className="text-sm font-bold text-slate-700"></span>
<span className="text-[11px] text-slate-400 font-bold"> · Kg</span>
</div>
<div className="mx-4 mb-2 grid grid-cols-3 gap-2 rounded-xl bg-slate-50 p-2">
<div>
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
{peakDay ? `${peakDay.date.slice(5)} · ${peakDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
</div>
</div>
<div>
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
{lowDay ? `${lowDay.date.slice(5)} · ${lowDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
</div>
</div>
<div>
<div className="text-[10px] font-black text-slate-400"></div>
<div className={`mt-0.5 text-[11px] font-black ${zeroDays > 0 ? 'text-amber-600' : 'text-emerald-600'}`}>
{zeroDays}
</div>
</div>
</div>
<div className="h-[180px] min-w-0 px-2 pb-2">
<ResponsiveContainer width="100%" height={180} minWidth={0}>
<BarChart data={trendData} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<XAxis
dataKey="date"
tickFormatter={(v: string) => v.slice(5)}
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
minTickGap={8}
/>
<YAxis
width={42}
axisLine={false}
tickLine={false}
tick={{ fontSize: 9, fill: '#94a3b8' }}
tickFormatter={(v: number) => v >= 1000 ? `${Math.round(v / 1000)}k` : `${Math.round(v)}`}
/>
<Tooltip
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']}
labelFormatter={(d) => `日期 ${d}`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
/>
{avgKg > 0 && (
<ReferenceLine
y={avgKg}
stroke="#f59e0b"
strokeDasharray="4 4"
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
/>
)}
<Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
{trendData.map(item => (
<Cell
key={item.date}
fill="url(#hydrogenBarGrad)"
stroke={selectedDate === item.date ? '#1d4ed8' : undefined}
strokeWidth={selectedDate === item.date ? 2 : undefined}
className={item.totalKg > 0 ? 'cursor-pointer outline-none focus:stroke-blue-700 focus:stroke-2' : undefined}
role={item.totalKg > 0 ? 'button' : undefined}
tabIndex={item.totalKg > 0 ? 0 : -1}
aria-expanded={item.totalKg > 0 ? selectedDate === item.date : undefined}
aria-controls={item.totalKg > 0 ? HYDROGEN_ORDERS_ID : undefined}
aria-label={`${item.date},加氢量 ${item.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg${item.totalKg > 0 ? `,查看${orderScopeLabel}加氢订单` : ',无可下钻订单'}`}
onClick={item.totalKg > 0 ? () => openOrders(item.date) : undefined}
onKeyDown={item.totalKg > 0
? event => activateDrillOnKeyDown(event, () => openOrders(item.date))
: undefined}
/>
))}
</Bar>
<defs>
<linearGradient id="hydrogenBarGrad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#22d3ee" />
<stop offset="100%" stopColor="#3b82f6" />
</linearGradient>
</defs>
</BarChart>
</ResponsiveContainer>
</div>
</SurfaceCard>
)}
{selectedDate && (
<HydrogenOrders
date={selectedDate}
dailyRow={selectedDailyRow}
page={drillContext.orderPage ?? 1}
vehicleScope={vehicleScope}
stationId={selectedStationId}
customerName={selectedCustomerName}
onPageChange={changeOrderPage}
onClose={closeOrders}
/>
)}
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
{!error && !(vehicleScope === 'external' && rows !== null && totalKg === 0) && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
{/* 表头 */}
<div className="grid grid-cols-[minmax(0,1fr)_72px_64px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
<span> / </span>
<span className="hidden md:block text-right"> (/Kg)</span>
<span className="text-right"> (Kg)</span>
<span className="text-right"></span>
</div>
{/* 合计行 */}
<div className="grid grid-cols-[minmax(0,1fr)_72px_64px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-blue-50/50 text-[12px] text-blue-600 font-bold">
<span></span>
<span className="hidden md:block" />
<span className="text-right">{totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</span>
<span />
</div>
{/* 主行 + 子行 */}
{rows === null ? (
<LoadingState label="正在加载加氢明细" variant="inline" />
) : rows.length === 0 ? (
<EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" variant="inline" />
) : rows.map(r => {
const open = expanded.has(r.date);
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
const abnormalBg = isAbnormal
? r.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
: '';
return (
<div key={r.date} className={`border-t border-slate-100 ${abnormalBg}`}>
<div className={`grid w-full grid-cols-[minmax(0,1fr)_72px_64px] gap-2 px-3 py-2.5 text-left transition-colors hover:bg-slate-50/60 md:grid-cols-[minmax(0,1fr)_140px_120px_104px] md:gap-3 ${selectedDate === r.date ? 'bg-blue-50/70' : ''}`}>
<span className="flex min-w-0 items-center gap-1">
<button
type="button"
onClick={() => toggle(r.date)}
className="flex min-w-0 items-center gap-1 text-[12px] font-bold text-slate-700"
aria-expanded={open}
aria-label={`${open ? '收起' : '展开'} ${r.date} 加氢站明细`}
>
<ChevronRight size={14} className={`shrink-0 transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
<span className="truncate">{r.date}</span>
</button>
<button
type="button"
onClick={() => openOrders(r.date)}
disabled={r.totalKg <= 0}
className="ml-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-slate-400 hover:bg-blue-100 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-25"
aria-label={`查看 ${r.date} 加氢订单`}
aria-expanded={selectedDate === r.date}
aria-controls={HYDROGEN_ORDERS_ID}
title="查看当日订单"
>
<ReceiptText size={13} />
</button>
</span>
<span className="hidden md:block text-right text-[12px] text-slate-300"></span>
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
{r.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span className="text-right"><TrendBadge value={r.chainPct} /></span>
</div>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden bg-slate-50/50"
>
{r.stations.map(s => (
<div
key={s.name}
className="grid grid-cols-[minmax(0,1fr)_72px_64px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 pl-6 md:pl-9 border-t border-slate-100 first:border-t-0 items-start"
>
<div className="min-w-0">
<div className="text-[12px] text-slate-700 font-medium whitespace-nowrap leading-snug">
{s.name}
</div>
{s.pricePerKg > 0 && (
<div className="md:hidden mt-1">
<span className="inline-flex items-center text-[10px] text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded font-bold whitespace-nowrap">
{s.pricePerKg} /Kg
</span>
</div>
)}
</div>
<span className="hidden md:block text-right text-[12px] text-slate-500 font-bold tabular-nums">{s.pricePerKg > 0 ? s.pricePerKg : '—'}</span>
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
{s.kg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span className="text-right"><TrendBadge value={s.chainPct} /></span>
</div>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
)}
{!error && <RotatingFooterHint />}
</div>
);
}
-41
View File
@@ -1,41 +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';
import BiHeaderActions from '../../components/BiHeaderActions';
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="数据单位清晰标注 · 支持日/总览切换"
actions={<BiHeaderActions domain="hydrogen" />}
>
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} idPrefix="hydrogen-view" ariaLabel="氢能分析视图" />
<AnimatePresence mode="wait">
<FadeIn key={sub}>
<div
id={`hydrogen-view-panel-${sub}`}
role="tabpanel"
aria-labelledby={`hydrogen-view-tab-${sub}`}
>
<HydrogenView sub={sub} />
</div>
</FadeIn>
</AnimatePresence>
</PageFrame>
);
}
-185
View File
@@ -1,185 +0,0 @@
import { useEffect, useState } from 'react';
import { CheckCircle2, ChevronLeft, ChevronRight, ReceiptText, TriangleAlert, X } from 'lucide-react';
import Blur from '../../components/Blur';
import { EmptyState, ErrorState, LoadingState } from '../../components/ui/surface';
import { fetchHydrogenOrders } from './api';
import type { CustomerType, HydrogenDailyRow, HydrogenOrderResponse } from './types';
import { reconcileHydrogenDaily } from './hydrogen-reconciliation';
function fmtMoney(value: number): string {
return `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export default function HydrogenOrders({
date,
dailyRow,
page,
vehicleScope,
stationId,
customerName,
onPageChange,
onClose,
}: {
date: string;
dailyRow?: HydrogenDailyRow;
page: number;
vehicleScope: CustomerType;
stationId?: number;
customerName?: string;
onPageChange: (page: number, mode: 'push' | 'replace') => void;
onClose: () => void;
}) {
const [data, setData] = useState<HydrogenOrderResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [retryKey, setRetryKey] = useState(0);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
setData(null);
fetchHydrogenOrders({ date, customer: vehicleScope, stationId, customerName, page, limit: 20 })
.then(result => {
if (cancelled) return;
if (page > result.totalPages) {
onPageChange(result.totalPages, 'replace');
return;
}
setData(result);
})
.catch(loadError => {
if (!cancelled) setError(loadError instanceof Error ? loadError.message : String(loadError));
})
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [vehicleScope, customerName, date, onPageChange, page, retryKey, stationId]);
const totalPages = data?.totalPages ?? 1;
const scope = customerName
? `客户:${customerName}`
: stationId !== undefined
? `站点 #${stationId}`
: vehicleScope === 'external' ? '外部车辆' : '羚牛车辆';
const reconciliation = data && dailyRow
? reconcileHydrogenDaily(dailyRow, data, vehicleScope, stationId, customerName)
: null;
const grainDifferences = reconciliation
? [
!reconciliation.dateMatches && '日期',
!reconciliation.scopeMatches && '车辆范围',
!reconciliation.entityMatches && '站点/客户范围',
].filter(Boolean).join('、')
: '';
return (
<section
id="hydrogen-orders"
tabIndex={-1}
className="scroll-mt-3 overflow-hidden rounded-lg border border-blue-100 bg-white shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
>
<header className="flex items-start justify-between gap-3 border-b border-blue-100 bg-blue-50/60 px-3 py-3 md:px-4">
<div className="min-w-0">
<div className="flex items-center gap-2 text-xs font-black text-blue-800">
<ReceiptText size={15} />{date}
</div>
<div className="mt-1 truncate text-[10px] font-bold text-blue-500">{scope} · </div>
</div>
<button
type="button"
onClick={onClose}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white text-blue-500 ring-1 ring-blue-100 hover:text-blue-700"
aria-label="关闭加氢订单明细"
title="关闭订单明细"
>
<X size={14} />
</button>
</header>
{data ? (
<>
<div className="grid grid-cols-3 gap-2 border-b border-slate-100 bg-slate-50 px-3 py-2 text-[10px] font-bold md:px-4">
<div className="min-w-0"><span className="text-slate-400"> / </span><div className="mt-1 break-words text-slate-700">{data.recordCount} · {data.totalKg.toLocaleString('zh-CN')} Kg</div></div>
<div className="min-w-0"><span className="text-slate-400"></span><div className="mt-1 break-all text-slate-700">{fmtMoney(data.totalCost)}</div></div>
<div className="min-w-0"><span className="text-slate-400"></span><div className="mt-1 break-all text-emerald-700">{fmtMoney(data.totalRevenue)}</div></div>
</div>
{reconciliation && (
<div
role={reconciliation.matches ? 'status' : 'alert'}
className={`flex items-start gap-2 border-b px-3 py-2 text-[10px] font-bold md:px-4 ${
reconciliation.matches
? 'border-emerald-100 bg-emerald-50 text-emerald-700'
: 'border-amber-200 bg-amber-50 text-amber-800'
}`}
>
{reconciliation.matches
? <CheckCircle2 size={14} className="mt-0.5 shrink-0" />
: <TriangleAlert size={14} className="mt-0.5 shrink-0" />}
<span>
{reconciliation.matches
? `口径核对通过:日汇总与 ${data.recordCount} 笔订单全量加氢量一致`
: `口径存在差异:加氢量 ${reconciliation.kgDifference >= 0 ? '+' : ''}${reconciliation.kgDifference.toFixed(2)} Kg${grainDifferences ? ` · ${grainDifferences}不一致` : ''}`}
</span>
</div>
)}
</>
) : null}
{error ? (
<ErrorState
title="订单数据暂不可用"
message={error}
onRetry={() => setRetryKey(key => key + 1)}
variant="inline"
/>
) : loading && !data ? (
<LoadingState label="正在加载加氢订单" variant="inline" />
) : data?.items.length === 0 ? (
<EmptyState title="当前筛选下无加氢订单" description="请切换日期、车辆归属或实体范围" variant="inline" />
) : data ? (
<>
<div className="hidden overflow-x-auto md:block">
<table className="w-full min-w-[820px] text-left text-[11px]">
<thead className="text-[10px] font-black text-slate-400">
<tr><th className="px-4 py-2"> / </th><th className="px-3 py-2"> / </th><th className="px-3 py-2 text-right"></th><th className="px-3 py-2 text-right"></th><th className="px-4 py-2 text-right"></th></tr>
</thead>
<tbody className="divide-y divide-slate-100">
{data.items.map(order => (
<tr key={order.rowNumber}>
<td className="px-4 py-3"><div className="font-bold text-slate-600">{order.refuelTime}</div><div className="mt-1 font-mono text-[10px] font-black text-slate-800"><Blur>{order.plate}</Blur></div></td>
<td className="px-3 py-3"><div className="font-bold text-slate-700"><Blur>{order.customerName}</Blur></div><div className="mt-1 text-[9px] font-bold text-slate-400">{order.stationName}</div></td>
<td className="px-3 py-3 text-right font-black text-slate-800">{order.amountKg.toLocaleString('zh-CN')} Kg</td>
<td className="px-3 py-3 text-right"><div className="font-black text-slate-800">{fmtMoney(order.costTotal)}</div><div className="mt-1 text-[9px] font-bold text-slate-400">{fmtMoney(order.costPrice)} / Kg</div></td>
<td className="px-4 py-3 text-right"><div className="font-black text-emerald-700">{fmtMoney(order.revenue)}</div><div className="mt-1 text-[9px] font-bold text-slate-400"> {fmtMoney(order.customerPrice)}</div></td>
</tr>
))}
</tbody>
</table>
</div>
<div className="divide-y divide-slate-100 md:hidden">
{data.items.map(order => (
<article key={order.rowNumber} className="px-3 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0"><div className="font-mono text-xs font-black text-slate-800"><Blur>{order.plate}</Blur></div><div className="mt-1 text-[9px] font-bold text-slate-400">{order.refuelTime}</div></div>
<div className="shrink-0 text-right text-sm font-black text-slate-800">{order.amountKg.toLocaleString('zh-CN')} Kg</div>
</div>
<div className="mt-2 truncate text-[10px] font-bold text-slate-600"><Blur>{order.customerName}</Blur> · {order.stationName}</div>
<div className="mt-2 flex justify-between gap-2 text-[9px] font-bold text-slate-400"><span> {fmtMoney(order.costTotal)}</span><span className="text-emerald-700"> {fmtMoney(order.revenue)}</span></div>
</article>
))}
</div>
</>
) : null}
{data ? (
<footer className="flex items-center justify-between border-t border-slate-100 px-3 py-3 md:px-4">
<span className="text-[10px] font-bold text-slate-400"> {page} / {totalPages} </span>
<div className="flex gap-2">
<button type="button" onClick={() => onPageChange(Math.max(1, page - 1), 'push')} disabled={page <= 1 || loading} className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 text-slate-500 disabled:opacity-30" aria-label="上一页加氢订单" title="上一页"><ChevronLeft size={14} /></button>
<button type="button" onClick={() => onPageChange(Math.min(totalPages, page + 1), 'push')} disabled={page >= totalPages || loading} className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 text-slate-500 disabled:opacity-30" aria-label="下一页加氢订单" title="下一页"><ChevronRight size={14} /></button>
</div>
</footer>
) : null}
</section>
);
}
-783
View File
@@ -1,783 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
} from 'recharts';
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw, Gauge, AlertTriangle, Building2, ChevronRight, UserRound } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { buildHydrogenDrillUrl } from './hydrogen-drill-context';
import { ErrorState } from '../../components/ui/surface';
const REGION_COLORS = [
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
'#94a3b8',
];
interface YAxisTickProps {
x?: number;
y?: number;
index?: number;
payload?: { value: string };
}
function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
return (
<g transform={`translate(${x},${y})`}>
<circle cx={-172} cy={0} r={9} fill="#3b82f6" />
<text x={-172} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
{index + 1}
</text>
<text x={-154} y={4} textAnchor="start" fontSize={11} fill="#475569">
{payload?.value}
</text>
</g>
);
}
// ---------- 数字格式化 ----------
function fmtKg(kg: number): { value: string; unit: string } {
if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' };
return { value: kg.toFixed(2), unit: 'Kg' };
}
function fmtYuan(yuan: number): { value: string; unit: string } {
const abs = Math.abs(yuan);
if (abs >= 100_000_000) return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' };
if (abs >= 10_000) {
const w = yuan / 10_000;
return { value: w.toLocaleString('zh-CN', { maximumFractionDigits: 2 }), unit: '万元' };
}
return { value: yuan.toLocaleString('zh-CN', { maximumFractionDigits: 0 }), unit: '元' };
}
// ---------- KPI 卡 ----------
interface KpiCardProps {
icon: React.ReactNode;
label: string;
hero: { value: string; unit: string };
rows: { label: string; value: string; valueClass?: string }[];
accentClass: string;
iconBg: string;
}
function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) {
return (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className={`w-7 h-7 rounded-xl flex items-center justify-center ${iconBg}`}>
{icon}
</div>
<span className="text-[11px] font-bold text-slate-500">{label}</span>
</div>
<div className="flex items-baseline gap-1">
<span className={`text-xl md:text-2xl font-black tabular-nums leading-none ${accentClass}`}>{hero.value}</span>
<span className="text-[11px] text-slate-400 font-bold">{hero.unit}</span>
</div>
<div className="space-y-0.5 pt-1 border-t border-slate-50">
{rows.map((r, i) => (
<div key={i} className="flex items-center justify-between text-[11px] font-bold">
<span className="text-slate-400">{r.label}</span>
<span className={`tabular-nums ${r.valueClass ?? 'text-slate-700'}`}>{r.value}</span>
</div>
))}
</div>
</div>
);
}
// ============================================================
export default function HydrogenOverview() {
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [year, setYear] = useState<number | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [lastRefreshAt, setLastRefreshAt] = useState<number>(0);
const refreshSeq = useRef(0);
const load = useCallback(async (selectedYear: number | null, force: boolean) => {
const seq = ++refreshSeq.current;
setRefreshing(true);
try {
const d = await fetchHydrogenOverview(selectedYear ?? undefined, force);
if (seq !== refreshSeq.current) return; // outdated
setData(d);
setError(null);
setLastRefreshAt(Date.now());
} catch (e) {
if (seq !== refreshSeq.current) return;
setError(e instanceof Error ? e.message : String(e));
} finally {
if (seq === refreshSeq.current) setRefreshing(false);
}
}, []);
// 初始加载 + 年份切换:用 force=false 命中热缓存
useEffect(() => { void load(year, false); }, [year, load]);
// 客户端兜底自动刷新:每 60s 静默拉一次(命中后端热缓存,几乎零成本)
useEffect(() => {
const t = setInterval(() => { void load(year, false); }, 60_000);
return () => clearInterval(t);
}, [year, load]);
if (error && !data) {
return (
<ErrorState
title="氢能数据源暂不可用"
message={error}
onRetry={() => void load(year, true)}
retrying={refreshing}
/>
);
}
if (!data) {
return <HydrogenOverviewSkeleton />;
}
const k = data.kpi;
const top5 = data.top5;
const regions = data.regions;
const monthly = data.monthly;
const customers = data.customers;
const stations = data.stations;
const availableYears = data.availableYears;
const activeYear = data.year;
const yearKgFmt = fmtKg(k.yearKg);
const yearFeeFmt = fmtYuan(k.yearFee);
const yearProfitFmt = fmtYuan(k.yearProfit);
const ourYearKgFmt = fmtKg(k.ourYearKg);
const customerYearKgFmt = fmtKg(k.customerYearKg);
const monthKgFmt = fmtKg(k.monthKg);
const monthFeeFmt = fmtYuan(k.monthFee);
const todayKgFmt = fmtKg(k.todayKg);
const todayFeeFmt = fmtYuan(k.todayFee);
const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee);
const customerYearFeeFmt = fmtYuan(customerYearFee);
const yearRevenueFmt = fmtYuan(k.yearRevenue);
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
const monthAvgKg = monthly.length > 0 ? monthly.reduce((sum, m) => sum + m.kg, 0) / monthly.length : 0;
const bestMonth = monthly.reduce<typeof monthly[number] | null>((best, item) => (!best || item.kg > best.kg ? item : best), null);
const latestMonth = monthly[monthly.length - 1];
const prevMonth = monthly[monthly.length - 2];
const monthMomentum = latestMonth && prevMonth && prevMonth.kg > 0 ? (latestMonth.kg - prevMonth.kg) / prevMonth.kg * 100 : null;
const top5Share = top5.reduce((sum, item) => sum + item.kg, 0) / Math.max(1, k.yearKg) * 100;
const profitYield = k.yearRevenue > 0 ? k.yearProfit / k.yearRevenue * 100 : 0;
const stationAvgKg = stations.length > 0 ? k.yearKg / stations.length : 0;
const openStation = (station: typeof stations[number]) => {
const now = new Date();
const endDate = activeYear === now.getFullYear()
? `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
: `${activeYear}-12-31`;
const url = buildHydrogenDrillUrl(
{ pathname: window.location.pathname, search: window.location.search, hash: '#hydrogen' },
{
level: 'station',
year: activeYear,
stationId: station.id,
stationName: station.name,
vehicleScope: 'lingniu',
startDate: `${activeYear}-01-01`,
endDate,
},
);
window.history.pushState(null, '', url);
window.dispatchEvent(new HashChangeEvent('hashchange'));
};
const openCustomer = (customer: typeof customers[number]) => {
const now = new Date();
const endDate = activeYear === now.getFullYear()
? `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
: `${activeYear}-12-31`;
const url = buildHydrogenDrillUrl(
{ pathname: window.location.pathname, search: window.location.search, hash: '#hydrogen' },
{
level: 'customer',
year: activeYear,
customerName: customer.name,
vehicleScope: 'lingniu',
startDate: `${activeYear}-01-01`,
endDate,
},
);
window.history.pushState(null, '', url);
window.dispatchEvent(new HashChangeEvent('hashchange'));
};
// 月度收支组合数据(推算"年内每月"图)
const monthlyDual = monthly.map(m => ({
...m,
monthLabel: m.month.slice(5).replace(/^0/, '') + '月',
}));
return (
<div className="flex flex-col gap-3 relative">
{/* 顶部说明条 + 年份切换 + 刷新按钮 */}
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
<span className="truncate">{lastRefreshAt ? `更新于 ${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={() => setYear(y)}
className={`px-2 py-0.5 text-[11px] font-bold rounded-md transition-all ${
active ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'
}`}
>
{y}
</button>
);
})}
</div>
<button
onClick={() => void load(year, true)}
disabled={refreshing}
className="flex items-center gap-1 px-2 py-0.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
title="手动刷新(绕过缓存)"
>
<RefreshCw size={11} className={refreshing ? 'animate-spin' : ''} strokeWidth={2.6} />
<span className="text-[11px] font-bold"></span>
</button>
</div>
</div>
{/* KPI 5 卡 */}
<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: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.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>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 md:gap-3">
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-lg font-black text-slate-900">
{monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`}
</div>
</div>
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
<Gauge size={18} />
</span>
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
{latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'}
{bestMonth ? ` · 峰值 ${bestMonth.month}` : ''}
{monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''}
</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-lg font-black text-slate-900">Top5 {top5Share.toFixed(1)}%</div>
</div>
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-cyan-50 text-cyan-600 ring-1 ring-cyan-100">
<Building2 size={18} />
</span>
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
{stations.length} · {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit}
{top5Share >= 70 ? ' · 头部站点依赖偏高' : ' · 分布相对健康'}
</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-[11px] font-black text-slate-400"></div>
<div className={`mt-1 text-lg font-black ${profitYield >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{profitYield.toFixed(1)}%
</div>
</div>
<span className={`flex h-9 w-9 items-center justify-center rounded-xl ring-1 ${profitYield >= 0 ? 'bg-emerald-50 text-emerald-600 ring-emerald-100' : 'bg-rose-50 text-rose-600 ring-rose-100'}`}>
<AlertTriangle size={18} />
</span>
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
{yearProfitFmt.value}{yearProfitFmt.unit} · {yearRevenueFmt.value}{yearRevenueFmt.unit}
{profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'}
</div>
</div>
</div>
{/* 月度趋势:年内每月加氢量 */}
{monthly.length > 0 && (
<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>
)}
{/* 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 max-w-[180px]">
<button
type="button"
onClick={() => openStation(s)}
className="flex max-w-full items-center gap-1 text-left font-bold text-slate-700 hover:text-blue-600"
title={`查看 ${s.name} 每日明细`}
>
<span className="truncate">{s.name}</span>
<ChevronRight size={12} className="shrink-0 text-slate-300" />
</button>
</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 max-w-[200px]">
<button
type="button"
onClick={() => openCustomer(c2)}
className="flex max-w-full items-center gap-1 text-left font-bold text-slate-700 hover:text-blue-600"
title={`查看 ${c2.name} 每日明细`}
>
<UserRound size={12} className="shrink-0 text-slate-300" />
<span className="truncate">{c2.name}</span>
<ChevronRight size={12} className="shrink-0 text-slate-300" />
</button>
</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 />
{/* 刷新中:透明遮罩 + 顶部进度条(不替换内容,避免闪烁) */}
<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 formatRefreshTime(ts: number): string {
const exactTime = new Date(ts).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
return `${formatRelative(ts)} · ${exactTime.replace(/\//g, '-')}`;
}
function HydrogenOverviewSkeleton() {
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>
);
}
-12
View File
@@ -1,12 +0,0 @@
import HydrogenOverview from './HydrogenOverview';
import HydrogenDaily from './HydrogenDaily';
export type HydrogenSubTab = 'daily' | 'overview';
interface Props {
sub: HydrogenSubTab;
}
export default function HydrogenView({ sub }: Props) {
return sub === 'overview' ? <HydrogenOverview /> : <HydrogenDaily />;
}
-24
View File
@@ -1,24 +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;
idPrefix: string;
ariaLabel: string;
}
export default function SubTabs<T extends string>({ tabs, active, onChange, idPrefix, ariaLabel }: 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} idPrefix={idPrefix} ariaLabel={ariaLabel} />
</div>
);
}
-24
View File
@@ -1,24 +0,0 @@
import { ArrowUp, ArrowDown, Minus } from 'lucide-react';
export interface TrendBadgeProps {
value: number; // -1..+1, 0 表示持平
className?: string;
}
export default function TrendBadge({ value, className = '' }: TrendBadgeProps) {
const isUp = value > 0.0001;
const isDown = value < -0.0001;
const cls = isUp
? 'bg-emerald-50 text-emerald-600'
: isDown
? 'bg-red-50 text-red-600'
: 'bg-slate-100 text-slate-500';
const Icon = isUp ? ArrowUp : isDown ? ArrowDown : Minus;
const sign = isUp ? '+' : '';
return (
<span className={`inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-[11px] font-bold ${cls} ${className}`}>
<Icon size={11} />
{sign}{(value * 100).toFixed(2)}%
</span>
);
}
-126
View File
@@ -1,126 +0,0 @@
import { fetchJson } from '../../auth/api-client';
import type {
HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenMonthlyPoint, HydrogenDailyRow,
HydrogenOrderResponse,
HydrogenCustomerRow, HydrogenStationFull,
ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
ElectricChargeOrderResponse,
EtcOverviewResponse,
EtcBillResponse, EtcTollRecordResponse,
CustomerType, DateQuickPick, ElectricVehicleScope,
} from './types';
const BASE = '/api/energy';
export interface HydrogenOverviewResponse {
kpi: HydrogenKpi;
top5: HydrogenStationTop[];
regions: HydrogenRegionShare[];
monthly: HydrogenMonthlyPoint[];
customers: HydrogenCustomerRow[];
stations: HydrogenStationFull[];
availableYears: number[];
year: number;
}
export function fetchHydrogenOverview(year?: number, force = false): Promise<HydrogenOverviewResponse> {
const params = new URLSearchParams();
if (year) params.set('year', String(year));
if (force) params.set('force', '1');
const q = params.toString();
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`);
}
export interface HydrogenDailyQuery {
range?: DateQuickPick;
startDate?: string;
endDate?: string;
stationId?: number;
customerName?: string;
}
export function fetchHydrogenDaily(query: HydrogenDailyQuery, vehicleScope: CustomerType): Promise<HydrogenDailyRow[]> {
const q = new URLSearchParams({ customer: vehicleScope });
if (query.range) q.set('range', query.range);
if (query.startDate) q.set('startDate', query.startDate);
if (query.endDate) q.set('endDate', query.endDate);
if (query.stationId !== undefined) q.set('stationId', String(query.stationId));
if (query.customerName) q.set('customerName', query.customerName);
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
}
export interface HydrogenOrderQuery {
date: string;
customer: CustomerType;
stationId?: number;
customerName?: string;
page?: number;
limit?: number;
}
export function fetchHydrogenOrders(query: HydrogenOrderQuery): Promise<HydrogenOrderResponse> {
const params = new URLSearchParams({
date: query.date,
customer: query.customer,
page: String(query.page ?? 1),
limit: String(query.limit ?? 20),
});
if (query.stationId !== undefined) params.set('stationId', String(query.stationId));
if (query.customerName) params.set('customerName', query.customerName);
return fetchJson<HydrogenOrderResponse>(`${BASE}/hydrogen/orders?${params}`);
}
export interface ElectricOverviewResponse {
kpi: ElectricKpi;
trend: ElectricDailyRow[];
latestRecordAt: string | null;
}
export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
}
export function fetchElectricMonthly(vehicleScope: ElectricVehicleScope, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> {
const q = new URLSearchParams({ customer: vehicleScope });
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()}`);
}
export function fetchElectricOrders(date: string, vehicleScope: ElectricVehicleScope): Promise<ElectricChargeOrderResponse> {
const q = new URLSearchParams({ date, customer: vehicleScope });
return fetchJson<ElectricChargeOrderResponse>(`${BASE}/electric/orders?${q.toString()}`);
}
export function fetchEtcOverview(force = false): Promise<EtcOverviewResponse> {
return fetchJson<EtcOverviewResponse>(`${BASE}/etc/overview${force ? '?force=1' : ''}`);
}
export interface EtcListQuery {
page?: number;
limit?: number;
startDate?: string;
endDate?: string;
search?: string;
}
function etcListParams(query: EtcListQuery): string {
const params = new URLSearchParams();
if (query.page) params.set('page', String(query.page));
if (query.limit) params.set('limit', String(query.limit));
if (query.startDate) params.set('startDate', query.startDate);
if (query.endDate) params.set('endDate', query.endDate);
if (query.search) params.set('search', query.search);
return params.toString();
}
export function fetchEtcRecords(query: EtcListQuery): Promise<EtcTollRecordResponse> {
const params = etcListParams(query);
return fetchJson<EtcTollRecordResponse>(`${BASE}/etc/records${params ? `?${params}` : ''}`);
}
export function fetchEtcBills(query: EtcListQuery): Promise<EtcBillResponse> {
const params = etcListParams(query);
return fetchJson<EtcBillResponse>(`${BASE}/etc/bills${params ? `?${params}` : ''}`);
}
-33
View File
@@ -1,33 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
dateRangeModeLabel,
getQuickDateRange,
inferDateRangeMode,
normalizeDateRange,
} from './date-range.js';
const FRIDAY = new Date(2026, 7, 7, 16, 30, 0);
test('builds shared quick date ranges from local calendar days', () => {
assert.deepEqual(getQuickDateRange('thisWeek', FRIDAY), { start: '2026-08-03', end: '2026-08-07' });
assert.deepEqual(getQuickDateRange('thisMonth', FRIDAY), { start: '2026-08-01', end: '2026-08-07' });
assert.deepEqual(getQuickDateRange('last15', FRIDAY), { start: '2026-07-24', end: '2026-08-07' });
});
test('normalizes date order and publishes consistent labels', () => {
assert.deepEqual(normalizeDateRange('2026-08-07', '2026-08-01'), { start: '2026-08-01', end: '2026-08-07' });
assert.equal(dateRangeModeLabel('thisMonth'), '本月');
assert.equal(dateRangeModeLabel('custom'), '自定义区间');
});
test('restores a quick mode only while URL dates still match that calendar range', () => {
assert.equal(inferDateRangeMode('2026-08-03', '2026-08-07', FRIDAY), 'thisWeek');
assert.equal(inferDateRangeMode('2026-08-01', '2026-08-07', FRIDAY), 'thisMonth');
assert.equal(inferDateRangeMode('2026-08-07', '2026-07-24', FRIDAY), 'last15');
assert.equal(inferDateRangeMode('2026-08-01', '2026-08-06', FRIDAY), 'custom');
assert.equal(
inferDateRangeMode('2026-08-01', '2026-08-07', new Date(2026, 7, 8, 9, 0, 0)),
'custom',
);
});
-59
View File
@@ -1,59 +0,0 @@
import type { DateQuickPick } from './types';
export type DateRangeMode = DateQuickPick | 'custom';
export interface DateRangeValue {
start: string;
end: string;
}
const LABELS: Record<DateQuickPick, string> = {
thisWeek: '本周',
thisMonth: '本月',
last15: '近 15 天',
};
function fmtYmd(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;
}
export function getQuickDateRange(pick: DateQuickPick, now = new Date()): DateRangeValue {
const today = new Date(now);
today.setHours(0, 0, 0, 0);
if (pick === 'thisWeek') {
const day = today.getDay() || 7;
return { start: fmtYmd(addDays(today, -(day - 1))), end: fmtYmd(today) };
}
if (pick === 'thisMonth') {
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end: fmtYmd(today) };
}
return { start: fmtYmd(addDays(today, -14)), end: fmtYmd(today) };
}
export function normalizeDateRange(start: string, end: string): DateRangeValue {
return start <= end ? { start, end } : { start: end, end: start };
}
export function inferDateRangeMode(start: string, end: string, now = new Date()): DateRangeMode {
const range = normalizeDateRange(start, end);
const match = QUICK_DATE_OPTIONS.find(({ id }) => {
const quickRange = getQuickDateRange(id, now);
return quickRange.start === range.start && quickRange.end === range.end;
});
return match?.id ?? 'custom';
}
export function dateRangeModeLabel(mode: DateRangeMode): string {
return mode === 'custom' ? '自定义区间' : LABELS[mode];
}
export const QUICK_DATE_OPTIONS = (Object.keys(LABELS) as DateQuickPick[]).map(id => ({
id,
label: LABELS[id],
}));
@@ -1,25 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { activateDrillOnKeyDown } from './drill-interaction.js';
test('activates drilldown with Enter and Space only', () => {
for (const key of ['Enter', ' ']) {
let prevented = false;
let activations = 0;
const handled = activateDrillOnKeyDown(
{ key, preventDefault: () => { prevented = true; } },
() => { activations += 1; },
);
assert.equal(handled, true);
assert.equal(prevented, true);
assert.equal(activations, 1);
}
let activations = 0;
const handled = activateDrillOnKeyDown(
{ key: 'ArrowRight', preventDefault: () => assert.fail('must not prevent navigation keys') },
() => { activations += 1; },
);
assert.equal(handled, false);
assert.equal(activations, 0);
});
-11
View File
@@ -1,11 +0,0 @@
export interface DrillKeyEvent {
key: string;
preventDefault: () => void;
}
export function activateDrillOnKeyDown(event: DrillKeyEvent, activate: () => void): boolean {
if (event.key !== 'Enter' && event.key !== ' ') return false;
event.preventDefault();
activate();
return true;
}
@@ -1,47 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildElectricDrillUrl,
parseElectricDrillContext,
} from './electric-drill-context.js';
test('parses electric scope, range, and selected day', () => {
assert.deepEqual(
parseElectricDrillContext('?electricScope=external&electricStart=2026-07-01&electricEnd=2026-07-31&electricDate=2026-07-24'),
{
vehicleScope: 'external',
startDate: '2026-07-01',
endDate: '2026-07-31',
selectedDate: '2026-07-24',
},
);
});
test('drops invalid electric dates and defaults scope', () => {
assert.deepEqual(
parseElectricDrillContext('?electricScope=unknown&electricDate=2026-02-31'),
{ vehicleScope: 'lingniu' },
);
});
test('preserves the all-vehicle scope for overview drilldown', () => {
assert.deepEqual(
parseElectricDrillContext('?electricScope=all&electricDate=2026-07-28'),
{ vehicleScope: 'all', selectedDate: '2026-07-28' },
);
});
test('builds namespaced electric drill URL and preserves unrelated filters', () => {
assert.equal(
buildElectricDrillUrl(
{ pathname: '/energy', search: '?company=5&electricDate=2026-01-01', hash: '#electric' },
{
vehicleScope: 'lingniu',
startDate: '2026-07-24',
endDate: '2026-08-07',
selectedDate: '2026-07-24',
},
),
'/energy?company=5&electricScope=lingniu&electricStart=2026-07-24&electricEnd=2026-08-07&electricDate=2026-07-24#electric',
);
});
@@ -1,58 +0,0 @@
import type { ElectricVehicleScope } from './types';
export interface ElectricDrillContext {
vehicleScope: ElectricVehicleScope;
startDate?: string;
endDate?: string;
selectedDate?: string;
}
interface LocationParts {
pathname: string;
search: string;
hash: string;
}
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
function validDate(value: string | null): string | undefined {
if (!value || !DATE_PATTERN.test(value)) return undefined;
const [year, month, day] = value.split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
return date.getUTCFullYear() === year
&& date.getUTCMonth() === month - 1
&& date.getUTCDate() === day
? value
: undefined;
}
export function parseElectricDrillContext(search: string): ElectricDrillContext {
const params = new URLSearchParams(search);
const scope = params.get('electricScope');
const context: ElectricDrillContext = {
vehicleScope: scope === 'external' || scope === 'all' ? scope : 'lingniu',
};
const startDate = validDate(params.get('electricStart'));
const endDate = validDate(params.get('electricEnd'));
const selectedDate = validDate(params.get('electricDate'));
if (startDate) context.startDate = startDate;
if (endDate) context.endDate = endDate;
if (selectedDate) context.selectedDate = selectedDate;
return context;
}
export function buildElectricDrillUrl(
location: LocationParts,
context: ElectricDrillContext,
): string {
const params = new URLSearchParams(location.search);
for (const key of ['electricScope', 'electricStart', 'electricEnd', 'electricDate']) {
params.delete(key);
}
params.set('electricScope', context.vehicleScope);
if (context.startDate) params.set('electricStart', context.startDate);
if (context.endDate) params.set('electricEnd', context.endDate);
if (context.selectedDate) params.set('electricDate', context.selectedDate);
const query = params.toString();
return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
}
@@ -1,44 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { reconcileElectricDaily } from './electric-reconciliation.js';
import type { ElectricChargeOrderResponse, ElectricDailyRow } from './types.js';
const daily: ElectricDailyRow = {
date: '2026-07-01',
kwh: 2736.67,
fee: 552.92,
chainPct: 0,
};
const orders: ElectricChargeOrderResponse = {
date: '2026-07-01',
vehicleScope: 'all',
recordCount: 29,
totalKwh: 2736.67,
totalFee: 552.92,
truncated: false,
items: [],
};
test('matches a daily aggregate to the full order summary at hundredth precision', () => {
assert.deepEqual(reconcileElectricDaily(daily, orders, 'all'), {
matches: true,
dateMatches: true,
scopeMatches: true,
kwhDifference: 0,
feeDifference: 0,
});
});
test('reports amount, date, and scope differences without averaging detail rows', () => {
const result = reconcileElectricDaily(
daily,
{ ...orders, date: '2026-07-02', vehicleScope: 'lingniu', totalKwh: 2736.68, totalFee: 550 },
'all',
);
assert.equal(result.matches, false);
assert.equal(result.dateMatches, false);
assert.equal(result.scopeMatches, false);
assert.equal(result.kwhDifference, 0.01);
assert.equal(result.feeDifference, -2.92);
});
@@ -1,35 +0,0 @@
import type { ElectricChargeOrderResponse, ElectricDailyRow, ElectricVehicleScope } from './types';
export interface ElectricDailyReconciliation {
matches: boolean;
dateMatches: boolean;
scopeMatches: boolean;
kwhDifference: number;
feeDifference: number;
}
function hundredths(value: number): number {
return Math.round(value * 100);
}
export function reconcileElectricDaily(
daily: ElectricDailyRow,
orders: ElectricChargeOrderResponse,
expectedScope: ElectricVehicleScope,
): ElectricDailyReconciliation {
const dateMatches = daily.date === orders.date;
const scopeMatches = expectedScope === orders.vehicleScope;
const kwhDifferenceInHundredths = hundredths(orders.totalKwh) - hundredths(daily.kwh);
const feeDifferenceInHundredths = hundredths(orders.totalFee) - hundredths(daily.fee);
return {
matches: dateMatches
&& scopeMatches
&& kwhDifferenceInHundredths === 0
&& feeDifferenceInHundredths === 0,
dateMatches,
scopeMatches,
kwhDifference: kwhDifferenceInHundredths / 100,
feeDifference: feeDifferenceInHundredths / 100,
};
}
@@ -1,40 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildEtcDrillUrl, parseEtcDrillContext } from './etc-drill-context.js';
test('parses and normalizes the ETC analysis context', () => {
assert.deepEqual(
parseEtcDrillContext('?etcView=bills&etcStart=2026-08-07&etcEnd=2026-08-01&etcSearch=%20%E5%AE%A2%E6%88%B7A%20&etcPage=3'),
{
view: 'bills',
startDate: '2026-08-01',
endDate: '2026-08-07',
search: '客户A',
page: 3,
},
);
});
test('rejects invalid ETC URL state', () => {
assert.deepEqual(
parseEtcDrillContext(`?etcView=unknown&etcStart=2026-02-31&etcSearch=${'x'.repeat(129)}&etcPage=-2`),
{ view: 'records', startDate: '', endDate: '', search: '', page: 1 },
);
});
test('builds ETC state without dropping unrelated URL context', () => {
assert.equal(
buildEtcDrillUrl({
pathname: '/energy',
search: '?electricScope=all&etcView=records',
hash: '#etc',
}, {
view: 'bills',
startDate: '2026-08-01',
endDate: '2026-08-07',
search: '客户A',
page: 2,
}),
'/energy?electricScope=all&etcView=bills&etcStart=2026-08-01&etcEnd=2026-08-07&etcSearch=%E5%AE%A2%E6%88%B7A&etcPage=2#etc',
);
});
-63
View File
@@ -1,63 +0,0 @@
export type EtcDetailView = 'records' | 'bills';
export interface EtcDrillContext {
view: EtcDetailView;
startDate: string;
endDate: string;
search: string;
page: number;
}
interface LocationParts {
pathname: string;
search: string;
hash: string;
}
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
function validDate(value: string | null): string {
if (!value || !DATE_PATTERN.test(value)) return '';
const [year, month, day] = value.split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
return date.getUTCFullYear() === year
&& date.getUTCMonth() === month - 1
&& date.getUTCDate() === day
? value
: '';
}
function validPage(value: string | null): number {
if (!value || !/^\d+$/.test(value)) return 1;
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 1;
}
export function parseEtcDrillContext(search: string): EtcDrillContext {
const params = new URLSearchParams(search);
const rawSearch = params.get('etcSearch')?.trim() ?? '';
const firstDate = validDate(params.get('etcStart'));
const secondDate = validDate(params.get('etcEnd'));
const startDate = firstDate && secondDate && firstDate > secondDate ? secondDate : firstDate;
const endDate = firstDate && secondDate && firstDate > secondDate ? firstDate : secondDate;
return {
view: params.get('etcView') === 'bills' ? 'bills' : 'records',
startDate,
endDate,
search: rawSearch.length <= 128 ? rawSearch : '',
page: validPage(params.get('etcPage')),
};
}
export function buildEtcDrillUrl(location: LocationParts, context: EtcDrillContext): string {
const params = new URLSearchParams(location.search);
for (const key of ['etcView', 'etcStart', 'etcEnd', 'etcSearch', 'etcPage']) params.delete(key);
params.set('etcView', context.view);
if (context.startDate) params.set('etcStart', context.startDate);
if (context.endDate) params.set('etcEnd', context.endDate);
const search = context.search.trim();
if (search && search.length <= 128) params.set('etcSearch', search);
if (context.page > 1) params.set('etcPage', String(context.page));
const query = params.toString();
return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
}
@@ -1,69 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { reconcileEtcBills, reconcileEtcRecords } from './etc-reconciliation.js';
import type { EtcBillResponse, EtcOverviewResponse, EtcTollRecordResponse } from './types.js';
const kpi: EtcOverviewResponse['kpi'] = {
passageCount: 12,
vehicleCount: 4,
tollAmount: 100.01,
serviceFee: 5.02,
totalAmount: 105.03,
billCount: 2,
receivableAmount: 105.03,
paidAmount: 80,
latestTransactionTime: '2026-07-01 12:00:00',
};
const records: EtcTollRecordResponse = {
page: 1,
limit: 20,
total: 12,
totalPages: 1,
filters: { startDate: null, endDate: null, search: '' },
summary: { vehicleCount: 4, tollAmount: 100.01, serviceFee: 5.02, totalAmount: 105.03 },
items: [],
};
const bills: EtcBillResponse = {
page: 1,
limit: 20,
total: 2,
totalPages: 1,
filters: { startDate: null, endDate: null, search: '' },
summary: { receivableAmount: 105.03, paidAmount: 80 },
items: [],
};
test('matches ETC overview against full record and bill summaries', () => {
assert.equal(reconcileEtcRecords(kpi, records).matches, true);
assert.equal(reconcileEtcBills(kpi, bills).matches, true);
});
test('reports record count, vehicle, and fee differences at hundredth precision', () => {
assert.deepEqual(reconcileEtcRecords(kpi, {
...records,
total: 13,
summary: { vehicleCount: 3, tollAmount: 100, serviceFee: 5.04, totalAmount: 105.04 },
}), {
matches: false,
passageCountDifference: 1,
vehicleCountDifference: -1,
tollAmountDifference: -0.01,
serviceFeeDifference: 0.02,
totalAmountDifference: 0.01,
});
});
test('reports bill count and settlement differences at hundredth precision', () => {
assert.deepEqual(reconcileEtcBills(kpi, {
...bills,
total: 1,
summary: { receivableAmount: 100, paidAmount: 80.01 },
}), {
matches: false,
billCountDifference: -1,
receivableAmountDifference: -5.03,
paidAmountDifference: 0.01,
});
});
-61
View File
@@ -1,61 +0,0 @@
import type { EtcBillResponse, EtcOverviewResponse, EtcTollRecordResponse } from './types';
export interface EtcRecordReconciliation {
matches: boolean;
passageCountDifference: number;
vehicleCountDifference: number;
tollAmountDifference: number;
serviceFeeDifference: number;
totalAmountDifference: number;
}
export interface EtcBillReconciliation {
matches: boolean;
billCountDifference: number;
receivableAmountDifference: number;
paidAmountDifference: number;
}
function hundredths(value: number): number {
return Math.round(value * 100);
}
export function reconcileEtcRecords(
kpi: EtcOverviewResponse['kpi'],
records: EtcTollRecordResponse,
): EtcRecordReconciliation {
const tollDifference = hundredths(records.summary.tollAmount) - hundredths(kpi.tollAmount);
const serviceFeeDifference = hundredths(records.summary.serviceFee) - hundredths(kpi.serviceFee);
const totalDifference = hundredths(records.summary.totalAmount) - hundredths(kpi.totalAmount);
const passageCountDifference = records.total - kpi.passageCount;
const vehicleCountDifference = records.summary.vehicleCount - kpi.vehicleCount;
return {
matches: passageCountDifference === 0
&& vehicleCountDifference === 0
&& tollDifference === 0
&& serviceFeeDifference === 0
&& totalDifference === 0,
passageCountDifference,
vehicleCountDifference,
tollAmountDifference: tollDifference / 100,
serviceFeeDifference: serviceFeeDifference / 100,
totalAmountDifference: totalDifference / 100,
};
}
export function reconcileEtcBills(
kpi: EtcOverviewResponse['kpi'],
bills: EtcBillResponse,
): EtcBillReconciliation {
const receivableDifference = hundredths(bills.summary.receivableAmount) - hundredths(kpi.receivableAmount);
const paidDifference = hundredths(bills.summary.paidAmount) - hundredths(kpi.paidAmount);
const billCountDifference = bills.total - kpi.billCount;
return {
matches: billCountDifference === 0 && receivableDifference === 0 && paidDifference === 0,
billCountDifference,
receivableAmountDifference: receivableDifference / 100,
paidAmountDifference: paidDifference / 100,
};
}
@@ -1,111 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildHydrogenDrillUrl,
parseHydrogenDrillContext,
} from './hydrogen-drill-context.js';
test('parses a station drill context including station zero', () => {
assert.deepEqual(
parseHydrogenDrillContext('?hydrogenLevel=station&hydrogenStation=0&hydrogenStationName=%E6%9C%AA%E5%85%B3%E8%81%94%E7%AB%99%E7%82%B9&hydrogenScope=lingniu&hydrogenStart=2026-08-01&hydrogenEnd=2026-08-07'),
{
level: 'station',
stationId: 0,
stationName: '未关联站点',
vehicleScope: 'lingniu',
startDate: '2026-08-01',
endDate: '2026-08-07',
},
);
});
test('requires a valid entity for station and customer levels', () => {
assert.equal(parseHydrogenDrillContext('?hydrogenLevel=station&hydrogenStation=-1').level, 'overview');
assert.equal(parseHydrogenDrillContext('?hydrogenLevel=customer').level, 'overview');
assert.equal(
parseHydrogenDrillContext(`?hydrogenLevel=customer&hydrogenCustomer=${'x'.repeat(129)}`).level,
'overview',
);
});
test('parses and builds a customer drill context', () => {
const context = parseHydrogenDrillContext(
'?hydrogenLevel=customer&hydrogenCustomer=%E6%AD%A6%E6%B1%89%E5%AE%A2%E6%88%B7&hydrogenScope=lingniu&hydrogenStart=2026-01-01&hydrogenEnd=2026-08-07',
);
assert.deepEqual(context, {
level: 'customer',
customerName: '武汉客户',
vehicleScope: 'lingniu',
startDate: '2026-01-01',
endDate: '2026-08-07',
});
assert.equal(
buildHydrogenDrillUrl(
{ pathname: '/energy', search: '', hash: '#hydrogen' },
{ ...context, year: 2026 },
),
'/energy?hydrogenLevel=customer&hydrogenYear=2026&hydrogenCustomer=%E6%AD%A6%E6%B1%89%E5%AE%A2%E6%88%B7&hydrogenScope=lingniu&hydrogenStart=2026-01-01&hydrogenEnd=2026-08-07#hydrogen',
);
});
test('builds a namespaced station URL and preserves unrelated parameters', () => {
const url = buildHydrogenDrillUrl(
{ pathname: '/energy', search: '?company=5', hash: '#hydrogen' },
{
level: 'station',
year: 2026,
stationId: 18,
stationName: '测试加氢站',
vehicleScope: 'lingniu',
startDate: '2026-08-01',
endDate: '2026-08-07',
},
);
assert.equal(
url,
'/energy?company=5&hydrogenLevel=station&hydrogenYear=2026&hydrogenStation=18&hydrogenStationName=%E6%B5%8B%E8%AF%95%E5%8A%A0%E6%B0%A2%E7%AB%99&hydrogenScope=lingniu&hydrogenStart=2026-08-01&hydrogenEnd=2026-08-07#hydrogen',
);
});
test('clears station parameters when returning to overview', () => {
const url = buildHydrogenDrillUrl(
{
pathname: '/energy',
search: '?hydrogenLevel=station&hydrogenStation=18&hydrogenStationName=test&company=5',
hash: '#hydrogen',
},
{ level: 'overview', vehicleScope: 'lingniu' },
);
assert.equal(url, '/energy?company=5&hydrogenScope=lingniu#hydrogen');
});
test('preserves a selected order date and page in the hydrogen analysis URL', () => {
const context = parseHydrogenDrillContext(
'?hydrogenScope=lingniu&hydrogenStart=2026-08-01&hydrogenEnd=2026-08-07&hydrogenDate=2026-08-05&hydrogenPage=3',
);
assert.equal(context.selectedDate, '2026-08-05');
assert.equal(context.orderPage, 3);
assert.equal(
buildHydrogenDrillUrl(
{ pathname: '/energy', search: '?company=5', hash: '#hydrogen' },
context,
),
'/energy?company=5&hydrogenScope=lingniu&hydrogenStart=2026-08-01&hydrogenEnd=2026-08-07&hydrogenDate=2026-08-05&hydrogenPage=3#hydrogen',
);
assert.equal(
parseHydrogenDrillContext('?hydrogenStart=2026-08-01&hydrogenEnd=2026-08-07&hydrogenDate=2026-08-08').selectedDate,
undefined,
);
assert.equal(parseHydrogenDrillContext('?hydrogenPage=3').orderPage, undefined);
assert.equal(parseHydrogenDrillContext('?hydrogenDate=2026-08-05&hydrogenPage=0').orderPage, undefined);
assert.equal(
buildHydrogenDrillUrl(
{ pathname: '/energy', search: '', hash: '#hydrogen' },
{ level: 'overview', vehicleScope: 'lingniu', orderPage: 3 },
),
'/energy?hydrogenScope=lingniu#hydrogen',
);
});
@@ -1,123 +0,0 @@
import type { CustomerType } from './types';
export type HydrogenDrillLevel = 'overview' | 'station' | 'customer';
export interface HydrogenDrillContext {
level: HydrogenDrillLevel;
year?: number;
stationId?: number;
stationName?: string;
customerName?: string;
vehicleScope: CustomerType;
startDate?: string;
endDate?: string;
selectedDate?: string;
orderPage?: number;
}
interface LocationParts {
pathname: string;
search: string;
hash: string;
}
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
function validDate(value: string | null): string | undefined {
if (!value || !DATE_PATTERN.test(value)) return undefined;
const timestamp = Date.parse(`${value}T00:00:00+08:00`);
return Number.isFinite(timestamp) ? value : undefined;
}
function positiveInteger(value: string | null): number | undefined {
if (!value || !/^\d+$/.test(value)) return undefined;
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
}
function stationId(value: string | null): number | undefined {
if (!value || !/^\d+$/.test(value)) return undefined;
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
}
export function parseHydrogenDrillContext(search: string): HydrogenDrillContext {
const params = new URLSearchParams(search);
const requestedLevel = params.get('hydrogenLevel');
const parsedStationId = stationId(params.get('hydrogenStation'));
const parsedStationName = params.get('hydrogenStationName')?.trim() || undefined;
const rawCustomerName = params.get('hydrogenCustomer')?.trim();
const customerName = rawCustomerName && rawCustomerName.length <= 128
? rawCustomerName
: undefined;
const context: HydrogenDrillContext = {
level: requestedLevel === 'station' && parsedStationId !== undefined
? 'station'
: requestedLevel === 'customer' && customerName
? 'customer'
: 'overview',
vehicleScope: params.get('hydrogenScope') === 'external' ? 'external' : 'lingniu',
};
const year = positiveInteger(params.get('hydrogenYear'));
if (year && year >= 2000 && year <= 2100) context.year = year;
if (context.level === 'station') {
context.stationId = parsedStationId;
if (parsedStationName) context.stationName = parsedStationName;
}
if (context.level === 'customer') context.customerName = customerName;
const startDate = validDate(params.get('hydrogenStart'));
const endDate = validDate(params.get('hydrogenEnd'));
const selectedDate = validDate(params.get('hydrogenDate'));
if (startDate) context.startDate = startDate;
if (endDate) context.endDate = endDate;
const rangeStart = startDate && endDate ? (startDate <= endDate ? startDate : endDate) : undefined;
const rangeEnd = startDate && endDate ? (startDate <= endDate ? endDate : startDate) : undefined;
if (selectedDate && (!rangeStart || !rangeEnd || (selectedDate >= rangeStart && selectedDate <= rangeEnd))) {
context.selectedDate = selectedDate;
const orderPage = positiveInteger(params.get('hydrogenPage'));
if (orderPage && orderPage > 1) context.orderPage = orderPage;
}
return context;
}
export function buildHydrogenDrillUrl(
location: LocationParts,
context: HydrogenDrillContext,
): string {
const params = new URLSearchParams(location.search);
for (const key of [
'hydrogenLevel',
'hydrogenYear',
'hydrogenStation',
'hydrogenStationName',
'hydrogenCustomer',
'hydrogenScope',
'hydrogenStart',
'hydrogenEnd',
'hydrogenDate',
'hydrogenPage',
]) {
params.delete(key);
}
if (context.level !== 'overview') params.set('hydrogenLevel', context.level);
if (context.year) params.set('hydrogenYear', String(context.year));
if (context.level === 'station' && context.stationId !== undefined) {
params.set('hydrogenStation', String(context.stationId));
if (context.stationName) params.set('hydrogenStationName', context.stationName);
}
if (context.level === 'customer' && context.customerName) {
params.set('hydrogenCustomer', context.customerName);
}
params.set('hydrogenScope', context.vehicleScope);
if (context.startDate) params.set('hydrogenStart', context.startDate);
if (context.endDate) params.set('hydrogenEnd', context.endDate);
if (context.selectedDate) params.set('hydrogenDate', context.selectedDate);
if (context.selectedDate && context.orderPage && context.orderPage > 1) {
params.set('hydrogenPage', String(context.orderPage));
}
const query = params.toString();
return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
}
@@ -1,67 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { reconcileHydrogenDaily } from './hydrogen-reconciliation.js';
import type { HydrogenDailyRow, HydrogenOrderResponse } from './types.js';
const daily: HydrogenDailyRow = {
date: '2026-07-01',
totalKg: 123.45,
chainPct: 0,
customerType: 'lingniu',
stations: [],
};
const orders: HydrogenOrderResponse = {
date: '2026-07-01',
vehicleScope: 'lingniu',
stationId: 18,
customerName: null,
page: 1,
limit: 20,
recordCount: 3,
totalPages: 1,
totalKg: 123.45,
totalCost: 1000,
totalRevenue: 1200,
items: [],
};
test('matches daily hydrogen totals at the same date, scope, and entity grain', () => {
assert.deepEqual(reconcileHydrogenDaily(daily, orders, 'lingniu', 18), {
matches: true,
dateMatches: true,
scopeMatches: true,
entityMatches: true,
kgDifference: 0,
});
});
test('reports amount and filter-grain differences independently', () => {
const result = reconcileHydrogenDaily(
daily,
{
...orders,
date: '2026-07-02',
vehicleScope: 'external',
stationId: null,
customerName: '武汉客户',
totalKg: 123.44,
},
'lingniu',
18,
);
assert.equal(result.matches, false);
assert.equal(result.dateMatches, false);
assert.equal(result.scopeMatches, false);
assert.equal(result.entityMatches, false);
assert.equal(result.kgDifference, -0.01);
});
test('matches an exact customer drill without requiring a station', () => {
const customerOrders = { ...orders, stationId: null, customerName: '武汉客户' };
assert.equal(
reconcileHydrogenDaily(daily, customerOrders, 'lingniu', undefined, '武汉客户').matches,
true,
);
});
@@ -1,35 +0,0 @@
import type { CustomerType, HydrogenDailyRow, HydrogenOrderResponse } from './types';
export interface HydrogenDailyReconciliation {
matches: boolean;
dateMatches: boolean;
scopeMatches: boolean;
entityMatches: boolean;
kgDifference: number;
}
function hundredths(value: number): number {
return Math.round(value * 100);
}
export function reconcileHydrogenDaily(
daily: HydrogenDailyRow,
orders: HydrogenOrderResponse,
expectedScope: CustomerType,
expectedStationId?: number,
expectedCustomerName?: string,
): HydrogenDailyReconciliation {
const dateMatches = daily.date === orders.date;
const scopeMatches = expectedScope === orders.vehicleScope;
const entityMatches = (expectedStationId ?? null) === orders.stationId
&& (expectedCustomerName ?? null) === orders.customerName;
const kgDifferenceInHundredths = hundredths(orders.totalKg) - hundredths(daily.totalKg);
return {
matches: dateMatches && scopeMatches && entityMatches && kgDifferenceInHundredths === 0,
dateMatches,
scopeMatches,
entityMatches,
kgDifference: kgDifferenceInHundredths / 100,
};
}
-233
View File
@@ -1,233 +0,0 @@
export type CustomerType = 'external' | 'lingniu';
export type ElectricVehicleScope = CustomerType | 'all';
export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15';
export interface HydrogenKpi {
yearKg: number;
yearFee: number;
yearRevenue: number;
yearProfit: number;
ourYearKg: number;
ourYearFee: number;
customerYearKg: number;
monthKg: number;
monthFee: number;
monthRevenue: number;
monthProfit: number;
todayKg: number;
todayFee: number;
todayRevenue: number;
todayProfit: number;
lingniuBornKg: number;
lingniuBornFee: number;
}
export interface HydrogenStationTop {
id: number;
rank: number;
name: string;
kg: number;
fee: number;
share: number;
}
export interface HydrogenRegionShare {
region: string;
kg: number;
share: number;
}
export interface HydrogenMonthlyPoint {
month: string; // YYYY-MM
kg: number;
fee: number;
revenue: number;
profit: number;
}
export interface HydrogenCustomerRow {
name: string;
payer: 'lingniu' | 'customer';
kg: number;
cost: number;
revenue: number;
}
export interface HydrogenStationFull {
id: number;
name: string;
kg: number;
revenue: number;
share: number; // 加氢量占比
revenueShare: number;// 收入占比
}
export interface HydrogenStationRow {
stationId: number;
name: string;
pricePerKg: number;
kg: number;
chainPct: number;
}
export interface HydrogenDailyRow {
date: string;
totalKg: number;
chainPct: number;
customerType: CustomerType;
stations: HydrogenStationRow[];
}
export interface HydrogenOrder {
rowNumber: number;
refuelTime: string;
plate: string;
customerName: string;
stationName: string;
amountKg: number;
costPrice: number;
costTotal: number;
customerPrice: number;
revenue: number;
}
export interface HydrogenOrderResponse {
date: string;
vehicleScope: CustomerType;
stationId: number | null;
customerName: string | null;
page: number;
limit: number;
recordCount: number;
totalPages: number;
totalKg: number;
totalCost: number;
totalRevenue: number;
items: HydrogenOrder[];
}
export interface ElectricKpi {
totalKwh: number;
totalFee: number;
monthKwh: number;
monthFee: number;
todayKwh: number;
todayFee: number;
todayChainPct: number;
}
export interface ElectricDailyRow {
date: string;
kwh: number;
fee: number;
chainPct: number;
}
export interface ElectricMonthGroup {
month: string;
kwh: number;
fee: number;
rows: ElectricDailyRow[];
}
export interface ElectricChargeOrder {
id: number;
orderNo: string;
startTime: string;
stationName: string;
plate: string;
vehicleKind: 'internal' | 'external' | 'unknown';
orderStatus: string;
kwh: number;
electricityFee: number;
serviceFee: number;
totalFee: number;
}
export interface ElectricChargeOrderResponse {
date: string;
vehicleScope: ElectricVehicleScope;
recordCount: number;
totalKwh: number;
totalFee: number;
truncated: boolean;
items: ElectricChargeOrder[];
}
export interface EtcOverviewResponse {
integration: {
providerConfigured: boolean;
syncEnabled: boolean;
providerType: string | null;
lastSyncAt: string | null;
lastSyncStatus: 'never' | 'success' | 'failed' | 'partial' | 'unknown';
};
kpi: {
passageCount: number;
vehicleCount: number;
tollAmount: number;
serviceFee: number;
totalAmount: number;
billCount: number;
receivableAmount: number;
paidAmount: number;
latestTransactionTime: string | null;
};
}
export interface EtcListFilters {
startDate: string | null;
endDate: string | null;
search: string;
}
export interface EtcTollRecord {
id: number;
recordCode: string;
plate: string;
customerName: string | null;
entryStation: string | null;
exitStation: string | null;
transTime: string;
tollAmount: number;
serviceFee: number;
totalAmount: number;
costType: number;
contractMatched: boolean;
reviewStatus: number;
}
export interface EtcTollRecordResponse {
page: number;
limit: number;
total: number;
totalPages: number;
filters: EtcListFilters;
summary: { vehicleCount: number; tollAmount: number; serviceFee: number; totalAmount: number };
items: EtcTollRecord[];
}
export interface EtcBill {
id: number;
billCode: string;
customerName: string | null;
periodStart: string;
periodEnd: string;
tollCount: number;
tollAmount: number;
serviceFee: number;
receivableAmount: number;
paidAmount: number;
paymentStatus: number;
reviewStatus: number;
}
export interface EtcBillResponse {
page: number;
limit: number;
total: number;
totalPages: number;
filters: EtcListFilters;
summary: { receivableAmount: number; paidAmount: number };
items: EtcBill[];
}
-16
View File
@@ -1,16 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildHashSubTab, parseHashSubTab } from './useHashSubTab.js';
const tabs = ['daily', 'overview'] as const;
test('parses valid sub tabs and falls back to the module default', () => {
assert.equal(parseHashSubTab('#electric/overview', 'electric', tabs), 'overview');
assert.equal(parseHashSubTab('#electric/unknown', 'electric', tabs), 'daily');
assert.equal(parseHashSubTab('#hydrogen/overview', 'electric', tabs), 'daily');
});
test('builds a compact default hash and an explicit secondary hash', () => {
assert.equal(buildHashSubTab('electric', 'daily', 'daily'), '#electric');
assert.equal(buildHashSubTab('electric', 'overview', 'daily'), '#electric/overview');
});
-51
View File
@@ -1,51 +0,0 @@
import { useEffect, useState } from 'react';
export function parseHashSubTab<T extends string>(
hash: string,
moduleId: string,
subs: readonly T[],
): T {
const [first, second] = hash.replace(/^#/, '').split('/');
if (first !== moduleId) return subs[0];
return second && (subs as readonly string[]).includes(second) ? second as T : subs[0];
}
export function buildHashSubTab<T extends string>(moduleId: string, sub: T, defaultSub: T): string {
return sub === defaultSub ? `#${moduleId}` : `#${moduleId}/${sub}`;
}
/**
* 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 => parseHashSubTab(window.location.hash, moduleId, subs);
const [sub, setSubState] = useState<T>(parse);
useEffect(() => {
const onChange = () => setSubState(parse());
window.addEventListener('hashchange', onChange);
window.addEventListener('popstate', onChange);
return () => {
window.removeEventListener('hashchange', onChange);
window.removeEventListener('popstate', onChange);
};
}, [moduleId]);
const setSub = (next: T) => {
if (next === sub) return;
const { pathname, search } = window.location;
const newHash = buildHashSubTab(moduleId, next, defaultSub);
window.history.pushState(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;
};
+6 -341
View File
@@ -1,347 +1,12 @@
import { useCallback, useEffect, useState } from 'react';
import { motion } from 'motion/react';
import {
Activity,
AlertTriangle,
CalendarDays,
ChevronRight,
CircleGauge,
Database,
RefreshCw,
Route,
Truck,
} from 'lucide-react';
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import Blur from '../../components/Blur';
import { MetricTile, SurfaceCard } from '../../components/ui/surface';
import {
fetchDailyReport,
fetchDailyReportTrend,
type DailyReportData,
type DailyReportVehicle,
} from './api';
import { buildMileageDrillUrl } from './drill-context';
function fmtKm(value: number): string {
if (value >= 10000) return `${(value / 10000).toFixed(2)}`;
return value.toLocaleString(undefined, { maximumFractionDigits: 1 });
}
function fmtTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
function VehicleRow({
vehicle,
mode,
onClick,
}: {
vehicle: DailyReportVehicle;
mode: 'top' | 'risk';
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="grid w-full grid-cols-[minmax(0,1fr)_auto_18px] items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-slate-50"
>
<span className="min-w-0">
<span className="flex items-center gap-2">
<span className="truncate font-mono text-xs font-black text-slate-800"><Blur>{vehicle.plate}</Blur></span>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${vehicle.isOnline ? 'bg-emerald-500' : 'bg-slate-300'}`} />
</span>
<span className="mt-0.5 block truncate text-[9px] font-bold text-slate-400">
{vehicle.model} · {vehicle.status} · <Blur>{vehicle.customer}</Blur>
</span>
</span>
<span className="text-right">
{mode === 'top' ? (
<>
<span className="block text-xs font-black text-blue-600">{fmtKm(vehicle.today)} km</span>
<span className="block text-[9px] font-bold text-slate-400"></span>
</>
) : (
<>
<span className="block text-xs font-black text-rose-600">{fmtKm(vehicle.shortfall)} km</span>
<span className="block text-[9px] font-bold text-slate-400"></span>
</>
)}
</span>
<ChevronRight size={14} className="text-slate-300" />
</button>
);
}
import { FileText } from 'lucide-react';
export default function DailyReportView() {
const [report, setReport] = useState<DailyReportData | null>(null);
const [loading, setLoading] = useState(true);
const [trendLoading, setTrendLoading] = useState(true);
const [error, setError] = useState('');
const load = useCallback(async (force = false) => {
setLoading(true);
setTrendLoading(true);
setError('');
const trendRequest = fetchDailyReportTrend(force);
try {
const nextReport = await fetchDailyReport(force);
setReport(nextReport);
setLoading(false);
try {
const trend = await trendRequest;
setReport(current => current ? {
...current,
trend: trend.map(item => ({ date: item.date, value: item.mileage })),
} : current);
} catch {
setError('7 日趋势加载失败');
} finally {
setTrendLoading(false);
}
} catch (cause) {
void trendRequest.catch(() => {});
setError(cause instanceof Error ? cause.message : '日报加载失败');
setLoading(false);
setTrendLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const openTarget = useCallback((targetId: number) => {
const url = buildMileageDrillUrl(
{ pathname: window.location.pathname, search: window.location.search, hash: '#mileage/statistics' },
{ level: 'overview', targetId, sourcePriority: ['instrument'] },
);
window.history.pushState(null, '', url);
window.dispatchEvent(new HashChangeEvent('hashchange'));
}, []);
const openVehicle = useCallback((vehicle: DailyReportVehicle) => {
if (!report) return;
const url = buildMileageDrillUrl(
{ pathname: window.location.pathname, search: window.location.search, hash: '#mileage' },
{
level: 'vehicle',
targetId: vehicle.targetId,
plate: vehicle.plate,
startDate: report.reportDate,
endDate: report.reportDate,
sourcePriority: ['instrument'],
},
);
window.history.pushState(null, '', url);
window.dispatchEvent(new HashChangeEvent('hashchange'));
}, [report]);
if (loading && !report) {
return (
<SurfaceCard className="min-h-[360px]">
<div className="flex min-h-[320px] items-center justify-center gap-2 text-xs font-bold text-slate-400">
<RefreshCw size={16} className="animate-spin" />
</div>
</SurfaceCard>
);
}
if (!report) {
return (
<SurfaceCard className="min-h-[360px]">
<div className="flex min-h-[320px] flex-col items-center justify-center gap-3 px-6 text-center">
<AlertTriangle size={24} className="text-rose-500" />
<div className="text-sm font-black text-slate-800"></div>
<div className="max-w-md text-xs font-bold text-slate-400">{error || '数据接口未返回有效结果'}</div>
<button
type="button"
onClick={() => void load(true)}
className="rounded-lg bg-slate-900 px-4 py-2 text-xs font-black text-white"
>
</button>
</div>
</SurfaceCard>
);
}
return (
<div className="space-y-3 pb-4">
<section className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-50 text-blue-600 ring-1 ring-blue-100">
<CalendarDays size={17} />
</span>
<div>
<h2 className="text-sm font-black text-slate-900">{report.reportDate} </h2>
<p className="mt-0.5 text-[10px] font-bold text-slate-400"> {fmtTime(report.updatedAt)} · </p>
</div>
</div>
<button
type="button"
onClick={() => void load(true)}
disabled={loading || trendLoading}
className="inline-flex h-9 items-center justify-center gap-2 rounded-lg border border-blue-100 bg-blue-50 px-3 text-xs font-black text-blue-600 transition-colors hover:bg-blue-100 disabled:opacity-50"
>
<RefreshCw size={14} className={loading || trendLoading ? 'animate-spin' : ''} />
</button>
</section>
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
<MetricTile
label="考核车辆当日里程"
value={fmtKm(report.totalToday)}
unit="km"
helper={`${report.activeCount}/${report.assessmentVehicleCount} 台产生里程`}
icon={Route}
tone="blue"
/>
<MetricTile
label="当日任务缺口"
value={fmtKm(report.shortfall)}
unit="km"
helper={`任务基准 ${fmtKm(report.dailyNeed)} km`}
icon={AlertTriangle}
tone={report.shortfall > 0 ? 'rose' : 'emerald'}
/>
<MetricTile
label="零里程车辆"
value={report.zeroCount}
unit="台"
helper={`考核车辆共 ${report.assessmentVehicleCount}`}
icon={Truck}
tone={report.zeroCount > 0 ? 'amber' : 'emerald'}
/>
<MetricTile
label="考核加权完成率"
value={report.completionRate.toFixed(1)}
unit="%"
helper={`${report.qualifiedCount} 台达标 · ${report.halfQualifiedCount} 台过半`}
icon={CircleGauge}
tone="emerald"
/>
</div>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.65fr)]">
<div className="space-y-3">
<SurfaceCard
title="全量监控车辆 7 日趋势"
subtitle={`当前监控范围 ${report.monitoringVehicleCount} 台,趋势不等同于考核车辆范围`}
>
<div className="h-[250px] px-2 py-3">
{report.trend.length === 0 && trendLoading ? (
<div className="flex h-full items-center justify-center gap-2 text-xs font-bold text-slate-400">
<RefreshCw size={14} className="animate-spin" />
</div>
) : report.trend.length === 0 ? (
<div className="flex h-full items-center justify-center text-xs font-bold text-slate-400">
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={report.trend} margin={{ top: 12, right: 12, left: -12, bottom: 0 }}>
<CartesianGrid vertical={false} stroke="#e2e8f0" strokeDasharray="3 3" />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} tickFormatter={fmtKm} />
<Tooltip
formatter={(value) => [`${fmtKm(Number(value))} km`, '总里程']}
contentStyle={{ borderRadius: 8, border: '1px solid #e2e8f0', fontSize: 11 }}
/>
<Bar dataKey="value" fill="#2563eb" radius={[4, 4, 0, 0]} maxBarSize={30} />
</BarChart>
</ResponsiveContainer>
)}
</div>
</SurfaceCard>
<SurfaceCard title="考核目标表现" subtitle="按当日任务缺口排序,点击进入考核统计">
<div className="divide-y divide-slate-100">
{report.models.map((model, index) => (
<motion.button
type="button"
key={model.id}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.025 }}
onClick={() => openTarget(model.id)}
className="grid w-full grid-cols-[minmax(0,1fr)_72px_78px_20px] items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-slate-50"
>
<span className="min-w-0">
<span className="block truncate text-xs font-black text-slate-800">{model.name}</span>
<span className="mt-0.5 block text-[9px] font-bold text-slate-400">
{model.active}/{model.count} · {model.zero}
{model.missingDailyTaskCount > 0 ? ` · ${model.missingDailyTaskCount} 台缺任务值` : ''}
</span>
</span>
<span className="text-right">
<span className="block text-[11px] font-black text-blue-600">{fmtKm(model.today)}</span>
<span className="block text-[9px] font-bold text-slate-400"> km</span>
</span>
<span className="text-right">
<span className={`block text-[11px] font-black ${model.shortfall > 0 ? 'text-rose-600' : 'text-emerald-600'}`}>
{fmtKm(model.shortfall)}
</span>
<span className="block text-[9px] font-bold text-slate-400"> km</span>
</span>
<ChevronRight size={14} className="text-slate-300" />
</motion.button>
))}
</div>
</SurfaceCard>
</div>
<div className="space-y-3">
<SurfaceCard title="当日里程 TOP 5" subtitle="考核车辆范围">
<div className="divide-y divide-slate-100">
{report.topVehicles.map(vehicle => (
<VehicleRow key={vehicle.plate} vehicle={vehicle} mode="top" onClick={() => openVehicle(vehicle)} />
))}
</div>
</SurfaceCard>
<SurfaceCard title="任务缺口车辆" subtitle="按车辆当日任务缺口排序,仅含已返回任务值的车辆">
<div className="divide-y divide-slate-100">
{report.riskVehicles.map(vehicle => (
<VehicleRow key={vehicle.plate} vehicle={vehicle} mode="risk" onClick={() => openVehicle(vehicle)} />
))}
</div>
</SurfaceCard>
<section className="rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
<div className="flex items-start gap-3">
<Database size={16} className="mt-0.5 shrink-0 text-slate-400" />
<div className="min-w-0 text-[10px] font-bold leading-relaxed text-slate-500">
<div> {report.assessmentVehicleCount} · {report.monitoringVehicleCount} </div>
<div className="mt-1"> {report.duplicateAssignmentCount} </div>
<div className={report.missingDailyTaskCount > 0 ? 'mt-1 text-amber-600' : 'mt-1'}>
{report.missingDailyTaskCount}
</div>
{error && <div className="mt-1 text-rose-600">{error}</div>}
</div>
<Activity size={14} className="ml-auto shrink-0 text-emerald-500" />
</div>
</section>
</div>
<div className="flex items-center justify-center py-20">
<div className="text-center">
<FileText size={48} className="mx-auto text-gray-300 mb-4" />
<h2 className="text-lg font-semibold text-gray-500"></h2>
<p className="text-sm text-gray-400 mt-2">...</p>
</div>
</div>
);
@@ -1,141 +0,0 @@
import { AlertTriangle, Building2, ChevronRight, Users } from 'lucide-react';
import type { TargetVehicle } from './types';
import {
buildMileageAttribution,
reconcileMileageAttribution,
type AttributionDimension,
} from './attribution';
import { ErrorState } from '../../components/ui/surface';
interface Props {
vehicles: TargetVehicle[];
dimension: AttributionDimension;
selectedValue?: string;
loading?: boolean;
error?: string | null;
targetMileagePerVehicle: number;
expectedShortfallMileage?: number;
assessmentLabel?: string;
onDimensionChange: (dimension: AttributionDimension) => void;
onSelect: (value: string) => void;
onRetry?: () => void;
}
function fmtKm(value: number): string {
return value >= 10000 ? `${(value / 10000).toFixed(2)}` : value.toLocaleString(undefined, { maximumFractionDigits: 1 });
}
export default function MileageAttributionView({
vehicles,
dimension,
selectedValue,
loading = false,
error,
targetMileagePerVehicle,
expectedShortfallMileage,
assessmentLabel,
onDimensionChange,
onSelect,
onRetry,
}: Props) {
const groups = buildMileageAttribution(vehicles, dimension, targetMileagePerVehicle);
const totalShortfall = groups.reduce((sum, group) => sum + group.shortfallMileage, 0);
const totalBehind = groups.reduce((sum, group) => sum + group.behindCount, 0);
const reconciliation = expectedShortfallMileage == null
? null
: reconcileMileageAttribution(groups, expectedShortfallMileage);
return (
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex flex-col gap-3 border-b border-slate-100 px-4 py-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="flex items-center gap-2">
<span className="h-4 w-1 rounded-full bg-blue-600" />
<h2 className="text-sm font-black text-slate-800"></h2>
</div>
<p className="mt-1 text-[10px] font-bold text-slate-400">
{assessmentLabel ? `${assessmentLabel} · ` : ''}
{loading
? '正在加载归因车辆'
: error
? '归因数据暂不可用'
: `${totalBehind} 台未达标,累计缺口 ${fmtKm(totalShortfall)} km`}
</p>
{!loading && !error && reconciliation && (
<p
role={reconciliation.matches ? 'status' : 'alert'}
className={`mt-1 text-[10px] font-black ${reconciliation.matches ? 'text-emerald-600' : 'text-rose-600'}`}
>
{reconciliation.matches
? '与考核目标汇总一致'
: `与考核目标汇总相差 ${reconciliation.difference > 0 ? '+' : ''}${fmtKm(reconciliation.difference)} km`}
</p>
)}
</div>
<div className="flex rounded-lg bg-slate-100 p-1" aria-label="归因维度">
{([
['department', '部门', Building2],
['customer', '客户', Users],
] as const).map(([value, label, Icon]) => (
<button
key={value}
type="button"
onClick={() => onDimensionChange(value)}
className={`flex min-w-[72px] items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-[10px] font-black transition-colors ${
dimension === value ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-800'
}`}
>
<Icon size={12} />
{label}
</button>
))}
</div>
</div>
{loading ? (
<div className="px-4 py-8 text-center text-xs font-bold text-slate-400">...</div>
) : error ? (
<ErrorState message={error} onRetry={onRetry} variant="inline" />
) : groups.length === 0 ? (
<div className="px-4 py-8 text-center text-xs font-bold text-slate-400"></div>
) : (
<div className="divide-y divide-slate-100">
{groups.slice(0, 8).map(group => (
<button
key={group.key}
type="button"
onClick={() => onSelect(group.key)}
className={`grid w-full grid-cols-[minmax(0,1fr)_70px_82px_24px] items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-slate-50 ${
selectedValue === group.key ? 'bg-blue-50/60' : ''
}`}
>
<span className="min-w-0">
<span className="block truncate text-xs font-black text-slate-800">{group.label}</span>
<span className="mt-0.5 flex items-center gap-2 text-[9px] font-bold text-slate-400">
<span>{group.vehicleCount} </span>
<span>{group.onlineCount} 线</span>
{group.zeroMileageCount > 0 && (
<span className="flex items-center gap-0.5 text-amber-600">
<AlertTriangle size={9} /> {group.zeroMileageCount}
</span>
)}
</span>
</span>
<span className="text-right">
<span className="block text-[11px] font-black text-slate-800">{fmtKm(group.todayMileage)}</span>
<span className="block text-[9px] font-bold text-slate-400"> {group.contributionRate.toFixed(1)}%</span>
</span>
<span className="text-right">
<span className={`block text-[11px] font-black ${group.shortfallMileage > 0 ? 'text-rose-600' : 'text-emerald-600'}`}>
{fmtKm(group.shortfallMileage)}
</span>
<span className="block text-[9px] font-bold text-slate-400"> · {group.behindCount} </span>
</span>
<ChevronRight size={14} className="text-slate-300" />
</button>
))}
</div>
)}
</section>
);
}
+37 -43
View File
@@ -1,53 +1,50 @@
import { useState } from 'react';
import { LayoutDashboard, BarChart3, FileText } from 'lucide-react';
import { AnimatePresence } from 'motion/react';
import { motion } from 'motion/react';
import MonitoringView from './MonitoringView';
import StatisticsView from './StatisticsView';
import DailyReportView from './DailyReportView';
import { useHashSubTab } from '../energy/useHashSubTab';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { FadeIn, PageFrame, SegmentedNav } from '../../components/ui/surface';
import BiHeaderActions from '../../components/BiHeaderActions';
type MileageSubTab = 'monitoring' | 'statistics' | 'report';
const MILEAGE_TABS = [
{ id: 'monitoring', label: '实时监控', icon: LayoutDashboard },
{ id: 'statistics', label: '统计报表', icon: BarChart3 },
{ id: 'report', label: '每日汇报', icon: FileText },
] as const satisfies readonly { id: MileageSubTab; label: string; icon: typeof LayoutDashboard }[];
const MILEAGE_SUB_IDS: readonly MileageSubTab[] = ['monitoring', 'statistics', 'report'];
export default function MileageModule() {
const [activeSubTab, setActiveSubTab] = useHashSubTab<MileageSubTab>('mileage', MILEAGE_SUB_IDS);
const [activeSubTab, setActiveSubTab] = useState<'monitoring' | 'statistics' | 'report'>('monitoring');
return (
<PageFrame
title="车辆里程中心"
subtitle="统一监控车辆日里程、累计里程、考核进度与日报经营口径,突出异常车辆和任务压力。"
icon={LayoutDashboard}
eyebrow="MILEAGE BI"
meta="实时监控 · 统计报表 · 每日汇报"
actions={<BiHeaderActions domain="mileage" />}
compactInfo
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 landscape:pb-0 landscape:h-full landscape:flex-1 landscape:overflow-hidden">
{/* Sub-navigation — sticky */}
<div className="bg-white px-4 py-2 rounded-2xl border border-slate-100 shadow-sm flex items-center gap-6 sticky top-0 z-30">
<button
onClick={() => setActiveSubTab('monitoring')}
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'monitoring' ? 'text-blue-600' : 'text-slate-400'}`}
>
<div className="sticky top-0 z-30 -mx-3 bg-[var(--app-bg)] px-3 pb-2 pt-1 shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)] md:-mx-6 md:top-12 md:px-6">
<SegmentedNav
tabs={MILEAGE_TABS}
active={activeSubTab}
onChange={setActiveSubTab}
idPrefix="mileage-view"
ariaLabel="里程分析视图"
/>
<LayoutDashboard size={14} />
<span className="text-[11px] font-bold"></span>
{activeSubTab === 'monitoring' && (
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
<button
onClick={() => setActiveSubTab('statistics')}
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'statistics' ? 'text-blue-600' : 'text-slate-400'}`}
>
<BarChart3 size={14} />
<span className="text-[11px] font-bold"></span>
{activeSubTab === 'statistics' && (
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
<button
onClick={() => setActiveSubTab('report')}
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'report' ? 'text-blue-600' : 'text-slate-400'}`}
>
<FileText size={14} />
<span className="text-[11px] font-bold"></span>
{activeSubTab === 'report' && (
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
</div>
<AnimatePresence mode="wait">
<FadeIn key={activeSubTab}>
<div
id={`mileage-view-panel-${activeSubTab}`}
role="tabpanel"
aria-labelledby={`mileage-view-tab-${activeSubTab}`}
>
{activeSubTab === 'monitoring' ? (
<MonitoringView />
) : activeSubTab === 'statistics' ? (
@@ -56,9 +53,6 @@ export default function MileageModule() {
<DailyReportView />
)}
</div>
</FadeIn>
</AnimatePresence>
<RotatingFooterHint />
</PageFrame>
</div>
);
}
File diff suppressed because it is too large Load Diff
-197
View File
@@ -1,197 +0,0 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { ChevronDown, X, AlertTriangle } from 'lucide-react';
interface Props {
allPlates: string[];
selected: string[];
onChange: (plates: string[]) => void;
placeholder?: string;
}
function parseInput(text: string): string[] {
return text
.split(/[\s,;,;、]+/)
.map(s => s.trim())
.filter(Boolean);
}
export default function PlateMultiSelect({ allPlates, selected, onChange, placeholder = '按车牌(可多选/粘贴)' }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [text, setText] = useState('');
const [search, setSearch] = useState('');
const [unmatched, setUnmatched] = useState<string[]>([]);
const wrapRef = useRef<HTMLDivElement>(null);
const allSet = useMemo(() => new Set(allPlates), [allPlates]);
useEffect(() => {
if (!isOpen) return;
const handler = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setIsOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [isOpen]);
const filtered = useMemo(() => {
if (!search) return allPlates.slice(0, 200);
const q = search.toLowerCase();
return allPlates.filter(p => p.toLowerCase().includes(q)).slice(0, 200);
}, [allPlates, search]);
const selectedSet = useMemo(() => new Set(selected), [selected]);
const apply = (input: string) => {
const tokens = parseInput(input);
if (tokens.length === 0) return;
const matched: string[] = [];
const missed: string[] = [];
const seen = new Set(selected);
for (const t of tokens) {
if (allSet.has(t)) {
if (!seen.has(t)) {
matched.push(t);
seen.add(t);
}
} else {
missed.push(t);
}
}
if (matched.length > 0) onChange([...selected, ...matched]);
setUnmatched(missed);
setText('');
};
const togglePlate = (plate: string) => {
if (selectedSet.has(plate)) {
onChange(selected.filter(p => p !== plate));
} else {
onChange([...selected, plate]);
}
};
const removePlate = (plate: string) => {
onChange(selected.filter(p => p !== plate));
};
const clearAll = () => {
onChange([]);
setUnmatched([]);
setText('');
};
const display = selected.length === 0
? placeholder
: selected.length === 1
? selected[0]
: `${selected[0]}${selected.length} 个车牌`;
return (
<div className="relative" ref={wrapRef}>
<div
onClick={() => setIsOpen(o => !o)}
className={`w-full bg-slate-50 rounded-lg py-1.5 px-2 text-[10px] font-bold cursor-pointer flex items-center justify-between gap-1 ${selected.length > 0 ? 'text-blue-600 ring-1 ring-blue-200' : 'text-slate-600'}`}
>
<span className="truncate">{display}</span>
<ChevronDown size={10} className="text-slate-400 flex-shrink-0" />
</div>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
className="absolute z-50 right-0 mt-1 bg-white border border-slate-100 rounded-xl shadow-xl"
style={{ width: 'min(280px, calc(100vw - 24px))', minWidth: '100%' }}
>
<div className="p-2 space-y-2">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="粘贴或输入车牌&#10;支持换行/逗号/空格分隔,回车或点添加确认"
className="w-full bg-slate-50 border-none rounded-lg p-2 text-[11px] text-slate-700 outline-none focus:ring-1 focus:ring-blue-500/30 resize-none"
rows={3}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
apply(text);
}
}}
/>
<div className="flex justify-between items-center">
<span className="text-[10px] text-slate-400"> <span className="font-bold text-blue-600">{selected.length}</span> </span>
<div className="flex gap-1">
<button
onClick={() => apply(text)}
disabled={!text.trim()}
className="px-2 py-1 bg-blue-600 text-white rounded-md text-[10px] font-bold disabled:bg-slate-200 disabled:text-slate-400"
></button>
<button
onClick={clearAll}
className="px-2 py-1 bg-slate-100 text-slate-500 rounded-md text-[10px] font-bold"
></button>
</div>
</div>
{unmatched.length > 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-2 space-y-1">
<div className="flex items-center gap-1 text-amber-700">
<AlertTriangle size={10} />
<span className="text-[10px] font-bold">{unmatched.length} </span>
<button
onClick={() => setUnmatched([])}
className="ml-auto text-amber-500 hover:text-amber-700"
><X size={10} /></button>
</div>
<div className="text-[10px] text-amber-600 break-all max-h-16 overflow-y-auto leading-relaxed">
{unmatched.join('')}
</div>
</div>
)}
{selected.length > 0 && (
<div className="flex flex-wrap gap-1 max-h-20 overflow-y-auto p-1 bg-slate-50 rounded-lg">
{selected.map(p => (
<span key={p} className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-white border border-blue-100 text-blue-600 rounded text-[10px] font-bold">
{p}
<button onClick={() => removePlate(p)} className="text-blue-400 hover:text-blue-700"><X size={9} /></button>
</span>
))}
</div>
)}
<div className="border-t border-slate-100 pt-2">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索车牌"
className="w-full bg-slate-50 border-none rounded-lg py-1.5 px-2 text-[10px] text-slate-700 outline-none focus:ring-1 focus:ring-blue-500/30"
/>
<div className="mt-1 max-h-40 overflow-y-auto">
{filtered.map(p => (
<div
key={p}
onClick={() => togglePlate(p)}
className={`px-2 py-1 text-[10px] font-bold cursor-pointer flex items-center gap-1.5 rounded ${selectedSet.has(p) ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50'}`}
>
<span className={`w-3 h-3 rounded border ${selectedSet.has(p) ? 'bg-blue-600 border-blue-600' : 'border-slate-300'} flex items-center justify-center`}>
{selectedSet.has(p) && <span className="text-white text-[8px] leading-none"></span>}
</span>
{p}
</div>
))}
{filtered.length === 0 && (
<div className="px-2 py-1 text-[10px] text-slate-300 italic"></div>
)}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
File diff suppressed because it is too large Load Diff
-420
View File
@@ -1,420 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { motion, AnimatePresence, useDragControls } from 'motion/react';
import { RefreshCw, X, Truck } from 'lucide-react';
import {
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
} from 'recharts';
import type { MileageSourceGroup, MonitoringVehicle } from './types';
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
import Blur from '../../components/Blur';
import { EmptyState, ErrorState } from '../../components/ui/surface';
import {
includeCurrentDayInVehicleDetail,
normalizeVehicleDetailContext,
resolveVehicleDetailRange,
vehicleDetailContextLabel,
type VehicleDetailRangeKey,
} from './vehicle-detail-range';
interface Props {
vehicle: MonitoringVehicle | null;
onClose: () => void;
sourcePriority: MileageSourceGroup[];
initialStartDate?: string;
initialEndDate?: string;
}
const RANGE_TABS: { key: VehicleDetailRangeKey; label: string }[] = [
{ key: 'last15', label: '近 15 天' },
{ key: 'month', label: '本月' },
{ key: 'quarter', label: '本季度' },
];
function isToday(date: string): boolean {
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
return date === today;
}
function formatLabel(date: string, key: VehicleDetailRangeKey): string {
// YYYY-MM-DD → MM-DD(季度时仍展示 MM-DD
void key;
return date.slice(5);
}
function daySourceLabel(day: VehicleRecentDay): string {
if (day.sourceCategory === 'INSTRUMENT') return '仪表数据';
if (day.sourceCategory === 'GPS') return 'GPS数据';
return day.isDataSynced ? '来源待接口' : '无数据';
}
export default function VehicleDetailModal({
vehicle,
onClose,
sourcePriority,
initialStartDate,
initialEndDate,
}: Props) {
const contextRange = useMemo(
() => normalizeVehicleDetailContext(initialStartDate, initialEndDate),
[initialEndDate, initialStartDate],
);
const [days, setDays] = useState<VehicleRecentDay[]>([]);
const [loadedRequestKey, setLoadedRequestKey] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [requestVersion, setRequestVersion] = useState(0);
const [range, setRange] = useState<VehicleDetailRangeKey>(() => (
contextRange ? 'context' : 'last15'
));
const dragControls = useDragControls();
const resolvedRange = useMemo(
() => resolveVehicleDetailRange(range, contextRange),
[contextRange, range],
);
const sourceKey = sourcePriority.join(',');
const requestKey = vehicle
? `${vehicle.plate}|${resolvedRange.start}|${resolvedRange.end}|${sourceKey}`
: null;
const hasSnapshot = requestKey !== null && loadedRequestKey === requestKey;
const rangeTabs = useMemo(() => (
contextRange
? [{ key: 'context' as const, label: vehicleDetailContextLabel(contextRange) }, ...RANGE_TABS]
: RANGE_TABS
), [contextRange]);
// 拉取数据(车辆或区间变化)
useEffect(() => {
if (!vehicle || !requestKey) return;
setLoading(true);
setError(null);
let cancelled = false;
fetchVehicleRecent(vehicle.plate, {
start: resolvedRange.start,
end: resolvedRange.end,
sourcePriority,
})
.then(d => {
if (cancelled) return;
setDays(d.days);
setLoadedRequestKey(requestKey);
})
.catch(cause => {
if (!cancelled) {
setError(cause instanceof Error ? cause.message : '车辆近期里程加载失败,请稍后重试。');
}
})
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [requestKey, requestVersion]); // eslint-disable-line react-hooks/exhaustive-deps
// 锁滚动
useEffect(() => {
if (!vehicle) return;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}, [vehicle]);
// 预设区间排除未完整的今日;显式下钻区间严格保留用户选择的日期。
const historyDays = useMemo(() => {
if (!hasSnapshot) return [];
return includeCurrentDayInVehicleDetail(range) ? days : days.filter(d => !isToday(d.date));
}, [days, hasSnapshot, range]);
const stats = useMemo(() => {
const totalKm = historyDays.reduce((s, d) => s + d.dailyKm, 0);
const synced = historyDays.filter(d => d.isDataSynced).length;
const avg = synced > 0 ? totalKm / synced : 0;
const max = Math.max(1, ...historyDays.map(d => d.dailyKm));
return { totalKm, synced, avg, max, totalDays: historyDays.length };
}, [historyDays]);
// 骨架天数:根据区间预估
const skeletonCount = useMemo(() => {
const start = new Date(`${resolvedRange.start}T00:00:00`);
const end = new Date(`${resolvedRange.end}T00:00:00`);
return Math.max(1, Math.round((end.getTime() - start.getTime()) / 86400000) + 1);
}, [resolvedRange.end, resolvedRange.start]);
const showInitialLoading = loading && !hasSnapshot;
const showUnavailable = !!error && !hasSnapshot;
return (
<AnimatePresence>
{vehicle && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[80] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
onClick={onClose}
>
<motion.div
initial={{ y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '100%', opacity: 0 }}
transition={{
y: { type: 'spring', damping: 32, stiffness: 320 },
opacity: { duration: 0.18 },
}}
drag="y"
dragControls={dragControls}
dragListener={false}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.6 }}
onDragEnd={(_, info) => {
if (info.offset.y > 100 || info.velocity.y > 600) onClose();
}}
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 touch-pan-y"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="vehicle-detail-title"
>
{/* iOS 风格 drag handle —— 长按下滑可关闭 */}
<div
className="flex justify-center pt-2.5 pb-1.5 cursor-grab active:cursor-grabbing select-none"
onPointerDown={(e) => dragControls.start(e)}
style={{ touchAction: 'none' }}
>
<div className="w-10 h-1 rounded-full bg-slate-300" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-4 pb-2 border-b border-slate-100">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 rounded-xl bg-blue-50 flex items-center justify-center flex-shrink-0">
<Truck size={16} className="text-blue-600" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span id="vehicle-detail-title" className="text-sm font-black text-slate-900 font-mono truncate"><Blur>{vehicle.plate}</Blur></span>
<span className={`text-[8px] px-1 rounded font-bold ${vehicle.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'}`}>
{vehicle.isOnline ? '在线' : '离线'}
</span>
</div>
<div className="text-[10px] font-bold text-slate-400 truncate">
{vehicle.rentStatus || ''}
{vehicle.department ? ` · ${vehicle.department.replace('业务', '')}` : ''}
{vehicle.customer ? ` · ` : ''}
{vehicle.customer && <Blur>{vehicle.customer}</Blur>}
</div>
</div>
</div>
<button type="button" onClick={onClose} aria-label="关闭车辆详情" className="p-2 -mr-1 text-slate-400 hover:text-slate-700 flex-shrink-0">
<X size={18} />
</button>
</div>
{/* 时间范围切换 */}
<div className="px-4 pt-3">
<div className="relative inline-flex max-w-full overflow-x-auto bg-slate-100 p-0.5 rounded-lg" role="tablist" aria-label="车辆里程区间">
{rangeTabs.map(tab => (
<button
type="button"
key={tab.key}
onClick={() => setRange(tab.key)}
role="tab"
aria-selected={range === tab.key}
className={`relative shrink-0 px-3 py-1 text-[10px] font-bold rounded-md transition-colors ${range === tab.key ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'}`}
>
{range === tab.key && (
<motion.div
layoutId="rangeTabBg"
className="absolute inset-0 bg-white shadow-sm rounded-md"
transition={{ type: 'spring', damping: 30, stiffness: 350 }}
/>
)}
<span className="relative">{tab.label}</span>
</button>
))}
</div>
{loading && hasSnapshot ? (
<span className="ml-2 inline-flex items-center gap-1 text-[9px] font-bold text-slate-400">
<RefreshCw size={10} className="animate-spin" />
</span>
) : null}
</div>
{error ? (
<div className="px-4 pt-3">
<ErrorState
title="车辆近期里程加载失败"
message={hasSnapshot ? `${error} 当前保留上一次成功加载的数据。` : error}
onRetry={() => setRequestVersion(version => version + 1)}
retrying={loading}
variant="inline"
/>
</div>
) : null}
{/* KPI cards */}
<div className="px-4 py-3 grid grid-cols-3 gap-2">
<div className="bg-slate-50 rounded-xl p-2.5">
<div className="text-[9px] font-bold text-slate-400 uppercase"></div>
<div className="text-base font-black text-slate-900 leading-tight">
{showInitialLoading ? <span className="inline-block h-4 w-14 bg-slate-200 rounded animate-pulse align-middle" />
: showUnavailable ? <span className="text-slate-300"></span>
: <>{Math.round(stats.totalKm).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
</div>
</div>
<div className="bg-slate-50 rounded-xl p-2.5">
<div className="text-[9px] font-bold text-slate-400 uppercase"></div>
<div className="text-base font-black text-slate-900 leading-tight">
{showInitialLoading ? <span className="inline-block h-4 w-10 bg-slate-200 rounded animate-pulse align-middle" />
: showUnavailable ? <span className="text-slate-300"></span>
: <>{Math.round(stats.avg).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
</div>
</div>
<div className="bg-slate-50 rounded-xl p-2.5">
<div className="text-[9px] font-bold text-slate-400 uppercase"></div>
<div className="text-base font-black text-slate-900 leading-tight">
{showInitialLoading ? <span className="inline-block h-4 w-12 bg-slate-200 rounded animate-pulse align-middle" />
: showUnavailable ? <span className="text-slate-300"></span>
: <>{stats.synced}<span className="text-[9px] font-bold text-slate-400 ml-0.5">/{stats.totalDays}</span></>}
</div>
</div>
</div>
{/* Bar chart */}
<div className="px-4 pb-2">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] font-bold text-slate-500"></span>
<span className="text-[9px] font-bold text-slate-300"> km</span>
</div>
<div className="bg-white rounded-xl border border-slate-50">
<div className="h-[140px]">
{showInitialLoading ? (
<SkeletonBars count={Math.min(skeletonCount, 30)} />
) : showUnavailable ? (
<div className="flex h-full items-center justify-center text-[10px] font-bold text-slate-300"></div>
) : historyDays.length === 0 ? (
<EmptyState title="暂无里程数据" description="所选区间没有可展示的车辆里程" variant="inline" />
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={historyDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<XAxis
dataKey="date"
tickFormatter={(d) => formatLabel(d, range)}
tick={{ fontSize: 9, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
minTickGap={6}
/>
<YAxis hide />
<Tooltip
formatter={(v) => [`${Math.round(Number(v) || 0).toLocaleString()} km`, '行驶里程']}
labelFormatter={(d) => `日期 ${d}`}
contentStyle={{ borderRadius: 12, fontSize: 11, padding: '4px 8px' }}
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
/>
<Bar dataKey="dailyKm" radius={[3, 3, 0, 0]} animationDuration={500}>
{historyDays.map((d, i) => (
<Cell key={i} fill={d.isDataSynced ? '#3b82f6' : '#e2e8f0'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
{/* 每日明细 */}
<div className="flex-1 overflow-y-auto px-4 pb-4">
<div className="text-[10px] font-bold text-slate-500 mb-1.5"></div>
{showInitialLoading ? (
<SkeletonList count={Math.min(skeletonCount, 15)} />
) : showUnavailable ? (
<div className="py-6 text-center text-[10px] font-bold text-slate-300"></div>
) : historyDays.length === 0 ? (
<div className="py-6 text-center text-[10px] font-bold text-slate-300"></div>
) : (
<motion.div
key={range}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
className="space-y-1"
>
{historyDays.slice().reverse().map((d, i) => (
<motion.div
key={d.date}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
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"
>
<div className="w-[88px] flex-shrink-0">
<div className="text-[11px] font-mono font-bold text-slate-600">{d.date}</div>
<div
className={`text-[8px] font-bold ${
d.sourceCategory === 'INSTRUMENT'
? 'text-violet-500'
: d.sourceCategory === 'GPS'
? 'text-emerald-500'
: 'text-amber-500'
}`}
title={d.sourceProtocol || 'OneOS 当前未返回 sourceProtocol'}
>
{daySourceLabel(d)}
</div>
</div>
<div className="flex items-center gap-2 flex-1 ml-3">
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${(d.dailyKm / stats.max) * 100}%` }}
transition={{ delay: Math.min(i * 0.012, 0.4) + 0.1, duration: 0.4 }}
className={`h-full rounded-full ${d.isDataSynced ? 'bg-blue-500' : 'bg-slate-200'}`}
/>
</div>
<span className={`text-[11px] font-mono font-bold w-20 text-right ${d.isDataSynced ? 'text-slate-700' : 'text-amber-500/60'}`}>
{d.isDataSynced
? <>{Math.round(d.dailyKm).toLocaleString()} <span className="text-[9px] text-slate-400">km</span></>
: <span className="text-[9px]"></span>}
</span>
</div>
</motion.div>
))}
</motion.div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
function SkeletonBars({ count }: { count: number }) {
return (
<div className="flex items-end gap-1 h-full px-2 pb-2 pt-2">
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className="flex-1 bg-slate-100 rounded-t animate-pulse"
style={{
height: `${30 + Math.sin(i * 0.7) * 25 + Math.cos(i * 0.4) * 15 + 30}%`,
animationDelay: `${i * 40}ms`,
}}
/>
))}
</div>
);
}
function SkeletonList({ count }: { count: number }) {
return (
<div className="space-y-1">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-1.5 px-2 animate-pulse" style={{ animationDelay: `${i * 30}ms` }}>
<div className="h-3 w-20 bg-slate-100 rounded" />
<div className="flex items-center gap-2 flex-1 ml-3">
<div className="flex-1 h-1.5 bg-slate-100 rounded-full" />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
</div>
))}
</div>
);
}
+3 -141
View File
@@ -1,29 +1,11 @@
import type {
MileageSourceCategory,
MileageSourceGroup,
MileageSourceProtocol,
MonitoringData,
TargetSummary,
TargetVehicle,
TrendPoint,
} from './types';
import type { MonitoringData, TargetSummary, TargetVehicle, TrendPoint } from './types';
import { fetchJson } from '../../auth/api-client';
import {
buildDailyReport,
type DailyReportData,
} from './daily-report';
export type {
DailyReportData,
DailyReportModel,
DailyReportVehicle,
} from './daily-report';
const BASE = '/api/mileage';
export async function fetchMonitoring(params?: {
sortBy?: 'today' | 'total' | 'statisticTime';
sortOrder?: 'asc' | 'desc';
sortBy?: string;
sortOrder?: string;
limit?: number;
page?: number;
search?: string;
@@ -34,17 +16,10 @@ export async function fetchMonitoring(params?: {
rentStatus?: string;
platePrefix?: string;
targetName?: string;
targetNames?: string[];
region?: string;
plate?: string;
mileageMin?: string;
mileageMax?: string;
date?: string;
startDate?: string;
endDate?: string;
brands?: string[];
sourcePriority?: MileageSourceGroup[];
force?: boolean;
}): Promise<MonitoringData> {
const query = new URLSearchParams();
if (params?.sortBy) query.set('sortBy', params.sortBy);
@@ -59,25 +34,10 @@ export async function fetchMonitoring(params?: {
if (params?.rentStatus) query.set('rentStatus', params.rentStatus);
if (params?.platePrefix) query.set('platePrefix', params.platePrefix);
if (params?.targetName) query.set('targetName', params.targetName);
if (params?.targetNames) {
params.targetNames.forEach(name => {
if (name) query.append('targetName', name);
});
}
if (params?.region) query.set('region', params.region);
if (params?.brands) {
params.brands.forEach(brand => {
if (brand) query.append('brand', brand);
});
}
if (params?.plate) query.set('plate', params.plate);
if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
if (params?.mileageMax) query.set('mileageMax', params.mileageMax);
if (params?.date) query.set('date', params.date);
if (params?.startDate) query.set('startDate', params.startDate);
if (params?.endDate) query.set('endDate', params.endDate);
if (params?.sourcePriority?.length) query.set('sourcePriority', params.sourcePriority.join(','));
if (params?.force) query.set('force', '1');
const qs = query.toString();
return fetchJson<MonitoringData>(`${BASE}/monitoring${qs ? `?${qs}` : ''}`);
}
@@ -99,101 +59,3 @@ export async function fetchTrend(targetId?: number, days = 7): Promise<TrendPoin
params.set('days', String(days));
return fetchJson<TrendPoint[]>(`${BASE}/trend?${params.toString()}`);
}
export interface VehicleRecentDay {
date: string;
dailyKm: number;
isDataSynced: boolean;
sourceProtocol: MileageSourceProtocol | null;
sourceCategory: Exclude<MileageSourceCategory, 'MIXED'> | null;
}
export interface VehicleRecentResponse {
plate: string;
start?: string;
end?: string;
days: VehicleRecentDay[];
sourcePriority?: MileageSourceGroup[];
}
export async function fetchVehicleRecent(
plate: string,
range: {
days?: number;
start?: string;
end?: string;
sourcePriority?: MileageSourceGroup[];
} = { days: 15 },
): Promise<VehicleRecentResponse> {
const params = new URLSearchParams();
if (range.start) params.set('start', range.start);
if (range.end) params.set('end', range.end);
if (range.days != null) params.set('days', String(range.days));
if (range.sourcePriority?.length) params.set('sourcePriority', range.sourcePriority.join(','));
return fetchJson<VehicleRecentResponse>(
`${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}`
);
}
function reportDateFromUpdatedAt(updatedAt: string): string {
if (/^\d{4}-\d{2}-\d{2}$/.test(updatedAt)) return updatedAt;
const d = new Date(updatedAt);
if (!Number.isNaN(d.getTime())) return d.toISOString().slice(0, 10);
return new Date().toISOString().slice(0, 10);
}
let dailyReportRequest: Promise<DailyReportData> | null = null;
let dailyReportCache: { data: DailyReportData; expiresAt: number } | null = null;
const DAILY_REPORT_CACHE_MS = 60_000;
async function loadDailyReport(): Promise<DailyReportData> {
const [targets, monitoring] = await Promise.all([
fetchTargets(),
fetchMonitoring({ sortBy: 'today', sortOrder: 'desc', limit: 5 }),
]);
const reportDate = monitoring.dateRange?.end || reportDateFromUpdatedAt(monitoring.updatedAt);
const targetVehicles = await Promise.all(
targets.map(async target => {
const vehicles = await fetchTargetVehicles(target.id, reportDate);
return { target, vehicles };
}),
);
return buildDailyReport(targets, targetVehicles, monitoring, [], reportDate);
}
export async function fetchDailyReport(force = false): Promise<DailyReportData> {
if (!force && dailyReportCache && dailyReportCache.expiresAt > Date.now()) {
return dailyReportCache.data;
}
if (!force && dailyReportRequest) return dailyReportRequest;
const request = loadDailyReport();
dailyReportRequest = request;
try {
const data = await request;
dailyReportCache = { data, expiresAt: Date.now() + DAILY_REPORT_CACHE_MS };
return data;
} finally {
if (dailyReportRequest === request) dailyReportRequest = null;
}
}
let dailyReportTrendRequest: Promise<TrendPoint[]> | null = null;
let dailyReportTrendCache: { data: TrendPoint[]; expiresAt: number } | null = null;
export async function fetchDailyReportTrend(force = false): Promise<TrendPoint[]> {
if (!force && dailyReportTrendCache && dailyReportTrendCache.expiresAt > Date.now()) {
return dailyReportTrendCache.data;
}
if (!force && dailyReportTrendRequest) return dailyReportTrendRequest;
const request = fetchTrend(undefined, 7);
dailyReportTrendRequest = request;
try {
const data = await request;
dailyReportTrendCache = { data, expiresAt: Date.now() + DAILY_REPORT_CACHE_MS };
return data;
} finally {
if (dailyReportTrendRequest === request) dailyReportTrendRequest = null;
}
}
@@ -1,25 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { assessVehicleCoverage } from './assessment-coverage.js';
test('reports matched, missing, and excess assessment vehicles', () => {
assert.deepEqual(assessVehicleCoverage(50, 46), {
status: 'missing',
declaredCount: 50,
activeCount: 46,
difference: -4,
coverageRate: 92,
});
assert.equal(assessVehicleCoverage(40, 40).status, 'matched');
assert.equal(assessVehicleCoverage(40, 42).status, 'excess');
});
test('normalizes invalid counts without inventing configured vehicles', () => {
assert.deepEqual(assessVehicleCoverage(Number.NaN, -2), {
status: 'matched',
declaredCount: 0,
activeCount: 0,
difference: 0,
coverageRate: 100,
});
});
@@ -1,26 +0,0 @@
export type AssessmentCoverageStatus = 'matched' | 'missing' | 'excess';
export interface AssessmentCoverage {
status: AssessmentCoverageStatus;
declaredCount: number;
activeCount: number;
difference: number;
coverageRate: number;
}
function count(value: number): number {
return Math.max(0, Math.trunc(Number(value) || 0));
}
export function assessVehicleCoverage(declaredCount: number, activeCount: number): AssessmentCoverage {
const declared = count(declaredCount);
const active = count(activeCount);
const difference = active - declared;
return {
status: difference === 0 ? 'matched' : difference < 0 ? 'missing' : 'excess',
declaredCount: declared,
activeCount: active,
difference,
coverageRate: declared > 0 ? active / declared * 100 : active === 0 ? 100 : 0,
};
}
-88
View File
@@ -1,88 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildMileageAttribution,
filterAttributionVehicles,
reconcileMileageAttribution,
} from './attribution.js';
import type { TargetVehicle } from './types.js';
function vehicle(overrides: Partial<TargetVehicle>): TargetVehicle {
return {
plateNumber: '粤A00000',
todayMileage: 0,
totalMileage: 0,
completionRate: 0,
isQualified: false,
currentYearIsQualified: false,
dailyRequiredMileage: 100,
rentStatus: null,
department: null,
customer: null,
isOnline: false,
...overrides,
};
}
test('groups vehicles and calculates additive attribution metrics', () => {
const result = buildMileageAttribution([
vehicle({ plateNumber: '粤A1', department: '业务一部', todayMileage: 120, totalMileage: 1000, completionRate: 0.8, isOnline: true }),
vehicle({ plateNumber: '粤A2', department: '业务一部', todayMileage: 40, totalMileage: 500, completionRate: 0.4 }),
vehicle({ plateNumber: '粤A3', department: '业务二部', todayMileage: 40, totalMileage: 400, completionRate: 0.2 }),
], 'department', 1000);
const first = result.find(group => group.key === '业务一部');
assert.deepEqual(first, {
key: '业务一部',
label: '业务一部',
vehicleCount: 2,
onlineCount: 1,
zeroMileageCount: 0,
behindCount: 1,
todayMileage: 160,
totalMileage: 1500,
targetMileage: 2000,
completedMileage: 1500,
shortfallMileage: 500,
completionRate: 75,
contributionRate: 80,
});
});
test('keeps unassigned vehicles visible and sorts largest shortfall first', () => {
const result = buildMileageAttribution([
vehicle({ plateNumber: '粤A1', department: null, todayMileage: 0 }),
vehicle({ plateNumber: '粤A2', department: '业务一部', todayMileage: 80, totalMileage: 100 }),
], 'department', 100);
assert.equal(result[0].label, '未分配部门');
assert.equal(result[0].shortfallMileage, 100);
assert.equal(result[0].zeroMileageCount, 1);
});
test('reconciles additive group shortfall with the target summary at display precision', () => {
const groups = buildMileageAttribution([
vehicle({ plateNumber: '粤A1', department: '业务一部', totalMileage: 1200 }),
vehicle({ plateNumber: '粤A2', department: '业务二部', totalMileage: 499.96 }),
], 'department', 1000);
assert.deepEqual(reconcileMileageAttribution(groups, 500), {
matches: true,
attributedShortfall: 500,
expectedShortfall: 500,
difference: 0,
});
assert.equal(reconcileMileageAttribution(groups, 450).difference, 50);
});
test('filters a group using the same empty-value semantics as aggregation', () => {
const vehicles = [
vehicle({ plateNumber: '粤A1', customer: null }),
vehicle({ plateNumber: '粤A2', customer: '客户甲' }),
];
assert.deepEqual(
filterAttributionVehicles(vehicles, 'customer', '未绑定客户').map(item => item.plateNumber),
['粤A1'],
);
});
-121
View File
@@ -1,121 +0,0 @@
import type { TargetVehicle } from './types';
export type AttributionDimension = 'department' | 'customer';
export interface MileageAttributionGroup {
key: string;
label: string;
vehicleCount: number;
onlineCount: number;
zeroMileageCount: number;
behindCount: number;
todayMileage: number;
totalMileage: number;
targetMileage: number;
completedMileage: number;
shortfallMileage: number;
completionRate: number;
contributionRate: number;
}
export interface MileageAttributionReconciliation {
matches: boolean;
attributedShortfall: number;
expectedShortfall: number;
difference: number;
}
const EMPTY_LABELS: Record<AttributionDimension, string> = {
department: '未分配部门',
customer: '未绑定客户',
};
export function attributionValue(
vehicle: TargetVehicle,
dimension: AttributionDimension,
): string {
return vehicle[dimension]?.trim() || EMPTY_LABELS[dimension];
}
export function buildMileageAttribution(
vehicles: TargetVehicle[],
dimension: AttributionDimension,
targetMileagePerVehicle: number,
): MileageAttributionGroup[] {
const perVehicleTarget = Math.max(0, Number(targetMileagePerVehicle) || 0);
const totalTodayMileage = vehicles.reduce(
(sum, vehicle) => sum + Math.max(0, Number(vehicle.todayMileage) || 0),
0,
);
const grouped = new Map<string, TargetVehicle[]>();
for (const vehicle of vehicles) {
const key = attributionValue(vehicle, dimension);
const current = grouped.get(key) || [];
current.push(vehicle);
grouped.set(key, current);
}
return Array.from(grouped, ([key, members]) => {
const todayMileage = members.reduce((sum, vehicle) => sum + Math.max(0, Number(vehicle.todayMileage) || 0), 0);
const totalMileage = members.reduce((sum, vehicle) => sum + Math.max(0, Number(vehicle.totalMileage) || 0), 0);
const targetMileage = perVehicleTarget * members.length;
const completedMileage = members.reduce((sum, vehicle) => (
sum + Math.min(Math.max(0, Number(vehicle.totalMileage) || 0), perVehicleTarget)
), 0);
const shortfallMileage = members.reduce((sum, vehicle) => {
const completed = Math.max(0, Number(vehicle.totalMileage) || 0);
return sum + Math.max(0, perVehicleTarget - completed);
}, 0);
return {
key,
label: key,
vehicleCount: members.length,
onlineCount: members.filter(vehicle => vehicle.isOnline).length,
zeroMileageCount: members.filter(vehicle => (Number(vehicle.todayMileage) || 0) <= 0).length,
behindCount: members.filter(vehicle => (
(Number(vehicle.totalMileage) || 0) < perVehicleTarget
)).length,
todayMileage,
totalMileage,
targetMileage,
completedMileage,
shortfallMileage,
completionRate: targetMileage > 0 ? completedMileage / targetMileage * 100 : 0,
contributionRate: totalTodayMileage > 0 ? todayMileage / totalTodayMileage * 100 : 0,
};
}).sort((a, b) => (
b.shortfallMileage - a.shortfallMileage
|| b.behindCount - a.behindCount
|| b.todayMileage - a.todayMileage
|| a.label.localeCompare(b.label, 'zh-CN')
));
}
function roundMileage(value: number): number {
return Math.round((Number(value) || 0) * 10) / 10;
}
export function reconcileMileageAttribution(
groups: MileageAttributionGroup[],
expectedShortfall: number,
): MileageAttributionReconciliation {
const attributedShortfall = roundMileage(groups.reduce((sum, group) => sum + group.shortfallMileage, 0));
const normalizedExpected = roundMileage(expectedShortfall);
const difference = roundMileage(attributedShortfall - normalizedExpected);
return {
matches: difference === 0,
attributedShortfall,
expectedShortfall: normalizedExpected,
difference,
};
}
export function filterAttributionVehicles(
vehicles: TargetVehicle[],
dimension: AttributionDimension,
value: string,
): TargetVehicle[] {
return vehicles.filter(vehicle => attributionValue(vehicle, dimension) === value);
}
-98
View File
@@ -1,98 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildDailyReport } from './daily-report.js';
import type { MonitoringData, TargetSummary, TargetVehicle } from './types.js';
function target(id: number, completed: number, goal: number): TargetSummary {
return {
id,
targetName: `目标${id}`,
vehicleCount: 2,
cumulativeTotal: completed,
currentYearCompleted: completed,
currentYearTarget: goal,
yearQualifiedCount: 0,
halfQualifiedCount: 0,
} as TargetSummary;
}
function vehicle(plateNumber: string, todayMileage: number, dailyRequiredMileage: number): TargetVehicle {
return {
plateNumber,
todayMileage,
totalMileage: 1000,
completionRate: 0.5,
isQualified: false,
currentYearIsQualified: false,
dailyRequiredMileage,
rentStatus: '租赁',
department: '业务一部',
customer: '客户甲',
isOnline: true,
};
}
const monitoring = {
stats: { totalToday: 0, totalAll: 0, vehicleCount: 10, yesterdayTotal: 0 },
updatedAt: '2026-08-07T10:00:00+08:00',
} as MonitoringData;
test('builds a weighted, additive daily report', () => {
const first = target(1, 50, 100);
const second = target(2, 100, 300);
const report = buildDailyReport(
[first, second],
[
{ target: first, vehicles: [vehicle('粤A1', 120, 100), vehicle('粤A2', 0, 100)] },
{ target: second, vehicles: [vehicle('粤A3', 50, 100)] },
],
monitoring,
[{ date: '08-06', mileage: 500 }],
'2026-08-07',
);
assert.equal(report.totalToday, 170);
assert.equal(report.dailyNeed, 300);
assert.equal(report.shortfall, 150);
assert.equal(report.activeCount, 2);
assert.equal(report.zeroCount, 1);
assert.equal(report.completionRate, 37.5);
assert.equal(report.models[0].shortfall, 100);
assert.deepEqual(report.trend, [{ date: '08-06', value: 500 }]);
});
test('deduplicates vehicles assigned to multiple targets', () => {
const first = target(1, 50, 100);
const second = target(2, 100, 300);
const report = buildDailyReport(
[first, second],
[
{ target: first, vehicles: [vehicle('粤A1', 80, 100)] },
{ target: second, vehicles: [vehicle('粤A1', 80, 100)] },
],
monitoring,
[],
'2026-08-07',
);
assert.equal(report.assessmentVehicleCount, 1);
assert.equal(report.duplicateAssignmentCount, 1);
assert.equal(report.totalToday, 80);
});
test('falls back to the target daily need when vehicle tasks are missing', () => {
const item = target(5, 100, 300);
item.dailyTarget = 250;
const report = buildDailyReport(
[item],
[{ target: item, vehicles: [vehicle('粤A1', 100, 0), vehicle('粤A2', 50, 0)] }],
monitoring,
[],
'2026-08-07',
);
assert.equal(report.dailyNeed, 250);
assert.equal(report.shortfall, 100);
assert.equal(report.missingDailyTaskCount, 2);
assert.equal(report.models[0].missingDailyTaskCount, 2);
});
-200
View File
@@ -1,200 +0,0 @@
import { weightedCompletionRate } from '../../shared/analytics/metrics';
import type {
MonitoringData,
TargetSummary,
TargetVehicle,
TrendPoint,
} from './types';
export interface DailyReportModel {
id: number;
name: string;
count: number;
today: number;
total: number;
completion: number;
active: number;
zero: number;
dailyNeed: number;
shortfall: number;
missingDailyTaskCount: number;
}
export interface DailyReportVehicle {
targetId: number;
plate: string;
model: string;
status: string;
customer: string;
today: number;
completion: number;
dailyNeed: number;
shortfall: number;
isOnline: boolean;
hasDailyTask: boolean;
}
export interface DailyReportData {
reportDate: string;
updatedAt: string;
monitoringVehicleCount: number;
assessmentVehicleCount: number;
duplicateAssignmentCount: number;
missingDailyTaskCount: number;
totalToday: number;
activeCount: number;
zeroCount: number;
dailyNeed: number;
shortfall: number;
completionRate: number;
models: DailyReportModel[];
trend: { date: string; value: number }[];
topVehicles: DailyReportVehicle[];
riskVehicles: DailyReportVehicle[];
qualifiedCount: number;
halfQualifiedCount: number;
}
interface TargetVehicleEntry {
target: TargetSummary;
vehicles: TargetVehicle[];
}
function compactTargetName(name: string): string {
return name
.replace(/^羚牛/, '')
.replace(/辆/g, '台')
.replace(/4\.5T普货/g, '普货')
.replace(/4\.5T冷链车/g, '冷链车')
.replace(/4\.5T冷链/g, '冷链车');
}
function normalizeStatus(status: string | null): string {
if (!status) return '未标注';
if (status === '自营' || status === '租赁') return status;
if (/租/.test(status)) return '租赁';
if (/自/.test(status)) return '自营';
if (/库|Inventory/i.test(status)) return '在库';
return status;
}
function positive(value: number | null | undefined): number {
return Math.max(0, Number(value) || 0);
}
function vehicleReportRow(
vehicle: TargetVehicle,
targetId: number,
model: string,
targetDailyNeed: number,
): DailyReportVehicle {
const today = positive(vehicle.todayMileage);
const dailyNeed = positive(vehicle.dailyRequiredMileage);
return {
targetId,
plate: vehicle.plateNumber,
model,
status: normalizeStatus(vehicle.rentStatus),
customer: vehicle.customer || '未绑定客户',
today,
completion: positive(vehicle.completionRate) * 100,
dailyNeed,
shortfall: Math.max(0, dailyNeed - today),
isOnline: vehicle.isOnline,
hasDailyTask: dailyNeed > 0 || targetDailyNeed <= 0,
};
}
export function buildDailyReport(
targets: TargetSummary[],
targetVehicles: TargetVehicleEntry[],
monitoring: MonitoringData,
trend: TrendPoint[],
reportDate: string,
): DailyReportData {
const models = targetVehicles.map(({ target, vehicles }) => {
const today = vehicles.reduce((sum, vehicle) => sum + positive(vehicle.todayMileage), 0);
const vehicleDailyNeed = vehicles.reduce((sum, vehicle) => sum + positive(vehicle.dailyRequiredMileage), 0);
const targetDailyNeed = positive(target.dailyTarget);
const dailyNeed = Math.max(vehicleDailyNeed, targetDailyNeed);
const vehicleShortfall = vehicles.reduce((sum, vehicle) => (
sum + Math.max(0, positive(vehicle.dailyRequiredMileage) - positive(vehicle.todayMileage))
), 0);
const shortfall = Math.max(vehicleShortfall, Math.max(0, dailyNeed - today));
return {
id: target.id,
name: compactTargetName(target.targetName),
count: vehicles.length,
today,
total: positive(target.cumulativeTotal),
completion: weightedCompletionRate([{
completed: positive(target.currentYearCompleted),
target: positive(target.currentYearTarget),
}]),
active: vehicles.filter(vehicle => positive(vehicle.todayMileage) > 0).length,
zero: vehicles.filter(vehicle => positive(vehicle.todayMileage) <= 0).length,
dailyNeed,
shortfall,
missingDailyTaskCount: targetDailyNeed > 0
? vehicles.filter(vehicle => positive(vehicle.dailyRequiredMileage) <= 0).length
: 0,
};
});
const uniqueVehicles = new Map<string, DailyReportVehicle>();
let duplicateAssignmentCount = 0;
for (const { target, vehicles } of targetVehicles) {
const model = compactTargetName(target.targetName);
for (const vehicle of vehicles) {
const next = vehicleReportRow(vehicle, target.id, model, positive(target.dailyTarget));
const existing = uniqueVehicles.get(next.plate);
if (!existing) {
uniqueVehicles.set(next.plate, next);
continue;
}
duplicateAssignmentCount += 1;
uniqueVehicles.set(next.plate, {
...existing,
today: Math.max(existing.today, next.today),
completion: Math.max(existing.completion, next.completion),
dailyNeed: Math.max(existing.dailyNeed, next.dailyNeed),
shortfall: Math.max(existing.shortfall, next.shortfall),
isOnline: existing.isOnline || next.isOnline,
hasDailyTask: existing.hasDailyTask || next.hasDailyTask,
});
}
}
const vehicles = Array.from(uniqueVehicles.values());
const activeCount = vehicles.filter(vehicle => vehicle.today > 0).length;
const totalToday = vehicles.reduce((sum, vehicle) => sum + vehicle.today, 0);
const dailyNeed = models.reduce((sum, model) => sum + model.dailyNeed, 0);
const shortfall = models.reduce((sum, model) => sum + model.shortfall, 0);
return {
reportDate,
updatedAt: monitoring.updatedAt,
monitoringVehicleCount: monitoring.stats.vehicleCount,
assessmentVehicleCount: vehicles.length,
duplicateAssignmentCount,
missingDailyTaskCount: models.reduce((sum, model) => sum + model.missingDailyTaskCount, 0),
totalToday,
activeCount,
zeroCount: vehicles.length - activeCount,
dailyNeed,
shortfall,
completionRate: weightedCompletionRate(targets.map(target => ({
completed: positive(target.currentYearCompleted),
target: positive(target.currentYearTarget),
}))),
models: models.sort((a, b) => b.shortfall - a.shortfall || b.today - a.today),
trend: trend.map(item => ({ date: item.date, value: item.mileage })),
topVehicles: [...vehicles].sort((a, b) => b.today - a.today).slice(0, 5),
riskVehicles: [...vehicles]
.filter(vehicle => vehicle.shortfall > 0)
.sort((a, b) => b.shortfall - a.shortfall || a.completion - b.completion)
.slice(0, 5),
qualifiedCount: targets.reduce((sum, target) => sum + target.yearQualifiedCount, 0),
halfQualifiedCount: targets.reduce((sum, target) => sum + target.halfQualifiedCount, 0),
};
}
@@ -1,18 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { formatMileageSnapshotDateTime, formatMileageSnapshotTime } from './data-freshness.js';
test('formats snapshot instants in Asia/Shanghai', () => {
assert.equal(formatMileageSnapshotTime('2026-08-07T07:45:06.000Z'), '15:45:06');
});
test('keeps historical date snapshots explicit', () => {
assert.equal(formatMileageSnapshotTime('2026-08-06'), '2026-08-06');
assert.equal(formatMileageSnapshotTime(null), '--:--:--');
});
test('formats assessment snapshot instants with the business date and minute', () => {
assert.equal(formatMileageSnapshotDateTime('2026-08-07T09:45:01.000Z'), '2026.8.7 17:45');
assert.equal(formatMileageSnapshotDateTime(null), '--');
assert.equal(formatMileageSnapshotDateTime('not-a-date'), 'not-a-date');
});
-32
View File
@@ -1,32 +0,0 @@
export function formatMileageSnapshotTime(value: string | null): string {
if (!value) return '--:--:--';
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
hourCycle: 'h23',
}).format(date);
}
export function formatMileageSnapshotDateTime(value: string | null): string {
if (!value) return '--';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const parts = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
hourCycle: 'h23',
}).formatToParts(date);
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find(item => item.type === type)?.value || '';
return `${part('year')}.${part('month')}.${part('day')} ${part('hour')}:${part('minute')}`;
}
-76
View File
@@ -1,76 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildMileageDrillUrl, parseMileageDrillContext } from './drill-context.js';
test('parses a vehicle drill context', () => {
const context = parseMileageDrillContext(
'?mileageLevel=vehicle&mileageTarget=5&mileagePlate=%E7%B2%A4A08190F&mileageStart=2026-08-01&mileageEnd=2026-08-07&mileageSource=instrument,gps',
);
assert.deepEqual(context, {
level: 'vehicle',
targetId: 5,
plate: '粤A08190F',
startDate: '2026-08-01',
endDate: '2026-08-07',
sourcePriority: ['instrument', 'gps'],
});
});
test('requires complete context for breakdown and vehicle levels', () => {
assert.equal(parseMileageDrillContext('?mileageLevel=vehicle').level, 'overview');
assert.equal(parseMileageDrillContext('?mileageLevel=breakdown&mileageDimension=region').level, 'overview');
});
test('parses a target-scoped breakdown context', () => {
assert.deepEqual(
parseMileageDrillContext('?mileageLevel=breakdown&mileageTarget=5&mileageYear=2&mileageDimension=department&mileageValue=%E4%B8%9A%E5%8A%A1%E4%B8%80%E9%83%A8'),
{
level: 'breakdown',
targetId: 5,
assessmentYear: 2,
dimension: 'department',
dimensionValue: '业务一部',
sourcePriority: ['instrument'],
},
);
});
test('accepts assessment years only with a target', () => {
assert.equal(parseMileageDrillContext('?mileageTarget=5&mileageYear=3').assessmentYear, 3);
assert.equal(parseMileageDrillContext('?mileageYear=3').assessmentYear, undefined);
assert.equal(parseMileageDrillContext('?mileageTarget=5&mileageYear=0').assessmentYear, undefined);
});
test('builds a namespaced URL and preserves unrelated parameters', () => {
const url = buildMileageDrillUrl(
{ pathname: '/asset', search: '?company=5&legacy=1', hash: '#mileage' },
{
level: 'vehicle',
targetId: 5,
assessmentYear: 2,
plate: '粤A08190F',
startDate: '2026-08-01',
endDate: '2026-08-07',
sourcePriority: ['instrument'],
},
);
assert.equal(
url,
'/asset?company=5&legacy=1&mileageLevel=vehicle&mileageTarget=5&mileageYear=2&mileagePlate=%E7%B2%A4A08190F&mileageStart=2026-08-01&mileageEnd=2026-08-07&mileageSource=instrument#mileage',
);
});
test('clears entity drill parameters when returning to overview', () => {
const url = buildMileageDrillUrl(
{
pathname: '/asset',
search: '?mileageLevel=vehicle&mileageTarget=5&mileageYear=2&mileagePlate=%E7%B2%A4A08190F&company=5',
hash: '#mileage',
},
{ level: 'overview', sourcePriority: ['instrument'] },
);
assert.equal(url, '/asset?company=5&mileageSource=instrument#mileage');
});
-125
View File
@@ -1,125 +0,0 @@
import type { MileageSourceGroup } from './types';
export type MileageDrillLevel = 'overview' | 'breakdown' | 'vehicle';
export type MileageBreakdownDimension =
| 'department'
| 'region'
| 'customer'
| 'vehicle-model'
| 'assessment-target';
export interface MileageDrillContext {
level: MileageDrillLevel;
targetId?: number;
assessmentYear?: number;
dimension?: MileageBreakdownDimension;
dimensionValue?: string;
plate?: string;
startDate?: string;
endDate?: string;
sourcePriority: MileageSourceGroup[];
}
interface LocationParts {
pathname: string;
search: string;
hash: string;
}
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const BREAKDOWN_DIMENSIONS = new Set<MileageBreakdownDimension>([
'department',
'region',
'customer',
'vehicle-model',
'assessment-target',
]);
function validDate(value: string | null): string | undefined {
if (!value || !DATE_PATTERN.test(value)) return undefined;
const timestamp = Date.parse(`${value}T00:00:00+08:00`);
return Number.isFinite(timestamp) ? value : undefined;
}
function parseSourcePriority(value: string | null): MileageSourceGroup[] {
if (!value) return ['instrument'];
const sources = value
.split(',')
.filter((source): source is MileageSourceGroup => source === 'instrument' || source === 'gps');
return Array.from(new Set(sources)).slice(0, 2).length > 0
? Array.from(new Set(sources)).slice(0, 2)
: ['instrument'];
}
export function parseMileageDrillContext(search: string): MileageDrillContext {
const params = new URLSearchParams(search);
const rawTargetId = Number(params.get('mileageTarget'));
const targetId = Number.isInteger(rawTargetId) && rawTargetId > 0 ? rawTargetId : undefined;
const rawAssessmentYear = Number(params.get('mileageYear'));
const assessmentYear = Number.isInteger(rawAssessmentYear) && rawAssessmentYear > 0
? rawAssessmentYear
: undefined;
const plate = params.get('mileagePlate')?.trim() || undefined;
const rawDimension = params.get('mileageDimension');
const dimension = rawDimension && BREAKDOWN_DIMENSIONS.has(rawDimension as MileageBreakdownDimension)
? rawDimension as MileageBreakdownDimension
: undefined;
const dimensionValue = params.get('mileageValue')?.trim() || undefined;
const requestedLevel = params.get('mileageLevel');
let level: MileageDrillLevel = 'overview';
if (requestedLevel === 'vehicle' && plate) level = 'vehicle';
if (requestedLevel === 'breakdown' && dimension && dimensionValue) level = 'breakdown';
const context: MileageDrillContext = {
level,
sourcePriority: parseSourcePriority(params.get('mileageSource')),
};
if (targetId) context.targetId = targetId;
if (targetId && assessmentYear) context.assessmentYear = assessmentYear;
if (level === 'breakdown') {
context.dimension = dimension;
context.dimensionValue = dimensionValue;
}
if (level === 'vehicle') context.plate = plate;
const startDate = validDate(params.get('mileageStart'));
const endDate = validDate(params.get('mileageEnd'));
if (startDate) context.startDate = startDate;
if (endDate) context.endDate = endDate;
return context;
}
export function buildMileageDrillUrl(
location: LocationParts,
context: MileageDrillContext,
): string {
const params = new URLSearchParams(location.search);
for (const key of [
'mileageLevel',
'mileageTarget',
'mileageYear',
'mileageDimension',
'mileageValue',
'mileagePlate',
'mileageStart',
'mileageEnd',
'mileageSource',
]) {
params.delete(key);
}
if (context.level !== 'overview') params.set('mileageLevel', context.level);
if (context.targetId) params.set('mileageTarget', String(context.targetId));
if (context.targetId && context.assessmentYear) params.set('mileageYear', String(context.assessmentYear));
if (context.level === 'breakdown' && context.dimension && context.dimensionValue) {
params.set('mileageDimension', context.dimension);
params.set('mileageValue', context.dimensionValue);
}
if (context.level === 'vehicle' && context.plate) params.set('mileagePlate', context.plate);
if (context.startDate) params.set('mileageStart', context.startDate);
if (context.endDate) params.set('mileageEnd', context.endDate);
if (context.sourcePriority.length > 0) params.set('mileageSource', context.sourcePriority.join(','));
const query = params.toString();
return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
}
@@ -1,100 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildMileageMonitoringUrl,
parseMileageMonitoringContext,
} from './monitoring-context.js';
test('parses a complete mileage monitoring context', () => {
const context = parseMileageMonitoringContext(
'?mileageStart=2026-08-01&mileageEnd=2026-08-07&mileageSource=gps,instrument'
+ '&mileageBatch=%E4%BA%A4%E6%8A%95190%E8%BE%86&mileageBatch=%E7%BE%9A%E7%89%9B136%E8%BE%86'
+ '&mileageRegion=%E5%B9%BF%E5%B7%9E&mileageFilterPlate=%E7%B2%A4A12345&mileageDepartment=%E4%B8%9A%E5%8A%A1%E4%B8%80%E9%83%A8'
+ '&mileageCustomer=%E5%AE%A2%E6%88%B7A&mileageProject=%E9%A1%B9%E7%9B%AEA&mileageEntity=%E4%B8%BB%E4%BD%93A'
+ '&mileageRentStatus=%E7%A7%9F%E8%B5%81&mileageBrand=%E7%8E%B0%E4%BB%A3&mileagePlatePrefix=%E7%B2%A4A'
+ '&mileageSearch=123&mileageMin=10&mileageMax=500&mileageSort=total&mileageOrder=asc',
);
assert.deepEqual(context, {
startDate: '2026-08-01',
endDate: '2026-08-07',
sourcePriority: ['gps', 'instrument'],
targetNames: ['交投190辆', '羚牛136辆'],
region: '广州',
plates: ['粤A12345'],
department: '业务一部',
customer: '客户A',
project: '项目A',
entity: '主体A',
rentStatus: '租赁',
brands: ['现代'],
platePrefix: '粤A',
search: '123',
mileageMin: '10',
mileageMax: '500',
sortBy: 'total',
sortOrder: 'asc',
});
});
test('normalizes invalid monitoring URL values', () => {
const context = parseMileageMonitoringContext(
'?mileageStart=invalid&mileageSource=unknown&mileageBatch=&mileageRegion=All&mileageSort=unknown&mileageOrder=unknown',
);
assert.deepEqual(context, {
startDate: undefined,
endDate: undefined,
sourcePriority: ['instrument'],
targetNames: [],
region: undefined,
plates: [],
department: undefined,
customer: undefined,
project: undefined,
entity: undefined,
rentStatus: undefined,
brands: [],
platePrefix: undefined,
search: undefined,
mileageMin: undefined,
mileageMax: undefined,
sortBy: 'today',
sortOrder: 'desc',
});
});
test('builds monitoring state without removing drill or unrelated context', () => {
const url = buildMileageMonitoringUrl(
{
pathname: '/asset',
search: '?company=5&mileageLevel=vehicle&mileagePlate=%E7%B2%A4A08190F&mileageBatch=old',
hash: '#mileage',
},
{
startDate: '2026-08-01',
endDate: '2026-08-07',
sourcePriority: ['instrument'],
targetNames: ['交投190辆', '羚牛136辆'],
region: '广州',
plates: ['粤A12345'],
department: undefined,
customer: undefined,
project: undefined,
entity: undefined,
rentStatus: undefined,
brands: [],
platePrefix: undefined,
search: undefined,
mileageMin: undefined,
mileageMax: undefined,
sortBy: 'statisticTime',
sortOrder: 'asc',
},
);
assert.equal(
url,
'/asset?company=5&mileageLevel=vehicle&mileagePlate=%E7%B2%A4A08190F&mileageStart=2026-08-01&mileageEnd=2026-08-07&mileageSource=instrument&mileageBatch=%E4%BA%A4%E6%8A%95190%E8%BE%86&mileageBatch=%E7%BE%9A%E7%89%9B136%E8%BE%86&mileageRegion=%E5%B9%BF%E5%B7%9E&mileageFilterPlate=%E7%B2%A4A12345&mileageSort=statisticTime&mileageOrder=asc#mileage',
);
});
-136
View File
@@ -1,136 +0,0 @@
import type { MileageSourceGroup } from './types';
export type MonitoringSortBy = 'today' | 'total' | 'statisticTime';
export type MonitoringSortOrder = 'asc' | 'desc';
export interface MileageMonitoringContext {
startDate?: string;
endDate?: string;
sourcePriority: MileageSourceGroup[];
targetNames: string[];
region?: string;
plates: string[];
department?: string;
customer?: string;
project?: string;
entity?: string;
rentStatus?: string;
brands: string[];
platePrefix?: string;
search?: string;
mileageMin?: string;
mileageMax?: string;
sortBy: MonitoringSortBy;
sortOrder: MonitoringSortOrder;
}
interface LocationParts {
pathname: string;
search: string;
hash: string;
}
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const CONTEXT_KEYS = [
'mileageStart',
'mileageEnd',
'mileageSource',
'mileageBatch',
'mileageRegion',
'mileageFilterPlate',
'mileageDepartment',
'mileageCustomer',
'mileageProject',
'mileageEntity',
'mileageRentStatus',
'mileageBrand',
'mileagePlatePrefix',
'mileageSearch',
'mileageMin',
'mileageMax',
'mileageSort',
'mileageOrder',
] as const;
function validDate(value: string | null): string | undefined {
if (!value || !DATE_PATTERN.test(value)) return undefined;
const timestamp = Date.parse(`${value}T00:00:00+08:00`);
return Number.isFinite(timestamp) ? value : undefined;
}
function boundedValue(value: string | null, maxLength = 200): string | undefined {
const normalized = value?.trim();
if (!normalized || normalized === 'All') return undefined;
return normalized.slice(0, maxLength);
}
function uniqueValues(values: string[], limit = 200): string[] {
return Array.from(new Set(values.map(value => boundedValue(value)).filter((value): value is string => !!value))).slice(0, limit);
}
function parseSourcePriority(value: string | null): MileageSourceGroup[] {
const sources = value
?.split(',')
.filter((source): source is MileageSourceGroup => source === 'instrument' || source === 'gps');
return sources && sources.length > 0 ? Array.from(new Set(sources)).slice(0, 2) : ['instrument'];
}
function appendValues(params: URLSearchParams, key: string, values: string[]): void {
uniqueValues(values).forEach(value => params.append(key, value));
}
export function parseMileageMonitoringContext(search: string): MileageMonitoringContext {
const params = new URLSearchParams(search);
const rawSortBy = params.get('mileageSort');
const rawSortOrder = params.get('mileageOrder');
return {
startDate: validDate(params.get('mileageStart')),
endDate: validDate(params.get('mileageEnd')),
sourcePriority: parseSourcePriority(params.get('mileageSource')),
targetNames: uniqueValues(params.getAll('mileageBatch')),
region: boundedValue(params.get('mileageRegion')),
plates: uniqueValues(params.getAll('mileageFilterPlate')),
department: boundedValue(params.get('mileageDepartment')),
customer: boundedValue(params.get('mileageCustomer')),
project: boundedValue(params.get('mileageProject')),
entity: boundedValue(params.get('mileageEntity')),
rentStatus: boundedValue(params.get('mileageRentStatus')),
brands: uniqueValues(params.getAll('mileageBrand')),
platePrefix: boundedValue(params.get('mileagePlatePrefix')),
search: boundedValue(params.get('mileageSearch')),
mileageMin: boundedValue(params.get('mileageMin'), 32),
mileageMax: boundedValue(params.get('mileageMax'), 32),
sortBy: rawSortBy === 'total' || rawSortBy === 'statisticTime' ? rawSortBy : 'today',
sortOrder: rawSortOrder === 'asc' ? 'asc' : 'desc',
};
}
export function buildMileageMonitoringUrl(
location: LocationParts,
context: MileageMonitoringContext,
): string {
const params = new URLSearchParams(location.search);
CONTEXT_KEYS.forEach(key => params.delete(key));
if (context.startDate) params.set('mileageStart', context.startDate);
if (context.endDate) params.set('mileageEnd', context.endDate);
if (context.sourcePriority.length > 0) params.set('mileageSource', context.sourcePriority.join(','));
appendValues(params, 'mileageBatch', context.targetNames);
if (context.region) params.set('mileageRegion', context.region);
appendValues(params, 'mileageFilterPlate', context.plates);
if (context.department) params.set('mileageDepartment', context.department);
if (context.customer) params.set('mileageCustomer', context.customer);
if (context.project) params.set('mileageProject', context.project);
if (context.entity) params.set('mileageEntity', context.entity);
if (context.rentStatus) params.set('mileageRentStatus', context.rentStatus);
appendValues(params, 'mileageBrand', context.brands);
if (context.platePrefix) params.set('mileagePlatePrefix', context.platePrefix);
if (context.search) params.set('mileageSearch', context.search);
if (context.mileageMin) params.set('mileageMin', context.mileageMin);
if (context.mileageMax) params.set('mileageMax', context.mileageMax);
if (context.sortBy !== 'today') params.set('mileageSort', context.sortBy);
if (context.sortOrder !== 'desc') params.set('mileageOrder', context.sortOrder);
const query = params.toString();
return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
}
@@ -1,47 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildMonitoringKpis } from './monitoring-kpis.js';
test('builds daily monitoring KPIs from the selected-day flow metric', () => {
const model = buildMonitoringKpis(
{ totalToday: 120, totalAll: 3_000, vehicleCount: 3, yesterdayTotal: 100 },
{ start: '2026-08-07', end: '2026-08-07' },
);
assert.deepEqual(model, {
distanceMetricId: 'mileage.daily_total',
distanceLabel: '当日总里程',
distanceShortLabel: '当日',
distanceKm: 120,
averagePerVehicleKm: 40,
vehicleCount: 3,
cumulativeOdometerKm: 3_000,
});
});
test('builds range KPIs without substituting cumulative odometer values', () => {
const model = buildMonitoringKpis(
{ totalToday: 900, totalAll: 30_000, vehicleCount: 6, yesterdayTotal: 0 },
{ start: '2026-08-01', end: '2026-08-07' },
);
assert.equal(model.distanceMetricId, 'mileage.period_total');
assert.equal(model.distanceLabel, '区间总里程');
assert.equal(model.distanceKm, 900);
assert.equal(model.averagePerVehicleKm, 150);
assert.equal(model.cumulativeOdometerKm, 30_000);
});
test('normalizes invalid monitoring KPI inputs', () => {
const model = buildMonitoringKpis({
totalToday: Number.NaN,
totalAll: -10,
vehicleCount: Number.POSITIVE_INFINITY,
yesterdayTotal: 0,
});
assert.equal(model.distanceKm, 0);
assert.equal(model.averagePerVehicleKm, 0);
assert.equal(model.vehicleCount, 0);
assert.equal(model.cumulativeOdometerKm, 0);
});
-31
View File
@@ -1,31 +0,0 @@
import type { MonitoringStats } from './types';
export interface MonitoringKpiModel {
distanceMetricId: 'mileage.daily_total' | 'mileage.period_total';
distanceLabel: '当日总里程' | '区间总里程';
distanceShortLabel: '当日' | '区间';
distanceKm: number;
averagePerVehicleKm: number;
vehicleCount: number;
cumulativeOdometerKm: number;
}
export function buildMonitoringKpis(
stats: MonitoringStats,
dateRange?: { start: string; end: string },
): MonitoringKpiModel {
const isRange = !!dateRange?.start && !!dateRange?.end && dateRange.start !== dateRange.end;
const vehicleCount = Number.isFinite(stats.vehicleCount) ? Math.max(0, stats.vehicleCount) : 0;
const distanceKm = Number.isFinite(stats.totalToday) ? Math.max(0, stats.totalToday) : 0;
const cumulativeOdometerKm = Number.isFinite(stats.totalAll) ? Math.max(0, stats.totalAll) : 0;
return {
distanceMetricId: isRange ? 'mileage.period_total' : 'mileage.daily_total',
distanceLabel: isRange ? '区间总里程' : '当日总里程',
distanceShortLabel: isRange ? '区间' : '当日',
distanceKm,
averagePerVehicleKm: vehicleCount > 0 ? distanceKm / vehicleCount : 0,
vehicleCount,
cumulativeOdometerKm,
};
}
-52
View File
@@ -1,20 +1,9 @@
export type MileageSourceGroup = 'instrument' | 'gps';
export type MileageSourceCategory = 'INSTRUMENT' | 'GPS' | 'MIXED';
export type MileageSourceProtocol = 'GB32960' | 'MQTT' | 'JT808';
export interface MonitoringVehicle {
plate: string;
vin: string;
dailyKm: number;
dailyMileage?: Record<string, number>;
totalKm: number | null;
source: string;
sourceProtocol: MileageSourceProtocol | null;
sourceCategory: MileageSourceCategory | null;
dailySourceProtocols?: Record<string, MileageSourceProtocol | null>;
dataTime: string | null;
calculatedAt: string | null;
updatedAt: string | null;
isOnline: boolean;
isDataSynced: boolean;
customer: string | null;
@@ -23,9 +12,6 @@ export interface MonitoringVehicle {
rentStatus: string | null;
entity: string | null;
project: string | null;
region: string | null;
brand: string | null;
targetNames: string[];
}
export interface MonitoringStats {
@@ -44,28 +30,21 @@ export interface MonitoringFilters {
rentStatuses: string[];
platePrefixes: { prefix: string; count: number }[];
targetNames: string[];
regions: string[];
brands: string[];
}
export interface MonitoringData {
vehicles: MonitoringVehicle[];
stats: MonitoringStats;
filters: MonitoringFilters;
rangeDailyTotals?: { date: string; totalKm: number }[];
dateRange?: { start: string; end: string };
total: number;
page: number;
totalPages: number;
updatedAt: string;
sourcePriority: MileageSourceGroup[];
}
export interface TargetSummary {
id: number;
targetName: string;
snapshotUpdatedAt: string | null;
declaredVehicleCount: number;
vehicleCount: number;
totalMileagePerVehicle: number;
annualMileagePerVehicle: number;
@@ -82,37 +61,6 @@ export interface TargetSummary {
remaining: number;
daysLeft: number;
dailyTarget: number;
firstYearVehicleCount: number;
firstYearTarget: number;
firstYearCompleted: number;
firstYearRemaining: number;
firstYearCompletionRate: number;
firstYearQualifiedCount: number;
firstYearQualifiedRate: number;
firstYearHalfQualifiedCount: number;
firstYearDaysLeft: number;
firstYearDailyTarget: number;
firstYearStartDate: string | null;
firstYearEndDate: string | null;
yearlyAssessments: TargetYearlyAssessment[];
}
export interface TargetYearlyAssessment {
yearNumber: number;
label: string;
vehicleCount: number;
target: number;
completed: number;
remaining: number;
completionRate: number;
qualifiedCount: number;
qualifiedRate: number;
halfQualifiedCount: number;
daysLeft: number;
dailyTarget: number;
startDate: string | null;
endDate: string | null;
periods: string[];
}
export interface TargetVehicle {
@@ -1,39 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
includeCurrentDayInVehicleDetail,
normalizeVehicleDetailContext,
resolveVehicleDetailRange,
vehicleDetailContextLabel,
} from './vehicle-detail-range.js';
const NOW = new Date(2026, 7, 7, 12, 0, 0);
test('normalizes and preserves a selected vehicle drill range', () => {
const context = normalizeVehicleDetailContext('2026-08-07', '2026-08-05');
assert.deepEqual(context, { start: '2026-08-05', end: '2026-08-07' });
assert.deepEqual(resolveVehicleDetailRange('context', context, NOW), context);
});
test('rejects invalid context dates and resolves calendar presets', () => {
assert.equal(normalizeVehicleDetailContext('2026-02-30', '2026-03-01'), null);
assert.deepEqual(resolveVehicleDetailRange('last15', null, NOW), {
start: '2026-07-24',
end: '2026-08-07',
});
assert.deepEqual(resolveVehicleDetailRange('month', null, NOW), {
start: '2026-08-01',
end: '2026-08-07',
});
assert.deepEqual(resolveVehicleDetailRange('quarter', null, NOW), {
start: '2026-07-01',
end: '2026-08-07',
});
});
test('labels selected dates and includes today only for explicit context', () => {
const context = { start: '2026-08-07', end: '2026-08-07' };
assert.equal(vehicleDetailContextLabel(context), '8月7日');
assert.equal(includeCurrentDayInVehicleDetail('context'), true);
assert.equal(includeCurrentDayInVehicleDetail('last15'), false);
});
@@ -1,66 +0,0 @@
export type VehicleDetailRangeKey = 'context' | 'last15' | 'month' | 'quarter';
export interface VehicleDetailDateRange {
start: string;
end: string;
}
const DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
function fmtYmd(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 validDate(value?: string): string | null {
if (!value) return null;
const match = DATE_PATTERN.exec(value);
if (!match) return null;
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
return fmtYmd(date) === value ? value : null;
}
export function normalizeVehicleDetailContext(
startDate?: string,
endDate?: string,
): VehicleDetailDateRange | null {
const start = validDate(startDate);
const end = validDate(endDate);
if (!start || !end) return null;
return start <= end ? { start, end } : { start: end, end: start };
}
export function resolveVehicleDetailRange(
key: VehicleDetailRangeKey,
context: VehicleDetailDateRange | null,
now = new Date(),
): VehicleDetailDateRange {
if (key === 'context' && context) return context;
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const end = fmtYmd(today);
if (key === 'month') {
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end };
}
if (key === 'quarter') {
const quarterStartMonth = Math.floor(today.getMonth() / 3) * 3;
return { start: fmtYmd(new Date(today.getFullYear(), quarterStartMonth, 1)), end };
}
const start = new Date(today);
start.setDate(today.getDate() - 14);
return { start: fmtYmd(start), end };
}
export function vehicleDetailContextLabel(context: VehicleDetailDateRange): string {
if (context.start === context.end) {
return `${Number(context.start.slice(5, 7))}${Number(context.start.slice(8, 10))}`;
}
return '所选区间';
}
export function includeCurrentDayInVehicleDetail(key: VehicleDetailRangeKey): boolean {
return key === 'context';
}
-182
View File
@@ -1,182 +0,0 @@
import * as XLSX from 'xlsx';
import type { MonitoringVehicle } from './types';
interface ExportContext {
date?: string;
startDate?: string;
endDate?: string;
sortBy: 'today' | 'total' | 'statisticTime';
}
const BASE_HEADERS = [
'状态', '数据来源', '车牌号', '客户', '业务部门', '项目', '租赁状态',
'运营区域',
] as const;
function sourceLabel(v: MonitoringVehicle): string {
if (v.sourceCategory === 'INSTRUMENT') return '仪表数据';
if (v.sourceCategory === 'GPS') return 'GPS数据';
if (v.sourceCategory === 'MIXED') return '仪表数据+GPS数据';
return v.isDataSynced ? '来源待接口' : '无数据';
}
function statusLabel(v: MonitoringVehicle): string {
if (!v.isDataSynced) return '未对接';
return v.isOnline ? '在线' : '离线';
}
function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | number {
if (kind === 'today') {
if (!v.isDataSynced && v.totalKm == null) return '未对接';
return Math.max(0, v.dailyKm || 0);
}
return v.totalKm != null ? v.totalKm : '未对接';
}
export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void {
const start = ctx.startDate || ctx.date || '';
const end = ctx.endDate || ctx.date || '';
const isRange = !!start && !!end && start !== end;
const dayKeys = Array.from(
new Set(vehicles.flatMap(v => Object.keys(v.dailyMileage || {})))
).sort();
// 车辆汇总 sheet:基础列 + 每日里程列 + 区间合计 + 累计里程
const dailyHeaders = dayKeys.map(day => `${day.slice(5)}里程(km)`);
const summaryHeaders = [
...BASE_HEADERS,
...(dayKeys.length > 0 ? dailyHeaders : []),
isRange ? '区间合计(km)' : '当日里程(km)',
'累计里程(km)',
];
const summaryData: (string | number)[][] = [
summaryHeaders,
...vehicles.map(v => {
const baseRow = [
statusLabel(v),
sourceLabel(v),
v.plate,
v.customer || '',
v.department || '',
v.project || '',
v.rentStatus || '',
v.region || '',
];
const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? 0);
return [
...baseRow,
...dailyCols,
mileageCell(v, 'today'),
mileageCell(v, 'total'),
];
}),
];
const ws = XLSX.utils.aoa_to_sheet(summaryData);
const numFixedCols = BASE_HEADERS.length;
const wsCols: { wch: number }[] = [
{ wch: 8 }, // 状态
{ wch: 16 }, // 数据来源
{ wch: 12 }, // 车牌号
{ wch: 28 }, // 客户
{ wch: 14 }, // 业务部门
{ wch: 16 }, // 项目
{ wch: 10 }, // 租赁状态
{ wch: 12 }, // 运营区域
];
for (let i = 0; i < dayKeys.length; i++) wsCols.push({ wch: 12 });
wsCols.push({ wch: 14 }); // 区间合计
wsCols.push({ wch: 14 }); // 累计里程
ws['!cols'] = wsCols;
// 冻结首行 + 固定列
ws['!freeze'] = { xSplit: numFixedCols, ySplit: 1 } as never;
for (let r = 1; r < summaryData.length; r++) {
for (let c = numFixedCols; c < summaryHeaders.length; c++) {
const ref = XLSX.utils.encode_cell({ r, c });
if (ws[ref]?.t === 'n') ws[ref].z = '0.##########';
}
}
// 表头样式
for (let c = 0; c < summaryHeaders.length; c++) {
const ref = XLSX.utils.encode_cell({ r: 0, c });
if (ws[ref]) {
(ws[ref] as { s?: unknown }).s = {
font: { bold: true, color: { rgb: 'FFFFFF' } },
fill: { fgColor: { rgb: '2563EB' } },
alignment: { horizontal: 'center', vertical: 'center' },
};
}
}
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '车辆汇总');
// 每日明细 sheet:保留原有格式
if (dayKeys.length > 0) {
const detailHeaders = [
'车牌号', '数据来源', '客户', '业务部门', '项目', '租赁状态', '运营区域',
...dayKeys.map(day => `${day}里程(km)`),
'区间合计(km)',
'累计里程(km)',
];
const detailData: (string | number)[][] = [
detailHeaders,
...vehicles.map(v => [
v.plate,
sourceLabel(v),
v.customer || '',
v.department || '',
v.project || '',
v.rentStatus || '',
v.region || '',
...dayKeys.map(day => v.dailyMileage?.[day] || 0),
Math.max(0, v.dailyKm || 0),
v.totalKm != null ? v.totalKm : '',
]),
];
const detailWs = XLSX.utils.aoa_to_sheet(detailData);
detailWs['!cols'] = [
{ wch: 12 },
{ wch: 16 },
{ wch: 28 },
{ wch: 14 },
{ wch: 16 },
{ wch: 10 },
{ wch: 12 },
...dayKeys.map(() => ({ wch: 14 })),
{ wch: 14 },
{ wch: 14 },
];
detailWs['!freeze'] = { xSplit: 7, ySplit: 1 } as never;
for (let r = 1; r < detailData.length; r++) {
for (let c = 7; c < detailHeaders.length; c++) {
const ref = XLSX.utils.encode_cell({ r, c });
if (detailWs[ref]?.t === 'n') detailWs[ref].z = '0.##########';
}
}
XLSX.utils.book_append_sheet(wb, detailWs, '每日明细');
}
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const dateTag = start && end
? `${start.replace(/-/g, '')}-${end.replace(/-/g, '')}`
: `${y}${m}${d}`;
const sortLabel = ctx.sortBy === 'total'
? '累计'
: ctx.sortBy === 'statisticTime'
? '统计时间'
: isRange ? '区间' : '今日';
const filename = `里程看板_${dateTag}_${hh}${mm}_${sortLabel}.xlsx`;
XLSX.writeFile(wb, filename);
}

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