Author SHA1 Message Date
kkfluous 1674f8b931 perf: reuse historical mileage range snapshots 2026-08-08 11:07:22 +08:00
kkfluous 559094cdc5 fix: decouple mileage KPIs from sorting 2026-08-07 18:44:49 +08:00
kkfluous c0d504cb7b feat: persist mileage monitoring context 2026-08-07 18:39:13 +08:00
kkfluous d958f3b28f fix: bind mileage list to request context 2026-08-07 18:30:49 +08:00
kkfluous f8e926876c fix: make mileage fullscreen failures visible 2026-08-07 18:23:50 +08:00
kkfluous 6bf88a25e3 fix: preserve vehicle drill date context 2026-08-07 18:16:14 +08:00
kkfluous 017001e889 fix: preserve dated vehicle panel snapshots 2026-08-07 18:09:52 +08:00
kkfluous b11187aba7 fix: surface mileage child request failures 2026-08-07 18:01:38 +08:00
kkfluous a344497505 fix: distinguish mileage statistics failures 2026-08-07 17:58:54 +08:00
kkfluous d1ed3903fe feat: flag mileage target coverage gaps 2026-08-07 17:52:49 +08:00
kkfluous bca0f7fe82 feat: publish mileage assessment freshness 2026-08-07 17:49:52 +08:00
kkfluous 8a7ce7eccf perf: reuse mileage target snapshots 2026-08-07 17:46:51 +08:00
kkfluous 2de417e109 fix: reconcile mileage attribution totals 2026-08-07 17:42:21 +08:00
kkfluous 67a269651e feat: preserve mileage assessment years 2026-08-07 17:37:05 +08:00
kkfluous 52d79ba260 feat: preserve BI sub tab history 2026-08-07 17:31:09 +08:00
kkfluous 39e869ad39 feat: focus electric order drilldowns 2026-08-07 17:25:40 +08:00
kkfluous 70498fc4fa feat: focus hydrogen order drilldowns 2026-08-07 17:22:46 +08:00
kkfluous 18287a2c70 feat: persist hydrogen order pages 2026-08-07 17:19:34 +08:00
kkfluous d786b984bb feat: reconcile ETC overview totals 2026-08-07 17:15:52 +08:00
kkfluous 5ea788e565 feat: reconcile hydrogen daily totals 2026-08-07 17:12:07 +08:00
kkfluous 003a0b580c feat: focus ETC metric drilldowns 2026-08-07 17:07:29 +08:00
kkfluous 64571d58c3 feat: reconcile electric daily totals 2026-08-07 17:03:16 +08:00
kkfluous 11b1afd68c fix: preserve electric drilldown history 2026-08-07 16:59:02 +08:00
kkfluous 0adc2c80f7 feat: make energy chart drilldowns keyboard accessible 2026-08-07 16:56:01 +08:00
kkfluous 86a23ad343 refactor: unify BI vehicle scope controls 2026-08-07 16:53:07 +08:00
kkfluous ea9c9212e2 fix: restore BI quick date state 2026-08-07 16:49:42 +08:00
kkfluous 3d57787e61 refactor: unify BI date range filters 2026-08-07 16:44:31 +08:00
kkfluous 55297a6240 refactor: unify embedded BI states 2026-08-07 16:37:53 +08:00
kkfluous 671a7a8224 refactor: persist ETC analysis state 2026-08-07 16:34:32 +08:00
kkfluous a8f7548314 feat: drill hydrogen trends into orders 2026-08-07 16:29:26 +08:00
kkfluous 263d8c6245 feat: add ETC records and bill drilldowns 2026-08-07 16:19:19 +08:00
kkfluous 47a4474608 feat: surface BI data source health 2026-08-07 16:09:01 +08:00
kkfluous 52c785f6ec feat: observe BI data source health 2026-08-07 16:05:20 +08:00
kkfluous 02d714175f feat: expose BI configuration readiness 2026-08-07 16:00:04 +08:00
kkfluous 686fc4e2e2 refactor: stabilize cumulative mileage metrics 2026-08-07 15:56:39 +08:00
kkfluous 8c6a3ad831 refactor: show mileage data freshness 2026-08-07 15:50:24 +08:00
kkfluous de2a0896c4 perf: reuse mileage monitoring snapshots 2026-08-07 15:48:07 +08:00
kkfluous 60e81cb4ba security: externalize BI data source credentials 2026-08-07 15:42:34 +08:00
kkfluous 616341adc8 perf: make mileage pagination explicit 2026-08-07 15:37:07 +08:00
kkfluous c2d42efa6d docs: define incremental BI refactor roadmap 2026-08-07 15:32:47 +08:00
kkfluous 326201abab refactor: surface hydrogen source failures 2026-08-07 15:29:21 +08:00
kkfluous 0d849fcbb1 refactor: expose electric data freshness 2026-08-07 15:22:04 +08:00
kkfluous 21f5e5388a refactor: align electric overview drilldown 2026-08-07 15:18:58 +08:00
kkfluous 08097f7c29 refactor: surface BI metric definitions 2026-08-07 15:12:34 +08:00
kkfluous 46dbe725de perf: deduplicate concurrent API reads 2026-08-07 15:04:47 +08:00
kkfluous 0fb62135f3 refactor: expose ETC integration readiness 2026-08-07 15:00:58 +08:00
kkfluous cdf1fd27e7 refactor: add electric order drilldown 2026-08-07 14:55:55 +08:00
kkfluous cf9782562b refactor: define electric BI metric semantics 2026-08-07 14:48:50 +08:00
kkfluous 757ed1ebd6 refactor: add hydrogen customer drilldown 2026-08-07 14:45:36 +08:00
kkfluous 71d0091457 refactor: add hydrogen station drilldown 2026-08-07 14:41:29 +08:00
kkfluous d53257c65c refactor: define hydrogen BI metric semantics 2026-08-07 14:33:38 +08:00
kkfluous b2044109e3 refactor: build drillable mileage daily report 2026-08-07 14:27:19 +08:00
kkfluous 91031bd034 refactor: add mileage attribution drilldown 2026-08-07 14:15:31 +08:00
kkfluous f24a18e459 refactor: persist mileage drill context in URL 2026-08-07 14:00:43 +08:00
kkfluous ce50265771 refactor: publish mileage metric catalog 2026-08-07 13:54:51 +08:00
kkfluous 13e720c2c3 refactor: add weighted mileage completion metric 2026-08-07 13:51:58 +08:00
105 changed files with 8885 additions and 945 deletions
+51
View File
@@ -0,0 +1,51 @@
# 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
+31 -26
View File
@@ -5,35 +5,40 @@ services:
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
network_mode: host
environment:
DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com"
DB_PORT: "3306"
DB_USER: "oneos_db_prod"
DB_PASSWORD: "adASHJcviqwjkbn23ngt1"
DB_NAME: "ln_asset_management"
HYDROGEN_DB_HOST: "47.99.185.173"
HYDROGEN_DB_PORT: "3306"
HYDROGEN_DB_USER: "root"
HYDROGEN_DB_PASSWORD: "lnMysql."
HYDROGEN_DB_NAME: "ln_asset_management"
MILEAGE_DB_HOST: "101.133.130.65"
MILEAGE_DB_PORT: "3306"
MILEAGE_DB_USER: "bi_reader_02"
MILEAGE_DB_PASSWORD: "bi_reader_02_Pass"
MILEAGE_DB_NAME: "hydrogen_energy"
# ECS production accesses OneOS over the private network. Configure the key
# as a Portainer stack environment variable; never commit it to this file.
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}"
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: "8111"
EXTERNAL_API_BASE: "https://lnh2e.com"
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
AMAP_WEB_KEY: "${AMAP_WEB_KEY}"
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}"
HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}"
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}"
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}"
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}"
deploy:
+216
View File
@@ -0,0 +1,216 @@
# 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 供应商未配置、未执行首次同步;记录与账单下钻代码已完成,但真实数据仍不可验收。
- 氢能连接信息已从当前版本迁出,但历史凭据仍必须在数据库侧轮换;文档不记录任何连接值。
+99
View File
@@ -0,0 +1,99 @@
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();
}
});
+62 -3
View File
@@ -1,13 +1,34 @@
/** 全局认证 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;
}
export async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const token = tokenGetter();
async function executeJsonRequest<T>(url: string, options: RequestInit | undefined, token: string | null): Promise<T> {
const res = await fetch(url, {
...options,
headers: {
@@ -19,6 +40,44 @@ export async function fetchJson<T>(url: string, options?: RequestInit): Promise<
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
throw new Error('Unauthorized');
}
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
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);
}
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
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,228 @@
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)}
</>
);
}
+166
View File
@@ -0,0 +1,166 @@
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)}
</>
);
}
+81 -11
View File
@@ -1,5 +1,5 @@
import { useState, type ComponentType, type ReactNode } from 'react';
import { AlertCircle, Info, Loader2, SearchX, X } from 'lucide-react';
import { AlertCircle, Info, Loader2, RefreshCw, SearchX, X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { cn } from '../../lib/cn';
@@ -56,7 +56,7 @@ export function PageFrame({
</div>
<h1 className="mt-0.5 truncate text-base font-black tracking-tight text-slate-950 md:text-lg">{title}</h1>
</div>
{actions ? <div className="hidden shrink-0 items-center gap-2 md:flex">{actions}</div> : null}
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
{subtitle ? (
<button
type="button"
@@ -190,23 +190,51 @@ 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 className="grid gap-1" style={{ gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))` }}>
{tabs.map(({ id, label, icon: Icon }) => {
<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',
@@ -233,9 +261,20 @@ export function SkeletonBlock({ className }: { className?: string }) {
return <div className={cn('overflow-hidden rounded-xl bg-slate-100 shimmer', className)} />;
}
export function LoadingState({ label = '数据加载中' }: { label?: string }) {
type StateVariant = 'card' | 'inline';
export function LoadingState({
label = '数据加载中',
variant = 'card',
}: {
label?: string;
variant?: StateVariant;
}) {
return (
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-sm">
<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>
@@ -245,12 +284,17 @@ export function LoadingState({ label = '数据加载中' }: { label?: string })
export function EmptyState({
title = '暂无数据',
description = '换个筛选条件或稍后再试',
variant = 'card',
}: {
title?: string;
description?: string;
variant?: StateVariant;
}) {
return (
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-sm">
<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>
@@ -258,14 +302,40 @@ export function EmptyState({
);
}
export function ErrorState({ message }: { message: string }) {
export function ErrorState({
message,
title = '加载失败',
onRetry,
retrying = false,
variant = 'card',
}: {
message: string;
title?: string;
onRetry?: () => void;
retrying?: boolean;
variant?: StateVariant;
}) {
return (
<div className="rounded-2xl border border-rose-100 bg-rose-50 p-4 text-rose-700 shadow-sm">
<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>
<div className="text-sm font-black"></div>
<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>
+110
View File
@@ -0,0 +1,110 @@
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"
/>
</>
);
}
@@ -0,0 +1,50 @@
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
@@ -0,0 +1,315 @@
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>
);
}
+174 -69
View File
@@ -1,79 +1,184 @@
import { motion } from 'motion/react';
import { Construction, Hammer } from 'lucide-react';
import RotatingFooterHint from '../../components/RotatingFooterHint';
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';
const ETC_HINTS = [
'ETC 通行费数据正在与发卡方系统打通…',
'工人 GG 正在搭脚手架,敬请期待 ~',
'马上能看到每月通行费明细啦',
'想看哪个维度的 ETC?反馈一下嘛',
'上线时机:等数据接通的那一天',
];
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-4">
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
>
<div className="relative w-20 h-20 mb-4">
<motion.div
animate={{ rotate: [0, -8, 8, -4, 4, 0] }}
transition={{ duration: 2.4, repeat: Infinity, ease: 'easeInOut' }}
className="absolute inset-0 rounded-3xl bg-gradient-to-br from-amber-50 to-orange-50 flex items-center justify-center"
>
<Construction size={36} className="text-amber-500" strokeWidth={2.2} />
</motion.div>
<motion.div
animate={{ rotate: [0, 18, -10, 0], y: [0, -2, 1, 0] }}
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
className="absolute -top-1 -right-1 w-9 h-9 rounded-2xl bg-white border border-amber-100 shadow-sm flex items-center justify-center"
>
<Hammer size={16} className="text-amber-500" strokeWidth={2.2} />
</motion.div>
</div>
<div className="text-base font-black text-slate-800 mb-1.5">ETC </div>
<div className="text-[12px] text-slate-500 font-bold leading-relaxed max-w-[280px]">
<br />
</div>
{/* 简单的里程碑进度感 */}
<div className="mt-6 w-full max-w-xs space-y-2">
{[
{ label: '需求评审', done: true },
{ label: '数据对接', done: true },
{ label: '页面开发', done: false, current: true },
{ label: '正式上线', done: false },
].map((m, i) => (
<motion.div
key={i}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 + i * 0.08, duration: 0.3 }}
className="flex items-center gap-2.5 text-[11px]"
>
<span className={`w-3 h-3 rounded-full flex-shrink-0 ${
m.done ? 'bg-emerald-400'
: m.current ? 'bg-amber-400 ring-4 ring-amber-100 animate-pulse'
: 'bg-slate-200'
}`} />
<span className={`font-bold ${m.done ? 'text-slate-500' : m.current ? 'text-amber-600' : 'text-slate-300'}`}>
{m.label}
<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>
{m.done && <span className="text-[10px] text-emerald-500 font-bold ml-auto"></span>}
{m.current && <span className="text-[10px] text-amber-500 font-bold ml-auto"></span>}
</motion.div>
))}
<div className="min-w-0 flex-1">
<div className="text-xs font-black text-slate-800">
{integration.providerConfigured ? 'ETC 数据提供方已配置' : 'ETC 数据提供方尚未配置'}
</div>
</motion.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>
<RotatingFooterHint hints={ETC_HINTS} />
<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>
);
}
+283 -124
View File
@@ -1,56 +1,70 @@
import { useEffect, useMemo, useState } from 'react';
import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Truck, Wallet } from 'lucide-react';
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 } from './api';
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
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 QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'thisWeek', label: '本周' },
{ id: 'thisMonth', label: '本月' },
{ id: 'last15', label: '近 15 天' },
const VEHICLE_SCOPE_OPTIONS: readonly AnalysisScopeOption<ElectricVehicleScope>[] = [
{ id: 'all', label: '全部车辆' },
{ id: 'lingniu', label: '羚牛车辆' },
{ id: 'external', label: '外部车辆' },
];
type RangeMode = DateQuickPick | 'custom';
function fmtYmd(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function addDays(d: Date, days: number): Date {
const next = new Date(d);
next.setDate(next.getDate() + days);
return next;
}
function getQuickRange(pick: DateQuickPick): { start: string; end: string } {
const today = new Date();
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) };
}
function normalizeRange(start: string, end: string): { start: string; end: string } {
return start <= end ? { start, end } : { start: end, end: start };
}
export default function ElectricDaily() {
const [customer, setCustomer] = useState<CustomerType>('lingniu');
const [pick, setPick] = useState<RangeMode>('last15');
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
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(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
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;
@@ -58,16 +72,32 @@ export default function ElectricDaily() {
const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick };
fetchElectricMonthly(customer, query)
fetchElectricMonthly(vehicleScope, query)
.then(m => {
if (cancelled) return;
setMonths(m);
// 默认展开最新一个月
if (m.length > 0) setOpenMonths(new Set([m[0].month]));
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; };
}, [customer, pick, effectiveRange.start, effectiveRange.end]);
}, [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);
@@ -81,90 +111,110 @@ export default function ElectricDaily() {
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 = pick === 'custom'
? '自定义区间'
: QUICK_PICK_OPTIONS.find(item => item.id === pick)?.label ?? '当前时段';
const scopeLabel = dateRangeModeLabel(pick);
const rangeText = `${effectiveRange.start}${effectiveRange.end}`;
const hasFeeDetail = totalFee > 0;
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
const showExternalEmpty = vehicleScope === 'external' && months !== null && totalKwh === 0;
const applyQuickPick = (nextPick: DateQuickPick) => {
const nextRange = getQuickDateRange(nextPick);
setPick(nextPick);
setDateRange(getQuickRange(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(prev => ({ ...prev, [field]: value }));
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">
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{QUICK_PICK_OPTIONS.map(opt => (
<button
key={opt.id}
onClick={() => applyQuickPick(opt.id)}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === opt.id
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
<button
onClick={() => setPick('custom')}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === 'custom'
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
</button>
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
type="date"
value={dateRange.start}
onChange={e => updateDateRange('start', e.target.value)}
onInput={e => updateDateRange('start', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
<AnalysisDateFilters
idPrefix="electric"
mode={pick}
startDate={dateRange.start}
endDate={dateRange.end}
onQuickPick={applyQuickPick}
onCustom={() => setPick('custom')}
onDateChange={updateDateRange}
/>
</label>
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
type="date"
value={dateRange.end}
onChange={e => updateDateRange('end', e.target.value)}
onInput={e => updateDateRange('end', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
</div>
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
{(['lingniu', 'external'] as const).map(c => (
<button
key={c}
onClick={() => setCustomer(c)}
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
customer === c ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
<Truck size={14} />
{c === 'external' ? '外部车辆' : '羚牛车辆'}
</button>
))}
</div>
<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">
@@ -172,9 +222,9 @@ export default function ElectricDaily() {
<MetricTile
icon={Wallet}
label="充电费用"
value={hasFeeDetail ? `¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '待同步'}
helper={hasFeeDetail ? `均价 ${avgPrice.toFixed(2)} 元/度` : '当前明细仅返回充电量'}
tone={hasFeeDetail ? 'emerald' : 'slate'}
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'} />
@@ -210,20 +260,23 @@ export default function ElectricDaily() {
<span className="text-right"></span>
</div>
{error ? (
<div className="p-3"><ErrorState message={error} /></div>
<ErrorState message={error} variant="inline" />
) : months === null ? (
<div className="p-3"><LoadingState label="正在加载充电明细" /></div>
<LoadingState label="正在加载充电明细" variant="inline" />
) : months.length === 0 ? (
<div className="p-3"><EmptyState title="暂无充电数据" description="请切换时间范围或车辆归属" /></div>
<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`} />
@@ -237,6 +290,7 @@ export default function ElectricDaily() {
<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 }}
@@ -248,16 +302,121 @@ export default function ElectricDaily() {
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={`grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2 pl-9 border-t border-slate-100 ${abnormalBg}`}
<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="text-[12px] text-slate-600">{d.date.slice(5)}</span>
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
<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>
);
})}
+9 -1
View File
@@ -4,6 +4,7 @@ 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 },
@@ -21,11 +22,18 @@ export default function ElectricModule() {
icon={CalendarDays}
eyebrow="ELECTRIC BI"
meta="时间单位清晰标注 · 支持日/总览切换"
actions={<BiHeaderActions domain="electric" />}
>
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<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>
+46 -13
View File
@@ -4,6 +4,8 @@ import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, Refere
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 })}`;
@@ -11,6 +13,11 @@ function fmtYuan(yuan: number) {
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);
@@ -34,33 +41,50 @@ export default function ElectricOverview() {
const trendData = data.trend;
// 当电能数据滞后(本月无数据走 fallback)时,柱图标题显示实际月份
const trendMonthLabel = trendData[0]?.date.slice(0, 7);
const currentMonth = new Date().toISOString().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 avgDailyKwh = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.kwh, 0) / activeDays : 0;
const avgDailyFee = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.fee, 0) / activeDays : 0;
const 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 monthPrice = k.monthKwh > 0 ? k.monthFee / k.monthKwh : 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">
2025-01-01
{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="本月充电费" value={fmtYuan(k.monthFee)} helper={fmtKwh(k.monthKwh)} tone="emerald" />
<MetricTile icon={Gauge} label="累计均价" value={avgPrice.toFixed(2)} unit="元/度" helper={`本月 ${monthPrice.toFixed(2)} 元/度`} tone="amber" />
<MetricTile icon={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"></div>
<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>
@@ -71,12 +95,12 @@ export default function ElectricOverview() {
</SurfaceCard>
<SurfaceCard className="p-3">
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-xl font-black text-emerald-600">{k.totalFee > 0 ? (k.monthFee / k.totalFee * 100).toFixed(1) : '0.0'}%</div>
<div className="mt-1 text-[11px] font-bold text-slate-500"> / </div>
<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>
@@ -109,8 +133,17 @@ export default function ElectricOverview() {
/>
)}
<Bar dataKey="fee" radius={[4, 4, 0, 0]}>
{trendData.map((_, i) => (
<Cell key={i} fill="url(#electricBarGrad)" />
{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>
+4 -2
View File
@@ -1,15 +1,17 @@
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="规划按车、按月、按线路拆分通行费,让车辆运营成本口径逐步完整。"
subtitle="区分通行记录与结算账单口径,展示通行费、服务费、车辆规模及数据同步状态。"
icon={Receipt}
eyebrow="ETC BI"
meta="数据对接中 · 页面能力预留"
meta="真实接入状态 · 指标口径已发布"
actions={<BiHeaderActions domain="etc" />}
>
<ETCView />
</PageFrame>
+299 -133
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-react';
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';
@@ -7,78 +7,97 @@ 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 QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'thisWeek', label: '本周' },
{ id: 'thisMonth', label: '本月' },
{ id: 'last15', label: '近 15 天' },
const VEHICLE_SCOPE_OPTIONS: readonly AnalysisScopeOption<CustomerType>[] = [
{ id: 'lingniu', label: '羚牛车辆' },
{ id: 'external', label: '外部车辆' },
];
type RangeMode = DateQuickPick | 'custom';
function fmtYmd(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function addDays(d: Date, days: number): Date {
const next = new Date(d);
next.setDate(next.getDate() + days);
return next;
}
function getQuickRange(pick: DateQuickPick): { start: string; end: string } {
const today = new Date();
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) };
}
function normalizeRange(start: string, end: string): { start: string; end: string } {
return start <= end ? { start, end } : { start: end, end: start };
}
const HYDROGEN_ORDERS_ID = 'hydrogen-orders';
export default function HydrogenDaily() {
const [pick, setPick] = useState<RangeMode>('last15');
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
const [customer, setCustomer] = useState<CustomerType>('lingniu');
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(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
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 }
: { range: pick };
fetchHydrogenDaily(query, customer)
? { 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, customer, effectiveRange.start, effectiveRange.end]);
}, [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 names = new Set<string>();
(rows ?? []).forEach(r => r.stations.forEach(s => names.add(s.name)));
return names.size;
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 = pick === 'custom'
? '自定义区间'
: QUICK_PICK_OPTIONS.find(item => item.id === pick)?.label ?? '当前时段';
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)
@@ -92,93 +111,197 @@ export default function HydrogenDaily() {
});
const applyQuickPick = (nextPick: DateQuickPick) => {
const nextRange = getQuickDateRange(nextPick);
setPick(nextPick);
setDateRange(getQuickRange(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');
setDateRange(prev => ({ ...prev, [field]: value }));
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">
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{QUICK_PICK_OPTIONS.map(opt => (
<button
key={opt.id}
onClick={() => applyQuickPick(opt.id)}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === opt.id
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
<button
onClick={() => setPick('custom')}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === 'custom'
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
</button>
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
type="date"
value={dateRange.start}
onChange={e => updateDateRange('start', e.target.value)}
onInput={e => updateDateRange('start', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
<AnalysisDateFilters
idPrefix="hydrogen"
mode={pick}
startDate={dateRange.start}
endDate={dateRange.end}
onQuickPick={applyQuickPick}
onCustom={() => setPick('custom')}
onDateChange={updateDateRange}
/>
</label>
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
type="date"
value={dateRange.end}
onChange={e => updateDateRange('end', e.target.value)}
onInput={e => updateDateRange('end', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
</div>
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
{(['lingniu', 'external'] as const).map(c => (
<button
key={c}
onClick={() => setCustomer(c)}
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
customer === c ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
<Truck size={14} />
{c === 'external' ? '外部车辆' : '羚牛车辆'}
</button>
))}
</div>
<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">
{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={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
<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>
</div>}
{/* 外部车辆:新系统数据还没准备好 */}
{customer === 'external' && rows !== null && totalKg === 0 && (
{!error && vehicleScope === 'external' && rows !== null && totalKg === 0 && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
@@ -199,7 +322,7 @@ export default function HydrogenDaily() {
)}
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
{!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>
@@ -259,8 +382,23 @@ export default function HydrogenDaily() {
/>
)}
<Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
{trendData.map((_, i) => (
<Cell key={i} fill="url(#hydrogenBarGrad)" />
{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>
@@ -275,30 +413,41 @@ export default function HydrogenDaily() {
</SurfaceCard>
)}
{selectedDate && (
<HydrogenOrders
date={selectedDate}
dailyRow={selectedDailyRow}
page={drillContext.orderPage ?? 1}
vehicleScope={vehicleScope}
stationId={selectedStationId}
customerName={selectedCustomerName}
onPageChange={changeOrderPage}
onClose={closeOrders}
/>
)}
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
{!(customer === 'external' && rows !== null && totalKg === 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)_84px_80px] 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">
<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)_84px_80px] 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">
<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>
{/* 主行 + 子行 */}
{error ? (
<div className="p-3"><ErrorState message={error} /></div>
) : rows === null ? (
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
{rows === null ? (
<LoadingState label="正在加载加氢明细" variant="inline" />
) : rows.length === 0 ? (
<div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div>
<EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" variant="inline" />
) : rows.map(r => {
const open = expanded.has(r.date);
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
@@ -307,20 +456,37 @@ export default function HydrogenDaily() {
: '';
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="w-full grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2.5 text-left hover:bg-slate-50/60 transition-colors"
className="flex min-w-0 items-center gap-1 text-[12px] font-bold text-slate-700"
aria-expanded={open}
aria-label={`${open ? '收起' : '展开'} ${r.date} 加氢站明细`}
>
<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`} />
{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>
</button>
</div>
<AnimatePresence initial={false}>
{open && (
<motion.div
@@ -333,7 +499,7 @@ export default function HydrogenDaily() {
{r.stations.map(s => (
<div
key={s.name}
className="grid grid-cols-[minmax(0,1fr)_84px_80px] 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"
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">
@@ -362,7 +528,7 @@ export default function HydrogenDaily() {
})}
</div>
)}
<RotatingFooterHint />
{!error && <RotatingFooterHint />}
</div>
);
}
+9 -1
View File
@@ -4,6 +4,7 @@ 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 },
@@ -21,11 +22,18 @@ export default function HydrogenModule() {
icon={CalendarDays}
eyebrow="ENERGY BI"
meta="数据单位清晰标注 · 支持日/总览切换"
actions={<BiHeaderActions domain="hydrogen" />}
>
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<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
@@ -0,0 +1,185 @@
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>
);
}
+80 -11
View File
@@ -2,10 +2,12 @@ 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 } from 'lucide-react';
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',
@@ -119,7 +121,14 @@ export default function HydrogenOverview() {
}, [year, load]);
if (error && !data) {
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">{error}</div>;
return (
<ErrorState
title="氢能数据源暂不可用"
message={error}
onRetry={() => void load(year, true)}
retrying={refreshing}
/>
);
}
if (!data) {
return <HydrogenOverviewSkeleton />;
@@ -155,6 +164,45 @@ export default function HydrogenOverview() {
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 => ({
@@ -213,22 +261,22 @@ export default function HydrogenOverview() {
icon={<Wallet size={14} className="text-blue-600" strokeWidth={2.4} />}
iconBg="bg-blue-50"
accentClass="text-slate-800"
label="累计加氢"
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}` },
{ 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="时享加氢获利"
label="客户单毛利"
hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }}
rows={[
{ label: '收入', value: `¥${yearRevenueFmt.value} ${yearRevenueFmt.unit}` },
{ label: '成本', value: `¥${yearFeeFmt.value} ${yearFeeFmt.unit}` },
{ label: '客户单成本', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` },
]}
/>
<KpiCard
@@ -292,7 +340,7 @@ export default function HydrogenOverview() {
<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="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>
@@ -302,7 +350,7 @@ export default function HydrogenOverview() {
</span>
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
{yearProfitFmt.value}{yearProfitFmt.unit} · {yearRevenueFmt.value}{yearRevenueFmt.unit}
{yearProfitFmt.value}{yearProfitFmt.unit} · {yearRevenueFmt.value}{yearRevenueFmt.unit}
{profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'}
</div>
</div>
@@ -497,7 +545,17 @@ export default function HydrogenOverview() {
return (
<tr key={s.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
<td className="py-1.5 text-slate-700 truncate max-w-[180px]">{s.name}</td>
<td className="py-1.5 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>
@@ -556,7 +614,18 @@ export default function HydrogenOverview() {
return (
<tr key={c2.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
<td className="py-1.5 text-slate-700 truncate max-w-[200px]">{c2.name}</td>
<td className="py-1.5 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>
+4 -2
View File
@@ -11,12 +11,14 @@ 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 }: Props<T>) {
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} />
<SegmentedNav tabs={tabs} active={active} onChange={onChange} idPrefix={idPrefix} ariaLabel={ariaLabel} />
</div>
);
}
+72 -5
View File
@@ -1,9 +1,13 @@
import { fetchJson } from '../../auth/api-client';
import type {
HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenMonthlyPoint, HydrogenDailyRow,
HydrogenOrderResponse,
HydrogenCustomerRow, HydrogenStationFull,
ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
CustomerType, DateQuickPick,
ElectricChargeOrderResponse,
EtcOverviewResponse,
EtcBillResponse, EtcTollRecordResponse,
CustomerType, DateQuickPick, ElectricVehicleScope,
} from './types';
const BASE = '/api/energy';
@@ -31,29 +35,92 @@ export interface HydrogenDailyQuery {
range?: DateQuickPick;
startDate?: string;
endDate?: string;
stationId?: number;
customerName?: string;
}
export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise<HydrogenDailyRow[]> {
const q = new URLSearchParams({ customer });
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(customer: CustomerType, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> {
const q = new URLSearchParams({ customer });
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
@@ -0,0 +1,33 @@
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
@@ -0,0 +1,59 @@
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],
}));
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,11 @@
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;
}
@@ -0,0 +1,47 @@
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',
);
});
@@ -0,0 +1,58 @@
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}`;
}
@@ -0,0 +1,44 @@
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);
});
@@ -0,0 +1,35 @@
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,
};
}
@@ -0,0 +1,40 @@
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
@@ -0,0 +1,63 @@
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}`;
}
@@ -0,0 +1,69 @@
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
@@ -0,0 +1,61 @@
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,
};
}
@@ -0,0 +1,111 @@
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',
);
});
@@ -0,0 +1,123 @@
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}`;
}
@@ -0,0 +1,67 @@
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,
);
});
@@ -0,0 +1,35 @@
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,
};
}
+134
View File
@@ -1,4 +1,5 @@
export type CustomerType = 'external' | 'lingniu';
export type ElectricVehicleScope = CustomerType | 'all';
export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15';
export interface HydrogenKpi {
@@ -22,6 +23,7 @@ export interface HydrogenKpi {
}
export interface HydrogenStationTop {
id: number;
rank: number;
name: string;
kg: number;
@@ -52,6 +54,7 @@ export interface HydrogenCustomerRow {
}
export interface HydrogenStationFull {
id: number;
name: string;
kg: number;
revenue: number;
@@ -60,6 +63,7 @@ export interface HydrogenStationFull {
}
export interface HydrogenStationRow {
stationId: number;
name: string;
pricePerKg: number;
kg: number;
@@ -74,6 +78,34 @@ export interface HydrogenDailyRow {
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;
@@ -97,3 +129,105 @@ export interface ElectricMonthGroup {
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
@@ -0,0 +1,16 @@
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');
});
+23 -10
View File
@@ -1,5 +1,19 @@
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>`。
@@ -11,26 +25,25 @@ export function useHashSubTab<T extends string>(
): [T, (sub: T) => void] {
const defaultSub = subs[0];
const parse = (): T => {
const hash = window.location.hash.slice(1);
const [first, second] = hash.split('/');
if (first !== moduleId) return defaultSub;
if (second && (subs as readonly string[]).includes(second)) return second as T;
return defaultSub;
};
const parse = (): T => parseHashSubTab(window.location.hash, moduleId, subs);
const [sub, setSubState] = useState<T>(parse);
useEffect(() => {
const onChange = () => setSubState(parse());
window.addEventListener('hashchange', onChange);
return () => window.removeEventListener('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 = next === defaultSub ? `#${moduleId}` : `#${moduleId}/${next}`;
window.history.replaceState(null, '', `${pathname}${search}${newHash}`);
const newHash = buildHashSubTab(moduleId, next, defaultSub);
window.history.pushState(null, '', `${pathname}${search}${newHash}`);
setSubState(next);
};
+340 -10
View File
@@ -1,18 +1,348 @@
import { FileText } from 'lucide-react';
import { SurfaceCard } from '../../components/ui/surface';
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>
);
}
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] flex-col items-center justify-center px-6 py-10 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<FileText size={26} />
</div>
<div className="mt-4 text-base font-black text-slate-800"></div>
<div className="mt-2 max-w-md text-xs font-bold leading-relaxed text-slate-400">
</div>
<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>
</div>
);
}
@@ -0,0 +1,141 @@
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>
);
}
+15 -1
View File
@@ -6,6 +6,7 @@ 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';
@@ -27,14 +28,26 @@ export default function MileageModule() {
icon={LayoutDashboard}
eyebrow="MILEAGE BI"
meta="实时监控 · 统计报表 · 每日汇报"
actions={<BiHeaderActions domain="mileage" />}
compactInfo
>
<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} />
<SegmentedNav
tabs={MILEAGE_TABS}
active={activeSubTab}
onChange={setActiveSubTab}
idPrefix="mileage-view"
ariaLabel="里程分析视图"
/>
</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' ? (
@@ -42,6 +55,7 @@ export default function MileageModule() {
) : (
<DailyReportView />
)}
</div>
</FadeIn>
</AnimatePresence>
<RotatingFooterHint />
+471 -144
View File
@@ -18,6 +18,18 @@ import Blur from '../../components/Blur';
import PlateMultiSelect from './PlateMultiSelect';
import { exportMileageXlsx } from './xlsx-export';
import VehicleDetailModal from './VehicleDetailModal';
import {
buildMileageDrillUrl,
parseMileageDrillContext,
type MileageDrillContext,
} from './drill-context';
import { formatMileageSnapshotTime } from './data-freshness';
import {
buildMileageMonitoringUrl,
parseMileageMonitoringContext,
type MileageMonitoringContext,
} from './monitoring-context';
import { buildMonitoringKpis } from './monitoring-kpis';
const HIGH_MILEAGE_ALERT_TARGETS = new Set([
'交投40辆4.5T普货',
@@ -25,6 +37,7 @@ const HIGH_MILEAGE_ALERT_TARGETS = new Set([
]);
const HIGH_MILEAGE_ALERT_KM = 800;
const ALL_MILEAGE_SOURCE_GROUPS: MileageSourceGroup[] = ['instrument', 'gps'];
type MonitoringRequestParams = NonNullable<Parameters<typeof fetchMonitoring>[0]>;
const MILEAGE_SOURCE_META = {
instrument: { label: '仪表数据', protocol: null },
@@ -444,34 +457,51 @@ const BatchMultiSelect = ({
};
export default function MonitoringView() {
const [searchTerm, setSearchTerm] = useState('');
const [filterDept, setFilterDept] = useState('All');
const [sortBy, setSortBy] = useState<'today' | 'total' | 'statisticTime'>('today');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [drillContext, setDrillContext] = useState<MileageDrillContext>(() => (
parseMileageDrillContext(window.location.search)
));
const [initialMonitoringContext] = useState(() => parseMileageMonitoringContext(window.location.search));
const [searchTerm, setSearchTerm] = useState(initialMonitoringContext.search || '');
const [filterDept, setFilterDept] = useState(initialMonitoringContext.department || 'All');
const [sortBy, setSortBy] = useState<'today' | 'total' | 'statisticTime'>(initialMonitoringContext.sortBy);
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(initialMonitoringContext.sortOrder);
const [isFilterOpen, setIsFilterOpen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [fullscreenVehicles, setFullscreenVehicles] = useState<MonitoringVehicle[]>([]);
const [fullscreenStats, setFullscreenStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
const [fullscreenRefresh, setFullscreenRefresh] = useState(0);
const [fullscreenLoading, setFullscreenLoading] = useState(false);
const [fullscreenError, setFullscreenError] = useState<string | null>(null);
const [fullscreenSnapshotKey, setFullscreenSnapshotKey] = useState<string | null>(null);
const [fullscreenUpdatedAt, setFullscreenUpdatedAt] = useState<string | null>(null);
const [fullscreenDateRange, setFullscreenDateRange] = useState<{ start: string; end: string } | undefined>();
// New filters from image
const [filterPlates, setFilterPlates] = useState<string[]>([]);
const [filterCustomer, setFilterCustomer] = useState('All');
const [filterProject, setFilterProject] = useState('All');
const [filterEntity, setFilterEntity] = useState('All');
const [filterRentStatus, setFilterRentStatus] = useState('All');
const [filterPlatePrefix, setFilterPlatePrefix] = useState('All');
const [filterTargetNames, setFilterTargetNames] = useState<string[]>([]);
const [filterRegion, setFilterRegion] = useState('All');
const [filterBrands, setFilterBrands] = useState<string[]>([]);
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
const [filterPlates, setFilterPlates] = useState<string[]>(initialMonitoringContext.plates);
const [drillPlateFallback, setDrillPlateFallback] = useState<string | undefined>(() => (
drillContext.level === 'vehicle' && drillContext.plate ? drillContext.plate : undefined
));
const [filterCustomer, setFilterCustomer] = useState(initialMonitoringContext.customer || 'All');
const [filterProject, setFilterProject] = useState(initialMonitoringContext.project || 'All');
const [filterEntity, setFilterEntity] = useState(initialMonitoringContext.entity || 'All');
const [filterRentStatus, setFilterRentStatus] = useState(initialMonitoringContext.rentStatus || 'All');
const [filterPlatePrefix, setFilterPlatePrefix] = useState(initialMonitoringContext.platePrefix || 'All');
const [filterTargetNames, setFilterTargetNames] = useState<string[]>(initialMonitoringContext.targetNames);
const [filterRegion, setFilterRegion] = useState(initialMonitoringContext.region || 'All');
const [filterBrands, setFilterBrands] = useState<string[]>(initialMonitoringContext.brands);
const [filterMileageRange, setFilterMileageRange] = useState({
min: initialMonitoringContext.mileageMin || '',
max: initialMonitoringContext.mileageMax || '',
});
const [appliedMileageRange, setAppliedMileageRange] = useState({
min: initialMonitoringContext.mileageMin || '',
max: initialMonitoringContext.mileageMax || '',
});
const [exporting, setExporting] = useState(false);
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
const [rangeStart, setRangeStart] = useState(defaultMileageDate);
const [rangeEnd, setRangeEnd] = useState(defaultMileageDate);
const [sourcePriority, setSourcePriority] = useState<MileageSourceGroup[]>(['instrument']);
const [exportStatus, setExportStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const [rangeStart, setRangeStart] = useState(() => initialMonitoringContext.startDate || drillContext.startDate || defaultMileageDate());
const [rangeEnd, setRangeEnd] = useState(() => initialMonitoringContext.endDate || drillContext.endDate || defaultMileageDate());
const [sourcePriority, setSourcePriority] = useState<MileageSourceGroup[]>(initialMonitoringContext.sourcePriority);
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
@@ -483,24 +513,83 @@ export default function MonitoringView() {
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [pageLoading, setPageLoading] = useState(true);
const [pageError, setPageError] = useState<{ message: string; scope: 'first' | 'more' } | null>(null);
const [pageSnapshotKey, setPageSnapshotKey] = useState<string | null>(null);
const [manualRefreshing, setManualRefreshing] = useState(false);
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null);
const [dataUpdatedAt, setDataUpdatedAt] = useState<string | null>(null);
const [showBackToTop, setShowBackToTop] = useState(false);
const [relativeNow, setRelativeNow] = useState(() => Date.now());
const firstPageRequestRef = useRef(0);
const loadMoreRequestRef = useRef(0);
const PAGE_SIZE = 50;
const commitDrillContext = useCallback((next: MileageDrillContext, mode: 'push' | 'replace') => {
const url = buildMileageDrillUrl(window.location, next);
window.history[mode === 'push' ? 'pushState' : 'replaceState'](null, '', url);
setDrillContext(next);
}, []);
const openVehicleDetail = useCallback((vehicle: MonitoringVehicle) => {
setDrillPlateFallback(undefined);
commitDrillContext({
level: 'vehicle',
plate: vehicle.plate,
startDate: effectiveRange.start,
endDate: effectiveRange.end,
sourcePriority,
}, 'push');
}, [commitDrillContext, effectiveRange.end, effectiveRange.start, sourcePriority]);
const closeVehicleDetail = useCallback(() => {
setDrillPlateFallback(undefined);
commitDrillContext({
level: 'overview',
startDate: effectiveRange.start,
endDate: effectiveRange.end,
sourcePriority,
}, 'replace');
}, [commitDrillContext, effectiveRange.end, effectiveRange.start, sourcePriority]);
useEffect(() => {
const timer = setInterval(() => setRelativeNow(Date.now()), 5000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
const handlePopState = () => {
const next = parseMileageDrillContext(window.location.search);
const nextMonitoring = parseMileageMonitoringContext(window.location.search);
setDrillContext(next);
setRangeStart(nextMonitoring.startDate || defaultMileageDate());
setRangeEnd(nextMonitoring.endDate || defaultMileageDate());
setSourcePriority(nextMonitoring.sourcePriority);
setFilterTargetNames(nextMonitoring.targetNames);
setFilterRegion(nextMonitoring.region || 'All');
setFilterPlates(nextMonitoring.plates);
setDrillPlateFallback(next.level === 'vehicle' && next.plate ? next.plate : undefined);
setFilterDept(nextMonitoring.department || 'All');
setFilterCustomer(nextMonitoring.customer || 'All');
setFilterProject(nextMonitoring.project || 'All');
setFilterEntity(nextMonitoring.entity || 'All');
setFilterRentStatus(nextMonitoring.rentStatus || 'All');
setFilterBrands(nextMonitoring.brands);
setFilterPlatePrefix(nextMonitoring.platePrefix || 'All');
setSearchTerm(nextMonitoring.search || '');
const nextMileageRange = {
min: nextMonitoring.mileageMin || '',
max: nextMonitoring.mileageMax || '',
};
setFilterMileageRange(nextMileageRange);
setAppliedMileageRange(nextMileageRange);
setSortBy(nextMonitoring.sortBy);
setSortOrder(nextMonitoring.sortOrder);
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
const departments = filterOptions.departments;
const plateNumbers = filterOptions.plates;
const rangeLabel = normalizeRangeLabel(effectiveRange.start, effectiveRange.end);
const isRangeMode = !!effectiveRange.start && !!effectiveRange.end && effectiveRange.start !== effectiveRange.end;
const averageDailyKm = rangeDailyTotals.length > 0
? rangeDailyTotals.reduce((sum, item) => sum + item.totalKm, 0) / rangeDailyTotals.length
: 0;
const applyRangePreset = useCallback((preset: RangePreset) => {
const range = getRangePreset(preset);
setRangeStart(range.start);
@@ -513,14 +602,12 @@ export default function MonitoringView() {
return inAlertTarget && Math.max(0, v.dailyKm || 0) >= HIGH_MILEAGE_ALERT_KM;
}, [filterTargetNames]);
// 加载首页数据
const loadFirstPage = useCallback((showPageLoading = true) => {
if (showPageLoading) setPageLoading(true);
return fetchMonitoring({
sortBy,
sortOrder,
limit: PAGE_SIZE,
page: 1,
const requestPlates = useMemo(() => Array.from(new Set([
...filterPlates,
...(drillPlateFallback ? [drillPlateFallback] : []),
])), [drillPlateFallback, filterPlates]);
const monitoringFilters = useMemo<MonitoringRequestParams>(() => ({
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
@@ -530,14 +617,118 @@ export default function MonitoringView() {
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
plate: requestPlates.length > 0 ? requestPlates.join(',') : undefined,
mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined,
startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
brands: filterBrands.length > 0 ? filterBrands : undefined,
sourcePriority,
}), [
appliedMileageRange.max,
appliedMileageRange.min,
filterBrands,
filterCustomer,
filterDept,
filterEntity,
filterPlatePrefix,
filterProject,
filterRegion,
filterRentStatus,
filterTargetNames,
rangeEnd,
rangeStart,
requestPlates,
searchTerm,
sourcePriority,
]);
const monitoringUrlContext = useMemo<MileageMonitoringContext>(() => ({
startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
sourcePriority,
targetNames: filterTargetNames,
region: filterRegion !== 'All' ? filterRegion : undefined,
plates: filterPlates,
department: filterDept !== 'All' ? filterDept : undefined,
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
project: filterProject !== 'All' ? filterProject : undefined,
entity: filterEntity !== 'All' ? filterEntity : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
brands: filterBrands,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
search: searchTerm || undefined,
mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined,
sortBy,
sortOrder,
}), [
appliedMileageRange.max,
appliedMileageRange.min,
filterBrands,
filterCustomer,
filterDept,
filterEntity,
filterPlatePrefix,
filterPlates,
filterProject,
filterRegion,
filterRentStatus,
filterTargetNames,
rangeEnd,
rangeStart,
searchTerm,
sortBy,
sortOrder,
sourcePriority,
]);
useEffect(() => {
const nextUrl = buildMileageMonitoringUrl(window.location, monitoringUrlContext);
const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (nextUrl !== currentUrl) window.history.replaceState(null, '', nextUrl);
}, [monitoringUrlContext]);
const monitoringRequestKey = useMemo(
() => JSON.stringify({ ...monitoringFilters, sortBy, sortOrder }),
[monitoringFilters, sortBy, sortOrder],
);
const pageHasSnapshot = pageSnapshotKey === monitoringRequestKey;
const fullscreenHasSnapshot = fullscreenSnapshotKey === monitoringRequestKey;
const pageUpdatedAt = pageHasSnapshot ? dataUpdatedAt : null;
const detailVehicle = pageHasSnapshot && drillContext.level === 'vehicle' && drillContext.plate
? vehicles.find(vehicle => vehicle.plate === drillContext.plate) || null
: null;
const displayedRange = pageHasSnapshot
? effectiveRange
: { start: rangeStart, end: rangeEnd };
const rangeLabel = normalizeRangeLabel(displayedRange.start, displayedRange.end);
const isRangeMode = !!displayedRange.start && !!displayedRange.end && displayedRange.start !== displayedRange.end;
const averageDailyKm = pageHasSnapshot && rangeDailyTotals.length > 0
? rangeDailyTotals.reduce((sum, item) => sum + item.totalKm, 0) / rangeDailyTotals.length
: 0;
const pageKpis = buildMonitoringKpis(stats, displayedRange);
const fullscreenKpis = buildMonitoringKpis(
fullscreenStats,
fullscreenHasSnapshot ? fullscreenDateRange : { start: rangeStart, end: rangeEnd },
);
// 加载首页数据
const loadFirstPage = useCallback((showPageLoading = true, force = false) => {
const requestId = ++firstPageRequestRef.current;
const requestKey = monitoringRequestKey;
++loadMoreRequestRef.current;
setLoadingMore(false);
if (showPageLoading) setPageLoading(true);
setPageError(null);
return fetchMonitoring({
...monitoringFilters,
sortBy,
sortOrder,
limit: PAGE_SIZE,
page: 1,
force,
}).then(d => {
if (requestId !== firstPageRequestRef.current) return;
setVehicles(d.vehicles);
setStats(d.stats);
setFilterOptions(d.filters);
@@ -546,17 +737,24 @@ export default function MonitoringView() {
setTotal(d.total);
setPage(1);
setHasMore(d.page < d.totalPages);
setLastRefreshedAt(new Date());
}).catch(() => {}).finally(() => {
if (showPageLoading) setPageLoading(false);
setDataUpdatedAt(d.updatedAt);
setPageSnapshotKey(requestKey);
}).catch(error => {
if (requestId !== firstPageRequestRef.current) return;
setPageError({
message: error instanceof Error ? error.message : '里程数据加载失败,请稍后重试',
scope: 'first',
});
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]);
}).finally(() => {
if (requestId === firstPageRequestRef.current) setPageLoading(false);
});
}, [monitoringFilters, monitoringRequestKey, rangeEnd, rangeStart, sortBy, sortOrder]);
const handleManualRefresh = useCallback(async () => {
if (manualRefreshing) return;
setManualRefreshing(true);
try {
await loadFirstPage(false);
await loadFirstPage(false, true);
} finally {
setManualRefreshing(false);
}
@@ -566,34 +764,31 @@ export default function MonitoringView() {
const loadMore = useCallback(() => {
if (loadingMore || !hasMore) return;
const nextPage = page + 1;
const requestId = ++loadMoreRequestRef.current;
setLoadingMore(true);
setPageError(null);
fetchMonitoring({
...monitoringFilters,
sortBy,
sortOrder,
limit: PAGE_SIZE,
page: nextPage,
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
project: filterProject !== 'All' ? filterProject : undefined,
entity: filterEntity !== 'All' ? filterEntity : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined,
startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
brands: filterBrands.length > 0 ? filterBrands : undefined,
sourcePriority,
}).then(d => {
if (requestId !== loadMoreRequestRef.current) return;
setVehicles(prev => [...prev, ...d.vehicles]);
setPage(nextPage);
setHasMore(nextPage < d.totalPages);
}).catch(() => {}).finally(() => setLoadingMore(false));
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, page, loadingMore, hasMore, filterBrands, sourcePriority]);
setDataUpdatedAt(d.updatedAt);
}).catch(error => {
if (requestId !== loadMoreRequestRef.current) return;
setPageError({
message: error instanceof Error ? error.message : '更多车辆加载失败,请稍后重试',
scope: 'more',
});
}).finally(() => {
if (requestId === loadMoreRequestRef.current) setLoadingMore(false);
});
}, [hasMore, loadingMore, monitoringFilters, page, sortBy, sortOrder]);
// 筛选/排序变化时重新加载
useEffect(() => {
@@ -603,6 +798,7 @@ export default function MonitoringView() {
// 区域级联:plate 选项收窄后,剔除已选但已不属于该区域的车牌
useEffect(() => {
if (filterPlates.length === 0) return;
if (filterOptions.plates.length === 0) return;
const valid = new Set(filterOptions.plates);
const next = filterPlates.filter(p => valid.has(p));
if (next.length !== filterPlates.length) setFilterPlates(next);
@@ -612,36 +808,27 @@ export default function MonitoringView() {
const handleDownload = useCallback(async () => {
if (exporting) return;
setExporting(true);
setExportStatus(null);
try {
const d = await fetchMonitoring({
...monitoringFilters,
sortBy,
sortOrder,
limit: 9999,
page: 1,
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
project: filterProject !== 'All' ? filterProject : undefined,
entity: filterEntity !== 'All' ? filterEntity : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined,
startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
brands: filterBrands.length > 0 ? filterBrands : undefined,
sourcePriority,
});
exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy });
setExportStatus({ type: 'success', message: `已导出 ${d.vehicles.length} 辆车辆` });
} catch (err) {
console.error('export failed', err);
setExportStatus({
type: 'error',
message: err instanceof Error ? err.message : '导出失败,请稍后重试',
});
} finally {
setExporting(false);
}
}, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]);
}, [exporting, monitoringFilters, rangeEnd, rangeStart, sortBy, sortOrder]);
// 每分钟自动刷新
useEffect(() => {
@@ -649,26 +836,6 @@ export default function MonitoringView() {
return () => clearInterval(timer);
}, [loadFirstPage]);
// 触底检测:用 IntersectionObserver 监听哨兵元素
const loadMoreRef = useRef(loadMore);
loadMoreRef.current = loadMore;
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMoreRef.current();
}
},
{ rootMargin: '200px' }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, []);
// 回到顶部按钮:用 IntersectionObserver 检测顶部哨兵是否离开视口
const topSentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -687,7 +854,7 @@ export default function MonitoringView() {
document.body.scrollTop = 0;
};
const filteredVehicles = vehicles;
const filteredVehicles = pageHasSnapshot ? vehicles : [];
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen);
@@ -696,30 +863,31 @@ export default function MonitoringView() {
// 全屏时加载全部数据(无分页),筛选变化时重新加载
useEffect(() => {
if (!isFullscreen) return;
let cancelled = false;
setFullscreenLoading(true);
setFullscreenError(null);
fetchMonitoring({
...monitoringFilters,
sortBy,
sortOrder,
limit: 9999,
page: 1,
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
brands: filterBrands.length > 0 ? filterBrands : undefined,
sourcePriority,
}).then(d => {
if (cancelled) return;
setFullscreenVehicles(d.vehicles);
setFullscreenStats(d.stats);
setFilterOptions(d.filters);
}).catch(() => {}).finally(() => setFullscreenLoading(false));
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, fullscreenRefresh, filterBrands, sourcePriority]);
setFullscreenUpdatedAt(d.updatedAt);
setFullscreenDateRange(d.dateRange || { start: rangeStart, end: rangeEnd });
setFullscreenSnapshotKey(monitoringRequestKey);
}).catch(error => {
if (cancelled) return;
setFullscreenError(error instanceof Error ? error.message : '全屏监控加载失败,请稍后重试');
}).finally(() => {
if (!cancelled) setFullscreenLoading(false);
});
return () => { cancelled = true; };
}, [fullscreenRefresh, isFullscreen, monitoringFilters, monitoringRequestKey, rangeEnd, rangeStart, sortBy, sortOrder]);
// 全屏时禁止背景滚动
useEffect(() => {
@@ -757,6 +925,9 @@ export default function MonitoringView() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
role="dialog"
aria-modal="true"
aria-label="里程全屏监控"
className="fixed z-[100] bg-slate-950 flex flex-col overflow-hidden"
style={
forceLandscape
@@ -780,13 +951,13 @@ export default function MonitoringView() {
<h2 className="text-white font-bold text-xs"></h2>
</div>
<div className="flex items-center gap-3 text-[10px]">
<span className="text-slate-500"> <span className="text-white font-black">{Math.round(fullscreenStats.totalToday).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-500">{fullscreenKpis.distanceShortLabel} <span className="text-white font-black">{fullscreenHasSnapshot ? Math.round(fullscreenKpis.distanceKm).toLocaleString() : '—'}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{Math.round(fullscreenStats.totalAll).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-500"> <span className="text-white font-black">{fullscreenHasSnapshot ? Math.round(fullscreenKpis.cumulativeOdometerKm).toLocaleString() : '—'}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{fullscreenStats.vehicleCount}</span> </span>
<span className="text-slate-500"> <span className="text-white font-black">{fullscreenHasSnapshot ? fullscreenKpis.vehicleCount : '—'}</span> </span>
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{(fullscreenStats.vehicleCount > 0 ? (sortBy === 'total' ? fullscreenStats.totalAll : fullscreenStats.totalToday) / fullscreenStats.vehicleCount : 0).toFixed(0)}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-500"> <span className="text-white font-black">{fullscreenHasSnapshot ? fullscreenKpis.averagePerVehicleKm.toFixed(0) : '—'}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span>
<span className="text-slate-500">
<span className="text-white font-black">
@@ -798,11 +969,14 @@ export default function MonitoringView() {
<div className="flex items-center gap-2">
<button
onClick={() => { setFullscreenRefresh(n => n + 1); }}
disabled={fullscreenLoading}
aria-label={fullscreenLoading ? '正在刷新全屏监控' : '刷新全屏监控'}
title={fullscreenLoading ? '刷新中' : '刷新全屏监控'}
className={`p-1.5 text-slate-500 hover:text-blue-400 transition-colors ${fullscreenLoading ? 'animate-spin' : ''}`}
>
<RotateCcw size={13} />
</button>
<button onClick={toggleFullscreen} className="p-1.5 text-slate-500 hover:text-white transition-colors">
<button onClick={toggleFullscreen} aria-label="退出全屏监控" title="退出全屏" className="p-1.5 text-slate-500 hover:text-white transition-colors">
<Minimize2 size={14} />
</button>
</div>
@@ -832,9 +1006,24 @@ export default function MonitoringView() {
{/* Table Area */}
<div className="flex-1 overflow-hidden flex flex-col">
{fullscreenError && fullscreenHasSnapshot && (
<div role="alert" className="flex flex-shrink-0 items-center justify-between gap-3 border-b border-amber-500/20 bg-amber-500/10 px-3 py-1.5 text-[10px] font-bold text-amber-300">
<span className="truncate" title={fullscreenError}>
{formatMileageSnapshotTime(fullscreenUpdatedAt)}
</span>
<button
type="button"
onClick={() => setFullscreenRefresh(value => value + 1)}
disabled={fullscreenLoading}
className="shrink-0 text-amber-200 underline underline-offset-2 disabled:opacity-50"
>
</button>
</div>
)}
<div className="flex-1 overflow-auto relative">
{fullscreenLoading && (
{fullscreenLoading && fullscreenHasSnapshot && (
<div className="absolute inset-0 bg-slate-950/60 z-20 flex items-center justify-center">
<div className="flex items-center gap-2 text-slate-400 text-xs font-bold">
<RotateCcw size={14} className="animate-spin" />
@@ -842,7 +1031,29 @@ export default function MonitoringView() {
</div>
</div>
)}
<table className="w-full text-left border-collapse">
{!fullscreenHasSnapshot && (
<div className="flex min-h-full items-center justify-center px-6 py-20">
{fullscreenLoading ? (
<div className="flex items-center gap-2 text-xs font-bold text-slate-400" role="status">
<RotateCcw size={14} className="animate-spin" />
...
</div>
) : (
<div className="max-w-md text-center" role="alert">
<p className="text-sm font-bold text-rose-300"></p>
<p className="mt-1 text-[10px] text-slate-500">{fullscreenError || '未取得当前筛选条件下的数据'}</p>
<button
type="button"
onClick={() => setFullscreenRefresh(value => value + 1)}
className="mt-3 rounded border border-slate-700 px-3 py-1.5 text-[10px] font-bold text-slate-300 hover:border-blue-500 hover:text-blue-300"
>
</button>
</div>
)}
</div>
)}
{fullscreenHasSnapshot && <table className="w-full text-left border-collapse">
<thead className="sticky top-0 bg-slate-900 z-10">
<tr className="border-b border-slate-800/60">
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase w-12 text-center"></th>
@@ -970,6 +1181,13 @@ export default function MonitoringView() {
</tr>
</thead>
<tbody className="divide-y divide-slate-800/30">
{fullscreenVehicles.length === 0 && (
<tr>
<td colSpan={8} className="px-3 py-20 text-center text-xs font-bold text-slate-500">
</td>
</tr>
)}
{fullscreenVehicles.map((v) => {
const highMileageAlert = isHighMileageAlert(v);
const statisticTime = vehicleStatisticTime(v, relativeNow);
@@ -1011,7 +1229,7 @@ export default function MonitoringView() {
);
})}
</tbody>
</table>
</table>}
</div>
</div>
</motion.div>
@@ -1025,23 +1243,34 @@ export default function MonitoringView() {
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="truncate text-lg font-black leading-none text-slate-900"></h1>
<button onClick={toggleFullscreen} className="p-1 text-slate-300 transition-colors hover:text-blue-600" title="全屏视图">
<button onClick={toggleFullscreen} aria-label="打开全屏监控" className="p-1 text-slate-300 transition-colors hover:text-blue-600" title="全屏视图">
<Maximize2 size={14} />
</button>
<button onClick={handleDownload} disabled={exporting} className="p-1 text-slate-300 transition-colors hover:text-blue-600 disabled:text-slate-200" title="下载当前筛选结果">
<button
onClick={handleDownload}
disabled={exporting}
aria-label={exporting ? '正在导出当前筛选结果' : '导出当前筛选结果'}
className="p-1 text-slate-300 transition-colors hover:text-blue-600 disabled:text-slate-200"
title={exporting ? '导出中' : '下载当前筛选结果'}
>
{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download size={14} />}
</button>
</div>
<div className="mt-1 flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
<span className="text-[9px] font-bold uppercase tracking-tight text-slate-400"> OneOS </span>
<span
className="truncate text-[9px] font-bold tracking-tight text-slate-400"
title={`OneOS 分钟级快照 · 数据截至 ${formatMileageSnapshotTime(pageUpdatedAt)}`}
>
OneOS · {formatMileageSnapshotTime(pageUpdatedAt)}
</span>
</div>
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-1.5">
<span className="hidden text-[9px] font-bold tabular-nums text-slate-400 sm:inline">
{lastRefreshedAt ? lastRefreshedAt.toLocaleTimeString('zh-CN', { hour12: false }) : '--:--:--'}
{formatMileageSnapshotTime(pageUpdatedAt)}
</span>
<button
type="button"
@@ -1054,9 +1283,9 @@ export default function MonitoringView() {
<span>{manualRefreshing ? '刷新中' : '刷新数据'}</span>
</button>
<div className="flex items-center gap-1 rounded-lg bg-slate-100 p-0.5">
<button onClick={() => setSortBy('today')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'today' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}></button>
<button onClick={() => setSortBy('total')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'total' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}></button>
<button onClick={() => setSortBy('statisticTime')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'statisticTime' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}></button>
<button aria-pressed={sortBy === 'today'} title="按所选日期区间里程排序" onClick={() => setSortBy('today')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'today' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}></button>
<button aria-pressed={sortBy === 'total'} title="按最新累计仪表里程排序" onClick={() => setSortBy('total')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'total' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}></button>
<button aria-pressed={sortBy === 'statisticTime'} title="按最后有效数据时间排序" onClick={() => setSortBy('statisticTime')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'statisticTime' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}></button>
<button
onClick={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')}
className="rounded-md p-1 text-blue-600 transition-all hover:bg-white"
@@ -1068,6 +1297,32 @@ export default function MonitoringView() {
</div>
</div>
{exportStatus && (
<div
role={exportStatus.type === 'error' ? 'alert' : 'status'}
className={`flex items-center justify-between gap-3 border-t pt-2 text-[10px] font-bold ${
exportStatus.type === 'error'
? 'border-rose-100 text-rose-600'
: 'border-emerald-100 text-emerald-600'
}`}
>
<span className="flex min-w-0 items-center gap-1.5">
{exportStatus.type === 'success' && <Check size={12} className="shrink-0" />}
<span className="truncate" title={exportStatus.message}>{exportStatus.message}</span>
</span>
{exportStatus.type === 'error' && (
<button
type="button"
onClick={handleDownload}
disabled={exporting}
className="shrink-0 underline underline-offset-2 disabled:opacity-50"
>
</button>
)}
</div>
)}
<SourcePriorityControl priority={sourcePriority} onChange={setSourcePriority} />
<div className="flex items-center gap-2">
@@ -1370,30 +1625,73 @@ export default function MonitoringView() {
);
})()}
{pageError?.scope === 'first' && (
<div
role="alert"
className={`flex items-center justify-between gap-3 rounded-xl border px-3 py-2 ${
pageHasSnapshot
? 'border-amber-200 bg-amber-50 text-amber-700'
: 'border-rose-200 bg-rose-50 text-rose-700'
}`}
>
<div className="min-w-0">
<p className="truncate text-[10px] font-black" title={pageError.message}>
{pageHasSnapshot
? `刷新失败,仍展示截至 ${formatMileageSnapshotTime(pageUpdatedAt)} 的数据`
: '当前筛选数据暂不可用'}
</p>
{!pageHasSnapshot && <p className="mt-0.5 truncate text-[9px] opacity-70">{pageError.message}</p>}
</div>
<button
type="button"
onClick={() => pageHasSnapshot ? handleManualRefresh() : loadFirstPage()}
disabled={pageLoading || manualRefreshing}
className="inline-flex h-7 shrink-0 items-center gap-1 rounded-lg border border-current/20 bg-white/70 px-2 text-[9px] font-black disabled:opacity-50"
>
<RefreshCw size={11} className={pageLoading || manualRefreshing ? 'animate-spin' : ''} />
</button>
</div>
)}
{/* Sticky header: KPI + 清单标题 */}
<div className="sticky top-[44px] z-20 bg-[var(--app-bg)] pt-1 pb-1 space-y-2">
<div className={`grid grid-cols-4 gap-2 transition-opacity ${pageLoading ? 'opacity-60' : ''}`}>
<div className={`grid grid-cols-4 gap-2 transition-opacity ${pageLoading && pageHasSnapshot ? 'opacity-60' : ''}`}>
<div className="relative col-span-2 flex min-h-[68px] flex-col justify-center overflow-hidden rounded-xl bg-slate-900 p-2.5 text-white">
<div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'total' ? '累计' : (isRangeMode ? '区间' : '当日')}</div>
<div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{pageKpis.distanceLabel}</div>
<div className="text-lg font-black tracking-tighter leading-tight flex items-baseline gap-1">
{pageLoading ? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div> : <>{Math.round(sortBy === 'total' ? stats.totalAll : stats.totalToday).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></>}
{pageLoading && !pageHasSnapshot
? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div>
: <>{pageHasSnapshot ? Math.round(pageKpis.distanceKm).toLocaleString() : '—'} <span className="text-[8px] text-slate-400">km</span></>}
</div>
<div className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div>
</div>
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm">
<div className="text-[7px] font-bold text-slate-400 uppercase"></div>
<div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : (stats.vehicleCount > 0 ? (sortBy === 'total' ? stats.totalAll : stats.totalToday) / stats.vehicleCount : 0).toFixed(0)}</div>
<div className="text-sm font-black text-slate-800 leading-tight">
{pageLoading && !pageHasSnapshot
? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div>
: pageHasSnapshot ? pageKpis.averagePerVehicleKm.toFixed(0) : '—'}
</div>
<div className="text-[7px] text-slate-400">km/</div>
</div>
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm">
<div className="text-[7px] font-bold text-slate-400 uppercase"></div>
<div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : stats.vehicleCount}</div>
<div className="text-sm font-black text-slate-800 leading-tight">
{pageLoading && !pageHasSnapshot
? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div>
: pageHasSnapshot ? pageKpis.vehicleCount : '—'}
</div>
<div className="text-[7px] text-slate-400"></div>
</div>
</div>
<div className="rounded-xl border border-slate-100 bg-white shadow-sm overflow-hidden">
{pageLoading ? (
{pageLoading && !pageHasSnapshot ? (
<div className="h-[74px] bg-slate-50 animate-pulse" />
) : !pageHasSnapshot ? (
<div className="flex h-[74px] items-center justify-center px-3 text-[10px] font-bold text-slate-400">
</div>
) : isRangeMode ? (
<div className="grid grid-cols-[92px_minmax(0,1fr)_62px] items-center gap-2 px-2 py-2">
<div className="min-w-0">
@@ -1439,18 +1737,20 @@ export default function MonitoringView() {
</div>
<div className="flex flex-col justify-center rounded-lg bg-blue-50 px-2 py-1.5 text-right">
<div className="text-[9px] font-black text-blue-400"></div>
<div className="mt-1 text-xs font-black tabular-nums text-blue-700">{Math.round(stats.totalToday).toLocaleString()}</div>
<div className="mt-1 text-xs font-black tabular-nums text-blue-700">{Math.round(pageKpis.distanceKm).toLocaleString()}</div>
</div>
<div className="flex flex-col justify-center rounded-lg bg-slate-50 px-2 py-1.5 text-right">
<div className="text-[9px] font-black text-slate-400"></div>
<div className="mt-1 text-xs font-black tabular-nums text-slate-800">{stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}</div>
<div className="mt-1 text-xs font-black tabular-nums text-slate-800">{Math.round(pageKpis.averagePerVehicleKm).toLocaleString()}</div>
</div>
</div>
)}
</div>
<div className="flex items-center justify-between px-1">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest"></span>
<span className="text-[9px] font-bold text-slate-300">{total} </span>
<span className="text-[9px] font-bold text-slate-400">
{pageHasSnapshot ? `已加载 ${filteredVehicles.length} / 共 ${total}` : '数据不可用'}
</span>
</div>
<div className="hidden grid-cols-[minmax(190px,1.1fr)_minmax(160px,1fr)_130px_130px] items-center gap-3 rounded-lg border border-slate-200 bg-white px-4 py-2 text-[10px] font-bold text-slate-400 md:grid">
<span> / </span>
@@ -1463,7 +1763,7 @@ export default function MonitoringView() {
{/* Vehicle List */}
<div className="space-y-1.5">
{pageLoading && (
{pageLoading && !pageHasSnapshot && (
<div className="space-y-1.5">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-white px-3 py-3 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between animate-pulse">
@@ -1494,7 +1794,7 @@ export default function MonitoringView() {
animate={{ opacity: 1 }}
key={v.plate}
className="grid cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2.5 shadow-sm transition-all hover:border-blue-200 hover:shadow-md active:bg-slate-50 md:grid-cols-[minmax(190px,1.1fr)_minmax(160px,1fr)_130px_130px] md:px-4"
onClick={() => setDetailVehicle(v)}
onClick={() => openVehicleDetail(v)}
>
<div className="flex min-w-0 items-center gap-3 overflow-hidden">
<div className="relative flex-shrink-0">
@@ -1565,31 +1865,58 @@ export default function MonitoringView() {
})}
</div>
{filteredVehicles.length === 0 && !loadingMore && (
{pageHasSnapshot && filteredVehicles.length === 0 && !loadingMore && !pageError && (
<div className="py-10 text-center bg-white rounded-2xl border border-dashed border-slate-100">
<p className="text-xs font-bold text-slate-300"></p>
</div>
)}
{/* 加载更多提示 */}
{loadingMore && (
<div className="py-4 text-center">
<span className="text-[10px] font-bold text-slate-400">...</span>
{pageError?.scope === 'more' && pageHasSnapshot && (
<div className="rounded-xl border border-rose-100 bg-rose-50 px-3 py-3 text-center">
<p className="text-[10px] font-bold text-rose-600">{pageError.message}</p>
<button
type="button"
onClick={loadMore}
disabled={pageLoading || loadingMore}
className="mt-2 inline-flex h-8 items-center gap-1.5 rounded-lg border border-rose-200 bg-white px-3 text-[10px] font-black text-rose-700 transition-colors hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-60"
>
<RefreshCw size={12} className={pageLoading || loadingMore ? 'animate-spin' : ''} />
</button>
</div>
)}
{!hasMore && filteredVehicles.length > 0 && (
{pageHasSnapshot && hasMore && !pageError && filteredVehicles.length > 0 && (
<div className="flex flex-col items-center gap-1.5 py-4">
<button
type="button"
onClick={loadMore}
disabled={loadingMore}
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-4 text-[11px] font-black text-slate-700 shadow-sm transition-colors hover:border-blue-200 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-60"
>
{loadingMore ? <RefreshCw size={13} className="animate-spin" /> : <ChevronDown size={14} />}
{loadingMore ? '加载中' : '加载更多'}
</button>
<span className="text-[9px] font-bold text-slate-300">
{filteredVehicles.length} / {total}
</span>
</div>
)}
{pageHasSnapshot && !hasMore && filteredVehicles.length > 0 && (
<div className="py-4 text-center">
<span className="text-[10px] font-bold text-slate-300"> {total} </span>
</div>
)}
{/* 哨兵元素:进入视口时触发加载更多 */}
<div ref={sentinelRef} className="h-1" />
</div>
<VehicleDetailModal
key={detailVehicle
? `${detailVehicle.plate}|${drillContext.startDate || effectiveRange.start}|${drillContext.endDate || effectiveRange.end}`
: 'closed'}
vehicle={detailVehicle}
onClose={() => setDetailVehicle(null)}
onClose={closeVehicleDetail}
sourcePriority={sourcePriority}
initialStartDate={drillContext.startDate || effectiveRange.start}
initialEndDate={drillContext.endDate || effectiveRange.end}
/>
{/* 回到顶部按钮 */}
+484 -71
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
@@ -6,12 +6,26 @@ import {
Cell, LabelList,
} from 'recharts';
import {
Truck, ChevronDown, Maximize2, Minimize2,
AlertTriangle, Truck, ChevronDown, Maximize2, Minimize2,
Search, ArrowUpDown, X, RotateCcw, Calendar,
} from 'lucide-react';
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from './types';
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
import Blur from '../../components/Blur';
import { EmptyState, ErrorState, LoadingState } from '../../components/ui/surface';
import { weightedCompletionRate } from '../../shared/analytics/metrics';
import { formatMileageSnapshotDateTime } from './data-freshness';
import { assessVehicleCoverage } from './assessment-coverage';
import MileageAttributionView from './MileageAttributionView';
import {
filterAttributionVehicles,
type AttributionDimension,
} from './attribution';
import {
buildMileageDrillUrl,
parseMileageDrillContext,
type MileageDrillContext,
} from './drill-context';
function getDefaultDate(): string {
const now = new Date();
@@ -19,11 +33,6 @@ function getDefaultDate(): string {
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
function getCurrentDateLabel(): string {
const now = new Date();
return `${now.getFullYear()}.${now.getMonth() + 1}.${now.getDate()}`;
}
function fmtKm(value: number): string {
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
return value.toLocaleString();
@@ -38,10 +47,8 @@ function getTargetAssessment(target: TargetSummary, selectedYear?: number): Targ
return target.yearlyAssessments.find(item => item.yearNumber === selectedYear) || target.yearlyAssessments[0];
}
function fmtDateLabel(date: string | null): string {
if (!date) return '';
const [year, month, day] = date.split('-');
return `${year}.${Number(month)}.${Number(day)}`;
function resolveAssessmentYear(target: TargetSummary, requestedYear?: number): number | undefined {
return getTargetAssessment(target, requestedYear)?.yearNumber;
}
function shortTargetName(name: string): string {
@@ -58,75 +65,373 @@ function shortTargetName(name: string): string {
return `${count}${desc}`;
}
function getRequestErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message && error.message !== 'Failed to fetch') {
return error.message;
}
return fallback;
}
export default function StatisticsView() {
const currentDateLabel = getCurrentDateLabel();
const [drillContext, setDrillContext] = useState<MileageDrillContext>(() => (
parseMileageDrillContext(window.location.search)
));
const [targets, setTargets] = useState<TargetSummary[]>([]);
const [targetsLoading, setTargetsLoading] = useState(true);
const [targetsError, setTargetsError] = useState<string | null>(null);
const [targetsRequestVersion, setTargetsRequestVersion] = useState(0);
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
const [trendLoading, setTrendLoading] = useState(false);
const [trendError, setTrendError] = useState<string | null>(null);
const [trendRequestVersion, setTrendRequestVersion] = useState(0);
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(null);
const [targetVehicleLoadingMap, setTargetVehicleLoadingMap] = useState<Record<number, boolean>>({});
const [targetVehicleErrorMap, setTargetVehicleErrorMap] = useState<Record<number, string>>({});
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(drillContext.targetId || null);
const [attributionDimension, setAttributionDimension] = useState<AttributionDimension>(() => (
drillContext.level === 'breakdown' && drillContext.dimension === 'customer' ? 'customer' : 'department'
));
const [chartType, setChartType] = useState<'bar' | 'line' | 'area'>('bar');
const [isTableFullscreen, setIsTableFullscreen] = useState(false);
const [expandedTargetId, setExpandedTargetId] = useState<number | null>(null);
const [assessmentYearMap, setAssessmentYearMap] = useState<Record<number, number>>({});
const [viewAllTargetId, setViewAllTargetId] = useState<number | null>(null);
const [viewAllTargetId, setViewAllTargetId] = useState<number | null>(() => (
drillContext.level === 'breakdown' ? drillContext.targetId || null : null
));
const [viewAllTargetName, setViewAllTargetName] = useState<string>('');
const [viewAllSearch, setViewAllSearch] = useState('');
const [viewAllSort, setViewAllSort] = useState<'asc' | 'desc'>('desc');
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
const [viewAllLoading, setViewAllLoading] = useState(false);
const [viewAllError, setViewAllError] = useState<string | null>(null);
const [viewAllRequestVersion, setViewAllRequestVersion] = useState(0);
const [viewAllHistoricalVehicles, setViewAllHistoricalVehicles] = useState<TargetVehicle[] | null>(null);
const [viewAllLoadedDate, setViewAllLoadedDate] = useState<string | null>(null);
const [viewAllLoadedTargetId, setViewAllLoadedTargetId] = useState<number | null>(null);
const targetVehicleRequestsRef = useRef(new Set<number>());
const initialDrillContextRef = useRef(drillContext);
const selectedTarget = targets.find(t => t.id === selectedTargetId);
const selectedTargetVehicles = selectedTargetId === null ? [] : targetVehiclesMap[selectedTargetId] || [];
const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null;
const selectedCoverage = selectedTarget
? assessVehicleCoverage(selectedTarget.declaredVehicleCount, selectedTarget.vehicleCount)
: null;
const selectedAssessmentYear = selectedAssessment?.yearNumber;
const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0;
const selectedRemaining = selectedAssessment?.remaining ?? 0;
const selectedDaysLeft = selectedAssessment?.daysLeft ?? selectedTarget?.daysLeft ?? 0;
const selectedDailyTarget = selectedAssessment?.dailyTarget ?? (selectedDaysLeft > 0 ? selectedRemaining / selectedDaysLeft : 0);
const selectedQualifiedRate = selectedAssessment?.qualifiedRate ?? (selectedTarget?.vehicleCount ? selectedTarget.yearQualifiedCount / selectedTarget.vehicleCount * 100 : 0);
const selectedYearCompletionRate = weightedCompletionRate(
targets.flatMap(target => {
const assessment = getTargetAssessment(target, assessmentYearMap[target.id]);
return assessment ? [{ completed: assessment.completed, target: assessment.target }] : [];
}),
);
const latestTrend = trendData[trendData.length - 1];
const previousTrend = trendData[trendData.length - 2];
const trendDelta = latestTrend && previousTrend ? latestTrend.mileage - previousTrend.mileage : 0;
const pressureLevel = selectedCompletion >= 90 ? '健康' : selectedCompletion >= 70 ? '关注' : '高压';
const selectedBreakdownValue = drillContext.level === 'breakdown'
? drillContext.dimensionValue
: undefined;
// Load targets on mount
const commitDrillContext = useCallback((next: MileageDrillContext, mode: 'push' | 'replace') => {
const url = buildMileageDrillUrl(window.location, next);
window.history[mode === 'push' ? 'pushState' : 'replaceState'](null, '', url);
setDrillContext(next);
}, []);
const retryTargets = useCallback(() => {
setTargetsRequestVersion(version => version + 1);
}, []);
const retryTrend = useCallback(() => {
setTrendRequestVersion(version => version + 1);
}, []);
const retryViewAllVehicles = useCallback(() => {
setViewAllRequestVersion(version => version + 1);
}, []);
const loadCurrentTargetVehicles = useCallback((targetId: number) => {
if (targetVehicleRequestsRef.current.has(targetId)) return;
targetVehicleRequestsRef.current.add(targetId);
setTargetVehicleLoadingMap(prev => ({ ...prev, [targetId]: true }));
setTargetVehicleErrorMap(prev => {
const next = { ...prev };
delete next[targetId];
return next;
});
fetchTargetVehicles(targetId).then(vehicles => {
setTargetVehiclesMap(prev => ({ ...prev, [targetId]: vehicles }));
}).catch(error => {
setTargetVehicleErrorMap(prev => ({
...prev,
[targetId]: getRequestErrorMessage(error, '无法获取考核车辆数据,请稍后重试。'),
}));
}).finally(() => {
setTargetVehicleLoadingMap(prev => ({ ...prev, [targetId]: false }));
targetVehicleRequestsRef.current.delete(targetId);
});
}, []);
const selectTarget = useCallback((targetId: number) => {
const target = targets.find(item => item.id === targetId);
const assessmentYear = target
? resolveAssessmentYear(target, assessmentYearMap[targetId])
: undefined;
setSelectedTargetId(targetId);
setExpandedTargetId(targetId);
setViewAllTargetId(null);
setViewAllSearch('');
commitDrillContext({
level: 'overview',
targetId,
assessmentYear,
sourcePriority: ['instrument'],
}, 'replace');
}, [assessmentYearMap, commitDrillContext, targets]);
const openAttributionGroup = useCallback((value: string) => {
if (selectedTargetId === null) return;
setViewAllTargetId(selectedTargetId);
setViewAllTargetName(selectedTarget?.targetName || '考核车辆');
setViewAllSearch('');
setViewAllDate(getDefaultDate());
setViewAllHistoricalVehicles(null);
setViewAllLoadedDate(null);
setViewAllLoadedTargetId(null);
setViewAllError(null);
commitDrillContext({
level: 'breakdown',
targetId: selectedTargetId,
assessmentYear: selectedAssessmentYear,
dimension: attributionDimension,
dimensionValue: value,
sourcePriority: ['instrument'],
}, 'push');
}, [attributionDimension, commitDrillContext, selectedAssessmentYear, selectedTarget?.targetName, selectedTargetId]);
const openTargetVehicles = useCallback((target: TargetSummary) => {
const assessmentYear = resolveAssessmentYear(target, assessmentYearMap[target.id]);
setViewAllTargetId(target.id);
setViewAllTargetName(target.targetName);
setViewAllSearch('');
setViewAllDate(getDefaultDate());
setViewAllHistoricalVehicles(null);
setViewAllLoadedDate(null);
setViewAllLoadedTargetId(null);
setViewAllError(null);
commitDrillContext({
level: 'breakdown',
targetId: target.id,
assessmentYear,
dimension: 'assessment-target',
dimensionValue: target.targetName,
sourcePriority: ['instrument'],
}, 'push');
}, [assessmentYearMap, commitDrillContext]);
const closeVehiclePanel = useCallback(() => {
setViewAllTargetId(null);
setViewAllSearch('');
if (drillContext.level === 'breakdown') {
commitDrillContext({
level: 'overview',
targetId: selectedTargetId || undefined,
assessmentYear: selectedAssessmentYear,
sourcePriority: ['instrument'],
}, 'replace');
}
}, [commitDrillContext, drillContext.level, selectedAssessmentYear, selectedTargetId]);
const openVehicleDiagnostic = useCallback((vehicle: TargetVehicle) => {
const url = buildMileageDrillUrl(
{ pathname: window.location.pathname, search: window.location.search, hash: '#mileage' },
{
level: 'vehicle',
targetId: viewAllTargetId || selectedTargetId || undefined,
assessmentYear: selectedAssessmentYear,
plate: vehicle.plateNumber,
startDate: viewAllDate,
endDate: viewAllDate,
sourcePriority: ['instrument'],
},
);
window.history.pushState(null, '', url);
window.dispatchEvent(new HashChangeEvent('hashchange'));
}, [selectedAssessmentYear, selectedTargetId, viewAllDate, viewAllTargetId]);
// Load targets on mount and through the shared retry path.
useEffect(() => {
let cancelled = false;
setTargetsLoading(true);
setTargetsError(null);
fetchTargets().then(data => {
if (cancelled) return;
const initialContext = initialDrillContextRef.current;
const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0];
const ordered = focused
? [focused, ...data.filter(item => item.id !== focused.id)]
: data;
setTargets(ordered);
if (ordered.length > 0 && !selectedTargetId) {
setSelectedTargetId(focused.id);
setExpandedTargetId(focused.id);
setAssessmentYearMap(Object.fromEntries(ordered.map(item => [item.id, item.yearlyAssessments[0]?.yearNumber || 1])));
fetchTargetVehicles(focused.id).then(vehicles => {
setTargetVehiclesMap(prev => ({ ...prev, [focused.id]: vehicles }));
}).catch(() => {});
const initialTarget = ordered.find(item => item.id === selectedTargetId) || focused;
if (initialTarget) {
setSelectedTargetId(initialTarget.id);
setExpandedTargetId(initialTarget.id);
const initialYears = Object.fromEntries(ordered.map(item => [
item.id,
resolveAssessmentYear(item, item.id === initialContext.targetId ? initialContext.assessmentYear : undefined) || 1,
]));
setAssessmentYearMap(initialYears);
if (initialContext.targetId === initialTarget.id) {
const resolvedYear = initialYears[initialTarget.id];
if (resolvedYear && resolvedYear !== initialContext.assessmentYear) {
const normalizedContext = { ...initialContext, assessmentYear: resolvedYear };
window.history.replaceState(null, '', buildMileageDrillUrl(window.location, normalizedContext));
setDrillContext(normalizedContext);
}
}).catch(() => {});
}, []);
}
} else {
setSelectedTargetId(null);
setExpandedTargetId(null);
setAssessmentYearMap({});
}
}).catch(error => {
if (!cancelled) {
setTargetsError(getRequestErrorMessage(error, '无法获取考核目标数据,请检查服务连接后重试。'));
}
}).finally(() => {
if (!cancelled) setTargetsLoading(false);
});
return () => {
cancelled = true;
};
}, [targetsRequestVersion]);
// Load trend when selectedTargetId changes
useEffect(() => {
if (selectedTargetId === null) return;
if (selectedTargetId === null) {
setTrendData([]);
setTrendError(null);
setTrendLoading(false);
return;
}
let cancelled = false;
setExpandedTargetId(selectedTargetId);
if (!targetVehiclesMap[selectedTargetId]) {
fetchTargetVehicles(selectedTargetId).then(vehicles => {
setTargetVehiclesMap(prev => ({ ...prev, [selectedTargetId]: vehicles }));
}).catch(() => {});
loadCurrentTargetVehicles(selectedTargetId);
}
fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
}, [selectedTargetId]);
setTrendLoading(true);
setTrendError(null);
fetchTrend(selectedTargetId).then(data => {
if (!cancelled) setTrendData(data);
}).catch(error => {
if (!cancelled) {
setTrendData([]);
setTrendError(getRequestErrorMessage(error, '无法获取里程趋势,请稍后重试。'));
}
}).finally(() => {
if (!cancelled) setTrendLoading(false);
});
return () => {
cancelled = true;
};
}, [selectedTargetId, trendRequestVersion]);
// Re-fetch target vehicles when viewAllDate changes
useEffect(() => {
if (viewAllTargetId === null) return;
let cancelled = false;
setViewAllLoading(true);
setViewAllError(null);
fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => {
setTargetVehiclesMap(prev => ({ ...prev, [viewAllTargetId]: data }));
}).catch(() => {}).finally(() => setViewAllLoading(false));
}, [viewAllTargetId, viewAllDate]);
if (cancelled) return;
setViewAllHistoricalVehicles(data);
setViewAllLoadedDate(viewAllDate);
setViewAllLoadedTargetId(viewAllTargetId);
}).catch(error => {
if (!cancelled) {
setViewAllError(getRequestErrorMessage(error, '无法获取所选日期的车辆明细,请稍后重试。'));
}
}).finally(() => {
if (!cancelled) setViewAllLoading(false);
});
return () => {
cancelled = true;
};
}, [viewAllTargetId, viewAllDate, viewAllRequestVersion]);
useEffect(() => {
const handlePopState = () => {
const next = parseMileageDrillContext(window.location.search);
setDrillContext(next);
if (next.targetId) {
setSelectedTargetId(next.targetId);
const target = targets.find(item => item.id === next.targetId);
const resolvedYear = target ? resolveAssessmentYear(target, next.assessmentYear) : undefined;
if (resolvedYear) {
setAssessmentYearMap(prev => ({ ...prev, [next.targetId!]: resolvedYear }));
}
}
if (next.level === 'breakdown' && next.targetId && next.dimensionValue) {
setAttributionDimension(next.dimension === 'customer' ? 'customer' : 'department');
setViewAllTargetId(next.targetId);
} else {
setViewAllTargetId(null);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [targets]);
const hasViewAllSnapshot = viewAllHistoricalVehicles !== null
&& viewAllLoadedTargetId === viewAllTargetId;
const viewAllVehicles = useMemo(() => {
const vehicles = hasViewAllSnapshot ? viewAllHistoricalVehicles : [];
if (
drillContext.level === 'breakdown'
&& drillContext.dimensionValue
&& (drillContext.dimension === 'department' || drillContext.dimension === 'customer')
) {
return filterAttributionVehicles(vehicles, drillContext.dimension, drillContext.dimensionValue);
}
return vehicles;
}, [drillContext, hasViewAllSnapshot, viewAllHistoricalVehicles]);
const searchedViewAllVehicles = useMemo(() => (
viewAllVehicles.filter(vehicle => (
vehicle.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase())
))
), [viewAllSearch, viewAllVehicles]);
if (targetsLoading && targets.length === 0) {
return <LoadingState label="正在加载考核统计" />;
}
if (targetsError && targets.length === 0) {
return (
<ErrorState
title="考核统计加载失败"
message={targetsError}
onRetry={retryTargets}
retrying={targetsLoading}
/>
);
}
if (!targetsLoading && targets.length === 0) {
return (
<EmptyState
title="暂无考核目标"
description="当前权限范围内没有有效考核目标"
/>
);
}
return (
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1 [overflow-anchor:none]" style={{ overflowX: 'clip' }}>
@@ -135,10 +440,7 @@ export default function StatisticsView() {
{targets.map(target => (
<button
key={target.id}
onClick={() => {
setSelectedTargetId(target.id);
setExpandedTargetId(target.id);
}}
onClick={() => selectTarget(target.id)}
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
selectedTargetId === target.id
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
@@ -150,6 +452,16 @@ export default function StatisticsView() {
))}
</div>
{targetsError && (
<ErrorState
title="考核统计刷新失败"
message={`${targetsError} 当前仍展示上一次成功加载的数据。`}
onRetry={retryTargets}
retrying={targetsLoading}
variant="inline"
/>
)}
{selectedTarget && (
<motion.div
initial={{ opacity: 0, y: 8 }}
@@ -181,14 +493,48 @@ export default function StatisticsView() {
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400"></div>
<div className={`mt-1 text-lg font-black ${trendDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{trendDelta >= 0 ? '+' : ''}{fmtKm(trendDelta)}
<div className={`mt-1 text-lg font-black ${trendLoading || trendError ? 'text-slate-500' : trendDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{trendLoading ? '加载中' : trendError ? '暂不可用' : `${trendDelta >= 0 ? '+' : ''}${fmtKm(trendDelta)}`}
</div>
<div className="mt-1 text-[10px] font-bold text-slate-400"> {fmtPercent(selectedQualifiedRate)}</div>
</div>
</motion.div>
)}
{selectedTarget && selectedCoverage && selectedCoverage.status !== 'matched' && (
<div role="alert" className="flex items-start gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-amber-900">
<AlertTriangle size={15} className="mt-0.5 shrink-0 text-amber-600" />
<div className="min-w-0 text-[11px] font-bold leading-5">
<span className="font-black"></span>
{selectedCoverage.declaredCount} {selectedCoverage.activeCount}
{selectedCoverage.status === 'missing'
? `缺少 ${Math.abs(selectedCoverage.difference)}`
: `超出 ${selectedCoverage.difference}`}
</div>
</div>
)}
{selectedTarget && (
<MileageAttributionView
vehicles={selectedTargetVehicles}
dimension={attributionDimension}
selectedValue={selectedBreakdownValue}
loading={targetVehicleLoadingMap[selectedTarget.id]
|| (!targetVehiclesMap[selectedTarget.id] && !targetVehicleErrorMap[selectedTarget.id])}
error={targetVehicleErrorMap[selectedTarget.id]}
targetMileagePerVehicle={selectedTarget.annualMileagePerVehicle * (selectedAssessmentYear || 1)}
expectedShortfallMileage={selectedAssessment?.remaining}
assessmentLabel={selectedAssessment?.label}
onDimensionChange={(dimension) => {
setAttributionDimension(dimension);
if (drillContext.level === 'breakdown') closeVehiclePanel();
}}
onSelect={openAttributionGroup}
onRetry={() => loadCurrentTargetVehicles(selectedTarget.id)}
/>
)}
<div className="flex flex-col landscape:flex-row gap-4 flex-1 landscape:overflow-hidden">
{/* Left Side: Trend Chart / Dashboard Sidebar */}
<div className="flex-none landscape:flex-1 landscape:w-2/3 space-y-4 flex flex-col overflow-y-auto no-scrollbar min-w-0">
@@ -250,6 +596,13 @@ export default function StatisticsView() {
</div>
</div>
<div className="h-[280px] w-full min-w-0">
{trendLoading ? (
<LoadingState label="正在加载里程趋势" variant="inline" />
) : trendError ? (
<ErrorState message={trendError} onRetry={retryTrend} variant="inline" />
) : trendData.length === 0 ? (
<EmptyState title="暂无趋势数据" description="当前考核目标在最近 7 天没有可用里程" variant="inline" />
) : (
<ResponsiveContainer width="100%" height={280} minWidth={0}>
{chartType === 'bar' ? (
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
@@ -292,6 +645,7 @@ export default function StatisticsView() {
</AreaChart>
)}
</ResponsiveContainer>
)}
</div>
</div>
</div>
@@ -327,11 +681,6 @@ export default function StatisticsView() {
className="bg-white px-3 py-2 rounded-xl border border-slate-100 shadow-sm flex flex-col active:bg-slate-50 transition-all cursor-pointer"
onClick={() => {
setExpandedTargetId(target.id);
if (!targetVehiclesMap[target.id]) {
fetchTargetVehicles(target.id).then(data => {
setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data }));
}).catch(() => {});
}
}}
>
<div className="flex items-center justify-between">
@@ -391,7 +740,13 @@ export default function StatisticsView() {
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
e.stopPropagation();
setAssessmentYearMap(prev => ({ ...prev, [target.id]: Number(e.target.value) }));
const assessmentYear = Number(e.target.value);
setAssessmentYearMap(prev => ({ ...prev, [target.id]: assessmentYear }));
commitDrillContext({
...drillContext,
targetId: target.id,
assessmentYear,
}, 'replace');
}}
className="bg-white border border-blue-100 rounded-lg px-2 py-1 text-[10px] font-bold text-blue-700 outline-none"
>
@@ -441,7 +796,7 @@ export default function StatisticsView() {
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p>
<p className="text-[10px] font-black text-emerald-600">{fmtKm(assessment.completed)} km</p>
<p className="text-[8px] font-bold text-slate-300">
{assessment.daysLeft === 0 ? fmtDateLabel(assessment.endDate) : currentDateLabel}
{formatMileageSnapshotDateTime(target.snapshotUpdatedAt)}
</p>
</div>
<div className="space-y-0.5">
@@ -480,9 +835,7 @@ export default function StatisticsView() {
<button
onClick={(e) => {
e.stopPropagation();
setViewAllTargetId(target.id);
setViewAllTargetName(target.targetName);
setViewAllDate(getDefaultDate());
openTargetVehicles(target);
}}
className="text-[8px] text-blue-500 font-bold hover:underline"
>
@@ -541,15 +894,19 @@ export default function StatisticsView() {
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{targets.reduce((sum, t) => sum + t.vehicleCount, 0)}</span> </span>
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{targets.length > 0 ? (targets.reduce((sum, t) => sum + (getTargetAssessment(t, assessmentYearMap[t.id])?.completionRate ?? t.avgCompletion), 0) / targets.length).toFixed(1) : '0.0'}</span> <span className="text-blue-400">%</span></span>
<span className="text-slate-500"> <span className="text-white font-black">{selectedYearCompletionRate.toFixed(1)}</span> <span className="text-blue-400">%</span></span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => { fetchTargets().then(data => { setTargets(data); }).catch(() => {}); }}
type="button"
onClick={retryTargets}
disabled={targetsLoading}
title="刷新考核统计"
aria-label="刷新考核统计"
className="p-1.5 text-slate-500 hover:text-blue-400 transition-colors"
>
<RotateCcw size={13} />
<RotateCcw size={13} className={targetsLoading ? 'animate-spin' : ''} />
</button>
<button
onClick={() => setIsTableFullscreen(false)}
@@ -560,6 +917,15 @@ export default function StatisticsView() {
</div>
</div>
{targetsError && (
<div role="alert" className="flex items-center justify-between gap-3 border-b border-rose-900/50 bg-rose-950/60 px-3 py-2 text-[10px] font-bold text-rose-200">
<span>{targetsError} </span>
<button type="button" onClick={retryTargets} disabled={targetsLoading} className="shrink-0 text-rose-100 underline disabled:opacity-60">
</button>
</div>
)}
{/* Table Area */}
<div className="flex-1 overflow-auto">
<table className="w-full text-left border-collapse">
@@ -643,7 +1009,7 @@ export default function StatisticsView() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setViewAllTargetId(null)}
onClick={closeVehiclePanel}
className="fixed inset-0 z-[110] bg-slate-950/60 backdrop-blur-sm"
/>
<motion.div
@@ -655,21 +1021,25 @@ export default function StatisticsView() {
>
<div className="p-6 border-b border-slate-100 flex items-center justify-between">
<div>
<h3 className="text-lg font-black text-slate-900">{viewAllTargetName}</h3>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1"></p>
<h3 className="text-lg font-black text-slate-900">
{viewAllTargetName || selectedTarget?.targetName || '考核车辆'}
</h3>
<p className="mt-1 text-xs font-bold uppercase tracking-widest text-slate-400">
{selectedAssessment ? `${selectedAssessment.label} · ` : ''}
</p>
{selectedBreakdownValue && drillContext.dimension !== 'assessment-target' && (
<p className="mt-1 text-[10px] font-bold text-blue-600">{selectedBreakdownValue}</p>
)}
</div>
<button
onClick={() => {
setViewAllTargetId(null);
setViewAllSearch('');
}}
onClick={closeVehiclePanel}
className="p-2 hover:bg-slate-100 rounded-full transition-colors text-slate-400 hover:text-slate-900"
>
<X size={20} />
</button>
</div>
<div className="px-6 py-3 border-b border-slate-50 space-y-2">
<div className="px-4 py-3 border-b border-slate-50 space-y-2 md:px-6">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
@@ -687,16 +1057,26 @@ export default function StatisticsView() {
type="date"
value={viewAllDate}
onChange={(e) => setViewAllDate(e.target.value)}
className="pl-8 pr-2 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all w-[130px]"
className="w-[145px] bg-slate-50 py-2 pl-8 pr-2 text-xs font-bold text-slate-700 border border-slate-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
/>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{viewAllLoading ? '加载中...' : (() => {
const filtered = (viewAllTargetId !== null ? (targetVehiclesMap[viewAllTargetId] || []) : []).filter(tv => tv.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase()));
const totalKm = filtered.reduce((sum, tv) => sum + (tv.todayMileage || 0), 0);
return `${filtered.length} 辆 · 合计 ${fmtKm(totalKm)} km`;
{viewAllLoading
? hasViewAllSnapshot
? `正在加载 ${viewAllDate} · 当前显示 ${viewAllLoadedDate}`
: `正在加载 ${viewAllDate}`
: viewAllError
? hasViewAllSnapshot
? `当前显示 ${viewAllLoadedDate} 已成功数据`
: `${viewAllDate} 暂不可用`
: (() => {
const totalKm = searchedViewAllVehicles.reduce(
(sum, vehicle) => sum + (vehicle.todayMileage || 0),
0,
);
return `${viewAllLoadedDate || viewAllDate} · ${searchedViewAllVehicles.length} 辆 · 合计 ${fmtKm(totalKm)} km`;
})()}
</span>
<button
@@ -710,14 +1090,45 @@ export default function StatisticsView() {
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2 no-scrollbar">
{(viewAllTargetId !== null ? (targetVehiclesMap[viewAllTargetId] || []) : []).filter(tv =>
tv.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase())
).sort((a, b) => {
{viewAllLoading && !hasViewAllSnapshot ? (
<LoadingState label={`正在加载 ${viewAllDate} 车辆明细`} variant="inline" />
) : viewAllError && !hasViewAllSnapshot ? (
<ErrorState
title="车辆明细加载失败"
message={viewAllError}
onRetry={retryViewAllVehicles}
retrying={viewAllLoading}
variant="inline"
/>
) : (
<>
{viewAllError && (
<ErrorState
title={`${viewAllDate} 车辆明细刷新失败`}
message={`${viewAllError} 当前保留 ${viewAllLoadedDate} 已成功加载的数据。`}
onRetry={retryViewAllVehicles}
retrying={viewAllLoading}
variant="inline"
/>
)}
{!viewAllLoading && !viewAllError && searchedViewAllVehicles.length === 0 ? (
<EmptyState
title={viewAllHistoricalVehicles?.length ? '没有匹配车辆' : '该日期暂无车辆里程'}
description={viewAllHistoricalVehicles?.length ? '请调整车牌搜索条件' : `${viewAllLoadedDate || viewAllDate} 未返回可展示的车辆数据`}
variant="inline"
/>
) : null}
{[...searchedViewAllVehicles].sort((a, b) => {
const valA = a.todayMileage || 0;
const valB = b.todayMileage || 0;
return viewAllSort === 'desc' ? valB - valA : valA - valB;
}).map(tv => (
<div key={tv.plateNumber} className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between hover:border-blue-200 transition-all">
<button
type="button"
key={tv.plateNumber}
onClick={() => openVehicleDiagnostic(tv)}
className="flex w-full items-center justify-between rounded-xl border border-slate-100 bg-white px-3 py-2 text-left shadow-sm transition-all hover:border-blue-200 hover:shadow-md"
>
<div className="flex items-center gap-3 overflow-hidden flex-1">
<div className="relative flex-shrink-0">
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
@@ -742,13 +1153,15 @@ export default function StatisticsView() {
<div className="text-sm font-black text-blue-600">{tv.todayMileage.toLocaleString()} <span className="text-[8px] text-slate-400">KM</span></div>
<div className="text-[9px] font-bold text-slate-300 mt-0.5">: {fmtKm(tv.totalMileage || 0)} km</div>
</div>
</div>
</button>
))}
</>
)}
</div>
<div className="p-4 bg-slate-50 border-t border-slate-100">
<button
onClick={() => setViewAllTargetId(null)}
onClick={closeVehiclePanel}
className="w-full py-3 bg-slate-900 text-white rounded-xl text-sm font-bold shadow-lg shadow-slate-200 active:scale-[0.98] transition-all"
>
+120 -63
View File
@@ -1,57 +1,42 @@
import { useEffect, useMemo, useState } from 'react';
import { motion, AnimatePresence, useDragControls } from 'motion/react';
import { X, Truck } from 'lucide-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;
}
type RangeKey = 'last15' | 'month' | 'quarter';
const RANGE_TABS: { key: RangeKey; label: string }[] = [
const RANGE_TABS: { key: VehicleDetailRangeKey; label: string }[] = [
{ key: 'last15', label: '近 15 天' },
{ key: 'month', label: '本月' },
{ key: 'quarter', label: '本季度' },
];
function fmtYmd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${dd}`;
}
function rangeFor(key: RangeKey): { start: string; end: string; rangeLabel: string } {
const today = new Date();
today.setHours(0, 0, 0, 0);
const end = fmtYmd(today);
if (key === 'last15') {
const start = new Date(today);
start.setDate(today.getDate() - 14);
return { start: fmtYmd(start), end, rangeLabel: '近 15 天' };
}
if (key === 'month') {
const start = new Date(today.getFullYear(), today.getMonth(), 1);
return { start: fmtYmd(start), end, rangeLabel: '本月' };
}
const q = Math.floor(today.getMonth() / 3);
const start = new Date(today.getFullYear(), q * 3, 1);
return { start: fmtYmd(start), end, rangeLabel: '本季度' };
}
function isToday(date: string): boolean {
return date === fmtYmd(new Date());
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: RangeKey): string {
function formatLabel(date: string, key: VehicleDetailRangeKey): string {
// YYYY-MM-DD → MM-DD(季度时仍展示 MM-DD
void key;
return date.slice(5);
@@ -63,30 +48,65 @@ function daySourceLabel(day: VehicleRecentDay): string {
return day.isDataSynced ? '来源待接口' : '无数据';
}
export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: Props) {
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 [range, setRange] = useState<RangeKey>('last15');
const [error, setError] = useState<string | null>(null);
const [requestVersion, setRequestVersion] = useState(0);
const [range, setRange] = useState<VehicleDetailRangeKey>(() => (
contextRange ? 'context' : 'last15'
));
const dragControls = useDragControls();
// 切换车辆时重置区间为默认
useEffect(() => {
if (vehicle) setRange('last15');
}, [vehicle?.plate]); // eslint-disable-line react-hooks/exhaustive-deps
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) return;
const { start, end } = rangeFor(range);
if (!vehicle || !requestKey) return;
setLoading(true);
setDays([]);
setError(null);
let cancelled = false;
fetchVehicleRecent(vehicle.plate, { start, end, sourcePriority })
.then(d => { if (!cancelled) setDays(d.days); })
.catch(() => { if (!cancelled) setDays([]); })
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; };
}, [vehicle?.plate, range, sourcePriority]); // eslint-disable-line react-hooks/exhaustive-deps
}, [requestKey, requestVersion]); // eslint-disable-line react-hooks/exhaustive-deps
// 锁滚动
useEffect(() => {
@@ -95,8 +115,11 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
return () => { document.body.style.overflow = ''; };
}, [vehicle]);
// 排除"今日"列(数据未到位时易引起误读)
const historyDays = useMemo(() => days.filter(d => !isToday(d.date)), [days]);
// 预设区间排除未完整的今日;显式下钻区间严格保留用户选择的日期。
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;
@@ -107,12 +130,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
// 骨架天数:根据区间预估
const skeletonCount = useMemo(() => {
if (range === 'last15') return 15;
const { start, end } = rangeFor(range);
const s = new Date(start);
const e = new Date(end);
return Math.max(1, Math.round((e.getTime() - s.getTime()) / 86400000));
}, [range]);
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>
@@ -142,6 +165,9 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
}}
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
@@ -160,7 +186,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-black text-slate-900 font-mono truncate"><Blur>{vehicle.plate}</Blur></span>
<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>
@@ -173,19 +199,22 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
</div>
</div>
</div>
<button onClick={onClose} className="p-2 -mr-1 text-slate-400 hover:text-slate-700 flex-shrink-0">
<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 bg-slate-100 p-0.5 rounded-lg">
{RANGE_TABS.map(tab => (
<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)}
className={`relative 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'}`}
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
@@ -198,28 +227,48 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
</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">
{loading ? <span className="inline-block h-4 w-14 bg-slate-200 rounded animate-pulse align-middle" />
{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">
{loading ? <span className="inline-block h-4 w-10 bg-slate-200 rounded animate-pulse align-middle" />
{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">
{loading ? <span className="inline-block h-4 w-12 bg-slate-200 rounded animate-pulse align-middle" />
{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>
@@ -233,8 +282,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
</div>
<div className="bg-white rounded-xl border border-slate-50">
<div className="h-[140px]">
{loading ? (
{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 }}>
@@ -269,8 +322,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
{/* 每日明细 */}
<div className="flex-1 overflow-y-auto px-4 pb-4">
<div className="text-[10px] font-bold text-slate-500 mb-1.5"></div>
{loading ? (
{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}
+53 -110
View File
@@ -8,6 +8,16 @@ import type {
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';
@@ -34,6 +44,7 @@ export async function fetchMonitoring(params?: {
endDate?: string;
brands?: string[];
sourcePriority?: MileageSourceGroup[];
force?: boolean;
}): Promise<MonitoringData> {
const query = new URLSearchParams();
if (params?.sortBy) query.set('sortBy', params.sortBy);
@@ -66,6 +77,7 @@ export async function fetchMonitoring(params?: {
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}` : ''}`);
}
@@ -123,38 +135,6 @@ export async function fetchVehicleRecent(
);
}
export interface DailyReportModel {
id: number;
name: string;
count: number;
today: number;
total: number;
completion: number;
active: number;
zero: number;
dailyNeed: number;
}
export interface DailyReportVehicle {
plate: string;
model: string;
status: string;
customer: string;
today?: number;
completion?: number;
}
export interface DailyReportData {
reportDate: string;
updatedAt: string;
models: DailyReportModel[];
trend: { date: string; value: number }[];
topVehicles: DailyReportVehicle[];
zeroRisk: DailyReportVehicle[];
qualifiedCount: number;
halfQualifiedCount: number;
}
function reportDateFromUpdatedAt(updatedAt: string): string {
if (/^\d{4}-\d{2}-\d{2}$/.test(updatedAt)) return updatedAt;
const d = new Date(updatedAt);
@@ -162,95 +142,58 @@ function reportDateFromUpdatedAt(updatedAt: string): string {
return new Date().toISOString().slice(0, 10);
}
function compactTargetName(name: string): string {
return name
.replace(/^羚牛/, '')
.replace(/辆/g, '台')
.replace(/4\.5T普货/g, '普货')
.replace(/4\.5T冷链车/g, '冷链车')
.replace(/4\.5T冷链/g, '冷链车');
}
let dailyReportRequest: Promise<DailyReportData> | null = null;
let dailyReportCache: { data: DailyReportData; expiresAt: number } | null = null;
const DAILY_REPORT_CACHE_MS = 60_000;
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;
}
export async function fetchDailyReport(): Promise<DailyReportData> {
const [targets, trend, monitoring, topMonitoring] = await Promise.all([
async function loadDailyReport(): Promise<DailyReportData> {
const [targets, monitoring] = await Promise.all([
fetchTargets(),
fetchTrend(undefined, 7),
fetchMonitoring({ limit: 1 }),
fetchMonitoring({ sortBy: 'today', sortOrder: 'desc', limit: 5 }),
]);
const reportDate = monitoring.dateRange?.end || reportDateFromUpdatedAt(monitoring.updatedAt);
const targetVehiclesEntries = await Promise.all(
const targetVehicles = await Promise.all(
targets.map(async target => {
const vehicles = await fetchTargetVehicles(target.id);
return [target.id, vehicles] as const;
const vehicles = await fetchTargetVehicles(target.id, reportDate);
return { target, vehicles };
}),
);
const targetVehiclesMap = new Map(targetVehiclesEntries);
const models: DailyReportModel[] = targets.map(target => {
const vehicles = targetVehiclesMap.get(target.id) ?? [];
const active = vehicles.filter(vehicle => vehicle.todayMileage > 0).length;
return {
id: target.id,
name: compactTargetName(target.targetName),
count: target.vehicleCount,
today: target.todayTotal,
total: target.cumulativeTotal,
completion: target.avgCompletion,
active,
zero: Math.max(0, target.vehicleCount - active),
dailyNeed: target.dailyTarget,
};
});
return buildDailyReport(targets, targetVehicles, monitoring, [], reportDate);
}
const targetNameByPlate = new Map<string, string>();
for (const target of targets) {
const vehicles = targetVehiclesMap.get(target.id) ?? [];
for (const vehicle of vehicles) targetNameByPlate.set(vehicle.plateNumber, compactTargetName(target.targetName));
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;
}
}
const topVehicles: DailyReportVehicle[] = topMonitoring.vehicles.map(vehicle => ({
plate: vehicle.plate,
model: targetNameByPlate.get(vehicle.plate) || vehicle.project || '未归入考核',
status: normalizeStatus(vehicle.rentStatus),
today: vehicle.dailyKm,
customer: vehicle.customer || '未绑定客户',
}));
let dailyReportTrendRequest: Promise<TrendPoint[]> | null = null;
let dailyReportTrendCache: { data: TrendPoint[]; expiresAt: number } | null = null;
const zeroRisk = targetVehiclesEntries
.flatMap(([targetId, vehicles]) => {
const target = targets.find(item => item.id === targetId);
const model = target ? compactTargetName(target.targetName) : '未归入考核';
return vehicles
.filter(vehicle => vehicle.todayMileage <= 0 && ['自营', '租赁'].includes(normalizeStatus(vehicle.rentStatus)))
.map(vehicle => ({
plate: vehicle.plateNumber,
model,
status: normalizeStatus(vehicle.rentStatus),
customer: vehicle.customer || '未绑定客户',
completion: vehicle.completionRate,
}));
})
.sort((a, b) => (b.completion ?? 0) - (a.completion ?? 0))
.slice(0, 5);
return {
reportDate: reportDateFromUpdatedAt(monitoring.updatedAt),
updatedAt: monitoring.updatedAt,
models,
trend: trend.map(item => ({ date: item.date, value: item.mileage })),
topVehicles,
zeroRisk,
qualifiedCount: targets.reduce((sum, target) => sum + target.yearQualifiedCount, 0),
halfQualifiedCount: targets.reduce((sum, target) => sum + target.halfQualifiedCount, 0),
};
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;
}
}
@@ -0,0 +1,25 @@
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,
});
});
@@ -0,0 +1,26 @@
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
@@ -0,0 +1,88 @@
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
@@ -0,0 +1,121 @@
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
@@ -0,0 +1,98 @@
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
@@ -0,0 +1,200 @@
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),
};
}
@@ -0,0 +1,18 @@
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
@@ -0,0 +1,32 @@
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
@@ -0,0 +1,76 @@
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
@@ -0,0 +1,125 @@
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}`;
}
@@ -0,0 +1,100 @@
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
@@ -0,0 +1,136 @@
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}`;
}
@@ -0,0 +1,47 @@
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
@@ -0,0 +1,31 @@
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,
};
}
+2
View File
@@ -64,6 +64,8 @@ export interface MonitoringData {
export interface TargetSummary {
id: number;
targetName: string;
snapshotUpdatedAt: string | null;
declaredVehicleCount: number;
vehicleCount: number;
totalMileagePerVehicle: number;
annualMileagePerVehicle: number;
@@ -0,0 +1,39 @@
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);
});
@@ -0,0 +1,66 @@
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';
}
+2 -1
View File
@@ -1,13 +1,14 @@
import { Hono } from 'hono';
import jwt from 'jsonwebtoken';
import pool from '../db.js';
import { requireEnv } from '../config.js';
import type { AuthUser, JwtPayload, PermissionLevel } from './types.js';
import { FULL_ACCESS_ROLES, DEPT_ACCESS_ROLES } from './types.js';
const app = new Hono();
const EXTERNAL_API_BASE = process.env.EXTERNAL_API_BASE || 'https://beta.lnh2e.com';
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
const JWT_SECRET = requireEnv('JWT_SECRET');
/** GET /api/auth/exchange?jumpToken=xxx — 一步完成:换取用户信息 + 签发 JWT */
app.get('/exchange', async (c) => {
+7 -2
View File
@@ -1,8 +1,9 @@
import type { Context, Next } from 'hono';
import jwt from 'jsonwebtoken';
import { requireEnv } from '../config.js';
import type { JwtPayload, AuthUser } from './types.js';
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
const JWT_SECRET = requireEnv('JWT_SECRET');
// 临时:跳过所有认证(保留完整逻辑便于快速恢复)
const BYPASS_AUTH = false;
@@ -30,7 +31,11 @@ export async function authMiddleware(c: Context, next: Next) {
}
// 跳过不需要认证的路径
if (path === '/api/health' || path.startsWith('/api/auth/')) {
if (
path === '/api/health'
|| path === '/api/health/readiness'
|| path.startsWith('/api/auth/')
) {
return next();
}
+26
View File
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { envPort, MissingEnvironmentError, requireEnv } from './config.js';
test('requireEnv returns a trimmed value', () => {
assert.equal(requireEnv('TOKEN', { TOKEN: ' configured ' }), 'configured');
});
test('requireEnv identifies the missing variable without exposing values', () => {
assert.throws(
() => requireEnv('TOKEN', {}),
(error: unknown) => {
assert.ok(error instanceof MissingEnvironmentError);
assert.equal(error.code, 'MISSING_ENVIRONMENT_VARIABLE');
assert.equal(error.variableName, 'TOKEN');
return true;
},
);
});
test('envPort validates configured ports', () => {
assert.equal(envPort('PORT', 3306, {}), 3306);
assert.equal(envPort('PORT', 3306, { PORT: '8111' }), 8111);
assert.throws(() => envPort('PORT', 3306, { PORT: '70000' }));
assert.throws(() => envPort('PORT', 3306, { PORT: 'invalid' }));
});
+33
View File
@@ -0,0 +1,33 @@
import 'dotenv/config';
export class MissingEnvironmentError extends Error {
readonly code = 'MISSING_ENVIRONMENT_VARIABLE';
constructor(readonly variableName: string) {
super(`Missing required environment variable: ${variableName}`);
this.name = 'MissingEnvironmentError';
}
}
export function requireEnv(
name: string,
env: NodeJS.ProcessEnv = process.env,
): string {
const value = env[name]?.trim();
if (!value) throw new MissingEnvironmentError(name);
return value;
}
export function envPort(
name: string,
fallback: number,
env: NodeJS.ProcessEnv = process.env,
): number {
const raw = env[name]?.trim();
if (!raw) return fallback;
const value = Number(raw);
if (!Number.isInteger(value) || value < 1 || value > 65535) {
throw new Error(`Invalid port in environment variable: ${name}`);
}
return value;
}
+15 -12
View File
@@ -1,17 +1,20 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
import mysql, { type Pool } from 'mysql2/promise';
import { envPort, requireEnv } from './config.js';
dotenv.config();
let hydrogenPool: Pool | null = null;
const hydrogenPool = mysql.createPool({
host: process.env.HYDROGEN_DB_HOST || '47.99.185.173',
port: Number(process.env.HYDROGEN_DB_PORT) || 3306,
user: process.env.HYDROGEN_DB_USER || 'root',
password: process.env.HYDROGEN_DB_PASSWORD || 'lnMysql.',
database: process.env.HYDROGEN_DB_NAME || 'ln_asset_management',
export function getHydrogenPool(): Pool {
if (hydrogenPool) return hydrogenPool;
hydrogenPool = mysql.createPool({
host: requireEnv('HYDROGEN_DB_HOST'),
port: envPort('HYDROGEN_DB_PORT', 3306),
user: requireEnv('HYDROGEN_DB_USER'),
password: requireEnv('HYDROGEN_DB_PASSWORD'),
database: requireEnv('HYDROGEN_DB_NAME'),
waitForConnections: true,
connectionLimit: 5,
queueLimit: 0,
});
export default hydrogenPool;
});
return hydrogenPool;
}
+5 -1
View File
@@ -11,6 +11,8 @@ import eleRouter from './routes/ele/index.js';
import feedbackRouter from './routes/feedback/index.js';
import vehicleHeatmapRouter from './routes/vehicle-heatmap.js';
import hydrogenHeatmapRouter from './routes/hydrogen-heatmap.js';
import analyticsRouter from './routes/analytics/index.js';
import healthRouter from './routes/health.js';
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
import authRouter from './auth/login.js';
import { authMiddleware } from './auth/middleware.js';
@@ -23,6 +25,7 @@ app.use('/api/*', cors());
// Auth 路由(不需要中间件)
app.route('/api/auth', authRouter);
app.route('/api/health', healthRouter);
// Auth 中间件(保护后续所有 /api/* 路由)
app.use('/api/*', authMiddleware);
@@ -35,8 +38,9 @@ app.route('/api/ele', eleRouter);
app.route('/api/feedback', feedbackRouter);
app.route('/api/vehicle-heatmap', vehicleHeatmapRouter);
app.route('/api/hydrogen-heatmap', hydrogenHeatmapRouter);
app.route('/api/analytics', analyticsRouter);
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));
app.all('/api/*', (c) => c.json({ error: 'API route not found' }, 404));
// Serve static files in production
app.use('/*', serveStatic({ root: './dist' }));
+15 -12
View File
@@ -1,17 +1,20 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
import mysql, { type Pool } from 'mysql2/promise';
import { envPort, requireEnv } from './config.js';
dotenv.config();
let mileagePool: Pool | null = null;
const mileagePool = mysql.createPool({
host: process.env.MILEAGE_DB_HOST || '101.133.130.65',
port: Number(process.env.MILEAGE_DB_PORT) || 3306,
user: process.env.MILEAGE_DB_USER || 'bi_reader_02',
password: process.env.MILEAGE_DB_PASSWORD || 'bi_reader_02_Pass',
database: process.env.MILEAGE_DB_NAME || 'hydrogen_energy',
export function getMileagePool(): Pool {
if (mileagePool) return mileagePool;
mileagePool = mysql.createPool({
host: requireEnv('MILEAGE_DB_HOST'),
port: envPort('MILEAGE_DB_PORT', 3306),
user: requireEnv('MILEAGE_DB_USER'),
password: requireEnv('MILEAGE_DB_PASSWORD'),
database: requireEnv('MILEAGE_DB_NAME'),
waitForConnections: true,
connectionLimit: 5,
queueLimit: 0,
});
export default mileagePool;
});
return mileagePool;
}
+50
View File
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildReadinessReport } from './readiness.js';
const completeEnv: NodeJS.ProcessEnv = {
DB_HOST: 'configured',
DB_USER: 'configured',
DB_PASSWORD: 'do-not-expose-secret',
DB_NAME: 'configured',
JWT_SECRET: 'configured',
ONEOS_MILEAGE_API_BASE_URL: 'configured',
ONEOS_MILEAGE_API_KEY: 'configured',
HYDROGEN_DB_HOST: 'configured',
HYDROGEN_DB_USER: 'configured',
HYDROGEN_DB_PASSWORD: 'configured',
HYDROGEN_DB_NAME: 'configured',
HEATMAP_DB_HOST: 'configured',
HEATMAP_DB_USER: 'configured',
HEATMAP_DB_PASSWORD: 'configured',
HEATMAP_DB_NAME: 'configured',
AMAP_WEB_KEY: 'configured',
AMAP_SECURITY_JS_CODE: 'configured',
OSS_ENDPOINT: 'configured',
OSS_ACCESS_KEY_ID: 'configured',
OSS_ACCESS_KEY_SECRET: 'configured',
OSS_BUCKET: 'configured',
};
test('reports ready only when core and integration configuration is complete', () => {
const report = buildReadinessReport(completeEnv, new Date('2026-08-07T08:00:00Z'));
assert.equal(report.status, 'ready');
assert.equal(report.semantics, 'configuration-only');
assert.equal(report.checkedAt, '2026-08-07T08:00:00.000Z');
});
test('distinguishes optional integration gaps from core readiness failures', () => {
const degraded = buildReadinessReport({ ...completeEnv, HYDROGEN_DB_PASSWORD: '' });
assert.equal(degraded.status, 'degraded');
assert.equal(degraded.integrations.hydrogenDatabase, 'incomplete');
const notReady = buildReadinessReport({ ...completeEnv, DB_PASSWORD: '' });
assert.equal(notReady.status, 'not-ready');
assert.equal(notReady.core.assetDatabase, 'incomplete');
});
test('does not expose configuration values or variable names', () => {
const serialized = JSON.stringify(buildReadinessReport(completeEnv));
assert.equal(serialized.includes('do-not-expose-secret'), false);
assert.equal(serialized.includes('DB_PASSWORD'), false);
});
+64
View File
@@ -0,0 +1,64 @@
export type ConfigurationState = 'configured' | 'incomplete';
export interface ReadinessReport {
status: 'ready' | 'degraded' | 'not-ready';
semantics: 'configuration-only';
checkedAt: string;
core: {
assetDatabase: ConfigurationState;
authentication: ConfigurationState;
};
integrations: {
oneOsMileage: ConfigurationState;
hydrogenDatabase: ConfigurationState;
heatmapDatabase: ConfigurationState;
mapSdk: ConfigurationState;
objectStorage: ConfigurationState;
};
}
function hasAll(env: NodeJS.ProcessEnv, names: readonly string[]): ConfigurationState {
return names.every(name => Boolean(env[name]?.trim())) ? 'configured' : 'incomplete';
}
export function buildReadinessReport(
env: NodeJS.ProcessEnv = process.env,
now = new Date(),
): ReadinessReport {
const core = {
assetDatabase: hasAll(env, ['DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME']),
authentication: hasAll(env, ['JWT_SECRET']),
} as const;
const integrations = {
oneOsMileage: hasAll(env, ['ONEOS_MILEAGE_API_BASE_URL', 'ONEOS_MILEAGE_API_KEY']),
hydrogenDatabase: hasAll(env, [
'HYDROGEN_DB_HOST',
'HYDROGEN_DB_USER',
'HYDROGEN_DB_PASSWORD',
'HYDROGEN_DB_NAME',
]),
heatmapDatabase: hasAll(env, [
'HEATMAP_DB_HOST',
'HEATMAP_DB_USER',
'HEATMAP_DB_PASSWORD',
'HEATMAP_DB_NAME',
]),
mapSdk: hasAll(env, ['AMAP_WEB_KEY', 'AMAP_SECURITY_JS_CODE']),
objectStorage: hasAll(env, [
'OSS_ENDPOINT',
'OSS_ACCESS_KEY_ID',
'OSS_ACCESS_KEY_SECRET',
'OSS_BUCKET',
]),
} as const;
const coreReady = Object.values(core).every(value => value === 'configured');
const integrationsReady = Object.values(integrations).every(value => value === 'configured');
return {
status: !coreReady ? 'not-ready' : integrationsReady ? 'ready' : 'degraded',
semantics: 'configuration-only',
checkedAt: now.toISOString(),
core,
integrations,
};
}
+63
View File
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import app from './index.js';
test('returns the published mileage metric contract', async () => {
const response = await app.request('/metrics?domain=mileage');
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.catalogVersion, 7);
assert.deepEqual(payload.domains, ['mileage']);
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'mileage.assessment_completion_rate'));
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'mileage.average_per_vehicle'));
});
test('returns the published hydrogen metric contract', async () => {
const response = await app.request('/metrics?domain=hydrogen');
const payload = await response.json();
assert.equal(response.status, 200);
assert.deepEqual(payload.domains, ['hydrogen']);
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'hydrogen.customer_revenue'));
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'hydrogen.customer_order_gross_profit'));
});
test('returns the published electric metric contract', async () => {
const response = await app.request('/metrics?domain=electric');
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.catalogVersion, 7);
assert.deepEqual(payload.domains, ['electric']);
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'electric.charge_total_fee'));
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'electric.blended_cost_intensity'));
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'electric.data_freshness'));
});
test('returns the published ETC metric contract', async () => {
const response = await app.request('/metrics?domain=etc');
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.catalogVersion, 7);
assert.deepEqual(payload.domains, ['etc']);
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'etc.total_amount'));
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'etc.bill_receivable'));
});
test('rejects unpublished metric domains', async () => {
const response = await app.request('/metrics?domain=energy');
assert.equal(response.status, 400);
});
test('returns observed data-source outcomes without sensitive details', async () => {
const response = await app.request('/data-sources');
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.semantics, 'observed-requests');
assert.ok(Array.isArray(payload.sources));
assert.equal(JSON.stringify(payload).includes('password'), false);
});
+32
View File
@@ -0,0 +1,32 @@
import { Hono } from 'hono';
import {
METRIC_CATALOG_VERSION,
isMetricDomain,
listMetricDefinitions,
type MetricDomain,
} from '../../../shared/analytics/catalog.js';
import { getDataSourceTelemetry } from '../../source-telemetry.js';
const app = new Hono();
app.get('/metrics', (c) => {
const requestedDomain = c.req.query('domain');
let domain: MetricDomain | undefined;
if (requestedDomain) {
if (!isMetricDomain(requestedDomain)) {
return c.json({ error: 'Unsupported metric domain', domain: requestedDomain }, 400);
}
domain = requestedDomain;
}
const metrics = listMetricDefinitions(domain);
return c.json({
catalogVersion: METRIC_CATALOG_VERSION,
domains: Array.from(new Set(metrics.map(metric => metric.domain))),
metrics,
});
});
app.get('/data-sources', (c) => c.json(getDataSourceTelemetry()));
export default app;
+11 -5
View File
@@ -1,3 +1,5 @@
import { observeDataSource, type DataSourceId } from '../../source-telemetry.js';
/**
* SWR 缓存:始终返回热数据,后台定时刷新。
*
@@ -70,24 +72,28 @@ async function runRefresh(key: string) {
export interface CachedOpts {
force?: boolean;
source?: DataSourceId;
}
export async function cached<T>(key: string, loader: () => Promise<T>, opts: CachedOpts = {}): Promise<T> {
const effectiveLoader = opts.source
? () => observeDataSource(opts.source!, loader)
: loader;
const now = Date.now();
const hit = cache.get(key) as Entry<T> | undefined;
if (hit) {
hit.lastAccess = now;
hit.loader = loader;
hit.loader = effectiveLoader;
}
// 强制刷新:等待 loader 完成
if (opts.force) {
const ongoing = inflight.get(key) as Promise<T> | undefined;
if (ongoing) return ongoing;
const p = loader()
const p = effectiveLoader()
.then(value => {
const t = Date.now();
const next: Entry<T> = { value, freshAt: t, expiresAt: t + TTL_MS, loader, lastAccess: t };
const next: Entry<T> = { value, freshAt: t, expiresAt: t + TTL_MS, loader: effectiveLoader, lastAccess: t };
cache.set(key, next);
scheduleRefresh(key, next);
return value;
@@ -112,10 +118,10 @@ export async function cached<T>(key: string, loader: () => Promise<T>, opts: Cac
const ongoing = inflight.get(key) as Promise<T> | undefined;
if (ongoing) return ongoing;
const p = loader()
const p = effectiveLoader()
.then(value => {
const t = Date.now();
const entry: Entry<T> = { value, freshAt: t, expiresAt: t + TTL_MS, loader, lastAccess: t };
const entry: Entry<T> = { value, freshAt: t, expiresAt: t + TTL_MS, loader: effectiveLoader, lastAccess: t };
cache.set(key, entry);
scheduleRefresh(key, entry);
return value;
+537 -16
View File
@@ -1,10 +1,24 @@
import { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2';
import pool from '../../db.js';
import hydrogenPool from '../../hydrogen-db.js';
import { getHydrogenPool } from '../../hydrogen-db.js';
import { cached } from './cache.js';
import {
parseHydrogenCustomerKind,
parseElectricVehicleScope,
parseHydrogenCustomerName,
parseHydrogenOrderLimit,
parseHydrogenOrderPage,
parseHydrogenStationId,
parseEnergyDate,
parseEtcLimit,
parseEtcPage,
parseEtcSearch,
type HydrogenCustomerKind,
} from './query.js';
import type { AuthUser } from '../../auth/types.js';
import { canAccessEnergy } from '../../auth/types.js';
import { observeDataSource } from '../../source-telemetry.js';
const app = new Hono();
@@ -18,6 +32,26 @@ app.use('*', async (c, next) => {
return next();
});
app.onError((error, c) => {
const sourceCode = typeof error === 'object' && error !== null && 'code' in error
? String((error as { code?: unknown }).code ?? 'UNKNOWN')
: 'UNKNOWN';
if (c.req.path.includes('/hydrogen/')) {
console.error('[energy/hydrogen] data source unavailable', { code: sourceCode });
return c.json({
error: '当前无法读取氢能业务数据,请稍后重试',
code: 'HYDROGEN_SOURCE_UNAVAILABLE',
retryable: true,
}, 503);
}
console.error('[energy] request failed', { path: c.req.path, code: sourceCode });
return c.json({
error: '能源数据请求失败,请稍后重试',
code: 'ENERGY_REQUEST_FAILED',
retryable: true,
}, 500);
});
const HYDROGEN_MIN_DATE = '2024-01-01';
// hydrogen_fuel_ledger.refuel_time 已是业务本地时间字面值,直接使用即可(不再 +8 小时)
@@ -27,10 +61,8 @@ const HYDROGEN_BASE_WHERE = `del_flag = '0'`;
const HYDROGEN_BASE_WHERE_B = `b.del_flag = '0'`;
const ELECTRIC_LOCAL = `charging_start_time`;
type CustomerKind = 'external' | 'lingniu' | 'all';
// 新账本 hydrogen_fuel_ledger 当前只承载羚牛车辆订单;外部车辆数据源待接入。
function customerClause(customer: CustomerKind): string {
function customerClause(customer: HydrogenCustomerKind): string {
if (customer === 'external') return '1=0';
if (customer === 'lingniu') return '1=1';
return '1=1';
@@ -121,6 +153,7 @@ app.get('/hydrogen/overview', async (c) => {
const requestedYear = yearParam ? Number(yearParam) || todayYear : todayYear;
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
const hydrogenPool = getHydrogenPool();
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
const [yearListRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
@@ -229,6 +262,7 @@ app.get('/hydrogen/overview', async (c) => {
);
const top5KgSum = kpi.yearKg || 1;
const top5 = top5Rows.map((r, i) => ({
id: Number(r.id) || 0,
rank: i + 1,
name: r.name as string,
kg: Number(r.kg) || 0,
@@ -256,6 +290,7 @@ app.get('/hydrogen/overview', async (c) => {
const stationKgSum = stationFullRows.reduce((s, r) => s + (Number(r.kg) || 0), 0) || 1;
const stationRevSum = stationFullRows.reduce((s, r) => s + (Number(r.revenue) || 0), 0) || 1;
const stations = stationFullRows.map(r => ({
id: Number(r.id) || 0,
name: r.name as string,
kg: Number(r.kg) || 0,
revenue: Number(r.revenue) || 0,
@@ -360,27 +395,48 @@ app.get('/hydrogen/overview', async (c) => {
}));
return { kpi, top5, regions, monthly, customers, stations, availableYears, year };
}, { force });
}, { force, source: 'hydrogenDatabase' });
return c.json(data);
});
// =========================================================
// 氢能 每日:日期范围 + 客户类型 + 站点下钻
// 氢能 每日:日期范围 + 车辆归属 + 站点/客户下钻
// =========================================================
app.get('/hydrogen/daily', async (c) => {
const range = (c.req.query('range') || 'last15') as Range;
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
const customer = (c.req.query('customer') || 'external') as CustomerKind;
const customer = parseHydrogenCustomerKind(c.req.query('customer'));
const stationIdParam = c.req.query('stationId');
const customerNameParam = c.req.query('customerName');
const stationId = parseHydrogenStationId(stationIdParam);
const customerName = parseHydrogenCustomerName(customerNameParam);
if (stationIdParam !== undefined && stationId === null) {
return c.json({ error: 'stationId 必须是非负整数' }, 400);
}
if (customerNameParam !== undefined && customerName === null) {
return c.json({ error: 'customerName 必须是 1-128 个字符' }, 400);
}
const force = c.req.query('force') === '1';
const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}`, async () => {
const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}&station=${stationId ?? 'all'}&customerName=${encodeURIComponent(customerName ?? 'all')}`, async () => {
const hydrogenPool = getHydrogenPool();
const where = [
const whereParts = [
HYDROGEN_BASE_WHERE_B,
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
dateRangeClause(`b.${HYDROGEN_LOCAL}`),
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
].join(' AND ');
];
const whereParams: Array<string | number> = [dateRange.start, dateRange.end];
if (stationId !== null) {
whereParts.push('COALESCE(b.station_id, 0) = ?');
whereParams.push(stationId);
}
if (customerName !== null) {
whereParts.push("COALESCE(NULLIF(TRIM(b.customer_name), ''), '未指定客户') = ?");
whereParams.push(customerName);
}
const where = whereParts.join(' AND ');
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
// 站点名 fallback:站点主数据 → 账本冗余站点名 → 未关联站点
@@ -399,7 +455,7 @@ app.get('/hydrogen/daily', async (c) => {
WHERE ${where}
GROUP BY d, COALESCE(b.station_id, 0)
ORDER BY d DESC, kg DESC`,
[dateRange.start, dateRange.end],
whereParams,
);
// 站点环比:同站点上一条记录的 kg
@@ -459,6 +515,7 @@ app.get('/hydrogen/daily', async (c) => {
customerType: customer,
stations: info
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
stationId: s.stationId,
name: s.name,
pricePerKg: Math.round(s.pricePerKg * 100) / 100,
kg: Math.round(s.kg * 100) / 100,
@@ -479,7 +536,117 @@ app.get('/hydrogen/daily', async (c) => {
// 按日期降序返回
const result = ascDays.slice().sort((a, b) => b.date.localeCompare(a.date));
return result;
}, { force });
}, { force, source: 'hydrogenDatabase' });
return c.json(data);
});
// =========================================================
// 氢能订单下钻:指定自然日 + 车辆归属 + 可选站点/客户
// =========================================================
app.get('/hydrogen/orders', async (c) => {
const date = parseEnergyDate(c.req.query('date'));
if (date === null) return c.json({ error: 'date 必须是有效的 YYYY-MM-DD 日期' }, 400);
const customer = parseHydrogenCustomerKind(c.req.query('customer'));
const stationIdParam = c.req.query('stationId');
const customerNameParam = c.req.query('customerName');
const stationId = parseHydrogenStationId(stationIdParam);
const customerName = parseHydrogenCustomerName(customerNameParam);
if (stationIdParam !== undefined && stationId === null) {
return c.json({ error: 'stationId 必须是非负整数' }, 400);
}
if (customerNameParam !== undefined && customerName === null) {
return c.json({ error: 'customerName 必须是 1-128 个字符' }, 400);
}
const page = parseHydrogenOrderPage(c.req.query('page'));
const limit = parseHydrogenOrderLimit(c.req.query('limit'));
const offset = (page - 1) * limit;
const whereParts = [
HYDROGEN_BASE_WHERE_B,
`b.${HYDROGEN_LOCAL} >= ?`,
`b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)`,
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
];
const params: Array<string | number> = [date, date];
if (stationId !== null) {
whereParts.push('COALESCE(b.station_id, 0) = ?');
params.push(stationId);
}
if (customerName !== null) {
whereParts.push("COALESCE(NULLIF(TRIM(b.customer_name), ''), '未指定客户') = ?");
params.push(customerName);
}
const where = whereParts.join(' AND ');
const data = await cached(
`hydrogen/orders?date=${date}&customer=${customer}&station=${stationId ?? 'all'}&customerName=${encodeURIComponent(customerName ?? 'all')}&page=${page}&limit=${limit}`,
async () => {
const hydrogenPool = getHydrogenPool();
const [[summaryRows], [detailRows]] = await Promise.all([
hydrogenPool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS recordCount,
SUM(b.amount_kg) AS totalKg,
SUM(b.cost_total) AS totalCost,
SUM(b.fee_total) AS totalRevenue
FROM ${HYDROGEN_TABLE} b
WHERE ${where}`,
params,
),
hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d %H:%i:%s') AS refuelTime,
COALESCE(NULLIF(TRIM(b.plate_number), ''), '未识别车辆') AS plate,
COALESCE(NULLIF(TRIM(b.customer_name), ''), '未指定客户') AS customerName,
COALESCE(s.station_short_name, s.station_name, NULLIF(TRIM(b.station_name), ''),
CASE WHEN b.station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.station_id) END) AS stationName,
b.amount_kg AS amountKg,
b.cost_price AS costPrice,
b.cost_total AS costTotal,
b.customer_price AS customerPrice,
b.fee_total AS revenue
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${where}
ORDER BY b.${HYDROGEN_LOCAL} DESC,
b.plate_number ASC,
b.station_id ASC,
b.amount_kg DESC
LIMIT ? OFFSET ?`,
[...params, limit, offset],
),
]);
const summary = summaryRows[0] ?? {};
const recordCount = Number(summary.recordCount) || 0;
return {
date,
vehicleScope: customer,
stationId,
customerName,
page,
limit,
recordCount,
totalPages: Math.max(1, Math.ceil(recordCount / limit)),
totalKg: Math.round((Number(summary.totalKg) || 0) * 100) / 100,
totalCost: Math.round((Number(summary.totalCost) || 0) * 100) / 100,
totalRevenue: Math.round((Number(summary.totalRevenue) || 0) * 100) / 100,
items: detailRows.map((row, index) => ({
rowNumber: offset + index + 1,
refuelTime: String(row.refuelTime),
plate: String(row.plate),
customerName: String(row.customerName),
stationName: String(row.stationName),
amountKg: Math.round((Number(row.amountKg) || 0) * 100) / 100,
costPrice: Math.round((Number(row.costPrice) || 0) * 100) / 100,
costTotal: Math.round((Number(row.costTotal) || 0) * 100) / 100,
customerPrice: Math.round((Number(row.customerPrice) || 0) * 100) / 100,
revenue: Math.round((Number(row.revenue) || 0) * 100) / 100,
})),
};
},
{ source: 'hydrogenDatabase' },
);
return c.json(data);
});
@@ -498,7 +665,8 @@ app.get('/electric/overview', async (c) => {
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN fee ELSE 0 END) AS monthFee,
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN kwh ELSE 0 END) AS todayKwh,
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN fee ELSE 0 END) AS todayFee
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN fee ELSE 0 END) AS todayFee,
DATE_FORMAT(MAX(start_time), '%Y-%m-%d %H:%i:%s') AS latestRecordAt
FROM bi_ele_charge_record`,
);
const k = kpiRows[0] ?? {};
@@ -508,6 +676,7 @@ app.get('/electric/overview', async (c) => {
const monthFee = Number(k.monthFee) || 0;
const todayKwh = Number(k.todayKwh) || 0;
const todayFee = Number(k.todayFee) || 0;
const latestRecordAt = k.latestRecordAt ? String(k.latestRecordAt) : null;
// 本月每日(用于柱图)
const [trendRows] = await pool.query<RowDataPacket[]>(
@@ -560,8 +729,83 @@ app.get('/electric/overview', async (c) => {
return {
kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct },
trend: trendArr,
latestRecordAt,
};
}, { force });
}, { force, source: 'electricDatabase' });
return c.json(data);
});
// =========================================================
// 电能 订单下钻:指定自然日 + 车辆归属,最多返回 500 条明细
// =========================================================
app.get('/electric/orders', async (c) => {
const dateParam = c.req.query('date');
const date = parseEnergyDate(dateParam);
if (date === null) return c.json({ error: 'date 必须是有效的 YYYY-MM-DD 日期' }, 400);
const customerParam = c.req.query('customer');
const customer = parseElectricVehicleScope(customerParam);
if (customer === null) return c.json({ error: 'customer 必须是 all、lingniu 或 external' }, 400);
const vehicleKind = customer === 'all' ? null : customer === 'lingniu' ? 'internal' : 'external';
const kindClause = vehicleKind === null ? '' : 'AND vehicle_kind = ?';
const data = await cached(`electric/orders?date=${date}&customer=${customer}`, async () => {
const params = vehicleKind === null ? [date, date] : [date, date, vehicleKind];
const [[summaryRows], [detailRows]] = await Promise.all([
pool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS recordCount,
SUM(kwh) AS totalKwh,
SUM(fee) AS totalFee
FROM bi_ele_charge_record
WHERE start_time >= ? AND start_time < DATE_ADD(?, INTERVAL 1 DAY)
${kindClause}`,
params,
),
pool.query<RowDataPacket[]>(
`SELECT id,
order_no AS orderNo,
DATE_FORMAT(start_time, '%Y-%m-%d %H:%i:%s') AS startTime,
COALESCE(NULLIF(TRIM(station_name), ''), '未指定站点') AS stationName,
COALESCE(NULLIF(TRIM(matched_plate), ''), NULLIF(TRIM(judged_plate), ''), NULLIF(TRIM(plate), ''), '未识别车辆') AS plate,
vehicle_kind AS vehicleKind,
COALESCE(NULLIF(TRIM(order_status), ''), '未指定状态') AS orderStatus,
kwh,
e_fee AS electricityFee,
service_fee AS serviceFee,
fee AS totalFee
FROM bi_ele_charge_record
WHERE start_time >= ? AND start_time < DATE_ADD(?, INTERVAL 1 DAY)
${kindClause}
ORDER BY start_time DESC, id DESC
LIMIT 501`,
params,
),
]);
const summary = summaryRows[0] ?? {};
const truncated = detailRows.length > 500;
return {
date,
vehicleScope: customer,
recordCount: Number(summary.recordCount) || 0,
totalKwh: Math.round((Number(summary.totalKwh) || 0) * 100) / 100,
totalFee: Math.round((Number(summary.totalFee) || 0) * 100) / 100,
truncated,
items: detailRows.slice(0, 500).map(row => ({
id: Number(row.id),
orderNo: String(row.orderNo),
startTime: String(row.startTime),
stationName: String(row.stationName),
plate: String(row.plate),
vehicleKind: row.vehicleKind as 'internal' | 'external' | 'unknown',
orderStatus: String(row.orderStatus),
kwh: Math.round((Number(row.kwh) || 0) * 100) / 100,
electricityFee: Math.round((Number(row.electricityFee) || 0) * 100) / 100,
serviceFee: Math.round((Number(row.serviceFee) || 0) * 100) / 100,
totalFee: Math.round((Number(row.totalFee) || 0) * 100) / 100,
})),
};
}, { source: 'electricDatabase' });
return c.json(data);
});
@@ -571,7 +815,8 @@ app.get('/electric/overview', async (c) => {
// 缺失日期补零
// =========================================================
app.get('/electric/monthly', async (c) => {
const customer = (c.req.query('customer') || 'lingniu') as CustomerKind;
const customer = parseElectricVehicleScope(c.req.query('customer'));
if (customer === null) return c.json({ error: 'customer 必须是 all、lingniu 或 external' }, 400);
const range = (c.req.query('range') || 'last15') as Range;
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
const force = c.req.query('force') === '1';
@@ -650,8 +895,284 @@ app.get('/electric/monthly', async (c) => {
});
return months;
}, { force });
}, { force, source: 'electricDatabase' });
return c.json(data);
});
// =========================================================
// ETC 总览:真实接入状态 + 通行记录/账单基础 KPI
// 不读取或返回同步账号、密码、API 地址等敏感配置。
// =========================================================
app.get('/etc/overview', async (c) => {
const force = c.req.query('force') === '1';
const data = await cached('etc/overview', async () => {
const [[recordRows], [billRows], [configRows], [logRows]] = await Promise.all([
pool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS passageCount,
COUNT(DISTINCT plate_number) AS vehicleCount,
SUM(toll_amount) AS tollAmount,
SUM(service_fee) AS serviceFee,
SUM(total_amount) AS totalAmount,
DATE_FORMAT(MAX(trans_time), '%Y-%m-%d %H:%i:%s') AS latestTransactionTime
FROM etc_toll_record
WHERE del_flag = '0'`,
),
pool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS billCount,
SUM(receivable_amount) AS receivableAmount,
SUM(paid_amount) AS paidAmount
FROM energy_etc_bill
WHERE del_flag = '0'`,
),
pool.query<RowDataPacket[]>(
`SELECT provider_type AS providerType,
sync_enabled AS syncEnabled,
DATE_FORMAT(last_sync_time, '%Y-%m-%d %H:%i:%s') AS lastSyncAt,
last_sync_status AS lastSyncStatus
FROM etc_sync_config
WHERE del_flag = '0'
ORDER BY update_time DESC, id DESC
LIMIT 1`,
),
pool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(sync_end_time, '%Y-%m-%d %H:%i:%s') AS lastSyncAt,
sync_status AS syncStatus
FROM etc_sync_log
ORDER BY sync_start_time DESC, id DESC
LIMIT 1`,
),
]);
const record = recordRows[0] ?? {};
const bill = billRows[0] ?? {};
const config = configRows[0];
const log = logRows[0];
const rawSyncStatus = log?.syncStatus ?? config?.lastSyncStatus;
const lastSyncStatus = rawSyncStatus === 1
? 'success'
: rawSyncStatus === 0
? 'failed'
: rawSyncStatus === 2
? 'partial'
: rawSyncStatus == null
? 'never'
: 'unknown';
return {
integration: {
providerConfigured: Boolean(config),
syncEnabled: Number(config?.syncEnabled) === 1,
providerType: config?.providerType ? String(config.providerType) : null,
lastSyncAt: log?.lastSyncAt ? String(log.lastSyncAt) : config?.lastSyncAt ? String(config.lastSyncAt) : null,
lastSyncStatus,
},
kpi: {
passageCount: Number(record.passageCount) || 0,
vehicleCount: Number(record.vehicleCount) || 0,
tollAmount: Math.round((Number(record.tollAmount) || 0) * 100) / 100,
serviceFee: Math.round((Number(record.serviceFee) || 0) * 100) / 100,
totalAmount: Math.round((Number(record.totalAmount) || 0) * 100) / 100,
billCount: Number(bill.billCount) || 0,
receivableAmount: Math.round((Number(bill.receivableAmount) || 0) * 100) / 100,
paidAmount: Math.round((Number(bill.paidAmount) || 0) * 100) / 100,
latestTransactionTime: record.latestTransactionTime ? String(record.latestTransactionTime) : null,
},
} as const;
}, { force, source: 'etcDatabase' });
return c.json(data);
});
app.get('/etc/records', async (c) => {
const page = parseEtcPage(c.req.query('page'));
const limit = parseEtcLimit(c.req.query('limit'));
const search = parseEtcSearch(c.req.query('search'));
if (search === null) return c.json({ error: 'search 最多 128 个字符' }, 400);
const startParam = c.req.query('startDate');
const endParam = c.req.query('endDate');
const parsedStart = startParam === undefined ? undefined : parseEnergyDate(startParam);
const parsedEnd = endParam === undefined ? undefined : parseEnergyDate(endParam);
if (parsedStart === null || parsedEnd === null) {
return c.json({ error: 'startDate/endDate 必须是有效的 YYYY-MM-DD 日期' }, 400);
}
const startDate = parsedStart && parsedEnd && parsedStart > parsedEnd ? parsedEnd : parsedStart;
const endDate = parsedStart && parsedEnd && parsedStart > parsedEnd ? parsedStart : parsedEnd;
const where = ["del_flag = '0'"];
const params: Array<string | number> = [];
if (startDate) {
where.push('trans_time >= ?');
params.push(startDate);
}
if (endDate) {
where.push('trans_time < DATE_ADD(?, INTERVAL 1 DAY)');
params.push(endDate);
}
if (search) {
where.push('(plate_number LIKE ? OR customer_name LIKE ? OR record_code LIKE ?)');
const pattern = `%${search}%`;
params.push(pattern, pattern, pattern);
}
const whereSql = where.join(' AND ');
const offset = (page - 1) * limit;
const [[summaryRows], [detailRows]] = await observeDataSource('etcDatabase', () => Promise.all([
pool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS total,
COUNT(DISTINCT plate_number) AS vehicleCount,
SUM(toll_amount) AS tollAmount,
SUM(service_fee) AS serviceFee,
SUM(total_amount) AS totalAmount
FROM etc_toll_record
WHERE ${whereSql}`,
params,
),
pool.query<RowDataPacket[]>(
`SELECT id,
record_code AS recordCode,
plate_number AS plate,
customer_name AS customerName,
entry_station_name AS entryStation,
exit_station_name AS exitStation,
DATE_FORMAT(trans_time, '%Y-%m-%d %H:%i:%s') AS transTime,
toll_amount AS tollAmount,
service_fee AS serviceFee,
total_amount AS totalAmount,
cost_type AS costType,
contract_matched AS contractMatched,
review_status AS reviewStatus
FROM etc_toll_record
WHERE ${whereSql}
ORDER BY trans_time DESC, id DESC
LIMIT ? OFFSET ?`,
[...params, limit, offset],
),
]));
const summary = summaryRows[0] ?? {};
const total = Number(summary.total) || 0;
return c.json({
page,
limit,
total,
totalPages: Math.max(1, Math.ceil(total / limit)),
filters: { startDate: startDate || null, endDate: endDate || null, search },
summary: {
vehicleCount: Number(summary.vehicleCount) || 0,
tollAmount: Math.round((Number(summary.tollAmount) || 0) * 100) / 100,
serviceFee: Math.round((Number(summary.serviceFee) || 0) * 100) / 100,
totalAmount: Math.round((Number(summary.totalAmount) || 0) * 100) / 100,
},
items: detailRows.map(row => ({
id: Number(row.id),
recordCode: String(row.recordCode),
plate: String(row.plate),
customerName: row.customerName ? String(row.customerName) : null,
entryStation: row.entryStation ? String(row.entryStation) : null,
exitStation: row.exitStation ? String(row.exitStation) : null,
transTime: String(row.transTime),
tollAmount: Math.round((Number(row.tollAmount) || 0) * 100) / 100,
serviceFee: Math.round((Number(row.serviceFee) || 0) * 100) / 100,
totalAmount: Math.round((Number(row.totalAmount) || 0) * 100) / 100,
costType: Number(row.costType) || 0,
contractMatched: Number(row.contractMatched) === 1,
reviewStatus: Number(row.reviewStatus) || 0,
})),
});
});
app.get('/etc/bills', async (c) => {
const page = parseEtcPage(c.req.query('page'));
const limit = parseEtcLimit(c.req.query('limit'));
const search = parseEtcSearch(c.req.query('search'));
if (search === null) return c.json({ error: 'search 最多 128 个字符' }, 400);
const startParam = c.req.query('startDate');
const endParam = c.req.query('endDate');
const parsedStart = startParam === undefined ? undefined : parseEnergyDate(startParam);
const parsedEnd = endParam === undefined ? undefined : parseEnergyDate(endParam);
if (parsedStart === null || parsedEnd === null) {
return c.json({ error: 'startDate/endDate 必须是有效的 YYYY-MM-DD 日期' }, 400);
}
const startDate = parsedStart && parsedEnd && parsedStart > parsedEnd ? parsedEnd : parsedStart;
const endDate = parsedStart && parsedEnd && parsedStart > parsedEnd ? parsedStart : parsedEnd;
const where = ["del_flag = '0'"];
const params: Array<string | number> = [];
if (startDate) {
where.push('bill_period_end >= ?');
params.push(startDate);
}
if (endDate) {
where.push('bill_period_start <= ?');
params.push(endDate);
}
if (search) {
where.push('(customer_name LIKE ? OR bill_code LIKE ?)');
const pattern = `%${search}%`;
params.push(pattern, pattern);
}
const whereSql = where.join(' AND ');
const offset = (page - 1) * limit;
const [[summaryRows], [detailRows]] = await observeDataSource('etcDatabase', () => Promise.all([
pool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS total,
SUM(receivable_amount) AS receivableAmount,
SUM(paid_amount) AS paidAmount
FROM energy_etc_bill
WHERE ${whereSql}`,
params,
),
pool.query<RowDataPacket[]>(
`SELECT id,
bill_code AS billCode,
customer_name AS customerName,
DATE_FORMAT(bill_period_start, '%Y-%m-%d') AS periodStart,
DATE_FORMAT(bill_period_end, '%Y-%m-%d') AS periodEnd,
total_toll_count AS tollCount,
total_toll_amount AS tollAmount,
total_service_fee AS serviceFee,
receivable_amount AS receivableAmount,
paid_amount AS paidAmount,
payment_status AS paymentStatus,
review_status AS reviewStatus
FROM energy_etc_bill
WHERE ${whereSql}
ORDER BY bill_period_end DESC, id DESC
LIMIT ? OFFSET ?`,
[...params, limit, offset],
),
]));
const summary = summaryRows[0] ?? {};
const total = Number(summary.total) || 0;
return c.json({
page,
limit,
total,
totalPages: Math.max(1, Math.ceil(total / limit)),
filters: { startDate: startDate || null, endDate: endDate || null, search },
summary: {
receivableAmount: Math.round((Number(summary.receivableAmount) || 0) * 100) / 100,
paidAmount: Math.round((Number(summary.paidAmount) || 0) * 100) / 100,
},
items: detailRows.map(row => ({
id: Number(row.id),
billCode: String(row.billCode),
customerName: row.customerName ? String(row.customerName) : null,
periodStart: String(row.periodStart),
periodEnd: String(row.periodEnd),
tollCount: Number(row.tollCount) || 0,
tollAmount: Math.round((Number(row.tollAmount) || 0) * 100) / 100,
serviceFee: Math.round((Number(row.serviceFee) || 0) * 100) / 100,
receivableAmount: Math.round((Number(row.receivableAmount) || 0) * 100) / 100,
paidAmount: Math.round((Number(row.paidAmount) || 0) * 100) / 100,
paymentStatus: Number(row.paymentStatus) || 0,
reviewStatus: Number(row.reviewStatus) || 0,
})),
});
});
export default app;
+97
View File
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import app from './index.js';
import {
parseHydrogenCustomerKind,
parseElectricVehicleScope,
parseHydrogenCustomerName,
parseHydrogenOrderLimit,
parseHydrogenOrderPage,
parseHydrogenStationId,
parseEnergyDate,
parseEtcLimit,
parseEtcPage,
parseEtcSearch,
} from './query.js';
test('parses only non-negative integer station ids', () => {
assert.equal(parseHydrogenStationId('0'), 0);
assert.equal(parseHydrogenStationId('18'), 18);
assert.equal(parseHydrogenStationId('-1'), null);
assert.equal(parseHydrogenStationId('1 OR 1=1'), null);
assert.equal(parseHydrogenStationId(undefined), null);
});
test('defaults unknown customer scopes to lingniu', () => {
assert.equal(parseHydrogenCustomerKind('external'), 'external');
assert.equal(parseHydrogenCustomerKind('all'), 'all');
assert.equal(parseHydrogenCustomerKind('invalid'), 'lingniu');
assert.equal(parseHydrogenCustomerKind(undefined), 'lingniu');
});
test('parses explicit electric vehicle scopes', () => {
assert.equal(parseElectricVehicleScope('all'), 'all');
assert.equal(parseElectricVehicleScope('lingniu'), 'lingniu');
assert.equal(parseElectricVehicleScope('external'), 'external');
assert.equal(parseElectricVehicleScope(undefined), 'lingniu');
assert.equal(parseElectricVehicleScope('unknown'), null);
});
test('normalizes bounded customer names for exact matching', () => {
assert.equal(parseHydrogenCustomerName(' 武汉客户 '), '武汉客户');
assert.equal(parseHydrogenCustomerName('未指定客户'), '未指定客户');
assert.equal(parseHydrogenCustomerName(' '), null);
assert.equal(parseHydrogenCustomerName('x'.repeat(129)), null);
assert.equal(parseHydrogenCustomerName(undefined), null);
});
test('bounds hydrogen order pagination', () => {
assert.equal(parseHydrogenOrderPage('2'), 2);
assert.equal(parseHydrogenOrderPage('0'), 1);
assert.equal(parseHydrogenOrderLimit('10'), 20);
assert.equal(parseHydrogenOrderLimit('500'), 100);
assert.equal(parseHydrogenOrderLimit(undefined), 20);
});
test('rejects invalid entity filters before querying hydrogen data', async () => {
const invalidStation = await app.request('/hydrogen/daily?stationId=-1');
assert.equal(invalidStation.status, 400);
assert.deepEqual(await invalidStation.json(), { error: 'stationId 必须是非负整数' });
const invalidCustomer = await app.request(`/hydrogen/daily?customerName=${'x'.repeat(129)}`);
assert.equal(invalidCustomer.status, 400);
assert.deepEqual(await invalidCustomer.json(), { error: 'customerName 必须是 1-128 个字符' });
const invalidOrderDate = await app.request('/hydrogen/orders?date=2026-02-31');
assert.equal(invalidOrderDate.status, 400);
assert.deepEqual(await invalidOrderDate.json(), { error: 'date 必须是有效的 YYYY-MM-DD 日期' });
});
test('parses only valid energy calendar dates', () => {
assert.equal(parseEnergyDate('2026-07-24'), '2026-07-24');
assert.equal(parseEnergyDate('2026-02-31'), null);
assert.equal(parseEnergyDate('2026-7-24'), null);
assert.equal(parseEnergyDate(undefined), null);
});
test('bounds ETC list pagination and search input', () => {
assert.equal(parseEtcPage('3'), 3);
assert.equal(parseEtcPage('-1'), 1);
assert.equal(parseEtcLimit('5'), 10);
assert.equal(parseEtcLimit('500'), 100);
assert.equal(parseEtcSearch(' 粤A '), '粤A');
assert.equal(parseEtcSearch('x'.repeat(129)), null);
});
test('rejects invalid electric drill filters before querying data', async () => {
const invalidDate = await app.request('/electric/orders?date=2026-02-31&customer=lingniu');
assert.equal(invalidDate.status, 400);
assert.deepEqual(await invalidDate.json(), { error: 'date 必须是有效的 YYYY-MM-DD 日期' });
const invalidScope = await app.request('/electric/orders?date=2026-07-28&customer=unknown');
assert.equal(invalidScope.status, 400);
assert.deepEqual(await invalidScope.json(), { error: 'customer 必须是 all、lingniu 或 external' });
const invalidMonthlyScope = await app.request('/electric/monthly?customer=unknown');
assert.equal(invalidMonthlyScope.status, 400);
});
+64
View File
@@ -0,0 +1,64 @@
export type HydrogenCustomerKind = 'external' | 'lingniu' | 'all';
export type ElectricVehicleScope = 'external' | 'lingniu' | 'all';
export function parseHydrogenCustomerKind(value: string | undefined): HydrogenCustomerKind {
if (value === 'external' || value === 'all') return value;
return 'lingniu';
}
export function parseElectricVehicleScope(value: string | undefined): ElectricVehicleScope | null {
if (value === undefined) return 'lingniu';
if (value === 'external' || value === 'lingniu' || value === 'all') return value;
return null;
}
export function parseHydrogenStationId(value: string | undefined): number | null {
if (value == null || !/^\d+$/.test(value)) return null;
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
}
export function parseHydrogenCustomerName(value: string | undefined): string | null {
if (value == null) return null;
const normalized = value.trim();
return normalized.length > 0 && normalized.length <= 128 ? normalized : null;
}
export function parseHydrogenOrderPage(value: string | undefined): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 1;
}
export function parseHydrogenOrderLimit(value: string | undefined): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? Math.min(Math.max(parsed, 20), 100) : 20;
}
const YMD_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export function parseEnergyDate(value: string | undefined): string | null {
if (!value || !YMD_PATTERN.test(value)) return null;
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
: null;
}
export function parseEtcPage(value: string | undefined): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 1;
}
export function parseEtcLimit(value: string | undefined): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? Math.min(Math.max(parsed, 10), 100) : 20;
}
export function parseEtcSearch(value: string | undefined): string | null {
if (value === undefined) return '';
const normalized = value.trim();
return normalized.length <= 128 ? normalized : null;
}
+15
View File
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import app from './health.js';
test('separates liveness from configuration readiness', async () => {
const liveness = await app.request('/');
const readiness = await app.request('/readiness');
const liveBody = await liveness.json();
const readyBody = await readiness.json();
assert.equal(liveness.status, 200);
assert.equal(liveBody.semantics, 'liveness');
assert.equal(readyBody.semantics, 'configuration-only');
assert.ok([200, 503].includes(readiness.status));
});
+17
View File
@@ -0,0 +1,17 @@
import { Hono } from 'hono';
import { buildReadinessReport } from '../readiness.js';
const app = new Hono();
app.get('/', (c) => c.json({
status: 'ok',
semantics: 'liveness',
time: new Date().toISOString(),
}));
app.get('/readiness', (c) => {
const report = buildReadinessReport();
return c.json(report, report.status === 'not-ready' ? 503 : 200);
});
export default app;
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
clearManualMonitoringSnapshot,
getManualMonitoringSnapshot,
storeManualMonitoringSnapshot,
} from './manual-snapshot.js';
const result = {
vehicles: [],
dailyTotals: [{ date: '2026-08-07', totalKm: 12 }],
start: '2026-08-07',
end: '2026-08-07',
};
test('reuses a manual snapshot only for its date range and TTL', () => {
clearManualMonitoringSnapshot();
storeManualMonitoringSnapshot(result, 1_000);
assert.ok(getManualMonitoringSnapshot('2026-08-07', '2026-08-07', 60_999));
assert.equal(getManualMonitoringSnapshot('2026-08-06', '2026-08-06', 60_999), null);
assert.equal(getManualMonitoringSnapshot('2026-08-07', '2026-08-07', 61_000), null);
});
@@ -0,0 +1,40 @@
import type { RangeMileageResult } from './cache.js';
const MANUAL_SNAPSHOT_TTL_MS = 60 * 1000;
interface ManualSnapshot {
result: RangeMileageResult;
updatedAt: string;
expiresAt: number;
}
let snapshot: ManualSnapshot | null = null;
export function storeManualMonitoringSnapshot(
result: RangeMileageResult,
now = Date.now(),
): ManualSnapshot {
snapshot = {
result,
updatedAt: new Date(now).toISOString(),
expiresAt: now + MANUAL_SNAPSHOT_TTL_MS,
};
return snapshot;
}
export function getManualMonitoringSnapshot(
start: string,
end: string,
now = Date.now(),
): ManualSnapshot | null {
if (!snapshot || snapshot.expiresAt <= now) {
snapshot = null;
return null;
}
if (snapshot.result.start !== start || snapshot.result.end !== end) return null;
return snapshot;
}
export function clearManualMonitoringSnapshot(): void {
snapshot = null;
}
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
isMonitoringCacheEligible,
isMonitoringSnapshotScope,
shanghaiDate,
} from './monitoring-cache-policy.js';
test('uses the monitoring snapshot only for the current instrument day', () => {
const base = {
startDate: '2026-08-07',
endDate: '2026-08-07',
today: '2026-08-07',
sourcePriority: ['instrument'] as const,
force: false,
};
assert.equal(isMonitoringCacheEligible({ ...base, sourcePriority: [...base.sourcePriority] }), true);
assert.equal(isMonitoringCacheEligible({ ...base, startDate: '2026-08-06', sourcePriority: ['instrument'] }), false);
assert.equal(isMonitoringCacheEligible({ ...base, sourcePriority: ['gps'] }), false);
assert.equal(isMonitoringCacheEligible({ ...base, sourcePriority: ['instrument'], force: true }), false);
assert.equal(isMonitoringSnapshotScope({ ...base, sourcePriority: ['instrument'] }), true);
});
test('formats the current date in Asia/Shanghai', () => {
assert.equal(shanghaiDate(new Date('2026-08-06T16:30:00.000Z')), '2026-08-07');
});
@@ -0,0 +1,32 @@
import type { MileageSourceGroup } from './source-policy.js';
export function isMonitoringCacheEligible(input: {
startDate: string;
endDate: string;
today: string;
sourcePriority: MileageSourceGroup[];
force: boolean;
}): boolean {
return !input.force && isMonitoringSnapshotScope(input);
}
export function isMonitoringSnapshotScope(input: {
startDate: string;
endDate: string;
today: string;
sourcePriority: MileageSourceGroup[];
}): boolean {
return input.startDate === input.today
&& input.endDate === input.today
&& input.sourcePriority.length === 1
&& input.sourcePriority[0] === 'instrument';
}
export function shanghaiDate(now = new Date()): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(now);
}
+76 -4
View File
@@ -8,6 +8,20 @@ import {
parseMileageSourcePriority,
protocolsForSourcePriority,
} from './source-policy.js';
import {
isMonitoringCacheEligible,
isMonitoringSnapshotScope,
shanghaiDate,
} from './monitoring-cache-policy.js';
import {
getManualMonitoringSnapshot,
storeManualMonitoringSnapshot,
} from './manual-snapshot.js';
import { sumMileageKm } from './precision.js';
import {
rangeMileageSnapshotCache,
type RangeSnapshotStatus,
} from './range-snapshot.js';
const app = new Hono();
@@ -115,6 +129,7 @@ app.get('/', async (c) => {
const range = normalizeRange(c.req.query('startDate') || '', c.req.query('endDate') || '');
const sourcePriority = parseMileageSourcePriority(c.req.query('sourcePriority'));
const protocolPriority = protocolsForSourcePriority(sourcePriority);
const force = c.req.query('force') === '1';
const filterParams = {
search: c.req.query('search') || '',
@@ -136,18 +151,74 @@ app.get('/', async (c) => {
let filters: MonitoringFilters;
let rangeDailyTotals: { date: string; totalKm: number }[] | undefined;
let dateRange: { start: string; end: string } | undefined;
let dataUpdatedAt: string | undefined;
let cacheStatus: 'hit' | 'manual-hit' | 'refresh' | 'miss' | 'bypass' | RangeSnapshotStatus = 'bypass';
if (range) {
const cache = getCache();
const today = shanghaiDate();
const snapshotScope = isMonitoringSnapshotScope({
startDate: range.start,
endDate: range.end,
today,
sourcePriority,
});
const cacheEligible = isMonitoringCacheEligible({
startDate: range.start,
endDate: range.end,
today,
sourcePriority,
force,
});
const manualSnapshot = cacheEligible
? getManualMonitoringSnapshot(range.start, range.end)
: null;
if (manualSnapshot) {
cacheStatus = 'manual-hit';
allVehicles = manualSnapshot.result.vehicles;
rangeDailyTotals = manualSnapshot.result.dailyTotals;
dateRange = { start: manualSnapshot.result.start, end: manualSnapshot.result.end };
dataUpdatedAt = manualSnapshot.updatedAt;
filters = buildDateFilters(allVehicles);
} else if (cacheEligible && cache) {
cacheStatus = 'hit';
allVehicles = cache.vehicles.map(vehicle => ({
...vehicle,
dailyMileage: { [range.start]: vehicle.dailyKm },
dailySourceProtocols: { [range.start]: vehicle.sourceProtocol },
}));
rangeDailyTotals = [{
date: range.start,
totalKm: Math.round(allVehicles.reduce((sum, vehicle) => sum + vehicle.dailyKm, 0)),
}];
dateRange = { ...range };
dataUpdatedAt = cache.updatedAt;
filters = cache.filters;
} else {
cacheStatus = cacheEligible ? 'miss' : 'bypass';
try {
const result = await queryRangeMileage(range.start, range.end, protocolPriority);
const loaded = await rangeMileageSnapshotCache.load({
startDate: range.start,
endDate: range.end,
protocolPriority,
force,
}, () => queryRangeMileage(range.start, range.end, protocolPriority));
const { result } = loaded;
cacheStatus = loaded.status;
allVehicles = result.vehicles;
rangeDailyTotals = result.dailyTotals;
dateRange = { start: result.start, end: result.end };
filters = buildDateFilters(allVehicles);
if (force && snapshotScope) {
const stored = storeManualMonitoringSnapshot(result);
cacheStatus = 'refresh';
dataUpdatedAt = stored.updatedAt;
}
} catch (e: unknown) {
console.error('monitoring range query error:', e);
return c.json(EMPTY_RESPONSE, 500);
}
}
} else if (date) {
try {
allVehicles = await queryDateMileage(date, protocolPriority);
@@ -186,9 +257,9 @@ app.get('/', async (c) => {
const stats = {
totalToday: Math.round(filtered.reduce((sum, v) => sum + v.dailyKm, 0)),
totalAll: filtered.reduce((sum, v) => sum + (v.totalKm || 0), 0),
totalAll: sumMileageKm(filtered.map(v => v.totalKm || 0)),
vehicleCount: filtered.length,
yesterdayTotal: filtered.reduce((sum, v) => sum + v.yesterdayKm, 0),
yesterdayTotal: sumMileageKm(filtered.map(v => v.yesterdayKm)),
};
const sorted = [...filtered].sort((a, b) => {
@@ -218,6 +289,7 @@ app.get('/', async (c) => {
const paged = sorted.slice(offset, offset + limit);
const total = filtered.length;
c.header('X-Mileage-Cache', cacheStatus);
return c.json({
vehicles: maskCustomerNames(paged),
stats,
@@ -227,7 +299,7 @@ app.get('/', async (c) => {
total,
page,
totalPages: Math.ceil(total / limit),
updatedAt: dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(),
updatedAt: dataUpdatedAt || dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(),
sourcePriority,
});
});
+4 -3
View File
@@ -4,6 +4,7 @@ import {
normalizeOneOsProtocol,
type OneOsProtocol,
} from './source-policy.js';
import { observeDataSource } from '../../source-telemetry.js';
dotenv.config();
@@ -313,7 +314,7 @@ export async function fetchOneOsDailyMileage(
} else {
let pending = inflight.get(cacheKey);
if (!pending) {
pending = requestDate(date, protocolPriority).then(result => {
pending = observeDataSource('oneOsMileage', () => requestDate(date, protocolPriority)).then(result => {
const ttl = date === shanghaiDate() ? CURRENT_DAY_TTL_MS : HISTORICAL_TTL_MS;
cache.delete(cacheKey);
if (ttl > 0) {
@@ -372,12 +373,12 @@ export async function fetchOneOsMileageDates(
].join(':');
let pending = rangeInflight.get(key);
if (!pending) {
pending = requestRange(
pending = observeDataSource('oneOsMileage', () => requestRange(
startDate,
endDate,
selectedPlates,
protocolPriority,
).finally(() => rangeInflight.delete(key));
)).finally(() => rangeInflight.delete(key));
rangeInflight.set(key, pending);
}
const rowsByDate = await pending;
@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { roundMileageKm, sumMileageKm } from './precision.js';
test('removes binary floating point tails at the published precision', () => {
assert.equal(sumMileageKm([0.1, 0.2]), 0.3);
assert.equal(roundMileageKm(44_488_200.300000004), 44_488_200.3);
});
test('rounds after aggregation and ignores invalid values', () => {
assert.equal(sumMileageKm([0.04, 0.04]), 0.1);
assert.equal(sumMileageKm([1, Number.NaN, Number.POSITIVE_INFINITY]), 1);
});
+13
View File
@@ -0,0 +1,13 @@
export function roundMileageKm(value: number, decimals = 1): number {
if (!Number.isFinite(value)) return 0;
const scale = 10 ** decimals;
return Math.round((value + Number.EPSILON) * scale) / scale;
}
export function sumMileageKm(values: Iterable<number>, decimals = 1): number {
let total = 0;
for (const value of values) {
if (Number.isFinite(value)) total += value;
}
return roundMileageKm(total, decimals);
}
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { RangeMileageSnapshotCache } from './range-snapshot.js';
import type { RangeMileageResult } from './cache.js';
function result(start = '2026-08-01', end = '2026-08-07'): RangeMileageResult {
return { vehicles: [], dailyTotals: [], start, end };
}
const input = {
startDate: '2026-08-01',
endDate: '2026-08-07',
protocolPriority: ['GB32960'] as const,
};
test('reuses a completed range snapshot until its TTL expires', async () => {
let now = 1_000;
let calls = 0;
const cache = new RangeMileageSnapshotCache({ ttlMs: 60_000, now: () => now });
const loader = async () => {
calls += 1;
return result();
};
assert.equal((await cache.load(input, loader)).status, 'range-miss');
assert.equal((await cache.load(input, loader)).status, 'range-hit');
now = 61_000;
assert.equal((await cache.load(input, loader)).status, 'range-miss');
assert.equal(calls, 2);
});
test('deduplicates concurrent requests for the same range', async () => {
const cache = new RangeMileageSnapshotCache();
let calls = 0;
let resolve!: (value: RangeMileageResult) => void;
const pending = new Promise<RangeMileageResult>(done => { resolve = done; });
const loader = async () => {
calls += 1;
return pending;
};
const first = cache.load(input, loader);
const second = cache.load(input, loader);
resolve(result());
assert.equal((await first).status, 'range-miss');
assert.equal((await second).status, 'range-inflight');
assert.equal(calls, 1);
});
test('keeps date ranges and source protocols in separate cache keys', async () => {
const cache = new RangeMileageSnapshotCache();
let calls = 0;
const loader = async () => {
calls += 1;
return result();
};
await cache.load(input, loader);
await cache.load({ ...input, endDate: '2026-08-06' }, loader);
await cache.load({ ...input, protocolPriority: ['MQTT'] }, loader);
assert.equal(calls, 3);
});
test('force refresh bypasses a completed snapshot', async () => {
const cache = new RangeMileageSnapshotCache();
let calls = 0;
const loader = async () => {
calls += 1;
return result();
};
await cache.load(input, loader);
const refreshed = await cache.load({ ...input, force: true }, loader);
assert.equal(refreshed.status, 'range-refresh');
assert.equal(calls, 2);
});
test('does not cache failed range requests', async () => {
const cache = new RangeMileageSnapshotCache();
let calls = 0;
const loader = async () => {
calls += 1;
if (calls === 1) throw new Error('upstream unavailable');
return result();
};
await assert.rejects(cache.load(input, loader), /upstream unavailable/);
const recovered = await cache.load(input, loader);
assert.equal(recovered.status, 'range-miss');
assert.equal(calls, 2);
});
@@ -0,0 +1,97 @@
import type { RangeMileageResult } from './cache.js';
import type { OneOsProtocol } from './source-policy.js';
export type RangeSnapshotStatus = 'range-hit' | 'range-miss' | 'range-refresh' | 'range-inflight';
export interface RangeSnapshotLoadResult {
result: RangeMileageResult;
status: RangeSnapshotStatus;
}
interface RangeSnapshotEntry {
result: RangeMileageResult;
expiresAt: number;
}
interface RangeSnapshotCacheOptions {
ttlMs?: number;
maxEntries?: number;
now?: () => number;
}
interface RangeSnapshotInput {
startDate: string;
endDate: string;
protocolPriority: readonly OneOsProtocol[];
force?: boolean;
}
const DEFAULT_TTL_MS = 60 * 1000;
const DEFAULT_MAX_ENTRIES = 8;
function snapshotKey(input: RangeSnapshotInput): string {
return [input.startDate, input.endDate, input.protocolPriority.join('>')].join(':');
}
export class RangeMileageSnapshotCache {
private readonly entries = new Map<string, RangeSnapshotEntry>();
private readonly inflight = new Map<string, Promise<RangeMileageResult>>();
private readonly ttlMs: number;
private readonly maxEntries: number;
private readonly now: () => number;
constructor(options: RangeSnapshotCacheOptions = {}) {
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
this.now = options.now ?? Date.now;
}
async load(
input: RangeSnapshotInput,
loader: () => Promise<RangeMileageResult>,
): Promise<RangeSnapshotLoadResult> {
const key = snapshotKey(input);
if (!input.force) {
const entry = this.entries.get(key);
if (entry && entry.expiresAt > this.now()) {
this.entries.delete(key);
this.entries.set(key, entry);
return { result: entry.result, status: 'range-hit' };
}
}
const existing = this.inflight.get(key);
if (existing) {
return { result: await existing, status: 'range-inflight' };
}
const pending = loader()
.then(result => {
this.entries.delete(key);
this.entries.set(key, {
result,
expiresAt: this.now() + this.ttlMs,
});
while (this.entries.size > this.maxEntries) {
const oldest = this.entries.keys().next().value as string | undefined;
if (!oldest) break;
this.entries.delete(oldest);
}
return result;
})
.finally(() => this.inflight.delete(key));
this.inflight.set(key, pending);
return {
result: await pending,
status: input.force ? 'range-refresh' : 'range-miss',
};
}
clear(): void {
this.entries.clear();
this.inflight.clear();
}
}
export const rangeMileageSnapshotCache = new RangeMileageSnapshotCache();
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { targetVehicleMileageFromMonitoringCache } from './target-vehicle-cache.js';
import type { CachedVehicle, MonitoringCache } from './types.js';
function vehicle(plate: string, overrides: Partial<CachedVehicle> = {}): CachedVehicle {
return {
plate,
vin: '',
dailyKm: 12.3,
totalKm: 456.7,
source: 'ONEOS_API',
sourceProtocol: 'GB32960',
sourceCategory: 'INSTRUMENT',
dataTime: '2026-08-07T10:00:00+08:00',
calculatedAt: null,
updatedAt: null,
isOnline: true,
isDataSynced: true,
customer: null,
department: null,
manager: null,
managerId: null,
rentStatus: null,
entity: null,
project: null,
region: null,
brand: null,
targetNames: [],
yesterdayKm: 0,
...overrides,
};
}
function cache(vehicles: CachedVehicle[]): MonitoringCache {
return {
vehicles,
stats: { totalToday: 0, totalAll: 0, vehicleCount: vehicles.length },
filters: {
departments: [], customers: [], plates: [], projects: [], entities: [],
rentStatuses: [], platePrefixes: [], targetNames: [], regions: [], brands: [],
},
targetPlatesMap: new Map(),
updatedAt: '2026-08-07T10:00:00+08:00',
};
}
test('reuses only the current-day monitoring snapshot and filters target plates', () => {
const monitoringCache = cache([
vehicle('粤A1'),
vehicle('粤A2', { dailyKm: -1, totalKm: null, isOnline: false }),
]);
const result = targetVehicleMileageFromMonitoringCache(
monitoringCache,
'2026-08-07',
'2026-08-07',
['粤A2'],
);
assert.deepEqual(Array.from(result || []), [['粤A2', {
dailyKm: 0,
totalKm: null,
isOnline: false,
}]]);
assert.equal(
targetVehicleMileageFromMonitoringCache(monitoringCache, '2026-08-06', '2026-08-07', ['粤A2']),
null,
);
assert.equal(targetVehicleMileageFromMonitoringCache(null, '2026-08-07', '2026-08-07', ['粤A2']), null);
});
@@ -0,0 +1,27 @@
import type { MonitoringCache } from './types.js';
export interface TargetVehicleMileageSnapshot {
dailyKm: number;
totalKm: number | null;
isOnline: boolean;
}
export function targetVehicleMileageFromMonitoringCache(
cache: MonitoringCache | null,
requestedDate: string,
today: string,
plates: string[],
): Map<string, TargetVehicleMileageSnapshot> | null {
if (!cache || !requestedDate || requestedDate !== today) return null;
const selected = new Set(plates);
return new Map(
cache.vehicles
.filter(vehicle => selected.has(vehicle.plate))
.map(vehicle => [vehicle.plate, {
dailyKm: Math.max(0, Number(vehicle.dailyKm) || 0),
totalKm: vehicle.totalKm == null ? null : Math.max(0, Number(vehicle.totalKm) || 0),
isOnline: vehicle.isOnline,
}]),
);
}
+20 -6
View File
@@ -4,6 +4,8 @@ import { getCache } from './cache.js';
import { fetchOneOsDailyMileage } from './oneos-api.js';
import { fetchVehicleInfoByPlates } from './vehicle-info.js';
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
import { shanghaiDate } from './monitoring-cache-policy.js';
import { targetVehicleMileageFromMonitoringCache } from './target-vehicle-cache.js';
const app = new Hono();
@@ -24,7 +26,8 @@ app.get('/', async (c) => {
SUM(CASE WHEN current_year_completion_rate >= 0.5 THEN 1 ELSE 0 END) as half_qualified_count,
SUM(current_year_mileage_task) as current_year_target,
SUM(current_year_mileage) as current_year_completed,
MAX(current_year_assessment_end_date) as year_end_date
MAX(current_year_assessment_end_date) as year_end_date,
DATE_FORMAT(MAX(update_time), '%Y-%m-%dT%H:%i:%s+08:00') as snapshot_updated_at
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
GROUP BY target_id
`) as [any[], unknown];
@@ -197,7 +200,9 @@ app.get('/', async (c) => {
return {
id: t.id,
targetName: t.target_name,
vehicleCount: Number(s.total) || t.vehicle_count,
snapshotUpdatedAt: s.snapshot_updated_at || null,
declaredVehicleCount: Number(t.vehicle_count) || 0,
vehicleCount: s.total == null ? 0 : Number(s.total) || 0,
totalMileagePerVehicle: Number(t.total_mileage_per_vehicle),
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
assessmentYears: t.assessment_years,
@@ -252,11 +257,20 @@ app.get('/:id/vehicles', async (c) => {
) as [any[], unknown];
const plates: string[] = rows.map((r: any) => r.plate_number);
const infoMap = await fetchVehicleInfoByPlates(plates);
const cachedDateMileageMap = targetVehicleMileageFromMonitoringCache(
getCache(),
date,
shanghaiDate(),
plates,
);
const [infoMap, mileageRows] = await Promise.all([
fetchVehicleInfoByPlates(plates),
date && !cachedDateMileageMap ? fetchOneOsDailyMileage(date, plates) : Promise.resolve([]),
]);
const dateMileageMap = new Map<string, { dailyKm: number; totalKm: number | null; isOnline: boolean }>();
if (date && plates.length > 0) {
const mileageRows = await fetchOneOsDailyMileage(date, plates);
const dateMileageMap = cachedDateMileageMap
|| new Map<string, { dailyKm: number; totalKm: number | null; isOnline: boolean }>();
if (!cachedDateMileageMap && date && plates.length > 0) {
for (const m of mileageRows) {
const existing = dateMileageMap.get(m.plateNumber);
const dailyKm = m.status === 'NORMAL' ? (Number(m.dailyMileageKm) || 0) : 0;
+5 -1
View File
@@ -98,7 +98,11 @@ app.get('/:plate/recent', async (c) => {
});
} catch (e: unknown) {
console.error('vehicle recent error:', e);
return c.json({ plate, days: [] }, 500);
return c.json({
error: '车辆近期里程服务暂时不可用',
code: 'MILEAGE_RECENT_UNAVAILABLE',
retryable: true,
}, 503);
}
});
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
clearDataSourceTelemetry,
getDataSourceTelemetry,
observeDataSource,
recordDataSourceOutcome,
} from './source-telemetry.js';
test('reports unobserved sources without treating them as failures', () => {
clearDataSourceTelemetry();
const report = getDataSourceTelemetry(new Date('2026-08-07T08:00:00Z'));
assert.ok(report.sources.every(source => source.state === 'unobserved'));
assert.ok(report.sources.every(source => source.failureRate === null));
});
test('publishes rolling outcomes and last success/failure timestamps', () => {
clearDataSourceTelemetry();
recordDataSourceOutcome('oneOsMileage', true, 1_000);
recordDataSourceOutcome('oneOsMileage', false, 2_000);
const source = getDataSourceTelemetry().sources.find(item => item.source === 'oneOsMileage');
assert.deepEqual(source && {
state: source.state,
attempts: source.attempts,
failures: source.failures,
failureRate: source.failureRate,
lastSuccessAt: source.lastSuccessAt,
lastFailureAt: source.lastFailureAt,
}, {
state: 'failing',
attempts: 2,
failures: 1,
failureRate: 0.5,
lastSuccessAt: '1970-01-01T00:00:01.000Z',
lastFailureAt: '1970-01-01T00:00:02.000Z',
});
});
test('observes final operation outcomes without retaining error details', async () => {
clearDataSourceTelemetry();
await assert.rejects(observeDataSource('hydrogenDatabase', async () => {
throw new Error('sensitive connection detail');
}));
const serialized = JSON.stringify(getDataSourceTelemetry());
assert.equal(serialized.includes('sensitive connection detail'), false);
});
test('keeps only the latest 100 outcomes', () => {
clearDataSourceTelemetry();
recordDataSourceOutcome('etcDatabase', false, 1);
for (let index = 0; index < 100; index += 1) {
recordDataSourceOutcome('etcDatabase', true, index + 2);
}
const source = getDataSourceTelemetry().sources.find(item => item.source === 'etcDatabase');
assert.equal(source?.attempts, 100);
assert.equal(source?.failures, 0);
assert.equal(source?.failureRate, 0);
});
+78
View File
@@ -0,0 +1,78 @@
export const DATA_SOURCE_IDS = [
'oneOsMileage',
'hydrogenDatabase',
'electricDatabase',
'etcDatabase',
] as const;
export type DataSourceId = typeof DATA_SOURCE_IDS[number];
interface Outcome {
success: boolean;
at: number;
}
const MAX_OUTCOMES = 100;
const outcomes = new Map<DataSourceId, Outcome[]>();
const startedAt = new Date().toISOString();
function lastMatching(entries: Outcome[], success: boolean): Outcome | undefined {
for (let index = entries.length - 1; index >= 0; index -= 1) {
if (entries[index].success === success) return entries[index];
}
return undefined;
}
export function recordDataSourceOutcome(
source: DataSourceId,
success: boolean,
at = Date.now(),
): void {
const entries = outcomes.get(source) || [];
entries.push({ success, at });
if (entries.length > MAX_OUTCOMES) entries.splice(0, entries.length - MAX_OUTCOMES);
outcomes.set(source, entries);
}
export async function observeDataSource<T>(
source: DataSourceId,
operation: () => Promise<T>,
): Promise<T> {
try {
const result = await operation();
recordDataSourceOutcome(source, true);
return result;
} catch (error) {
recordDataSourceOutcome(source, false);
throw error;
}
}
export function getDataSourceTelemetry(now = new Date()) {
return {
semantics: 'observed-requests' as const,
windowSize: MAX_OUTCOMES,
processStartedAt: startedAt,
checkedAt: now.toISOString(),
sources: DATA_SOURCE_IDS.map(source => {
const entries = outcomes.get(source) || [];
const failures = entries.filter(entry => !entry.success).length;
const last = entries.at(-1);
const lastSuccess = lastMatching(entries, true);
const lastFailure = lastMatching(entries, false);
return {
source,
state: !last ? 'unobserved' as const : last.success ? 'available' as const : 'failing' as const,
attempts: entries.length,
failures,
failureRate: entries.length > 0 ? Math.round(failures / entries.length * 10_000) / 10_000 : null,
lastSuccessAt: lastSuccess ? new Date(lastSuccess.at).toISOString() : null,
lastFailureAt: lastFailure ? new Date(lastFailure.at).toISOString() : null,
};
}),
};
}
export function clearDataSourceTelemetry(): void {
outcomes.clear();
}
+71
View File
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
METRIC_CATALOG,
isMetricDomain,
listMetricDefinitions,
} from './catalog.js';
test('metric ids are unique and namespaced by domain', () => {
const ids = METRIC_CATALOG.map(metric => metric.id);
assert.equal(new Set(ids).size, ids.length);
assert.ok(METRIC_CATALOG.every(metric => metric.id.startsWith(`${metric.domain}.`)));
});
test('lists mileage metrics without exposing the catalog array', () => {
const metrics = listMetricDefinitions('mileage');
assert.ok(metrics.length >= 5);
assert.notEqual(metrics, METRIC_CATALOG);
assert.ok(metrics.every(metric => metric.domain === 'mileage'));
assert.equal(
metrics.find(metric => metric.id === 'mileage.cumulative_odometer_sum')?.timeSemantics,
'snapshot',
);
assert.equal(
metrics.find(metric => metric.id === 'mileage.average_per_vehicle')?.formula,
'mileage.period_total / NULLIF(mileage.vehicle_count, 0)',
);
});
test('publishes hydrogen cost, revenue, and gross profit as separate metrics', () => {
const metrics = listMetricDefinitions('hydrogen');
const byId = new Map(metrics.map(metric => [metric.id, metric]));
assert.ok(metrics.length >= 8);
assert.equal(byId.get('hydrogen.refuel_cost')?.formula, "SUM(cost_total) WHERE del_flag = '0'");
assert.equal(byId.get('hydrogen.customer_revenue')?.formula, "SUM(fee_total) WHERE del_flag = '0'");
assert.equal(byId.get('hydrogen.customer_order_gross_profit')?.aggregation, 'derived');
});
test('publishes electric fee components, blended cost, and freshness semantics', () => {
const metrics = listMetricDefinitions('electric');
const byId = new Map(metrics.map(metric => [metric.id, metric]));
assert.ok(metrics.length >= 7);
assert.equal(byId.get('electric.charge_total_fee')?.formula, 'SUM(fee)');
assert.equal(byId.get('electric.electricity_fee')?.formula, 'SUM(e_fee)');
assert.equal(byId.get('electric.service_fee')?.formula, 'SUM(service_fee)');
assert.equal(byId.get('electric.blended_cost_intensity')?.aggregation, 'weighted-ratio');
assert.equal(byId.get('electric.data_freshness')?.timeSemantics, 'freshness');
});
test('publishes ETC passage and billing metrics as separate grains', () => {
const metrics = listMetricDefinitions('etc');
const byId = new Map(metrics.map(metric => [metric.id, metric]));
assert.ok(metrics.length >= 9);
assert.equal(byId.get('etc.total_amount')?.sources[0], 'etc_toll_record');
assert.equal(byId.get('etc.bill_receivable')?.sources[0], 'energy_etc_bill');
assert.equal(byId.get('etc.company_borne_amount')?.formula.includes('cost_type = 2'), true);
assert.equal(byId.get('etc.customer_borne_amount')?.formula.includes('cost_type = 1'), true);
});
test('validates only published metric domains', () => {
assert.equal(isMetricDomain('mileage'), true);
assert.equal(isMetricDomain('hydrogen'), true);
assert.equal(isMetricDomain('electric'), true);
assert.equal(isMetricDomain('etc'), true);
assert.equal(isMetricDomain('unknown'), false);
});

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