Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1674f8b931 | ||
|
|
559094cdc5 | ||
|
|
c0d504cb7b | ||
|
|
d958f3b28f | ||
|
|
f8e926876c | ||
|
|
6bf88a25e3 | ||
|
|
017001e889 | ||
|
|
b11187aba7 | ||
|
|
a344497505 | ||
|
|
d1ed3903fe | ||
|
|
bca0f7fe82 | ||
|
|
8a7ce7eccf | ||
|
|
2de417e109 | ||
|
|
67a269651e | ||
|
|
52d79ba260 | ||
|
|
39e869ad39 | ||
|
|
70498fc4fa | ||
|
|
18287a2c70 | ||
|
|
d786b984bb | ||
|
|
5ea788e565 | ||
|
|
003a0b580c | ||
|
|
64571d58c3 | ||
|
|
11b1afd68c | ||
|
|
0adc2c80f7 | ||
|
|
86a23ad343 | ||
|
|
ea9c9212e2 | ||
|
|
3d57787e61 | ||
|
|
55297a6240 | ||
|
|
671a7a8224 | ||
|
|
a8f7548314 | ||
|
|
263d8c6245 | ||
|
|
47a4474608 | ||
|
|
52c785f6ec | ||
|
|
02d714175f | ||
|
|
686fc4e2e2 | ||
|
|
8c6a3ad831 | ||
|
|
de2a0896c4 | ||
|
|
60e81cb4ba | ||
|
|
616341adc8 | ||
|
|
c2d42efa6d | ||
|
|
326201abab | ||
|
|
0d849fcbb1 | ||
|
|
21f5e5388a | ||
|
|
08097f7c29 | ||
|
|
46dbe725de | ||
|
|
0fb62135f3 | ||
|
|
cdf1fd27e7 | ||
|
|
cf9782562b | ||
|
|
757ed1ebd6 | ||
|
|
71d0091457 | ||
|
|
d53257c65c | ||
|
|
b2044109e3 | ||
|
|
91031bd034 | ||
|
|
f24a18e459 | ||
|
|
ce50265771 | ||
|
|
13e720c2c3 |
@@ -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
@@ -5,35 +5,40 @@ services:
|
|||||||
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
|
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
|
||||||
network_mode: host
|
network_mode: host
|
||||||
environment:
|
environment:
|
||||||
DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com"
|
DB_HOST: "${DB_HOST:?Set DB_HOST in the deployment environment}"
|
||||||
DB_PORT: "3306"
|
DB_PORT: "${DB_PORT:-3306}"
|
||||||
DB_USER: "oneos_db_prod"
|
DB_USER: "${DB_USER:?Set DB_USER in the deployment environment}"
|
||||||
DB_PASSWORD: "adASHJcviqwjkbn23ngt1"
|
DB_PASSWORD: "${DB_PASSWORD:?Set DB_PASSWORD in the deployment environment}"
|
||||||
DB_NAME: "ln_asset_management"
|
DB_NAME: "${DB_NAME:?Set DB_NAME in the deployment environment}"
|
||||||
HYDROGEN_DB_HOST: "47.99.185.173"
|
HYDROGEN_DB_HOST: "${HYDROGEN_DB_HOST:?Set HYDROGEN_DB_HOST in the deployment environment}"
|
||||||
HYDROGEN_DB_PORT: "3306"
|
HYDROGEN_DB_PORT: "${HYDROGEN_DB_PORT:-3306}"
|
||||||
HYDROGEN_DB_USER: "root"
|
HYDROGEN_DB_USER: "${HYDROGEN_DB_USER:?Set HYDROGEN_DB_USER in the deployment environment}"
|
||||||
HYDROGEN_DB_PASSWORD: "lnMysql."
|
HYDROGEN_DB_PASSWORD: "${HYDROGEN_DB_PASSWORD:?Set HYDROGEN_DB_PASSWORD in the deployment environment}"
|
||||||
HYDROGEN_DB_NAME: "ln_asset_management"
|
HYDROGEN_DB_NAME: "${HYDROGEN_DB_NAME:?Set HYDROGEN_DB_NAME in the deployment environment}"
|
||||||
MILEAGE_DB_HOST: "101.133.130.65"
|
MILEAGE_DB_HOST: "${MILEAGE_DB_HOST:?Set MILEAGE_DB_HOST in the deployment environment}"
|
||||||
MILEAGE_DB_PORT: "3306"
|
MILEAGE_DB_PORT: "${MILEAGE_DB_PORT:-3306}"
|
||||||
MILEAGE_DB_USER: "bi_reader_02"
|
MILEAGE_DB_USER: "${MILEAGE_DB_USER:?Set MILEAGE_DB_USER in the deployment environment}"
|
||||||
MILEAGE_DB_PASSWORD: "bi_reader_02_Pass"
|
MILEAGE_DB_PASSWORD: "${MILEAGE_DB_PASSWORD:?Set MILEAGE_DB_PASSWORD in the deployment environment}"
|
||||||
MILEAGE_DB_NAME: "hydrogen_energy"
|
MILEAGE_DB_NAME: "${MILEAGE_DB_NAME:?Set MILEAGE_DB_NAME in the deployment environment}"
|
||||||
# ECS production accesses OneOS over the private network. Configure the key
|
|
||||||
# as a Portainer stack environment variable; never commit it to this file.
|
|
||||||
ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}"
|
ONEOS_MILEAGE_API_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}"
|
ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}"
|
||||||
SERVER_PORT: "8111"
|
SERVER_PORT: "${SERVER_PORT:-8111}"
|
||||||
EXTERNAL_API_BASE: "https://lnh2e.com"
|
EXTERNAL_API_BASE: "${EXTERNAL_API_BASE:-https://lnh2e.com}"
|
||||||
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
|
JWT_SECRET: "${JWT_SECRET:?Set JWT_SECRET in the deployment environment}"
|
||||||
AMAP_WEB_KEY: "${AMAP_WEB_KEY}"
|
AMAP_WEB_KEY: "${AMAP_WEB_KEY:?Set AMAP_WEB_KEY in the deployment environment}"
|
||||||
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}"
|
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE:?Set AMAP_SECURITY_JS_CODE in the deployment environment}"
|
||||||
HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}"
|
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_PORT: "${HEATMAP_DB_PORT:-36453}"
|
||||||
HEATMAP_DB_USER: "${HEATMAP_DB_USER}"
|
HEATMAP_DB_USER: "${HEATMAP_DB_USER:?Set HEATMAP_DB_USER in the deployment environment}"
|
||||||
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}"
|
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_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}"
|
||||||
HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}"
|
HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}"
|
||||||
deploy:
|
deploy:
|
||||||
|
|||||||
@@ -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 供应商未配置、未执行首次同步;记录与账单下钻代码已完成,但真实数据仍不可验收。
|
||||||
|
- 氢能连接信息已从当前版本迁出,但历史凭据仍必须在数据库侧轮换;文档不记录任何连接值。
|
||||||
@@ -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
@@ -1,13 +1,34 @@
|
|||||||
/** 全局认证 fetch 客户端 */
|
/** 全局认证 fetch 客户端 */
|
||||||
|
|
||||||
let tokenGetter: () => string | null = () => null;
|
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) {
|
export function setTokenGetter(fn: () => string | null) {
|
||||||
tokenGetter = fn;
|
tokenGetter = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
async function executeJsonRequest<T>(url: string, options: RequestInit | undefined, token: string | null): Promise<T> {
|
||||||
const token = tokenGetter();
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -19,6 +40,44 @@ export async function fetchJson<T>(url: string, options?: RequestInit): Promise<
|
|||||||
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
|
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
|
||||||
throw new Error('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();
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, type ComponentType, type ReactNode } from 'react';
|
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 { AnimatePresence, motion } from 'motion/react';
|
||||||
import { cn } from '../../lib/cn';
|
import { cn } from '../../lib/cn';
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ export function PageFrame({
|
|||||||
</div>
|
</div>
|
||||||
<h1 className="mt-0.5 truncate text-base font-black tracking-tight text-slate-950 md:text-lg">{title}</h1>
|
<h1 className="mt-0.5 truncate text-base font-black tracking-tight text-slate-950 md:text-lg">{title}</h1>
|
||||||
</div>
|
</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 ? (
|
{subtitle ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -190,23 +190,51 @@ export function SegmentedNav<T extends string>({
|
|||||||
tabs,
|
tabs,
|
||||||
active,
|
active,
|
||||||
onChange,
|
onChange,
|
||||||
|
idPrefix,
|
||||||
|
ariaLabel,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
tabs: readonly { id: T; label: string; icon?: SurfaceIcon }[];
|
tabs: readonly { id: T; label: string; icon?: SurfaceIcon }[];
|
||||||
active: T;
|
active: T;
|
||||||
onChange: (id: T) => void;
|
onChange: (id: T) => void;
|
||||||
|
idPrefix: string;
|
||||||
|
ariaLabel: string;
|
||||||
className?: 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 (
|
return (
|
||||||
<div className={cn('rounded-2xl border border-white/70 bg-white/90 p-1 shadow-sm backdrop-blur-xl', className)}>
|
<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))` }}>
|
<div
|
||||||
{tabs.map(({ id, label, icon: Icon }) => {
|
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;
|
const isActive = active === id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
|
id={`${idPrefix}-tab-${id}`}
|
||||||
type="button"
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
aria-controls={`${idPrefix}-panel-${id}`}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
onClick={() => onChange(id)}
|
onClick={() => onChange(id)}
|
||||||
|
onKeyDown={event => activateFromKeyboard(event, index)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex min-h-10 items-center justify-center gap-1.5 rounded-xl px-2 text-[12px] font-black transition-colors',
|
'relative flex min-h-10 items-center justify-center gap-1.5 rounded-xl px-2 text-[12px] font-black transition-colors',
|
||||||
isActive ? 'text-blue-700' : 'text-slate-400 hover:bg-slate-50 hover:text-slate-600',
|
isActive ? '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)} />;
|
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 (
|
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} />
|
<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 className="text-xs font-black text-slate-500">{label}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,12 +284,17 @@ export function LoadingState({ label = '数据加载中' }: { label?: string })
|
|||||||
export function EmptyState({
|
export function EmptyState({
|
||||||
title = '暂无数据',
|
title = '暂无数据',
|
||||||
description = '换个筛选条件或稍后再试',
|
description = '换个筛选条件或稍后再试',
|
||||||
|
variant = 'card',
|
||||||
}: {
|
}: {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
variant?: StateVariant;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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} />
|
<SearchX className="mx-auto mb-3 text-slate-300" size={26} />
|
||||||
<div className="text-sm font-black text-slate-700">{title}</div>
|
<div className="text-sm font-black text-slate-700">{title}</div>
|
||||||
<div className="mt-1 text-xs font-bold text-slate-400">{description}</div>
|
<div 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 (
|
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">
|
<div className="flex items-start gap-2">
|
||||||
<AlertCircle size={18} className="mt-0.5 shrink-0" />
|
<AlertCircle size={18} className="mt-0.5 shrink-0" />
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-sm font-black">加载失败</div>
|
<div className="text-sm font-black">{title}</div>
|
||||||
<div className="mt-1 text-xs font-bold leading-relaxed">{message}</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+173
-68
@@ -1,79 +1,184 @@
|
|||||||
import { motion } from 'motion/react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Construction, Hammer } from 'lucide-react';
|
import type { ReactNode } from 'react';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
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 = [
|
function fmtMoney(value: number): string {
|
||||||
'ETC 通行费数据正在与发卡方系统打通…',
|
return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
||||||
'工人 GG 正在搭脚手架,敬请期待 ~',
|
}
|
||||||
'马上能看到每月通行费明细啦',
|
|
||||||
'想看哪个维度的 ETC?反馈一下嘛',
|
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() {
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-3">
|
||||||
<motion.div
|
<SurfaceCard className="flex items-center gap-3 p-3">
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<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'}`}>
|
||||||
animate={{ opacity: 1, y: 0 }}
|
{integration.providerConfigured ? <CircleCheck size={18} /> : <CircleX size={18} />}
|
||||||
transition={{ duration: 0.3 }}
|
</span>
|
||||||
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
|
<div className="min-w-0 flex-1">
|
||||||
>
|
<div className="text-xs font-black text-slate-800">
|
||||||
<div className="relative w-20 h-20 mb-4">
|
{integration.providerConfigured ? 'ETC 数据提供方已配置' : 'ETC 数据提供方尚未配置'}
|
||||||
<motion.div
|
</div>
|
||||||
animate={{ rotate: [0, -8, 8, -4, 4, 0] }}
|
<div className="mt-1 truncate text-[10px] font-bold text-slate-400">
|
||||||
transition={{ duration: 2.4, repeat: Infinity, ease: 'easeInOut' }}
|
{integration.providerConfigured
|
||||||
className="absolute inset-0 rounded-3xl bg-gradient-to-br from-amber-50 to-orange-50 flex items-center justify-center"
|
? `${integration.providerType || '已配置提供方'} · ${syncStatusLabel(integration.lastSyncStatus)}`
|
||||||
>
|
: '数据库结构已准备,等待配置同步账户并完成首次同步'}
|
||||||
<Construction size={36} className="text-amber-500" strokeWidth={2.2} />
|
</div>
|
||||||
</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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void load(true)}
|
||||||
|
disabled={refreshing}
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 hover:text-blue-600 disabled:opacity-50"
|
||||||
|
aria-label="刷新 ETC 状态"
|
||||||
|
title="刷新 ETC 状态"
|
||||||
|
>
|
||||||
|
<RefreshCw size={15} className={refreshing ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</SurfaceCard>
|
||||||
|
|
||||||
<div className="text-base font-black text-slate-800 mb-1.5">ETC 模块建设中</div>
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<div className="text-[12px] text-slate-500 font-bold leading-relaxed max-w-[280px]">
|
<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)}`} />
|
||||||
<br />
|
</DrilldownMetricButton>
|
||||||
这些数据都在路上啦
|
<DrilldownMetricButton label={`查看 ETC 通行记录,共 ${kpi.passageCount} 次`} title="查看通行记录" onClick={() => openDetailView('records')}>
|
||||||
</div>
|
<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">
|
||||||
<div className="mt-6 w-full max-w-xs space-y-2">
|
<SurfaceCard className="p-3">
|
||||||
{[
|
<div className="text-[10px] font-black text-slate-400">提供方配置</div>
|
||||||
{ label: '需求评审', done: true },
|
<div className={`mt-1 text-sm font-black ${integration.providerConfigured ? 'text-emerald-600' : 'text-amber-600'}`}>
|
||||||
{ label: '数据对接', done: true },
|
{integration.providerConfigured ? '已配置' : '未配置'}
|
||||||
{ label: '页面开发', done: false, current: true },
|
</div>
|
||||||
{ label: '正式上线', done: false },
|
</SurfaceCard>
|
||||||
].map((m, i) => (
|
<SurfaceCard className="p-3">
|
||||||
<motion.div
|
<div className="text-[10px] font-black text-slate-400">自动同步</div>
|
||||||
key={i}
|
<div className={`mt-1 text-sm font-black ${integration.syncEnabled ? 'text-emerald-600' : 'text-slate-500'}`}>
|
||||||
initial={{ opacity: 0, x: -8 }}
|
{integration.syncEnabled ? '已启用' : '未启用'}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
</div>
|
||||||
transition={{ delay: 0.1 + i * 0.08, duration: 0.3 }}
|
</SurfaceCard>
|
||||||
className="flex items-center gap-2.5 text-[11px]"
|
<SurfaceCard className="p-3">
|
||||||
>
|
<div className="flex items-center gap-1 text-[10px] font-black text-slate-400"><Clock3 size={11} />最近同步</div>
|
||||||
<span className={`w-3 h-3 rounded-full flex-shrink-0 ${
|
<div className="mt-1 text-sm font-black text-slate-700">{syncStatusLabel(integration.lastSyncStatus)}</div>
|
||||||
m.done ? 'bg-emerald-400'
|
<div className="mt-1 text-[10px] font-bold text-slate-400">{integration.lastSyncAt || '无同步记录'}</div>
|
||||||
: m.current ? 'bg-amber-400 ring-4 ring-amber-100 animate-pulse'
|
</SurfaceCard>
|
||||||
: 'bg-slate-200'
|
</div>
|
||||||
}`} />
|
|
||||||
<span className={`font-bold ${m.done ? 'text-slate-500' : m.current ? 'text-amber-600' : 'text-slate-300'}`}>
|
|
||||||
{m.label}
|
|
||||||
</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>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<RotatingFooterHint hints={ETC_HINTS} />
|
<ETCDetails context={detailContext} overviewKpi={kpi} onContextChange={commitDetailContext} />
|
||||||
|
|
||||||
|
{error && <ErrorState message={error} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,70 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Truck, Wallet } from 'lucide-react';
|
import { BatteryCharging, CalendarDays, CheckCircle2, ChevronRight, MapPin, Plug, TrendingUp, TriangleAlert, Truck, Wallet, X } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import TrendBadge from './TrendBadge';
|
import TrendBadge from './TrendBadge';
|
||||||
import { fetchElectricMonthly } from './api';
|
import { fetchElectricMonthly, fetchElectricOrders } from './api';
|
||||||
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
|
import type { DateQuickPick, ElectricChargeOrderResponse, ElectricMonthGroup, ElectricVehicleScope } from './types';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
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 }> = [
|
const VEHICLE_SCOPE_OPTIONS: readonly AnalysisScopeOption<ElectricVehicleScope>[] = [
|
||||||
{ id: 'thisWeek', label: '本周' },
|
{ id: 'all', label: '全部车辆' },
|
||||||
{ id: 'thisMonth', label: '本月' },
|
{ id: 'lingniu', label: '羚牛车辆' },
|
||||||
{ id: 'last15', label: '近 15 天' },
|
{ 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() {
|
export default function ElectricDaily() {
|
||||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
const [drillContext, setDrillContext] = useState<ElectricDrillContext>(() => (
|
||||||
const [pick, setPick] = useState<RangeMode>('last15');
|
parseElectricDrillContext(window.location.search)
|
||||||
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
|
));
|
||||||
|
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 [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
|
||||||
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
|
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -58,16 +72,32 @@ export default function ElectricDaily() {
|
|||||||
const query = pick === 'custom'
|
const query = pick === 'custom'
|
||||||
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
||||||
: { range: pick };
|
: { range: pick };
|
||||||
fetchElectricMonthly(customer, query)
|
fetchElectricMonthly(vehicleScope, query)
|
||||||
.then(m => {
|
.then(m => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setMonths(m);
|
setMonths(m);
|
||||||
// 默认展开最新一个月
|
if (m.length > 0) {
|
||||||
if (m.length > 0) setOpenMonths(new Set([m[0].month]));
|
setOpenMonths(new Set([selectedDateRef.current?.slice(0, 7) || m[0].month]));
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [customer, pick, effectiveRange.start, effectiveRange.end]);
|
}, [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 toggleMonth = (m: string) => setOpenMonths(prev => {
|
||||||
const next = new Set(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 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 avgKwh = activeDays > 0 ? totalKwh / activeDays : 0;
|
||||||
const avgPrice = totalKwh > 0 ? totalFee / totalKwh : 0;
|
const avgPrice = totalKwh > 0 ? totalFee / totalKwh : 0;
|
||||||
const scopeLabel = pick === 'custom'
|
const scopeLabel = dateRangeModeLabel(pick);
|
||||||
? '自定义区间'
|
|
||||||
: QUICK_PICK_OPTIONS.find(item => item.id === pick)?.label ?? '当前时段';
|
|
||||||
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
||||||
const hasFeeDetail = totalFee > 0;
|
const showExternalEmpty = vehicleScope === 'external' && months !== null && totalKwh === 0;
|
||||||
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
|
|
||||||
|
|
||||||
const applyQuickPick = (nextPick: DateQuickPick) => {
|
const applyQuickPick = (nextPick: DateQuickPick) => {
|
||||||
|
const nextRange = getQuickDateRange(nextPick);
|
||||||
setPick(nextPick);
|
setPick(nextPick);
|
||||||
setDateRange(getQuickRange(nextPick));
|
setDateRange(nextRange);
|
||||||
|
commitDrillContext({
|
||||||
|
vehicleScope,
|
||||||
|
startDate: nextRange.start,
|
||||||
|
endDate: nextRange.end,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateDateRange = (field: 'start' | 'end', value: string) => {
|
const updateDateRange = (field: 'start' | 'end', value: string) => {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
|
const nextRange = { ...dateRange, [field]: value };
|
||||||
|
const normalized = normalizeDateRange(nextRange.start, nextRange.end);
|
||||||
setPick('custom');
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<SurfaceCard className="p-2 md:p-3">
|
<SurfaceCard className="p-2 md:p-3">
|
||||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
<AnalysisDateFilters
|
||||||
{QUICK_PICK_OPTIONS.map(opt => (
|
idPrefix="electric"
|
||||||
<button
|
mode={pick}
|
||||||
key={opt.id}
|
startDate={dateRange.start}
|
||||||
onClick={() => applyQuickPick(opt.id)}
|
endDate={dateRange.end}
|
||||||
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
onQuickPick={applyQuickPick}
|
||||||
pick === opt.id
|
onCustom={() => setPick('custom')}
|
||||||
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
onDateChange={updateDateRange}
|
||||||
: '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">
|
<AnalysisScopeSwitch
|
||||||
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
ariaLabel="电能车辆范围"
|
||||||
<span className="block text-[10px] font-black text-slate-400">开始日期</span>
|
value={vehicleScope}
|
||||||
<input
|
options={VEHICLE_SCOPE_OPTIONS}
|
||||||
type="date"
|
onChange={updateVehicleScope}
|
||||||
value={dateRange.start}
|
icon={Truck}
|
||||||
onChange={e => updateDateRange('start', e.target.value)}
|
className="mt-2"
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
</SurfaceCard>
|
</SurfaceCard>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
@@ -172,9 +222,9 @@ export default function ElectricDaily() {
|
|||||||
<MetricTile
|
<MetricTile
|
||||||
icon={Wallet}
|
icon={Wallet}
|
||||||
label="充电费用"
|
label="充电费用"
|
||||||
value={hasFeeDetail ? `¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '待同步'}
|
value={`¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
|
||||||
helper={hasFeeDetail ? `均价 ${avgPrice.toFixed(2)} 元/度` : '当前明细仅返回充电量'}
|
helper={`综合费用强度 ${avgPrice.toFixed(2)} 元/度`}
|
||||||
tone={hasFeeDetail ? 'emerald' : 'slate'}
|
tone="emerald"
|
||||||
/>
|
/>
|
||||||
<MetricTile icon={CalendarDays} label="有效天数" value={`${activeDays}`} unit="天" helper={`日均 ${avgKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} tone="amber" />
|
<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'} />
|
<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>
|
<span className="text-right">环比</span>
|
||||||
</div>
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="p-3"><ErrorState message={error} /></div>
|
<ErrorState message={error} variant="inline" />
|
||||||
) : months === null ? (
|
) : months === null ? (
|
||||||
<div className="p-3"><LoadingState label="正在加载充电明细" /></div>
|
<LoadingState label="正在加载充电明细" variant="inline" />
|
||||||
) : months.length === 0 ? (
|
) : months.length === 0 ? (
|
||||||
<div className="p-3"><EmptyState title="暂无充电数据" description="请切换时间范围或车辆归属" /></div>
|
<EmptyState title="暂无充电数据" description="请切换时间范围或车辆归属" variant="inline" />
|
||||||
) : months.map(m => {
|
) : months.map(m => {
|
||||||
const open = openMonths.has(m.month);
|
const open = openMonths.has(m.month);
|
||||||
return (
|
return (
|
||||||
<div key={m.month} className="border-t border-slate-100 first:border-t-0">
|
<div key={m.month} className="border-t border-slate-100 first:border-t-0">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => toggleMonth(m.month)}
|
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 ${
|
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'
|
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">
|
<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`} />
|
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
|
||||||
@@ -237,6 +290,7 @@ export default function ElectricDaily() {
|
|||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{open && (
|
{open && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
id={`electric-month-${m.month}`}
|
||||||
initial={{ height: 0, opacity: 0 }}
|
initial={{ height: 0, opacity: 0 }}
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
exit={{ height: 0, opacity: 0 }}
|
exit={{ height: 0, opacity: 0 }}
|
||||||
@@ -248,16 +302,121 @@ export default function ElectricDaily() {
|
|||||||
const abnormalBg = isAbnormal
|
const abnormalBg = isAbnormal
|
||||||
? d.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
|
? d.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
|
||||||
: 'bg-slate-50/50';
|
: 'bg-slate-50/50';
|
||||||
|
const reconciliation = orders?.date === d.date
|
||||||
|
? reconcileElectricDaily(d, orders, vehicleScope)
|
||||||
|
: null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={d.date} className="border-t border-slate-100">
|
||||||
key={d.date}
|
<button
|
||||||
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}`}
|
id={`electric-date-trigger-${d.date}`}
|
||||||
>
|
type="button"
|
||||||
<span className="text-[12px] text-slate-600">{d.date.slice(5)}</span>
|
onClick={() => toggleDate(d.date)}
|
||||||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
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}`}
|
||||||
{d.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
aria-expanded={selectedDate === d.date}
|
||||||
</span>
|
aria-controls={`electric-orders-${d.date}`}
|
||||||
<span className="text-right"><TrendBadge value={d.chainPct} /></span>
|
>
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import ElectricView, { type ElectricSubTab } from './ElectricView';
|
|||||||
import SubTabs from './SubTabs';
|
import SubTabs from './SubTabs';
|
||||||
import { useHashSubTab } from './useHashSubTab';
|
import { useHashSubTab } from './useHashSubTab';
|
||||||
import { FadeIn, PageFrame } from '../../components/ui/surface';
|
import { FadeIn, PageFrame } from '../../components/ui/surface';
|
||||||
|
import BiHeaderActions from '../../components/BiHeaderActions';
|
||||||
|
|
||||||
const SUB_TABS = [
|
const SUB_TABS = [
|
||||||
{ id: 'daily', label: '每日', icon: CalendarDays },
|
{ id: 'daily', label: '每日', icon: CalendarDays },
|
||||||
@@ -21,11 +22,18 @@ export default function ElectricModule() {
|
|||||||
icon={CalendarDays}
|
icon={CalendarDays}
|
||||||
eyebrow="ELECTRIC BI"
|
eyebrow="ELECTRIC BI"
|
||||||
meta="时间单位清晰标注 · 支持日/总览切换"
|
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">
|
<AnimatePresence mode="wait">
|
||||||
<FadeIn key={sub}>
|
<FadeIn key={sub}>
|
||||||
<ElectricView sub={sub} />
|
<div
|
||||||
|
id={`electric-view-panel-${sub}`}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={`electric-view-tab-${sub}`}
|
||||||
|
>
|
||||||
|
<ElectricView sub={sub} />
|
||||||
|
</div>
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</PageFrame>
|
</PageFrame>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, Refere
|
|||||||
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
|
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||||
|
import { buildElectricDrillUrl } from './electric-drill-context';
|
||||||
|
import { activateDrillOnKeyDown } from './drill-interaction';
|
||||||
|
|
||||||
function fmtYuan(yuan: number) {
|
function fmtYuan(yuan: number) {
|
||||||
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
||||||
@@ -11,6 +13,11 @@ function fmtYuan(yuan: number) {
|
|||||||
function fmtKwh(kwh: number) {
|
function fmtKwh(kwh: number) {
|
||||||
return `${kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} 度`;
|
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() {
|
export default function ElectricOverview() {
|
||||||
const [data, setData] = useState<ElectricOverviewResponse | null>(null);
|
const [data, setData] = useState<ElectricOverviewResponse | null>(null);
|
||||||
@@ -34,33 +41,50 @@ export default function ElectricOverview() {
|
|||||||
const trendData = data.trend;
|
const trendData = data.trend;
|
||||||
// 当电能数据滞后(本月无数据走 fallback)时,柱图标题显示实际月份
|
// 当电能数据滞后(本月无数据走 fallback)时,柱图标题显示实际月份
|
||||||
const trendMonthLabel = trendData[0]?.date.slice(0, 7);
|
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
|
const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
|
||||||
? `${trendMonthLabel} 每日充电`
|
? `${trendMonthLabel} 每日充电`
|
||||||
: '本月每日充电';
|
: '本月每日充电';
|
||||||
const activeDays = trendData.filter(item => item.kwh > 0).length;
|
const activeDays = trendData.filter(item => item.kwh > 0).length;
|
||||||
const avgDailyKwh = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.kwh, 0) / activeDays : 0;
|
const trendKwh = trendData.reduce((sum, item) => sum + item.kwh, 0);
|
||||||
const avgDailyFee = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.fee, 0) / activeDays : 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 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 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 (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<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">
|
<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>
|
</div>
|
||||||
{/* 横向 mini KPI 头 */}
|
{/* 横向 mini KPI 头 */}
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
<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={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={CalendarClock} label={`${trendMonthText}充电费`} value={fmtYuan(trendFee)} helper={fmtKwh(trendKwh)} tone="emerald" />
|
||||||
<MetricTile icon={Gauge} label="累计均价" value={avgPrice.toFixed(2)} unit="元/度" helper={`本月 ${monthPrice.toFixed(2)} 元/度`} tone="amber" />
|
<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'} />
|
<MetricTile icon={BatteryCharging} label="今日充电" value={k.todayKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={`${fmtYuan(k.todayFee)} · ${k.todayChainPct >= 0 ? '+' : ''}${(k.todayChainPct * 100).toFixed(1)}%`} tone={Math.abs(k.todayChainPct) >= 0.3 ? 'rose' : 'slate'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||||
<SurfaceCard className="p-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-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>
|
<div className="mt-1 text-[11px] font-bold text-slate-500">日均 {avgDailyKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度</div>
|
||||||
</SurfaceCard>
|
</SurfaceCard>
|
||||||
@@ -71,12 +95,12 @@ export default function ElectricOverview() {
|
|||||||
</SurfaceCard>
|
</SurfaceCard>
|
||||||
<SurfaceCard className="p-3">
|
<SurfaceCard className="p-3">
|
||||||
<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-xl font-black text-emerald-600">{k.totalFee > 0 ? (k.monthFee / k.totalFee * 100).toFixed(1) : '0.0'}%</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">本月费用 / 累计费用</div>
|
<div className="mt-1 text-[11px] font-bold text-slate-500">{trendMonthText}费用 / 累计费用</div>
|
||||||
</SurfaceCard>
|
</SurfaceCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 本月每日充电柱图 */}
|
{/* 当前月无数据时展示最近一个有数据的自然月 */}
|
||||||
<SurfaceCard className="p-4">
|
<SurfaceCard className="p-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-sm font-bold text-slate-700">{chartTitle}</span>
|
<span className="text-sm font-bold text-slate-700">{chartTitle}</span>
|
||||||
@@ -109,8 +133,17 @@ export default function ElectricOverview() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Bar dataKey="fee" radius={[4, 4, 0, 0]}>
|
<Bar dataKey="fee" radius={[4, 4, 0, 0]}>
|
||||||
{trendData.map((_, i) => (
|
{trendData.map(item => (
|
||||||
<Cell key={i} fill="url(#electricBarGrad)" />
|
<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>
|
</Bar>
|
||||||
<defs>
|
<defs>
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { Receipt } from 'lucide-react';
|
import { Receipt } from 'lucide-react';
|
||||||
import ETCView from './ETCView';
|
import ETCView from './ETCView';
|
||||||
import { PageFrame } from '../../components/ui/surface';
|
import { PageFrame } from '../../components/ui/surface';
|
||||||
|
import BiHeaderActions from '../../components/BiHeaderActions';
|
||||||
|
|
||||||
export default function EtcModule() {
|
export default function EtcModule() {
|
||||||
return (
|
return (
|
||||||
<PageFrame
|
<PageFrame
|
||||||
title="ETC 通行费看板"
|
title="ETC 通行费看板"
|
||||||
subtitle="规划按车、按月、按线路拆分通行费,让车辆运营成本口径逐步完整。"
|
subtitle="区分通行记录与结算账单口径,展示通行费、服务费、车辆规模及数据同步状态。"
|
||||||
icon={Receipt}
|
icon={Receipt}
|
||||||
eyebrow="ETC BI"
|
eyebrow="ETC BI"
|
||||||
meta="数据对接中 · 页面能力预留"
|
meta="真实接入状态 · 指标口径已发布"
|
||||||
|
actions={<BiHeaderActions domain="etc" />}
|
||||||
>
|
>
|
||||||
<ETCView />
|
<ETCView />
|
||||||
</PageFrame>
|
</PageFrame>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-react';
|
import { Building2, ChevronRight, Fuel, Plug, ReceiptText, TrendingUp, Truck, UserRound, X } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
|
||||||
import TrendBadge from './TrendBadge';
|
import TrendBadge from './TrendBadge';
|
||||||
@@ -7,78 +7,97 @@ import { fetchHydrogenDaily } from './api';
|
|||||||
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
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 }> = [
|
const VEHICLE_SCOPE_OPTIONS: readonly AnalysisScopeOption<CustomerType>[] = [
|
||||||
{ id: 'thisWeek', label: '本周' },
|
{ id: 'lingniu', label: '羚牛车辆' },
|
||||||
{ id: 'thisMonth', label: '本月' },
|
{ id: 'external', label: '外部车辆' },
|
||||||
{ id: 'last15', label: '近 15 天' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
type RangeMode = DateQuickPick | 'custom';
|
const HYDROGEN_ORDERS_ID = 'hydrogen-orders';
|
||||||
|
|
||||||
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 HydrogenDaily() {
|
export default function HydrogenDaily() {
|
||||||
const [pick, setPick] = useState<RangeMode>('last15');
|
const [drillContext, setDrillContext] = useState<HydrogenDrillContext>(() => (
|
||||||
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
|
parseHydrogenDrillContext(window.location.search)
|
||||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
));
|
||||||
|
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 [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setError(null);
|
setError(null);
|
||||||
const query = pick === 'custom'
|
const query = pick === 'custom'
|
||||||
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
? { startDate: effectiveRange.start, endDate: effectiveRange.end, stationId: selectedStationId, customerName: selectedCustomerName }
|
||||||
: { range: pick };
|
: { range: pick, stationId: selectedStationId, customerName: selectedCustomerName };
|
||||||
fetchHydrogenDaily(query, customer)
|
fetchHydrogenDaily(query, vehicleScope)
|
||||||
.then(r => { if (!cancelled) setRows(r); })
|
.then(r => { if (!cancelled) setRows(r); })
|
||||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [pick, customer, effectiveRange.start, effectiveRange.end]);
|
}, [pick, vehicleScope, effectiveRange.start, effectiveRange.end, selectedStationId, selectedCustomerName, retryKey]);
|
||||||
|
|
||||||
// 柱图:按日期升序,用于"从左到右时间流"
|
// 柱图:按日期升序,用于"从左到右时间流"
|
||||||
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
|
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 totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0);
|
||||||
const activeDays = (rows ?? []).filter(r => r.totalKg > 0).length;
|
const activeDays = (rows ?? []).filter(r => r.totalKg > 0).length;
|
||||||
const stationCount = useMemo(() => {
|
const stationCount = useMemo(() => {
|
||||||
const names = new Set<string>();
|
const ids = new Set<number>();
|
||||||
(rows ?? []).forEach(r => r.stations.forEach(s => names.add(s.name)));
|
(rows ?? []).forEach(r => r.stations.forEach(s => ids.add(s.stationId)));
|
||||||
return names.size;
|
return ids.size;
|
||||||
}, [rows]);
|
}, [rows]);
|
||||||
const avgKg = activeDays > 0 ? totalKg / activeDays : 0;
|
const avgKg = activeDays > 0 ? totalKg / activeDays : 0;
|
||||||
const scopeLabel = pick === 'custom'
|
const scopeLabel = dateRangeModeLabel(pick);
|
||||||
? '自定义区间'
|
|
||||||
: QUICK_PICK_OPTIONS.find(item => item.id === pick)?.label ?? '当前时段';
|
|
||||||
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
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 peakDay = trendData.reduce<HydrogenDailyRow | null>((best, item) => (!best || item.totalKg > best.totalKg ? item : best), null);
|
||||||
const lowDay = trendData
|
const lowDay = trendData
|
||||||
.filter(item => item.totalKg > 0)
|
.filter(item => item.totalKg > 0)
|
||||||
@@ -92,93 +111,197 @@ export default function HydrogenDaily() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const applyQuickPick = (nextPick: DateQuickPick) => {
|
const applyQuickPick = (nextPick: DateQuickPick) => {
|
||||||
|
const nextRange = getQuickDateRange(nextPick);
|
||||||
setPick(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) => {
|
const updateDateRange = (field: 'start' | 'end', value: string) => {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
|
const nextRange = { ...dateRange, [field]: value };
|
||||||
setPick('custom');
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<SurfaceCard className="p-2 md:p-3">
|
<SurfaceCard className="p-2 md:p-3">
|
||||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
<AnalysisDateFilters
|
||||||
{QUICK_PICK_OPTIONS.map(opt => (
|
idPrefix="hydrogen"
|
||||||
<button
|
mode={pick}
|
||||||
key={opt.id}
|
startDate={dateRange.start}
|
||||||
onClick={() => applyQuickPick(opt.id)}
|
endDate={dateRange.end}
|
||||||
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
onQuickPick={applyQuickPick}
|
||||||
pick === opt.id
|
onCustom={() => setPick('custom')}
|
||||||
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
onDateChange={updateDateRange}
|
||||||
: '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">
|
<AnalysisScopeSwitch
|
||||||
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
ariaLabel="氢能车辆范围"
|
||||||
<span className="block text-[10px] font-black text-slate-400">开始日期</span>
|
value={vehicleScope}
|
||||||
<input
|
options={VEHICLE_SCOPE_OPTIONS}
|
||||||
type="date"
|
onChange={updateVehicleScope}
|
||||||
value={dateRange.start}
|
icon={Truck}
|
||||||
onChange={e => updateDateRange('start', e.target.value)}
|
className="mt-2"
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
</SurfaceCard>
|
</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={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={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" />
|
<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
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 8 }}
|
initial={{ opacity: 0, y: 8 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
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>
|
<SurfaceCard>
|
||||||
<div className="flex items-center justify-between px-4 pt-4 mb-2">
|
<div className="flex items-center justify-between px-4 pt-4 mb-2">
|
||||||
<span className="text-sm font-bold text-slate-700">每日加氢量</span>
|
<span className="text-sm font-bold text-slate-700">每日加氢量</span>
|
||||||
@@ -259,8 +382,23 @@ export default function HydrogenDaily() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
|
<Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
|
||||||
{trendData.map((_, i) => (
|
{trendData.map(item => (
|
||||||
<Cell key={i} fill="url(#hydrogenBarGrad)" />
|
<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>
|
</Bar>
|
||||||
<defs>
|
<defs>
|
||||||
@@ -275,30 +413,41 @@ export default function HydrogenDaily() {
|
|||||||
</SurfaceCard>
|
</SurfaceCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedDate && (
|
||||||
|
<HydrogenOrders
|
||||||
|
date={selectedDate}
|
||||||
|
dailyRow={selectedDailyRow}
|
||||||
|
page={drillContext.orderPage ?? 1}
|
||||||
|
vehicleScope={vehicleScope}
|
||||||
|
stationId={selectedStationId}
|
||||||
|
customerName={selectedCustomerName}
|
||||||
|
onPageChange={changeOrderPage}
|
||||||
|
onClose={closeOrders}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
|
{/* 表格(外部车辆 + 全 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="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>日期 / 加氢站</span>
|
||||||
<span className="hidden md:block text-right">单价 (元/Kg)</span>
|
<span className="hidden md:block text-right">单价 (元/Kg)</span>
|
||||||
<span className="text-right">加氢量 (Kg)</span>
|
<span className="text-right">加氢量 (Kg)</span>
|
||||||
<span className="text-right">环比</span>
|
<span className="text-right">环比</span>
|
||||||
</div>
|
</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>合计</span>
|
||||||
<span className="hidden md:block" />
|
<span className="hidden md:block" />
|
||||||
<span className="text-right">{totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</span>
|
<span className="text-right">{totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</span>
|
||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
{/* 主行 + 子行 */}
|
{/* 主行 + 子行 */}
|
||||||
{error ? (
|
{rows === null ? (
|
||||||
<div className="p-3"><ErrorState message={error} /></div>
|
<LoadingState label="正在加载加氢明细" variant="inline" />
|
||||||
) : rows === null ? (
|
|
||||||
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
|
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div>
|
<EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" variant="inline" />
|
||||||
) : rows.map(r => {
|
) : rows.map(r => {
|
||||||
const open = expanded.has(r.date);
|
const open = expanded.has(r.date);
|
||||||
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
|
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
|
||||||
@@ -307,20 +456,37 @@ export default function HydrogenDaily() {
|
|||||||
: '';
|
: '';
|
||||||
return (
|
return (
|
||||||
<div key={r.date} className={`border-t border-slate-100 ${abnormalBg}`}>
|
<div key={r.date} className={`border-t border-slate-100 ${abnormalBg}`}>
|
||||||
<button
|
<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' : ''}`}>
|
||||||
onClick={() => toggle(r.date)}
|
<span className="flex min-w-0 items-center gap-1">
|
||||||
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"
|
<button
|
||||||
>
|
type="button"
|
||||||
<span className="flex items-center gap-1 text-[12px] text-slate-700 font-bold">
|
onClick={() => toggle(r.date)}
|
||||||
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
|
className="flex min-w-0 items-center gap-1 text-[12px] font-bold text-slate-700"
|
||||||
{r.date}
|
aria-expanded={open}
|
||||||
|
aria-label={`${open ? '收起' : '展开'} ${r.date} 加氢站明细`}
|
||||||
|
>
|
||||||
|
<ChevronRight size={14} className={`shrink-0 transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
|
||||||
|
<span className="truncate">{r.date}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openOrders(r.date)}
|
||||||
|
disabled={r.totalKg <= 0}
|
||||||
|
className="ml-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-slate-400 hover:bg-blue-100 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-25"
|
||||||
|
aria-label={`查看 ${r.date} 加氢订单`}
|
||||||
|
aria-expanded={selectedDate === r.date}
|
||||||
|
aria-controls={HYDROGEN_ORDERS_ID}
|
||||||
|
title="查看当日订单"
|
||||||
|
>
|
||||||
|
<ReceiptText size={13} />
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
<span className="hidden md:block text-right text-[12px] text-slate-300">—</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">
|
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
||||||
{r.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
{r.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-right"><TrendBadge value={r.chainPct} /></span>
|
<span className="text-right"><TrendBadge value={r.chainPct} /></span>
|
||||||
</button>
|
</div>
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{open && (
|
{open && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -333,7 +499,7 @@ export default function HydrogenDaily() {
|
|||||||
{r.stations.map(s => (
|
{r.stations.map(s => (
|
||||||
<div
|
<div
|
||||||
key={s.name}
|
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="min-w-0">
|
||||||
<div className="text-[12px] text-slate-700 font-medium whitespace-nowrap leading-snug">
|
<div className="text-[12px] text-slate-700 font-medium whitespace-nowrap leading-snug">
|
||||||
@@ -362,7 +528,7 @@ export default function HydrogenDaily() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<RotatingFooterHint />
|
{!error && <RotatingFooterHint />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
|
|||||||
import SubTabs from './SubTabs';
|
import SubTabs from './SubTabs';
|
||||||
import { useHashSubTab } from './useHashSubTab';
|
import { useHashSubTab } from './useHashSubTab';
|
||||||
import { FadeIn, PageFrame } from '../../components/ui/surface';
|
import { FadeIn, PageFrame } from '../../components/ui/surface';
|
||||||
|
import BiHeaderActions from '../../components/BiHeaderActions';
|
||||||
|
|
||||||
const SUB_TABS = [
|
const SUB_TABS = [
|
||||||
{ id: 'daily', label: '每日', icon: CalendarDays },
|
{ id: 'daily', label: '每日', icon: CalendarDays },
|
||||||
@@ -21,11 +22,18 @@ export default function HydrogenModule() {
|
|||||||
icon={CalendarDays}
|
icon={CalendarDays}
|
||||||
eyebrow="ENERGY BI"
|
eyebrow="ENERGY BI"
|
||||||
meta="数据单位清晰标注 · 支持日/总览切换"
|
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">
|
<AnimatePresence mode="wait">
|
||||||
<FadeIn key={sub}>
|
<FadeIn key={sub}>
|
||||||
<HydrogenView sub={sub} />
|
<div
|
||||||
|
id={`hydrogen-view-panel-${sub}`}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={`hydrogen-view-tab-${sub}`}
|
||||||
|
>
|
||||||
|
<HydrogenView sub={sub} />
|
||||||
|
</div>
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</PageFrame>
|
</PageFrame>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
|
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
|
||||||
} from 'recharts';
|
} 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 { motion, AnimatePresence } from 'motion/react';
|
||||||
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
import { buildHydrogenDrillUrl } from './hydrogen-drill-context';
|
||||||
|
import { ErrorState } from '../../components/ui/surface';
|
||||||
|
|
||||||
const REGION_COLORS = [
|
const REGION_COLORS = [
|
||||||
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
||||||
@@ -119,7 +121,14 @@ export default function HydrogenOverview() {
|
|||||||
}, [year, load]);
|
}, [year, load]);
|
||||||
|
|
||||||
if (error && !data) {
|
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) {
|
if (!data) {
|
||||||
return <HydrogenOverviewSkeleton />;
|
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 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 profitYield = k.yearRevenue > 0 ? k.yearProfit / k.yearRevenue * 100 : 0;
|
||||||
const stationAvgKg = stations.length > 0 ? k.yearKg / stations.length : 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 => ({
|
const monthlyDual = monthly.map(m => ({
|
||||||
@@ -213,22 +261,22 @@ export default function HydrogenOverview() {
|
|||||||
icon={<Wallet size={14} className="text-blue-600" strokeWidth={2.4} />}
|
icon={<Wallet size={14} className="text-blue-600" strokeWidth={2.4} />}
|
||||||
iconBg="bg-blue-50"
|
iconBg="bg-blue-50"
|
||||||
accentClass="text-slate-800"
|
accentClass="text-slate-800"
|
||||||
label="累计加氢费"
|
label="累计加氢成本"
|
||||||
hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }}
|
hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }}
|
||||||
rows={[
|
rows={[
|
||||||
{ label: '我司承担', value: `¥${fmtYuan(k.ourYearFee).value} ${fmtYuan(k.ourYearFee).unit}` },
|
{ label: '公司单成本', value: `¥${fmtYuan(k.ourYearFee).value} ${fmtYuan(k.ourYearFee).unit}` },
|
||||||
{ label: '客户承担', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` },
|
{ label: '客户单成本', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={<TrendingUp size={14} className="text-emerald-600" strokeWidth={2.4} />}
|
icon={<TrendingUp size={14} className="text-emerald-600" strokeWidth={2.4} />}
|
||||||
iconBg="bg-emerald-50"
|
iconBg="bg-emerald-50"
|
||||||
accentClass={profitColor}
|
accentClass={profitColor}
|
||||||
label="时享加氢获利"
|
label="客户单毛利"
|
||||||
hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }}
|
hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }}
|
||||||
rows={[
|
rows={[
|
||||||
{ label: '收入', value: `¥${yearRevenueFmt.value} ${yearRevenueFmt.unit}` },
|
{ label: '收入', value: `¥${yearRevenueFmt.value} ${yearRevenueFmt.unit}` },
|
||||||
{ label: '成本', value: `¥${yearFeeFmt.value} ${yearFeeFmt.unit}` },
|
{ label: '客户单成本', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<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="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div>
|
<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'}`}>
|
<div className={`mt-1 text-lg font-black ${profitYield >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||||
{profitYield.toFixed(1)}%
|
{profitYield.toFixed(1)}%
|
||||||
</div>
|
</div>
|
||||||
@@ -302,7 +350,7 @@ export default function HydrogenOverview() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
<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 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'}
|
{profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -497,7 +545,17 @@ export default function HydrogenOverview() {
|
|||||||
return (
|
return (
|
||||||
<tr key={s.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
<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 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">
|
<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>
|
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -556,7 +614,18 @@ export default function HydrogenOverview() {
|
|||||||
return (
|
return (
|
||||||
<tr key={c2.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
<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 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">
|
<td className="py-1.5 text-center hidden sm:table-cell">
|
||||||
{c2.payer === 'lingniu' ? (
|
{c2.payer === 'lingniu' ? (
|
||||||
<span className="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-[10px] font-bold">羚牛</span>
|
<span className="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-[10px] font-bold">羚牛</span>
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ interface Props<T extends string> {
|
|||||||
tabs: readonly SubTab<T>[];
|
tabs: readonly SubTab<T>[];
|
||||||
active: T;
|
active: T;
|
||||||
onChange: (id: T) => void;
|
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 (
|
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">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { fetchJson } from '../../auth/api-client';
|
import { fetchJson } from '../../auth/api-client';
|
||||||
import type {
|
import type {
|
||||||
HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenMonthlyPoint, HydrogenDailyRow,
|
HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenMonthlyPoint, HydrogenDailyRow,
|
||||||
|
HydrogenOrderResponse,
|
||||||
HydrogenCustomerRow, HydrogenStationFull,
|
HydrogenCustomerRow, HydrogenStationFull,
|
||||||
ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
|
ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
|
||||||
CustomerType, DateQuickPick,
|
ElectricChargeOrderResponse,
|
||||||
|
EtcOverviewResponse,
|
||||||
|
EtcBillResponse, EtcTollRecordResponse,
|
||||||
|
CustomerType, DateQuickPick, ElectricVehicleScope,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const BASE = '/api/energy';
|
const BASE = '/api/energy';
|
||||||
@@ -31,29 +35,92 @@ export interface HydrogenDailyQuery {
|
|||||||
range?: DateQuickPick;
|
range?: DateQuickPick;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
|
stationId?: number;
|
||||||
|
customerName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise<HydrogenDailyRow[]> {
|
export function fetchHydrogenDaily(query: HydrogenDailyQuery, vehicleScope: CustomerType): Promise<HydrogenDailyRow[]> {
|
||||||
const q = new URLSearchParams({ customer });
|
const q = new URLSearchParams({ customer: vehicleScope });
|
||||||
if (query.range) q.set('range', query.range);
|
if (query.range) q.set('range', query.range);
|
||||||
if (query.startDate) q.set('startDate', query.startDate);
|
if (query.startDate) q.set('startDate', query.startDate);
|
||||||
if (query.endDate) q.set('endDate', query.endDate);
|
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()}`);
|
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 {
|
export interface ElectricOverviewResponse {
|
||||||
kpi: ElectricKpi;
|
kpi: ElectricKpi;
|
||||||
trend: ElectricDailyRow[];
|
trend: ElectricDailyRow[];
|
||||||
|
latestRecordAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
|
export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
|
||||||
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
|
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> {
|
export function fetchElectricMonthly(vehicleScope: ElectricVehicleScope, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> {
|
||||||
const q = new URLSearchParams({ customer });
|
const q = new URLSearchParams({ customer: vehicleScope });
|
||||||
if (query.range) q.set('range', query.range);
|
if (query.range) q.set('range', query.range);
|
||||||
if (query.startDate) q.set('startDate', query.startDate);
|
if (query.startDate) q.set('startDate', query.startDate);
|
||||||
if (query.endDate) q.set('endDate', query.endDate);
|
if (query.endDate) q.set('endDate', query.endDate);
|
||||||
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
|
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}` : ''}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export type CustomerType = 'external' | 'lingniu';
|
export type CustomerType = 'external' | 'lingniu';
|
||||||
|
export type ElectricVehicleScope = CustomerType | 'all';
|
||||||
export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15';
|
export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15';
|
||||||
|
|
||||||
export interface HydrogenKpi {
|
export interface HydrogenKpi {
|
||||||
@@ -22,6 +23,7 @@ export interface HydrogenKpi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HydrogenStationTop {
|
export interface HydrogenStationTop {
|
||||||
|
id: number;
|
||||||
rank: number;
|
rank: number;
|
||||||
name: string;
|
name: string;
|
||||||
kg: number;
|
kg: number;
|
||||||
@@ -52,6 +54,7 @@ export interface HydrogenCustomerRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HydrogenStationFull {
|
export interface HydrogenStationFull {
|
||||||
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
kg: number;
|
kg: number;
|
||||||
revenue: number;
|
revenue: number;
|
||||||
@@ -60,6 +63,7 @@ export interface HydrogenStationFull {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HydrogenStationRow {
|
export interface HydrogenStationRow {
|
||||||
|
stationId: number;
|
||||||
name: string;
|
name: string;
|
||||||
pricePerKg: number;
|
pricePerKg: number;
|
||||||
kg: number;
|
kg: number;
|
||||||
@@ -74,6 +78,34 @@ export interface HydrogenDailyRow {
|
|||||||
stations: HydrogenStationRow[];
|
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 {
|
export interface ElectricKpi {
|
||||||
totalKwh: number;
|
totalKwh: number;
|
||||||
totalFee: number;
|
totalFee: number;
|
||||||
@@ -97,3 +129,105 @@ export interface ElectricMonthGroup {
|
|||||||
fee: number;
|
fee: number;
|
||||||
rows: ElectricDailyRow[];
|
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[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 二级段。
|
* 把模块内子 tab 状态同步到 URL hash 二级段。
|
||||||
* hash 形如 `#<moduleId>`(= 默认 sub)或 `#<moduleId>/<sub>`。
|
* hash 形如 `#<moduleId>`(= 默认 sub)或 `#<moduleId>/<sub>`。
|
||||||
@@ -11,26 +25,25 @@ export function useHashSubTab<T extends string>(
|
|||||||
): [T, (sub: T) => void] {
|
): [T, (sub: T) => void] {
|
||||||
const defaultSub = subs[0];
|
const defaultSub = subs[0];
|
||||||
|
|
||||||
const parse = (): T => {
|
const parse = (): T => parseHashSubTab(window.location.hash, moduleId, subs);
|
||||||
const hash = window.location.hash.slice(1);
|
|
||||||
const [first, second] = hash.split('/');
|
|
||||||
if (first !== moduleId) return defaultSub;
|
|
||||||
if (second && (subs as readonly string[]).includes(second)) return second as T;
|
|
||||||
return defaultSub;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [sub, setSubState] = useState<T>(parse);
|
const [sub, setSubState] = useState<T>(parse);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onChange = () => setSubState(parse());
|
const onChange = () => setSubState(parse());
|
||||||
window.addEventListener('hashchange', onChange);
|
window.addEventListener('hashchange', onChange);
|
||||||
return () => window.removeEventListener('hashchange', onChange);
|
window.addEventListener('popstate', onChange);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('hashchange', onChange);
|
||||||
|
window.removeEventListener('popstate', onChange);
|
||||||
|
};
|
||||||
}, [moduleId]);
|
}, [moduleId]);
|
||||||
|
|
||||||
const setSub = (next: T) => {
|
const setSub = (next: T) => {
|
||||||
|
if (next === sub) return;
|
||||||
const { pathname, search } = window.location;
|
const { pathname, search } = window.location;
|
||||||
const newHash = next === defaultSub ? `#${moduleId}` : `#${moduleId}/${next}`;
|
const newHash = buildHashSubTab(moduleId, next, defaultSub);
|
||||||
window.history.replaceState(null, '', `${pathname}${search}${newHash}`);
|
window.history.pushState(null, '', `${pathname}${search}${newHash}`);
|
||||||
setSubState(next);
|
setSubState(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,348 @@
|
|||||||
import { FileText } from 'lucide-react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { SurfaceCard } from '../../components/ui/surface';
|
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';
|
||||||
|
|
||||||
export default function DailyReportView() {
|
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 (
|
return (
|
||||||
<SurfaceCard className="min-h-[360px]">
|
<button
|
||||||
<div className="flex min-h-[320px] flex-col items-center justify-center px-6 py-10 text-center">
|
type="button"
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
|
onClick={onClick}
|
||||||
<FileText size={26} />
|
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] items-center justify-center gap-2 text-xs font-bold text-slate-400">
|
||||||
|
<RefreshCw size={16} className="animate-spin" />
|
||||||
|
正在生成里程日报
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 text-base font-black text-slate-800">每日汇报接入中</div>
|
</SurfaceCard>
|
||||||
<div className="mt-2 max-w-md text-xs font-bold leading-relaxed text-slate-400">
|
);
|
||||||
日报数据口径正在整理,完成后将接入数据库统计与导出能力。
|
}
|
||||||
|
|
||||||
|
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>
|
</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>
|
||||||
</SurfaceCard>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import DailyReportView from './DailyReportView';
|
|||||||
import { useHashSubTab } from '../energy/useHashSubTab';
|
import { useHashSubTab } from '../energy/useHashSubTab';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
import { FadeIn, PageFrame, SegmentedNav } from '../../components/ui/surface';
|
import { FadeIn, PageFrame, SegmentedNav } from '../../components/ui/surface';
|
||||||
|
import BiHeaderActions from '../../components/BiHeaderActions';
|
||||||
|
|
||||||
type MileageSubTab = 'monitoring' | 'statistics' | 'report';
|
type MileageSubTab = 'monitoring' | 'statistics' | 'report';
|
||||||
|
|
||||||
@@ -27,21 +28,34 @@ export default function MileageModule() {
|
|||||||
icon={LayoutDashboard}
|
icon={LayoutDashboard}
|
||||||
eyebrow="MILEAGE BI"
|
eyebrow="MILEAGE BI"
|
||||||
meta="实时监控 · 统计报表 · 每日汇报"
|
meta="实时监控 · 统计报表 · 每日汇报"
|
||||||
|
actions={<BiHeaderActions domain="mileage" />}
|
||||||
compactInfo
|
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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<FadeIn key={activeSubTab}>
|
<FadeIn key={activeSubTab}>
|
||||||
{activeSubTab === 'monitoring' ? (
|
<div
|
||||||
<MonitoringView />
|
id={`mileage-view-panel-${activeSubTab}`}
|
||||||
) : activeSubTab === 'statistics' ? (
|
role="tabpanel"
|
||||||
<StatisticsView />
|
aria-labelledby={`mileage-view-tab-${activeSubTab}`}
|
||||||
) : (
|
>
|
||||||
<DailyReportView />
|
{activeSubTab === 'monitoring' ? (
|
||||||
)}
|
<MonitoringView />
|
||||||
|
) : activeSubTab === 'statistics' ? (
|
||||||
|
<StatisticsView />
|
||||||
|
) : (
|
||||||
|
<DailyReportView />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
<RotatingFooterHint />
|
<RotatingFooterHint />
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ import Blur from '../../components/Blur';
|
|||||||
import PlateMultiSelect from './PlateMultiSelect';
|
import PlateMultiSelect from './PlateMultiSelect';
|
||||||
import { exportMileageXlsx } from './xlsx-export';
|
import { exportMileageXlsx } from './xlsx-export';
|
||||||
import VehicleDetailModal from './VehicleDetailModal';
|
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([
|
const HIGH_MILEAGE_ALERT_TARGETS = new Set([
|
||||||
'交投40辆4.5T普货',
|
'交投40辆4.5T普货',
|
||||||
@@ -25,6 +37,7 @@ const HIGH_MILEAGE_ALERT_TARGETS = new Set([
|
|||||||
]);
|
]);
|
||||||
const HIGH_MILEAGE_ALERT_KM = 800;
|
const HIGH_MILEAGE_ALERT_KM = 800;
|
||||||
const ALL_MILEAGE_SOURCE_GROUPS: MileageSourceGroup[] = ['instrument', 'gps'];
|
const ALL_MILEAGE_SOURCE_GROUPS: MileageSourceGroup[] = ['instrument', 'gps'];
|
||||||
|
type MonitoringRequestParams = NonNullable<Parameters<typeof fetchMonitoring>[0]>;
|
||||||
|
|
||||||
const MILEAGE_SOURCE_META = {
|
const MILEAGE_SOURCE_META = {
|
||||||
instrument: { label: '仪表数据', protocol: null },
|
instrument: { label: '仪表数据', protocol: null },
|
||||||
@@ -444,34 +457,51 @@ const BatchMultiSelect = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function MonitoringView() {
|
export default function MonitoringView() {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [drillContext, setDrillContext] = useState<MileageDrillContext>(() => (
|
||||||
const [filterDept, setFilterDept] = useState('All');
|
parseMileageDrillContext(window.location.search)
|
||||||
const [sortBy, setSortBy] = useState<'today' | 'total' | 'statisticTime'>('today');
|
));
|
||||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
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 [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [fullscreenVehicles, setFullscreenVehicles] = useState<MonitoringVehicle[]>([]);
|
const [fullscreenVehicles, setFullscreenVehicles] = useState<MonitoringVehicle[]>([]);
|
||||||
const [fullscreenStats, setFullscreenStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
const [fullscreenStats, setFullscreenStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
||||||
const [fullscreenRefresh, setFullscreenRefresh] = useState(0);
|
const [fullscreenRefresh, setFullscreenRefresh] = useState(0);
|
||||||
const [fullscreenLoading, setFullscreenLoading] = useState(false);
|
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
|
// New filters from image
|
||||||
const [filterPlates, setFilterPlates] = useState<string[]>([]);
|
const [filterPlates, setFilterPlates] = useState<string[]>(initialMonitoringContext.plates);
|
||||||
const [filterCustomer, setFilterCustomer] = useState('All');
|
const [drillPlateFallback, setDrillPlateFallback] = useState<string | undefined>(() => (
|
||||||
const [filterProject, setFilterProject] = useState('All');
|
drillContext.level === 'vehicle' && drillContext.plate ? drillContext.plate : undefined
|
||||||
const [filterEntity, setFilterEntity] = useState('All');
|
));
|
||||||
const [filterRentStatus, setFilterRentStatus] = useState('All');
|
const [filterCustomer, setFilterCustomer] = useState(initialMonitoringContext.customer || 'All');
|
||||||
const [filterPlatePrefix, setFilterPlatePrefix] = useState('All');
|
const [filterProject, setFilterProject] = useState(initialMonitoringContext.project || 'All');
|
||||||
const [filterTargetNames, setFilterTargetNames] = useState<string[]>([]);
|
const [filterEntity, setFilterEntity] = useState(initialMonitoringContext.entity || 'All');
|
||||||
const [filterRegion, setFilterRegion] = useState('All');
|
const [filterRentStatus, setFilterRentStatus] = useState(initialMonitoringContext.rentStatus || 'All');
|
||||||
const [filterBrands, setFilterBrands] = useState<string[]>([]);
|
const [filterPlatePrefix, setFilterPlatePrefix] = useState(initialMonitoringContext.platePrefix || 'All');
|
||||||
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
const [filterTargetNames, setFilterTargetNames] = useState<string[]>(initialMonitoringContext.targetNames);
|
||||||
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
|
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 [exporting, setExporting] = useState(false);
|
||||||
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
|
const [exportStatus, setExportStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||||
const [rangeStart, setRangeStart] = useState(defaultMileageDate);
|
const [rangeStart, setRangeStart] = useState(() => initialMonitoringContext.startDate || drillContext.startDate || defaultMileageDate());
|
||||||
const [rangeEnd, setRangeEnd] = useState(defaultMileageDate);
|
const [rangeEnd, setRangeEnd] = useState(() => initialMonitoringContext.endDate || drillContext.endDate || defaultMileageDate());
|
||||||
const [sourcePriority, setSourcePriority] = useState<MileageSourceGroup[]>(['instrument']);
|
const [sourcePriority, setSourcePriority] = useState<MileageSourceGroup[]>(initialMonitoringContext.sourcePriority);
|
||||||
|
|
||||||
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
|
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
|
||||||
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
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 [hasMore, setHasMore] = useState(true);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [pageLoading, setPageLoading] = useState(true);
|
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 [manualRefreshing, setManualRefreshing] = useState(false);
|
||||||
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null);
|
const [dataUpdatedAt, setDataUpdatedAt] = useState<string | null>(null);
|
||||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||||
const [relativeNow, setRelativeNow] = useState(() => Date.now());
|
const [relativeNow, setRelativeNow] = useState(() => Date.now());
|
||||||
|
const firstPageRequestRef = useRef(0);
|
||||||
|
const loadMoreRequestRef = useRef(0);
|
||||||
const PAGE_SIZE = 50;
|
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(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => setRelativeNow(Date.now()), 5000);
|
const timer = setInterval(() => setRelativeNow(Date.now()), 5000);
|
||||||
return () => clearInterval(timer);
|
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 departments = filterOptions.departments;
|
||||||
const plateNumbers = filterOptions.plates;
|
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 applyRangePreset = useCallback((preset: RangePreset) => {
|
||||||
const range = getRangePreset(preset);
|
const range = getRangePreset(preset);
|
||||||
setRangeStart(range.start);
|
setRangeStart(range.start);
|
||||||
@@ -513,31 +602,133 @@ export default function MonitoringView() {
|
|||||||
return inAlertTarget && Math.max(0, v.dailyKm || 0) >= HIGH_MILEAGE_ALERT_KM;
|
return inAlertTarget && Math.max(0, v.dailyKm || 0) >= HIGH_MILEAGE_ALERT_KM;
|
||||||
}, [filterTargetNames]);
|
}, [filterTargetNames]);
|
||||||
|
|
||||||
|
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,
|
||||||
|
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: 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) => {
|
const loadFirstPage = useCallback((showPageLoading = true, force = false) => {
|
||||||
|
const requestId = ++firstPageRequestRef.current;
|
||||||
|
const requestKey = monitoringRequestKey;
|
||||||
|
++loadMoreRequestRef.current;
|
||||||
|
setLoadingMore(false);
|
||||||
if (showPageLoading) setPageLoading(true);
|
if (showPageLoading) setPageLoading(true);
|
||||||
|
setPageError(null);
|
||||||
return fetchMonitoring({
|
return fetchMonitoring({
|
||||||
|
...monitoringFilters,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
page: 1,
|
page: 1,
|
||||||
search: searchTerm || undefined,
|
force,
|
||||||
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 => {
|
}).then(d => {
|
||||||
|
if (requestId !== firstPageRequestRef.current) return;
|
||||||
setVehicles(d.vehicles);
|
setVehicles(d.vehicles);
|
||||||
setStats(d.stats);
|
setStats(d.stats);
|
||||||
setFilterOptions(d.filters);
|
setFilterOptions(d.filters);
|
||||||
@@ -546,17 +737,24 @@ export default function MonitoringView() {
|
|||||||
setTotal(d.total);
|
setTotal(d.total);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
setHasMore(d.page < d.totalPages);
|
setHasMore(d.page < d.totalPages);
|
||||||
setLastRefreshedAt(new Date());
|
setDataUpdatedAt(d.updatedAt);
|
||||||
}).catch(() => {}).finally(() => {
|
setPageSnapshotKey(requestKey);
|
||||||
if (showPageLoading) setPageLoading(false);
|
}).catch(error => {
|
||||||
|
if (requestId !== firstPageRequestRef.current) return;
|
||||||
|
setPageError({
|
||||||
|
message: error instanceof Error ? error.message : '里程数据加载失败,请稍后重试',
|
||||||
|
scope: 'first',
|
||||||
|
});
|
||||||
|
}).finally(() => {
|
||||||
|
if (requestId === firstPageRequestRef.current) setPageLoading(false);
|
||||||
});
|
});
|
||||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]);
|
}, [monitoringFilters, monitoringRequestKey, rangeEnd, rangeStart, sortBy, sortOrder]);
|
||||||
|
|
||||||
const handleManualRefresh = useCallback(async () => {
|
const handleManualRefresh = useCallback(async () => {
|
||||||
if (manualRefreshing) return;
|
if (manualRefreshing) return;
|
||||||
setManualRefreshing(true);
|
setManualRefreshing(true);
|
||||||
try {
|
try {
|
||||||
await loadFirstPage(false);
|
await loadFirstPage(false, true);
|
||||||
} finally {
|
} finally {
|
||||||
setManualRefreshing(false);
|
setManualRefreshing(false);
|
||||||
}
|
}
|
||||||
@@ -566,34 +764,31 @@ export default function MonitoringView() {
|
|||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (loadingMore || !hasMore) return;
|
if (loadingMore || !hasMore) return;
|
||||||
const nextPage = page + 1;
|
const nextPage = page + 1;
|
||||||
|
const requestId = ++loadMoreRequestRef.current;
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
|
setPageError(null);
|
||||||
fetchMonitoring({
|
fetchMonitoring({
|
||||||
|
...monitoringFilters,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
page: nextPage,
|
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 => {
|
}).then(d => {
|
||||||
|
if (requestId !== loadMoreRequestRef.current) return;
|
||||||
setVehicles(prev => [...prev, ...d.vehicles]);
|
setVehicles(prev => [...prev, ...d.vehicles]);
|
||||||
setPage(nextPage);
|
setPage(nextPage);
|
||||||
setHasMore(nextPage < d.totalPages);
|
setHasMore(nextPage < d.totalPages);
|
||||||
}).catch(() => {}).finally(() => setLoadingMore(false));
|
setDataUpdatedAt(d.updatedAt);
|
||||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, page, loadingMore, hasMore, filterBrands, sourcePriority]);
|
}).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(() => {
|
useEffect(() => {
|
||||||
@@ -603,6 +798,7 @@ export default function MonitoringView() {
|
|||||||
// 区域级联:plate 选项收窄后,剔除已选但已不属于该区域的车牌
|
// 区域级联:plate 选项收窄后,剔除已选但已不属于该区域的车牌
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filterPlates.length === 0) return;
|
if (filterPlates.length === 0) return;
|
||||||
|
if (filterOptions.plates.length === 0) return;
|
||||||
const valid = new Set(filterOptions.plates);
|
const valid = new Set(filterOptions.plates);
|
||||||
const next = filterPlates.filter(p => valid.has(p));
|
const next = filterPlates.filter(p => valid.has(p));
|
||||||
if (next.length !== filterPlates.length) setFilterPlates(next);
|
if (next.length !== filterPlates.length) setFilterPlates(next);
|
||||||
@@ -612,36 +808,27 @@ export default function MonitoringView() {
|
|||||||
const handleDownload = useCallback(async () => {
|
const handleDownload = useCallback(async () => {
|
||||||
if (exporting) return;
|
if (exporting) return;
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
|
setExportStatus(null);
|
||||||
try {
|
try {
|
||||||
const d = await fetchMonitoring({
|
const d = await fetchMonitoring({
|
||||||
|
...monitoringFilters,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
limit: 9999,
|
limit: 9999,
|
||||||
page: 1,
|
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 });
|
exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy });
|
||||||
|
setExportStatus({ type: 'success', message: `已导出 ${d.vehicles.length} 辆车辆` });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('export failed', err);
|
console.error('export failed', err);
|
||||||
|
setExportStatus({
|
||||||
|
type: 'error',
|
||||||
|
message: err instanceof Error ? err.message : '导出失败,请稍后重试',
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -649,26 +836,6 @@ export default function MonitoringView() {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [loadFirstPage]);
|
}, [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 检测顶部哨兵是否离开视口
|
// 回到顶部按钮:用 IntersectionObserver 检测顶部哨兵是否离开视口
|
||||||
const topSentinelRef = useRef<HTMLDivElement>(null);
|
const topSentinelRef = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -687,7 +854,7 @@ export default function MonitoringView() {
|
|||||||
document.body.scrollTop = 0;
|
document.body.scrollTop = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredVehicles = vehicles;
|
const filteredVehicles = pageHasSnapshot ? vehicles : [];
|
||||||
|
|
||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = () => {
|
||||||
setIsFullscreen(!isFullscreen);
|
setIsFullscreen(!isFullscreen);
|
||||||
@@ -696,30 +863,31 @@ export default function MonitoringView() {
|
|||||||
// 全屏时加载全部数据(无分页),筛选变化时重新加载
|
// 全屏时加载全部数据(无分页),筛选变化时重新加载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFullscreen) return;
|
if (!isFullscreen) return;
|
||||||
|
let cancelled = false;
|
||||||
setFullscreenLoading(true);
|
setFullscreenLoading(true);
|
||||||
|
setFullscreenError(null);
|
||||||
fetchMonitoring({
|
fetchMonitoring({
|
||||||
|
...monitoringFilters,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
limit: 9999,
|
limit: 9999,
|
||||||
page: 1,
|
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 => {
|
}).then(d => {
|
||||||
|
if (cancelled) return;
|
||||||
setFullscreenVehicles(d.vehicles);
|
setFullscreenVehicles(d.vehicles);
|
||||||
setFullscreenStats(d.stats);
|
setFullscreenStats(d.stats);
|
||||||
setFilterOptions(d.filters);
|
setFilterOptions(d.filters);
|
||||||
}).catch(() => {}).finally(() => setFullscreenLoading(false));
|
setFullscreenUpdatedAt(d.updatedAt);
|
||||||
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, fullscreenRefresh, filterBrands, sourcePriority]);
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -757,6 +925,9 @@ export default function MonitoringView() {
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="里程全屏监控"
|
||||||
className="fixed z-[100] bg-slate-950 flex flex-col overflow-hidden"
|
className="fixed z-[100] bg-slate-950 flex flex-col overflow-hidden"
|
||||||
style={
|
style={
|
||||||
forceLandscape
|
forceLandscape
|
||||||
@@ -780,13 +951,13 @@ export default function MonitoringView() {
|
|||||||
<h2 className="text-white font-bold text-xs">全屏监控</h2>
|
<h2 className="text-white font-bold text-xs">全屏监控</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-[10px]">
|
<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-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-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-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-700">|</span>
|
||||||
<span className="text-slate-500">
|
<span className="text-slate-500">
|
||||||
来源 <span className="text-white font-black">
|
来源 <span className="text-white font-black">
|
||||||
@@ -798,11 +969,14 @@ export default function MonitoringView() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setFullscreenRefresh(n => n + 1); }}
|
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' : ''}`}
|
className={`p-1.5 text-slate-500 hover:text-blue-400 transition-colors ${fullscreenLoading ? 'animate-spin' : ''}`}
|
||||||
>
|
>
|
||||||
<RotateCcw size={13} />
|
<RotateCcw size={13} />
|
||||||
</button>
|
</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} />
|
<Minimize2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -832,9 +1006,24 @@ export default function MonitoringView() {
|
|||||||
|
|
||||||
{/* Table Area */}
|
{/* Table Area */}
|
||||||
<div className="flex-1 overflow-hidden flex flex-col">
|
<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">
|
<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="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">
|
<div className="flex items-center gap-2 text-slate-400 text-xs font-bold">
|
||||||
<RotateCcw size={14} className="animate-spin" />
|
<RotateCcw size={14} className="animate-spin" />
|
||||||
@@ -842,7 +1031,29 @@ export default function MonitoringView() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<thead className="sticky top-0 bg-slate-900 z-10">
|
||||||
<tr className="border-b border-slate-800/60">
|
<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>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/30">
|
<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) => {
|
{fullscreenVehicles.map((v) => {
|
||||||
const highMileageAlert = isHighMileageAlert(v);
|
const highMileageAlert = isHighMileageAlert(v);
|
||||||
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
||||||
@@ -1011,7 +1229,7 @@ export default function MonitoringView() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -1025,23 +1243,34 @@ export default function MonitoringView() {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="truncate text-lg font-black leading-none text-slate-900">里程看板</h1>
|
<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} />
|
<Maximize2 size={14} />
|
||||||
</button>
|
</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} />}
|
{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download size={14} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center gap-1.5">
|
<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="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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-shrink-0 items-center gap-1.5">
|
<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">
|
<span className="hidden text-[9px] font-bold tabular-nums text-slate-400 sm:inline">
|
||||||
{lastRefreshedAt ? lastRefreshedAt.toLocaleTimeString('zh-CN', { hour12: false }) : '--:--:--'}
|
数据截至 {formatMileageSnapshotTime(pageUpdatedAt)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1054,9 +1283,9 @@ export default function MonitoringView() {
|
|||||||
<span>{manualRefreshing ? '刷新中' : '刷新数据'}</span>
|
<span>{manualRefreshing ? '刷新中' : '刷新数据'}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-1 rounded-lg bg-slate-100 p-0.5">
|
<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 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 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 === '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 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 === '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
|
<button
|
||||||
onClick={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')}
|
onClick={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')}
|
||||||
className="rounded-md p-1 text-blue-600 transition-all hover:bg-white"
|
className="rounded-md p-1 text-blue-600 transition-all hover:bg-white"
|
||||||
@@ -1068,6 +1297,32 @@ export default function MonitoringView() {
|
|||||||
</div>
|
</div>
|
||||||
</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} />
|
<SourcePriorityControl priority={sourcePriority} onChange={setSourcePriority} />
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<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 + 清单标题 */}
|
{/* Sticky header: KPI + 清单标题 */}
|
||||||
<div className="sticky top-[44px] z-20 bg-[var(--app-bg)] pt-1 pb-1 space-y-2">
|
<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="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">
|
<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>
|
||||||
<div className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div>
|
<div className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div>
|
||||||
</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="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-[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 className="text-[7px] text-slate-400">km/台</div>
|
||||||
</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="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-[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 className="text-[7px] text-slate-400">台</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-slate-100 bg-white shadow-sm overflow-hidden">
|
<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" />
|
<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 ? (
|
) : isRangeMode ? (
|
||||||
<div className="grid grid-cols-[92px_minmax(0,1fr)_62px] items-center gap-2 px-2 py-2">
|
<div className="grid grid-cols-[92px_minmax(0,1fr)_62px] items-center gap-2 px-2 py-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -1439,18 +1737,20 @@ export default function MonitoringView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col justify-center rounded-lg bg-blue-50 px-2 py-1.5 text-right">
|
<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="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>
|
||||||
<div className="flex flex-col justify-center rounded-lg bg-slate-50 px-2 py-1.5 text-right">
|
<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="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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between px-1">
|
<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-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>
|
||||||
<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">
|
<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>
|
<span>车牌 / 数据状态</span>
|
||||||
@@ -1463,7 +1763,7 @@ export default function MonitoringView() {
|
|||||||
{/* Vehicle List */}
|
{/* Vehicle List */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|
||||||
{pageLoading && (
|
{pageLoading && !pageHasSnapshot && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{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">
|
<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 }}
|
animate={{ opacity: 1 }}
|
||||||
key={v.plate}
|
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"
|
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="flex min-w-0 items-center gap-3 overflow-hidden">
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
@@ -1565,31 +1865,58 @@ export default function MonitoringView() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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">
|
<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>
|
<p className="text-xs font-bold text-slate-300">未找到匹配数据</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 加载更多提示 */}
|
{pageError?.scope === 'more' && pageHasSnapshot && (
|
||||||
{loadingMore && (
|
<div className="rounded-xl border border-rose-100 bg-rose-50 px-3 py-3 text-center">
|
||||||
<div className="py-4 text-center">
|
<p className="text-[10px] font-bold text-rose-600">{pageError.message}</p>
|
||||||
<span className="text-[10px] font-bold text-slate-400">加载中...</span>
|
<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>
|
</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">
|
<div className="py-4 text-center">
|
||||||
<span className="text-[10px] font-bold text-slate-300">已加载全部 {total} 条</span>
|
<span className="text-[10px] font-bold text-slate-300">已加载全部 {total} 条</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* 哨兵元素:进入视口时触发加载更多 */}
|
|
||||||
<div ref={sentinelRef} className="h-1" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VehicleDetailModal
|
<VehicleDetailModal
|
||||||
|
key={detailVehicle
|
||||||
|
? `${detailVehicle.plate}|${drillContext.startDate || effectiveRange.start}|${drillContext.endDate || effectiveRange.end}`
|
||||||
|
: 'closed'}
|
||||||
vehicle={detailVehicle}
|
vehicle={detailVehicle}
|
||||||
onClose={() => setDetailVehicle(null)}
|
onClose={closeVehicleDetail}
|
||||||
sourcePriority={sourcePriority}
|
sourcePriority={sourcePriority}
|
||||||
|
initialStartDate={drillContext.startDate || effectiveRange.start}
|
||||||
|
initialEndDate={drillContext.endDate || effectiveRange.end}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 回到顶部按钮 */}
|
{/* 回到顶部按钮 */}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
@@ -6,12 +6,26 @@ import {
|
|||||||
Cell, LabelList,
|
Cell, LabelList,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import {
|
import {
|
||||||
Truck, ChevronDown, Maximize2, Minimize2,
|
AlertTriangle, Truck, ChevronDown, Maximize2, Minimize2,
|
||||||
Search, ArrowUpDown, X, RotateCcw, Calendar,
|
Search, ArrowUpDown, X, RotateCcw, Calendar,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from './types';
|
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from './types';
|
||||||
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
||||||
import Blur from '../../components/Blur';
|
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 {
|
function getDefaultDate(): string {
|
||||||
const now = new Date();
|
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')}`;
|
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 {
|
function fmtKm(value: number): string {
|
||||||
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
|
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
|
||||||
return value.toLocaleString();
|
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];
|
return target.yearlyAssessments.find(item => item.yearNumber === selectedYear) || target.yearlyAssessments[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDateLabel(date: string | null): string {
|
function resolveAssessmentYear(target: TargetSummary, requestedYear?: number): number | undefined {
|
||||||
if (!date) return '';
|
return getTargetAssessment(target, requestedYear)?.yearNumber;
|
||||||
const [year, month, day] = date.split('-');
|
|
||||||
return `${year}.${Number(month)}.${Number(day)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortTargetName(name: string): string {
|
function shortTargetName(name: string): string {
|
||||||
@@ -58,75 +65,373 @@ function shortTargetName(name: string): string {
|
|||||||
return `${count}台${desc}`;
|
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() {
|
export default function StatisticsView() {
|
||||||
const currentDateLabel = getCurrentDateLabel();
|
const [drillContext, setDrillContext] = useState<MileageDrillContext>(() => (
|
||||||
|
parseMileageDrillContext(window.location.search)
|
||||||
|
));
|
||||||
const [targets, setTargets] = useState<TargetSummary[]>([]);
|
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 [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 [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 [chartType, setChartType] = useState<'bar' | 'line' | 'area'>('bar');
|
||||||
const [isTableFullscreen, setIsTableFullscreen] = useState(false);
|
const [isTableFullscreen, setIsTableFullscreen] = useState(false);
|
||||||
const [expandedTargetId, setExpandedTargetId] = useState<number | null>(null);
|
const [expandedTargetId, setExpandedTargetId] = useState<number | null>(null);
|
||||||
const [assessmentYearMap, setAssessmentYearMap] = useState<Record<number, number>>({});
|
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 [viewAllTargetName, setViewAllTargetName] = useState<string>('');
|
||||||
const [viewAllSearch, setViewAllSearch] = useState('');
|
const [viewAllSearch, setViewAllSearch] = useState('');
|
||||||
const [viewAllSort, setViewAllSort] = useState<'asc' | 'desc'>('desc');
|
const [viewAllSort, setViewAllSort] = useState<'asc' | 'desc'>('desc');
|
||||||
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
|
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
|
||||||
const [viewAllLoading, setViewAllLoading] = useState(false);
|
const [viewAllLoading, setViewAllLoading] = useState(false);
|
||||||
|
const [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 selectedTarget = targets.find(t => t.id === selectedTargetId);
|
||||||
|
const selectedTargetVehicles = selectedTargetId === null ? [] : targetVehiclesMap[selectedTargetId] || [];
|
||||||
const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null;
|
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 selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0;
|
||||||
const selectedRemaining = selectedAssessment?.remaining ?? 0;
|
const selectedRemaining = selectedAssessment?.remaining ?? 0;
|
||||||
const selectedDaysLeft = selectedAssessment?.daysLeft ?? selectedTarget?.daysLeft ?? 0;
|
const selectedDaysLeft = selectedAssessment?.daysLeft ?? selectedTarget?.daysLeft ?? 0;
|
||||||
const selectedDailyTarget = selectedAssessment?.dailyTarget ?? (selectedDaysLeft > 0 ? selectedRemaining / selectedDaysLeft : 0);
|
const selectedDailyTarget = selectedAssessment?.dailyTarget ?? (selectedDaysLeft > 0 ? selectedRemaining / selectedDaysLeft : 0);
|
||||||
const selectedQualifiedRate = selectedAssessment?.qualifiedRate ?? (selectedTarget?.vehicleCount ? selectedTarget.yearQualifiedCount / selectedTarget.vehicleCount * 100 : 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 latestTrend = trendData[trendData.length - 1];
|
||||||
const previousTrend = trendData[trendData.length - 2];
|
const previousTrend = trendData[trendData.length - 2];
|
||||||
const trendDelta = latestTrend && previousTrend ? latestTrend.mileage - previousTrend.mileage : 0;
|
const trendDelta = latestTrend && previousTrend ? latestTrend.mileage - previousTrend.mileage : 0;
|
||||||
const pressureLevel = selectedCompletion >= 90 ? '健康' : selectedCompletion >= 70 ? '关注' : '高压';
|
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(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setTargetsLoading(true);
|
||||||
|
setTargetsError(null);
|
||||||
fetchTargets().then(data => {
|
fetchTargets().then(data => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const initialContext = initialDrillContextRef.current;
|
||||||
const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0];
|
const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0];
|
||||||
const ordered = focused
|
const ordered = focused
|
||||||
? [focused, ...data.filter(item => item.id !== focused.id)]
|
? [focused, ...data.filter(item => item.id !== focused.id)]
|
||||||
: data;
|
: data;
|
||||||
setTargets(ordered);
|
setTargets(ordered);
|
||||||
if (ordered.length > 0 && !selectedTargetId) {
|
const initialTarget = ordered.find(item => item.id === selectedTargetId) || focused;
|
||||||
setSelectedTargetId(focused.id);
|
if (initialTarget) {
|
||||||
setExpandedTargetId(focused.id);
|
setSelectedTargetId(initialTarget.id);
|
||||||
setAssessmentYearMap(Object.fromEntries(ordered.map(item => [item.id, item.yearlyAssessments[0]?.yearNumber || 1])));
|
setExpandedTargetId(initialTarget.id);
|
||||||
fetchTargetVehicles(focused.id).then(vehicles => {
|
const initialYears = Object.fromEntries(ordered.map(item => [
|
||||||
setTargetVehiclesMap(prev => ({ ...prev, [focused.id]: vehicles }));
|
item.id,
|
||||||
}).catch(() => {});
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSelectedTargetId(null);
|
||||||
|
setExpandedTargetId(null);
|
||||||
|
setAssessmentYearMap({});
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(error => {
|
||||||
}, []);
|
if (!cancelled) {
|
||||||
|
setTargetsError(getRequestErrorMessage(error, '无法获取考核目标数据,请检查服务连接后重试。'));
|
||||||
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
if (!cancelled) setTargetsLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [targetsRequestVersion]);
|
||||||
|
|
||||||
// Load trend when selectedTargetId changes
|
// Load trend when selectedTargetId changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedTargetId === null) return;
|
if (selectedTargetId === null) {
|
||||||
|
setTrendData([]);
|
||||||
|
setTrendError(null);
|
||||||
|
setTrendLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
setExpandedTargetId(selectedTargetId);
|
setExpandedTargetId(selectedTargetId);
|
||||||
if (!targetVehiclesMap[selectedTargetId]) {
|
if (!targetVehiclesMap[selectedTargetId]) {
|
||||||
fetchTargetVehicles(selectedTargetId).then(vehicles => {
|
loadCurrentTargetVehicles(selectedTargetId);
|
||||||
setTargetVehiclesMap(prev => ({ ...prev, [selectedTargetId]: vehicles }));
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
}
|
||||||
fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
|
setTrendLoading(true);
|
||||||
}, [selectedTargetId]);
|
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
|
// Re-fetch target vehicles when viewAllDate changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewAllTargetId === null) return;
|
if (viewAllTargetId === null) return;
|
||||||
|
let cancelled = false;
|
||||||
setViewAllLoading(true);
|
setViewAllLoading(true);
|
||||||
|
setViewAllError(null);
|
||||||
fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => {
|
fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => {
|
||||||
setTargetVehiclesMap(prev => ({ ...prev, [viewAllTargetId]: data }));
|
if (cancelled) return;
|
||||||
}).catch(() => {}).finally(() => setViewAllLoading(false));
|
setViewAllHistoricalVehicles(data);
|
||||||
}, [viewAllTargetId, viewAllDate]);
|
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 (
|
return (
|
||||||
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1 [overflow-anchor:none]" style={{ overflowX: 'clip' }}>
|
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1 [overflow-anchor:none]" style={{ overflowX: 'clip' }}>
|
||||||
@@ -135,10 +440,7 @@ export default function StatisticsView() {
|
|||||||
{targets.map(target => (
|
{targets.map(target => (
|
||||||
<button
|
<button
|
||||||
key={target.id}
|
key={target.id}
|
||||||
onClick={() => {
|
onClick={() => selectTarget(target.id)}
|
||||||
setSelectedTargetId(target.id);
|
|
||||||
setExpandedTargetId(target.id);
|
|
||||||
}}
|
|
||||||
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
|
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
|
||||||
selectedTargetId === target.id
|
selectedTargetId === target.id
|
||||||
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
|
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
|
||||||
@@ -150,6 +452,16 @@ export default function StatisticsView() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{targetsError && (
|
||||||
|
<ErrorState
|
||||||
|
title="考核统计刷新失败"
|
||||||
|
message={`${targetsError} 当前仍展示上一次成功加载的数据。`}
|
||||||
|
onRetry={retryTargets}
|
||||||
|
retrying={targetsLoading}
|
||||||
|
variant="inline"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedTarget && (
|
{selectedTarget && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 8 }}
|
initial={{ opacity: 0, y: 8 }}
|
||||||
@@ -181,14 +493,48 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
<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="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'}`}>
|
<div className={`mt-1 text-lg font-black ${trendLoading || trendError ? 'text-slate-500' : trendDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||||
{trendDelta >= 0 ? '+' : ''}{fmtKm(trendDelta)}
|
{trendLoading ? '加载中' : trendError ? '暂不可用' : `${trendDelta >= 0 ? '+' : ''}${fmtKm(trendDelta)}`}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[10px] font-bold text-slate-400">达标率 {fmtPercent(selectedQualifiedRate)}</div>
|
<div className="mt-1 text-[10px] font-bold text-slate-400">达标率 {fmtPercent(selectedQualifiedRate)}</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.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">
|
<div className="flex flex-col landscape:flex-row gap-4 flex-1 landscape:overflow-hidden">
|
||||||
{/* Left Side: Trend Chart / Dashboard Sidebar */}
|
{/* 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">
|
<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>
|
</div>
|
||||||
<div className="h-[280px] w-full min-w-0">
|
<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}>
|
<ResponsiveContainer width="100%" height={280} minWidth={0}>
|
||||||
{chartType === 'bar' ? (
|
{chartType === 'bar' ? (
|
||||||
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
|
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
|
||||||
@@ -292,6 +645,7 @@ export default function StatisticsView() {
|
|||||||
</AreaChart>
|
</AreaChart>
|
||||||
)}
|
)}
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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"
|
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={() => {
|
onClick={() => {
|
||||||
setExpandedTargetId(target.id);
|
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">
|
<div className="flex items-center justify-between">
|
||||||
@@ -391,7 +740,13 @@ export default function StatisticsView() {
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
e.stopPropagation();
|
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"
|
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-[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-[10px] font-black text-emerald-600">{fmtKm(assessment.completed)} km</p>
|
||||||
<p className="text-[8px] font-bold text-slate-300">
|
<p className="text-[8px] font-bold text-slate-300">
|
||||||
数据截至 {assessment.daysLeft === 0 ? fmtDateLabel(assessment.endDate) : currentDateLabel}
|
数据截至 {formatMileageSnapshotDateTime(target.snapshotUpdatedAt)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
@@ -480,9 +835,7 @@ export default function StatisticsView() {
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setViewAllTargetId(target.id);
|
openTargetVehicles(target);
|
||||||
setViewAllTargetName(target.targetName);
|
|
||||||
setViewAllDate(getDefaultDate());
|
|
||||||
}}
|
}}
|
||||||
className="text-[8px] text-blue-500 font-bold hover:underline"
|
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-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-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-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>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsTableFullscreen(false)}
|
onClick={() => setIsTableFullscreen(false)}
|
||||||
@@ -560,6 +917,15 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Table Area */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<table className="w-full text-left border-collapse">
|
<table className="w-full text-left border-collapse">
|
||||||
@@ -643,7 +1009,7 @@ export default function StatisticsView() {
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
onClick={() => setViewAllTargetId(null)}
|
onClick={closeVehiclePanel}
|
||||||
className="fixed inset-0 z-[110] bg-slate-950/60 backdrop-blur-sm"
|
className="fixed inset-0 z-[110] bg-slate-950/60 backdrop-blur-sm"
|
||||||
/>
|
/>
|
||||||
<motion.div
|
<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 className="p-6 border-b border-slate-100 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-black text-slate-900">{viewAllTargetName}</h3>
|
<h3 className="text-lg font-black text-slate-900">
|
||||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">车辆实时明细清单</p>
|
{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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={closeVehiclePanel}
|
||||||
setViewAllTargetId(null);
|
|
||||||
setViewAllSearch('');
|
|
||||||
}}
|
|
||||||
className="p-2 hover:bg-slate-100 rounded-full transition-colors text-slate-400 hover:text-slate-900"
|
className="p-2 hover:bg-slate-100 rounded-full transition-colors text-slate-400 hover:text-slate-900"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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="flex items-center gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
|
||||||
@@ -687,17 +1057,27 @@ export default function StatisticsView() {
|
|||||||
type="date"
|
type="date"
|
||||||
value={viewAllDate}
|
value={viewAllDate}
|
||||||
onChange={(e) => setViewAllDate(e.target.value)}
|
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>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
||||||
{viewAllLoading ? '加载中...' : (() => {
|
{viewAllLoading
|
||||||
const filtered = (viewAllTargetId !== null ? (targetVehiclesMap[viewAllTargetId] || []) : []).filter(tv => tv.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase()));
|
? hasViewAllSnapshot
|
||||||
const totalKm = filtered.reduce((sum, tv) => sum + (tv.todayMileage || 0), 0);
|
? `正在加载 ${viewAllDate} · 当前显示 ${viewAllLoadedDate}`
|
||||||
return `${filtered.length} 辆 · 合计 ${fmtKm(totalKm)} km`;
|
: `正在加载 ${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>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewAllSort(prev => prev === 'desc' ? 'asc' : 'desc')}
|
onClick={() => setViewAllSort(prev => prev === 'desc' ? 'asc' : 'desc')}
|
||||||
@@ -710,45 +1090,78 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-2 no-scrollbar">
|
<div className="flex-1 overflow-y-auto p-4 space-y-2 no-scrollbar">
|
||||||
{(viewAllTargetId !== null ? (targetVehiclesMap[viewAllTargetId] || []) : []).filter(tv =>
|
{viewAllLoading && !hasViewAllSnapshot ? (
|
||||||
tv.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase())
|
<LoadingState label={`正在加载 ${viewAllDate} 车辆明细`} variant="inline" />
|
||||||
).sort((a, b) => {
|
) : viewAllError && !hasViewAllSnapshot ? (
|
||||||
const valA = a.todayMileage || 0;
|
<ErrorState
|
||||||
const valB = b.todayMileage || 0;
|
title="车辆明细加载失败"
|
||||||
return viewAllSort === 'desc' ? valB - valA : valA - valB;
|
message={viewAllError}
|
||||||
}).map(tv => (
|
onRetry={retryViewAllVehicles}
|
||||||
<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">
|
retrying={viewAllLoading}
|
||||||
<div className="flex items-center gap-3 overflow-hidden flex-1">
|
variant="inline"
|
||||||
<div className="relative flex-shrink-0">
|
/>
|
||||||
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
|
) : (
|
||||||
<Truck size={14} className="text-slate-400" />
|
<>
|
||||||
|
{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 => (
|
||||||
|
<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">
|
||||||
|
<Truck size={14} className="text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${tv.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} />
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{tv.plateNumber}</Blur></span>
|
||||||
|
<span className={`text-[8px] px-1 rounded ${tv.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
|
||||||
|
{tv.isOnline ? '在线' : '离线'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[8px] text-slate-300 font-bold">{tv.rentStatus || ''}{tv.department ? ` · ${tv.department.replace('业务', '')}` : ''}</span>
|
||||||
|
<span className="text-[9px] font-bold text-slate-600 truncate">{tv.customer || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${tv.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} />
|
<div className="text-right flex-shrink-0 ml-2">
|
||||||
</div>
|
<div className="text-sm font-black text-blue-600">{tv.todayMileage.toLocaleString()} <span className="text-[8px] text-slate-400">KM</span></div>
|
||||||
<div className="overflow-hidden flex-1">
|
<div className="text-[9px] font-bold text-slate-300 mt-0.5">累计: {fmtKm(tv.totalMileage || 0)} km</div>
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{tv.plateNumber}</Blur></span>
|
|
||||||
<span className={`text-[8px] px-1 rounded ${tv.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
|
|
||||||
{tv.isOnline ? '在线' : '离线'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
</button>
|
||||||
<span className="text-[8px] text-slate-300 font-bold">{tv.rentStatus || ''}{tv.department ? ` · ${tv.department.replace('业务', '')}` : ''}</span>
|
))}
|
||||||
<span className="text-[9px] font-bold text-slate-600 truncate">{tv.customer || '-'}</span>
|
</>
|
||||||
</div>
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right flex-shrink-0 ml-2">
|
|
||||||
<div className="text-sm font-black text-blue-600">{tv.todayMileage.toLocaleString()} <span className="text-[8px] text-slate-400">KM</span></div>
|
|
||||||
<div className="text-[9px] font-bold text-slate-300 mt-0.5">累计: {fmtKm(tv.totalMileage || 0)} km</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 bg-slate-50 border-t border-slate-100">
|
<div className="p-4 bg-slate-50 border-t border-slate-100">
|
||||||
<button
|
<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"
|
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"
|
||||||
>
|
>
|
||||||
关闭明细
|
关闭明细
|
||||||
|
|||||||
@@ -1,57 +1,42 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { motion, AnimatePresence, useDragControls } from 'motion/react';
|
import { motion, AnimatePresence, useDragControls } from 'motion/react';
|
||||||
import { X, Truck } from 'lucide-react';
|
import { RefreshCw, X, Truck } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
|
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import type { MileageSourceGroup, MonitoringVehicle } from './types';
|
import type { MileageSourceGroup, MonitoringVehicle } from './types';
|
||||||
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
|
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
|
import { EmptyState, ErrorState } from '../../components/ui/surface';
|
||||||
|
import {
|
||||||
|
includeCurrentDayInVehicleDetail,
|
||||||
|
normalizeVehicleDetailContext,
|
||||||
|
resolveVehicleDetailRange,
|
||||||
|
vehicleDetailContextLabel,
|
||||||
|
type VehicleDetailRangeKey,
|
||||||
|
} from './vehicle-detail-range';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vehicle: MonitoringVehicle | null;
|
vehicle: MonitoringVehicle | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
sourcePriority: MileageSourceGroup[];
|
sourcePriority: MileageSourceGroup[];
|
||||||
|
initialStartDate?: string;
|
||||||
|
initialEndDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RangeKey = 'last15' | 'month' | 'quarter';
|
const RANGE_TABS: { key: VehicleDetailRangeKey; label: string }[] = [
|
||||||
|
|
||||||
const RANGE_TABS: { key: RangeKey; label: string }[] = [
|
|
||||||
{ key: 'last15', label: '近 15 天' },
|
{ key: 'last15', label: '近 15 天' },
|
||||||
{ key: 'month', label: '本月' },
|
{ key: 'month', label: '本月' },
|
||||||
{ key: 'quarter', 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 {
|
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)
|
// YYYY-MM-DD → MM-DD(季度时仍展示 MM-DD)
|
||||||
void key;
|
void key;
|
||||||
return date.slice(5);
|
return date.slice(5);
|
||||||
@@ -63,30 +48,65 @@ function daySourceLabel(day: VehicleRecentDay): string {
|
|||||||
return day.isDataSynced ? '来源待接口' : '无数据';
|
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 [days, setDays] = useState<VehicleRecentDay[]>([]);
|
||||||
|
const [loadedRequestKey, setLoadedRequestKey] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
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();
|
const dragControls = useDragControls();
|
||||||
|
const resolvedRange = useMemo(
|
||||||
// 切换车辆时重置区间为默认
|
() => resolveVehicleDetailRange(range, contextRange),
|
||||||
useEffect(() => {
|
[contextRange, range],
|
||||||
if (vehicle) setRange('last15');
|
);
|
||||||
}, [vehicle?.plate]); // eslint-disable-line react-hooks/exhaustive-deps
|
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(() => {
|
useEffect(() => {
|
||||||
if (!vehicle) return;
|
if (!vehicle || !requestKey) return;
|
||||||
const { start, end } = rangeFor(range);
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setDays([]);
|
setError(null);
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetchVehicleRecent(vehicle.plate, { start, end, sourcePriority })
|
fetchVehicleRecent(vehicle.plate, {
|
||||||
.then(d => { if (!cancelled) setDays(d.days); })
|
start: resolvedRange.start,
|
||||||
.catch(() => { if (!cancelled) setDays([]); })
|
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); });
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [vehicle?.plate, range, sourcePriority]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [requestKey, requestVersion]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// 锁滚动
|
// 锁滚动
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -95,8 +115,11 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
return () => { document.body.style.overflow = ''; };
|
return () => { document.body.style.overflow = ''; };
|
||||||
}, [vehicle]);
|
}, [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 stats = useMemo(() => {
|
||||||
const totalKm = historyDays.reduce((s, d) => s + d.dailyKm, 0);
|
const totalKm = historyDays.reduce((s, d) => s + d.dailyKm, 0);
|
||||||
const synced = historyDays.filter(d => d.isDataSynced).length;
|
const synced = historyDays.filter(d => d.isDataSynced).length;
|
||||||
@@ -107,12 +130,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
|
|
||||||
// 骨架天数:根据区间预估
|
// 骨架天数:根据区间预估
|
||||||
const skeletonCount = useMemo(() => {
|
const skeletonCount = useMemo(() => {
|
||||||
if (range === 'last15') return 15;
|
const start = new Date(`${resolvedRange.start}T00:00:00`);
|
||||||
const { start, end } = rangeFor(range);
|
const end = new Date(`${resolvedRange.end}T00:00:00`);
|
||||||
const s = new Date(start);
|
return Math.max(1, Math.round((end.getTime() - start.getTime()) / 86400000) + 1);
|
||||||
const e = new Date(end);
|
}, [resolvedRange.end, resolvedRange.start]);
|
||||||
return Math.max(1, Math.round((e.getTime() - s.getTime()) / 86400000));
|
const showInitialLoading = loading && !hasSnapshot;
|
||||||
}, [range]);
|
const showUnavailable = !!error && !hasSnapshot;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<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"
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="vehicle-detail-title"
|
||||||
>
|
>
|
||||||
{/* iOS 风格 drag handle —— 长按下滑可关闭 */}
|
{/* iOS 风格 drag handle —— 长按下滑可关闭 */}
|
||||||
<div
|
<div
|
||||||
@@ -160,7 +186,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<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'}`}>
|
<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 ? '在线' : '离线'}
|
{vehicle.isOnline ? '在线' : '离线'}
|
||||||
</span>
|
</span>
|
||||||
@@ -173,19 +199,22 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 时间范围切换 */}
|
{/* 时间范围切换 */}
|
||||||
<div className="px-4 pt-3">
|
<div className="px-4 pt-3">
|
||||||
<div className="relative inline-flex bg-slate-100 p-0.5 rounded-lg">
|
<div className="relative inline-flex max-w-full overflow-x-auto bg-slate-100 p-0.5 rounded-lg" role="tablist" aria-label="车辆里程区间">
|
||||||
{RANGE_TABS.map(tab => (
|
{rangeTabs.map(tab => (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setRange(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 && (
|
{range === tab.key && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -198,29 +227,49 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</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 */}
|
{/* KPI cards */}
|
||||||
<div className="px-4 py-3 grid grid-cols-3 gap-2">
|
<div className="px-4 py-3 grid grid-cols-3 gap-2">
|
||||||
<div className="bg-slate-50 rounded-xl p-2.5">
|
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||||
<div className="text-[9px] font-bold text-slate-400 uppercase">区间合计</div>
|
<div className="text-[9px] font-bold text-slate-400 uppercase">区间合计</div>
|
||||||
<div className="text-base font-black text-slate-900 leading-tight">
|
<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" />
|
||||||
: <>{Math.round(stats.totalKm).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
: 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>
|
</div>
|
||||||
<div className="bg-slate-50 rounded-xl p-2.5">
|
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||||
<div className="text-[9px] font-bold text-slate-400 uppercase">日均</div>
|
<div className="text-[9px] font-bold text-slate-400 uppercase">日均</div>
|
||||||
<div className="text-base font-black text-slate-900 leading-tight">
|
<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" />
|
||||||
: <>{Math.round(stats.avg).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
: 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>
|
</div>
|
||||||
<div className="bg-slate-50 rounded-xl p-2.5">
|
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||||
<div className="text-[9px] font-bold text-slate-400 uppercase">有数据天</div>
|
<div className="text-[9px] font-bold text-slate-400 uppercase">有数据天</div>
|
||||||
<div className="text-base font-black text-slate-900 leading-tight">
|
<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" />
|
||||||
: <>{stats.synced}<span className="text-[9px] font-bold text-slate-400 ml-0.5">/{stats.totalDays}</span></>}
|
: showUnavailable ? <span className="text-slate-300">—</span>
|
||||||
|
: <>{stats.synced}<span className="text-[9px] font-bold text-slate-400 ml-0.5">/{stats.totalDays}</span></>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,8 +282,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl border border-slate-50">
|
<div className="bg-white rounded-xl border border-slate-50">
|
||||||
<div className="h-[140px]">
|
<div className="h-[140px]">
|
||||||
{loading ? (
|
{showInitialLoading ? (
|
||||||
<SkeletonBars count={Math.min(skeletonCount, 30)} />
|
<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%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={historyDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
<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="flex-1 overflow-y-auto px-4 pb-4">
|
||||||
<div className="text-[10px] font-bold text-slate-500 mb-1.5">每日明细</div>
|
<div className="text-[10px] font-bold text-slate-500 mb-1.5">每日明细</div>
|
||||||
{loading ? (
|
{showInitialLoading ? (
|
||||||
<SkeletonList count={Math.min(skeletonCount, 15)} />
|
<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
|
<motion.div
|
||||||
key={range}
|
key={range}
|
||||||
|
|||||||
+53
-110
@@ -8,6 +8,16 @@ import type {
|
|||||||
TrendPoint,
|
TrendPoint,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { fetchJson } from '../../auth/api-client';
|
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';
|
const BASE = '/api/mileage';
|
||||||
|
|
||||||
@@ -34,6 +44,7 @@ export async function fetchMonitoring(params?: {
|
|||||||
endDate?: string;
|
endDate?: string;
|
||||||
brands?: string[];
|
brands?: string[];
|
||||||
sourcePriority?: MileageSourceGroup[];
|
sourcePriority?: MileageSourceGroup[];
|
||||||
|
force?: boolean;
|
||||||
}): Promise<MonitoringData> {
|
}): Promise<MonitoringData> {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (params?.sortBy) query.set('sortBy', params.sortBy);
|
if (params?.sortBy) query.set('sortBy', params.sortBy);
|
||||||
@@ -66,6 +77,7 @@ export async function fetchMonitoring(params?: {
|
|||||||
if (params?.startDate) query.set('startDate', params.startDate);
|
if (params?.startDate) query.set('startDate', params.startDate);
|
||||||
if (params?.endDate) query.set('endDate', params.endDate);
|
if (params?.endDate) query.set('endDate', params.endDate);
|
||||||
if (params?.sourcePriority?.length) query.set('sourcePriority', params.sourcePriority.join(','));
|
if (params?.sourcePriority?.length) query.set('sourcePriority', params.sourcePriority.join(','));
|
||||||
|
if (params?.force) query.set('force', '1');
|
||||||
const qs = query.toString();
|
const qs = query.toString();
|
||||||
return fetchJson<MonitoringData>(`${BASE}/monitoring${qs ? `?${qs}` : ''}`);
|
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 {
|
function reportDateFromUpdatedAt(updatedAt: string): string {
|
||||||
if (/^\d{4}-\d{2}-\d{2}$/.test(updatedAt)) return updatedAt;
|
if (/^\d{4}-\d{2}-\d{2}$/.test(updatedAt)) return updatedAt;
|
||||||
const d = new Date(updatedAt);
|
const d = new Date(updatedAt);
|
||||||
@@ -162,95 +142,58 @@ function reportDateFromUpdatedAt(updatedAt: string): string {
|
|||||||
return new Date().toISOString().slice(0, 10);
|
return new Date().toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
function compactTargetName(name: string): string {
|
let dailyReportRequest: Promise<DailyReportData> | null = null;
|
||||||
return name
|
let dailyReportCache: { data: DailyReportData; expiresAt: number } | null = null;
|
||||||
.replace(/^羚牛/, '')
|
const DAILY_REPORT_CACHE_MS = 60_000;
|
||||||
.replace(/辆/g, '台')
|
|
||||||
.replace(/4\.5T普货/g, '普货')
|
|
||||||
.replace(/4\.5T冷链车/g, '冷链车')
|
|
||||||
.replace(/4\.5T冷链/g, '冷链车');
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeStatus(status: string | null): string {
|
async function loadDailyReport(): Promise<DailyReportData> {
|
||||||
if (!status) return '未标注';
|
const [targets, monitoring] = await Promise.all([
|
||||||
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([
|
|
||||||
fetchTargets(),
|
fetchTargets(),
|
||||||
fetchTrend(undefined, 7),
|
|
||||||
fetchMonitoring({ limit: 1 }),
|
|
||||||
fetchMonitoring({ sortBy: 'today', sortOrder: 'desc', limit: 5 }),
|
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 => {
|
targets.map(async target => {
|
||||||
const vehicles = await fetchTargetVehicles(target.id);
|
const vehicles = await fetchTargetVehicles(target.id, reportDate);
|
||||||
return [target.id, vehicles] as const;
|
return { target, vehicles };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const targetVehiclesMap = new Map(targetVehiclesEntries);
|
|
||||||
|
|
||||||
const models: DailyReportModel[] = targets.map(target => {
|
return buildDailyReport(targets, targetVehicles, monitoring, [], reportDate);
|
||||||
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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const targetNameByPlate = new Map<string, string>();
|
export async function fetchDailyReport(force = false): Promise<DailyReportData> {
|
||||||
for (const target of targets) {
|
if (!force && dailyReportCache && dailyReportCache.expiresAt > Date.now()) {
|
||||||
const vehicles = targetVehiclesMap.get(target.id) ?? [];
|
return dailyReportCache.data;
|
||||||
for (const vehicle of vehicles) targetNameByPlate.set(vehicle.plateNumber, compactTargetName(target.targetName));
|
|
||||||
}
|
}
|
||||||
|
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 => ({
|
let dailyReportTrendRequest: Promise<TrendPoint[]> | null = null;
|
||||||
plate: vehicle.plate,
|
let dailyReportTrendCache: { data: TrendPoint[]; expiresAt: number } | null = null;
|
||||||
model: targetNameByPlate.get(vehicle.plate) || vehicle.project || '未归入考核',
|
|
||||||
status: normalizeStatus(vehicle.rentStatus),
|
|
||||||
today: vehicle.dailyKm,
|
|
||||||
customer: vehicle.customer || '未绑定客户',
|
|
||||||
}));
|
|
||||||
|
|
||||||
const zeroRisk = targetVehiclesEntries
|
export async function fetchDailyReportTrend(force = false): Promise<TrendPoint[]> {
|
||||||
.flatMap(([targetId, vehicles]) => {
|
if (!force && dailyReportTrendCache && dailyReportTrendCache.expiresAt > Date.now()) {
|
||||||
const target = targets.find(item => item.id === targetId);
|
return dailyReportTrendCache.data;
|
||||||
const model = target ? compactTargetName(target.targetName) : '未归入考核';
|
}
|
||||||
return vehicles
|
if (!force && dailyReportTrendRequest) return dailyReportTrendRequest;
|
||||||
.filter(vehicle => vehicle.todayMileage <= 0 && ['自营', '租赁'].includes(normalizeStatus(vehicle.rentStatus)))
|
const request = fetchTrend(undefined, 7);
|
||||||
.map(vehicle => ({
|
dailyReportTrendRequest = request;
|
||||||
plate: vehicle.plateNumber,
|
try {
|
||||||
model,
|
const data = await request;
|
||||||
status: normalizeStatus(vehicle.rentStatus),
|
dailyReportTrendCache = { data, expiresAt: Date.now() + DAILY_REPORT_CACHE_MS };
|
||||||
customer: vehicle.customer || '未绑定客户',
|
return data;
|
||||||
completion: vehicle.completionRate,
|
} finally {
|
||||||
}));
|
if (dailyReportTrendRequest === request) dailyReportTrendRequest = null;
|
||||||
})
|
}
|
||||||
.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),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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')}`;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -64,6 +64,8 @@ export interface MonitoringData {
|
|||||||
export interface TargetSummary {
|
export interface TargetSummary {
|
||||||
id: number;
|
id: number;
|
||||||
targetName: string;
|
targetName: string;
|
||||||
|
snapshotUpdatedAt: string | null;
|
||||||
|
declaredVehicleCount: number;
|
||||||
vehicleCount: number;
|
vehicleCount: number;
|
||||||
totalMileagePerVehicle: number;
|
totalMileagePerVehicle: number;
|
||||||
annualMileagePerVehicle: 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';
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import pool from '../db.js';
|
import pool from '../db.js';
|
||||||
|
import { requireEnv } from '../config.js';
|
||||||
import type { AuthUser, JwtPayload, PermissionLevel } from './types.js';
|
import type { AuthUser, JwtPayload, PermissionLevel } from './types.js';
|
||||||
import { FULL_ACCESS_ROLES, DEPT_ACCESS_ROLES } from './types.js';
|
import { FULL_ACCESS_ROLES, DEPT_ACCESS_ROLES } from './types.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
const EXTERNAL_API_BASE = process.env.EXTERNAL_API_BASE || 'https://beta.lnh2e.com';
|
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 */
|
/** GET /api/auth/exchange?jumpToken=xxx — 一步完成:换取用户信息 + 签发 JWT */
|
||||||
app.get('/exchange', async (c) => {
|
app.get('/exchange', async (c) => {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { Context, Next } from 'hono';
|
import type { Context, Next } from 'hono';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { requireEnv } from '../config.js';
|
||||||
import type { JwtPayload, AuthUser } from './types.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;
|
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();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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' }));
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+17
-14
@@ -1,17 +1,20 @@
|
|||||||
import mysql from 'mysql2/promise';
|
import mysql, { type Pool } from 'mysql2/promise';
|
||||||
import dotenv from 'dotenv';
|
import { envPort, requireEnv } from './config.js';
|
||||||
|
|
||||||
dotenv.config();
|
let hydrogenPool: Pool | null = null;
|
||||||
|
|
||||||
const hydrogenPool = mysql.createPool({
|
export function getHydrogenPool(): Pool {
|
||||||
host: process.env.HYDROGEN_DB_HOST || '47.99.185.173',
|
if (hydrogenPool) return hydrogenPool;
|
||||||
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',
|
|
||||||
waitForConnections: true,
|
|
||||||
connectionLimit: 5,
|
|
||||||
queueLimit: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default 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,
|
||||||
|
});
|
||||||
|
return hydrogenPool;
|
||||||
|
}
|
||||||
|
|||||||
+5
-1
@@ -11,6 +11,8 @@ import eleRouter from './routes/ele/index.js';
|
|||||||
import feedbackRouter from './routes/feedback/index.js';
|
import feedbackRouter from './routes/feedback/index.js';
|
||||||
import vehicleHeatmapRouter from './routes/vehicle-heatmap.js';
|
import vehicleHeatmapRouter from './routes/vehicle-heatmap.js';
|
||||||
import hydrogenHeatmapRouter from './routes/hydrogen-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 { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
|
||||||
import authRouter from './auth/login.js';
|
import authRouter from './auth/login.js';
|
||||||
import { authMiddleware } from './auth/middleware.js';
|
import { authMiddleware } from './auth/middleware.js';
|
||||||
@@ -23,6 +25,7 @@ app.use('/api/*', cors());
|
|||||||
|
|
||||||
// Auth 路由(不需要中间件)
|
// Auth 路由(不需要中间件)
|
||||||
app.route('/api/auth', authRouter);
|
app.route('/api/auth', authRouter);
|
||||||
|
app.route('/api/health', healthRouter);
|
||||||
|
|
||||||
// Auth 中间件(保护后续所有 /api/* 路由)
|
// Auth 中间件(保护后续所有 /api/* 路由)
|
||||||
app.use('/api/*', authMiddleware);
|
app.use('/api/*', authMiddleware);
|
||||||
@@ -35,8 +38,9 @@ app.route('/api/ele', eleRouter);
|
|||||||
app.route('/api/feedback', feedbackRouter);
|
app.route('/api/feedback', feedbackRouter);
|
||||||
app.route('/api/vehicle-heatmap', vehicleHeatmapRouter);
|
app.route('/api/vehicle-heatmap', vehicleHeatmapRouter);
|
||||||
app.route('/api/hydrogen-heatmap', hydrogenHeatmapRouter);
|
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
|
// Serve static files in production
|
||||||
app.use('/*', serveStatic({ root: './dist' }));
|
app.use('/*', serveStatic({ root: './dist' }));
|
||||||
|
|||||||
+17
-14
@@ -1,17 +1,20 @@
|
|||||||
import mysql from 'mysql2/promise';
|
import mysql, { type Pool } from 'mysql2/promise';
|
||||||
import dotenv from 'dotenv';
|
import { envPort, requireEnv } from './config.js';
|
||||||
|
|
||||||
dotenv.config();
|
let mileagePool: Pool | null = null;
|
||||||
|
|
||||||
const mileagePool = mysql.createPool({
|
export function getMileagePool(): Pool {
|
||||||
host: process.env.MILEAGE_DB_HOST || '101.133.130.65',
|
if (mileagePool) return mileagePool;
|
||||||
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',
|
|
||||||
waitForConnections: true,
|
|
||||||
connectionLimit: 5,
|
|
||||||
queueLimit: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default 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,
|
||||||
|
});
|
||||||
|
return mileagePool;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { observeDataSource, type DataSourceId } from '../../source-telemetry.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SWR 缓存:始终返回热数据,后台定时刷新。
|
* SWR 缓存:始终返回热数据,后台定时刷新。
|
||||||
*
|
*
|
||||||
@@ -70,24 +72,28 @@ async function runRefresh(key: string) {
|
|||||||
|
|
||||||
export interface CachedOpts {
|
export interface CachedOpts {
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
|
source?: DataSourceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cached<T>(key: string, loader: () => Promise<T>, opts: CachedOpts = {}): Promise<T> {
|
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 now = Date.now();
|
||||||
const hit = cache.get(key) as Entry<T> | undefined;
|
const hit = cache.get(key) as Entry<T> | undefined;
|
||||||
if (hit) {
|
if (hit) {
|
||||||
hit.lastAccess = now;
|
hit.lastAccess = now;
|
||||||
hit.loader = loader;
|
hit.loader = effectiveLoader;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 强制刷新:等待 loader 完成
|
// 强制刷新:等待 loader 完成
|
||||||
if (opts.force) {
|
if (opts.force) {
|
||||||
const ongoing = inflight.get(key) as Promise<T> | undefined;
|
const ongoing = inflight.get(key) as Promise<T> | undefined;
|
||||||
if (ongoing) return ongoing;
|
if (ongoing) return ongoing;
|
||||||
const p = loader()
|
const p = effectiveLoader()
|
||||||
.then(value => {
|
.then(value => {
|
||||||
const t = Date.now();
|
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);
|
cache.set(key, next);
|
||||||
scheduleRefresh(key, next);
|
scheduleRefresh(key, next);
|
||||||
return value;
|
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;
|
const ongoing = inflight.get(key) as Promise<T> | undefined;
|
||||||
if (ongoing) return ongoing;
|
if (ongoing) return ongoing;
|
||||||
|
|
||||||
const p = loader()
|
const p = effectiveLoader()
|
||||||
.then(value => {
|
.then(value => {
|
||||||
const t = Date.now();
|
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);
|
cache.set(key, entry);
|
||||||
scheduleRefresh(key, entry);
|
scheduleRefresh(key, entry);
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { RowDataPacket } from 'mysql2';
|
import type { RowDataPacket } from 'mysql2';
|
||||||
import pool from '../../db.js';
|
import pool from '../../db.js';
|
||||||
import hydrogenPool from '../../hydrogen-db.js';
|
import { getHydrogenPool } from '../../hydrogen-db.js';
|
||||||
import { cached } from './cache.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 type { AuthUser } from '../../auth/types.js';
|
||||||
import { canAccessEnergy } from '../../auth/types.js';
|
import { canAccessEnergy } from '../../auth/types.js';
|
||||||
|
import { observeDataSource } from '../../source-telemetry.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -18,6 +32,26 @@ app.use('*', async (c, next) => {
|
|||||||
return 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';
|
const HYDROGEN_MIN_DATE = '2024-01-01';
|
||||||
|
|
||||||
// hydrogen_fuel_ledger.refuel_time 已是业务本地时间字面值,直接使用即可(不再 +8 小时)
|
// 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 HYDROGEN_BASE_WHERE_B = `b.del_flag = '0'`;
|
||||||
const ELECTRIC_LOCAL = `charging_start_time`;
|
const ELECTRIC_LOCAL = `charging_start_time`;
|
||||||
|
|
||||||
type CustomerKind = 'external' | 'lingniu' | 'all';
|
|
||||||
|
|
||||||
// 新账本 hydrogen_fuel_ledger 当前只承载羚牛车辆订单;外部车辆数据源待接入。
|
// 新账本 hydrogen_fuel_ledger 当前只承载羚牛车辆订单;外部车辆数据源待接入。
|
||||||
function customerClause(customer: CustomerKind): string {
|
function customerClause(customer: HydrogenCustomerKind): string {
|
||||||
if (customer === 'external') return '1=0';
|
if (customer === 'external') return '1=0';
|
||||||
if (customer === 'lingniu') return '1=1';
|
if (customer === 'lingniu') return '1=1';
|
||||||
return '1=1';
|
return '1=1';
|
||||||
@@ -121,6 +153,7 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
const requestedYear = yearParam ? Number(yearParam) || todayYear : todayYear;
|
const requestedYear = yearParam ? Number(yearParam) || todayYear : todayYear;
|
||||||
|
|
||||||
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
|
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
|
||||||
|
const hydrogenPool = getHydrogenPool();
|
||||||
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
|
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
|
||||||
const [yearListRows] = await hydrogenPool.query<RowDataPacket[]>(
|
const [yearListRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
|
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
|
||||||
@@ -229,6 +262,7 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
);
|
);
|
||||||
const top5KgSum = kpi.yearKg || 1;
|
const top5KgSum = kpi.yearKg || 1;
|
||||||
const top5 = top5Rows.map((r, i) => ({
|
const top5 = top5Rows.map((r, i) => ({
|
||||||
|
id: Number(r.id) || 0,
|
||||||
rank: i + 1,
|
rank: i + 1,
|
||||||
name: r.name as string,
|
name: r.name as string,
|
||||||
kg: Number(r.kg) || 0,
|
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 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 stationRevSum = stationFullRows.reduce((s, r) => s + (Number(r.revenue) || 0), 0) || 1;
|
||||||
const stations = stationFullRows.map(r => ({
|
const stations = stationFullRows.map(r => ({
|
||||||
|
id: Number(r.id) || 0,
|
||||||
name: r.name as string,
|
name: r.name as string,
|
||||||
kg: Number(r.kg) || 0,
|
kg: Number(r.kg) || 0,
|
||||||
revenue: Number(r.revenue) || 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 };
|
return { kpi, top5, regions, monthly, customers, stations, availableYears, year };
|
||||||
}, { force });
|
}, { force, source: 'hydrogenDatabase' });
|
||||||
return c.json(data);
|
return c.json(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// =========================================================
|
// =========================================================
|
||||||
// 氢能 每日:日期范围 + 客户类型 + 站点级下钻
|
// 氢能 每日:日期范围 + 车辆归属 + 站点/客户下钻
|
||||||
// =========================================================
|
// =========================================================
|
||||||
app.get('/hydrogen/daily', async (c) => {
|
app.get('/hydrogen/daily', async (c) => {
|
||||||
const range = (c.req.query('range') || 'last15') as Range;
|
const range = (c.req.query('range') || 'last15') as Range;
|
||||||
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
|
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 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,
|
HYDROGEN_BASE_WHERE_B,
|
||||||
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
|
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
|
||||||
dateRangeClause(`b.${HYDROGEN_LOCAL}`),
|
dateRangeClause(`b.${HYDROGEN_LOCAL}`),
|
||||||
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
|
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
|
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
||||||
// 站点名 fallback:站点主数据 → 账本冗余站点名 → 未关联站点
|
// 站点名 fallback:站点主数据 → 账本冗余站点名 → 未关联站点
|
||||||
@@ -399,7 +455,7 @@ app.get('/hydrogen/daily', async (c) => {
|
|||||||
WHERE ${where}
|
WHERE ${where}
|
||||||
GROUP BY d, COALESCE(b.station_id, 0)
|
GROUP BY d, COALESCE(b.station_id, 0)
|
||||||
ORDER BY d DESC, kg DESC`,
|
ORDER BY d DESC, kg DESC`,
|
||||||
[dateRange.start, dateRange.end],
|
whereParams,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 站点环比:同站点上一条记录的 kg
|
// 站点环比:同站点上一条记录的 kg
|
||||||
@@ -459,6 +515,7 @@ app.get('/hydrogen/daily', async (c) => {
|
|||||||
customerType: customer,
|
customerType: customer,
|
||||||
stations: info
|
stations: info
|
||||||
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
|
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
|
||||||
|
stationId: s.stationId,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
pricePerKg: Math.round(s.pricePerKg * 100) / 100,
|
pricePerKg: Math.round(s.pricePerKg * 100) / 100,
|
||||||
kg: Math.round(s.kg * 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));
|
const result = ascDays.slice().sort((a, b) => b.date.localeCompare(a.date));
|
||||||
return result;
|
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);
|
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')
|
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||||
THEN fee ELSE 0 END) AS monthFee,
|
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 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`,
|
FROM bi_ele_charge_record`,
|
||||||
);
|
);
|
||||||
const k = kpiRows[0] ?? {};
|
const k = kpiRows[0] ?? {};
|
||||||
@@ -508,6 +676,7 @@ app.get('/electric/overview', async (c) => {
|
|||||||
const monthFee = Number(k.monthFee) || 0;
|
const monthFee = Number(k.monthFee) || 0;
|
||||||
const todayKwh = Number(k.todayKwh) || 0;
|
const todayKwh = Number(k.todayKwh) || 0;
|
||||||
const todayFee = Number(k.todayFee) || 0;
|
const todayFee = Number(k.todayFee) || 0;
|
||||||
|
const latestRecordAt = k.latestRecordAt ? String(k.latestRecordAt) : null;
|
||||||
|
|
||||||
// 本月每日(用于柱图)
|
// 本月每日(用于柱图)
|
||||||
const [trendRows] = await pool.query<RowDataPacket[]>(
|
const [trendRows] = await pool.query<RowDataPacket[]>(
|
||||||
@@ -560,8 +729,83 @@ app.get('/electric/overview', async (c) => {
|
|||||||
return {
|
return {
|
||||||
kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct },
|
kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct },
|
||||||
trend: trendArr,
|
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);
|
return c.json(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -571,7 +815,8 @@ app.get('/electric/overview', async (c) => {
|
|||||||
// 缺失日期补零
|
// 缺失日期补零
|
||||||
// =========================================================
|
// =========================================================
|
||||||
app.get('/electric/monthly', 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 range = (c.req.query('range') || 'last15') as Range;
|
||||||
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
|
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
|
||||||
const force = c.req.query('force') === '1';
|
const force = c.req.query('force') === '1';
|
||||||
@@ -650,8 +895,284 @@ app.get('/electric/monthly', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return months;
|
return months;
|
||||||
}, { force });
|
}, { force, source: 'electricDatabase' });
|
||||||
return c.json(data);
|
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;
|
export default app;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -8,6 +8,20 @@ import {
|
|||||||
parseMileageSourcePriority,
|
parseMileageSourcePriority,
|
||||||
protocolsForSourcePriority,
|
protocolsForSourcePriority,
|
||||||
} from './source-policy.js';
|
} 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();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -115,6 +129,7 @@ app.get('/', async (c) => {
|
|||||||
const range = normalizeRange(c.req.query('startDate') || '', c.req.query('endDate') || '');
|
const range = normalizeRange(c.req.query('startDate') || '', c.req.query('endDate') || '');
|
||||||
const sourcePriority = parseMileageSourcePriority(c.req.query('sourcePriority'));
|
const sourcePriority = parseMileageSourcePriority(c.req.query('sourcePriority'));
|
||||||
const protocolPriority = protocolsForSourcePriority(sourcePriority);
|
const protocolPriority = protocolsForSourcePriority(sourcePriority);
|
||||||
|
const force = c.req.query('force') === '1';
|
||||||
|
|
||||||
const filterParams = {
|
const filterParams = {
|
||||||
search: c.req.query('search') || '',
|
search: c.req.query('search') || '',
|
||||||
@@ -136,17 +151,73 @@ app.get('/', async (c) => {
|
|||||||
let filters: MonitoringFilters;
|
let filters: MonitoringFilters;
|
||||||
let rangeDailyTotals: { date: string; totalKm: number }[] | undefined;
|
let rangeDailyTotals: { date: string; totalKm: number }[] | undefined;
|
||||||
let dateRange: { start: string; end: string } | undefined;
|
let dateRange: { start: string; end: string } | undefined;
|
||||||
|
let dataUpdatedAt: string | undefined;
|
||||||
|
let cacheStatus: 'hit' | 'manual-hit' | 'refresh' | 'miss' | 'bypass' | RangeSnapshotStatus = 'bypass';
|
||||||
|
|
||||||
if (range) {
|
if (range) {
|
||||||
try {
|
const cache = getCache();
|
||||||
const result = await queryRangeMileage(range.start, range.end, protocolPriority);
|
const today = shanghaiDate();
|
||||||
allVehicles = result.vehicles;
|
const snapshotScope = isMonitoringSnapshotScope({
|
||||||
rangeDailyTotals = result.dailyTotals;
|
startDate: range.start,
|
||||||
dateRange = { start: result.start, end: result.end };
|
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);
|
filters = buildDateFilters(allVehicles);
|
||||||
} catch (e: unknown) {
|
} else if (cacheEligible && cache) {
|
||||||
console.error('monitoring range query error:', e);
|
cacheStatus = 'hit';
|
||||||
return c.json(EMPTY_RESPONSE, 500);
|
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 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) {
|
} else if (date) {
|
||||||
try {
|
try {
|
||||||
@@ -186,9 +257,9 @@ app.get('/', async (c) => {
|
|||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
totalToday: Math.round(filtered.reduce((sum, v) => sum + v.dailyKm, 0)),
|
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,
|
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) => {
|
const sorted = [...filtered].sort((a, b) => {
|
||||||
@@ -218,6 +289,7 @@ app.get('/', async (c) => {
|
|||||||
const paged = sorted.slice(offset, offset + limit);
|
const paged = sorted.slice(offset, offset + limit);
|
||||||
const total = filtered.length;
|
const total = filtered.length;
|
||||||
|
|
||||||
|
c.header('X-Mileage-Cache', cacheStatus);
|
||||||
return c.json({
|
return c.json({
|
||||||
vehicles: maskCustomerNames(paged),
|
vehicles: maskCustomerNames(paged),
|
||||||
stats,
|
stats,
|
||||||
@@ -227,7 +299,7 @@ app.get('/', async (c) => {
|
|||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
totalPages: Math.ceil(total / limit),
|
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,
|
sourcePriority,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
normalizeOneOsProtocol,
|
normalizeOneOsProtocol,
|
||||||
type OneOsProtocol,
|
type OneOsProtocol,
|
||||||
} from './source-policy.js';
|
} from './source-policy.js';
|
||||||
|
import { observeDataSource } from '../../source-telemetry.js';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -313,7 +314,7 @@ export async function fetchOneOsDailyMileage(
|
|||||||
} else {
|
} else {
|
||||||
let pending = inflight.get(cacheKey);
|
let pending = inflight.get(cacheKey);
|
||||||
if (!pending) {
|
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;
|
const ttl = date === shanghaiDate() ? CURRENT_DAY_TTL_MS : HISTORICAL_TTL_MS;
|
||||||
cache.delete(cacheKey);
|
cache.delete(cacheKey);
|
||||||
if (ttl > 0) {
|
if (ttl > 0) {
|
||||||
@@ -372,12 +373,12 @@ export async function fetchOneOsMileageDates(
|
|||||||
].join(':');
|
].join(':');
|
||||||
let pending = rangeInflight.get(key);
|
let pending = rangeInflight.get(key);
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
pending = requestRange(
|
pending = observeDataSource('oneOsMileage', () => requestRange(
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
selectedPlates,
|
selectedPlates,
|
||||||
protocolPriority,
|
protocolPriority,
|
||||||
).finally(() => rangeInflight.delete(key));
|
)).finally(() => rangeInflight.delete(key));
|
||||||
rangeInflight.set(key, pending);
|
rangeInflight.set(key, pending);
|
||||||
}
|
}
|
||||||
const rowsByDate = await 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);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
}]),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { getCache } from './cache.js';
|
|||||||
import { fetchOneOsDailyMileage } from './oneos-api.js';
|
import { fetchOneOsDailyMileage } from './oneos-api.js';
|
||||||
import { fetchVehicleInfoByPlates } from './vehicle-info.js';
|
import { fetchVehicleInfoByPlates } from './vehicle-info.js';
|
||||||
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.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();
|
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(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_task) as current_year_target,
|
||||||
SUM(current_year_mileage) as current_year_completed,
|
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
|
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
|
||||||
GROUP BY target_id
|
GROUP BY target_id
|
||||||
`) as [any[], unknown];
|
`) as [any[], unknown];
|
||||||
@@ -197,7 +200,9 @@ app.get('/', async (c) => {
|
|||||||
return {
|
return {
|
||||||
id: t.id,
|
id: t.id,
|
||||||
targetName: t.target_name,
|
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),
|
totalMileagePerVehicle: Number(t.total_mileage_per_vehicle),
|
||||||
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
|
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
|
||||||
assessmentYears: t.assessment_years,
|
assessmentYears: t.assessment_years,
|
||||||
@@ -252,11 +257,20 @@ app.get('/:id/vehicles', async (c) => {
|
|||||||
) as [any[], unknown];
|
) as [any[], unknown];
|
||||||
|
|
||||||
const plates: string[] = rows.map((r: any) => r.plate_number);
|
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 }>();
|
const dateMileageMap = cachedDateMileageMap
|
||||||
if (date && plates.length > 0) {
|
|| new Map<string, { dailyKm: number; totalKm: number | null; isOnline: boolean }>();
|
||||||
const mileageRows = await fetchOneOsDailyMileage(date, plates);
|
if (!cachedDateMileageMap && date && plates.length > 0) {
|
||||||
for (const m of mileageRows) {
|
for (const m of mileageRows) {
|
||||||
const existing = dateMileageMap.get(m.plateNumber);
|
const existing = dateMileageMap.get(m.plateNumber);
|
||||||
const dailyKm = m.status === 'NORMAL' ? (Number(m.dailyMileageKm) || 0) : 0;
|
const dailyKm = m.status === 'NORMAL' ? (Number(m.dailyMileageKm) || 0) : 0;
|
||||||
|
|||||||
@@ -98,7 +98,11 @@ app.get('/:plate/recent', async (c) => {
|
|||||||
});
|
});
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error('vehicle recent error:', e);
|
console.error('vehicle recent error:', e);
|
||||||
return c.json({ plate, days: [] }, 500);
|
return c.json({
|
||||||
|
error: '车辆近期里程服务暂时不可用',
|
||||||
|
code: 'MILEAGE_RECENT_UNAVAILABLE',
|
||||||
|
retryable: true,
|
||||||
|
}, 503);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user