Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,206 @@
|
|||||||
|
# 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 状态;目标、部门/客户归因和车辆清单下钻继承同一年度,无效年度会按目标真实年度归一。
|
||||||
|
- 已完成氢能站点、客户下钻代码。
|
||||||
|
- 已完成氢能每日趋势到加氢订单的 URL 下钻、分页和结构化故障状态;待数据源恢复后验收真实明细。
|
||||||
|
- 已为电能总览与氢能每日趋势柱图统一 Enter/Space 键盘下钻;氢能有效日期柱提供可读名称、焦点和选中反馈。
|
||||||
|
- 已完成电能全部车辆、日期和订单下钻。
|
||||||
|
- 电能每日列表打开新日期订单使用浏览器历史 `pushState`,返回/前进可恢复进入前后的分析状态;筛选调整仍使用 `replaceState`。
|
||||||
|
- 已完成 ETC 通行记录与结算账单下钻代码、筛选、分页和未同步空状态;待首次真实同步后验收真实数据。
|
||||||
|
|
||||||
|
### 阶段 C:数据可信度
|
||||||
|
|
||||||
|
- 里程年度归因使用考核台账 `current_mileage` 快照,按车辆目标封顶后加总完成量与缺口,并自动核对归因合计和目标汇总;带日期的 OneOS 里程只用于车辆实时明细。
|
||||||
|
- 里程考核目标接口发布车辆台账 `MAX(update_time)`,统计页使用真实日期和时间展示数据截至点,不再用页面访问日或考核结束日代替快照时间。
|
||||||
|
- 已完成电能数据截至时间和实际趋势月展示。
|
||||||
|
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
|
||||||
|
- 已完成氢能结构化故障状态与重试体验。
|
||||||
|
- 已完成里程今日仪表快照复用:监控查询和考核车辆当前日明细命中同一分钟级快照,手动刷新生成一致的分页快照;历史日仍使用独立日期缓存。
|
||||||
|
- 已在各 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+174
-69
@@ -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 }}
|
|
||||||
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
|
|
||||||
>
|
|
||||||
<div className="relative w-20 h-20 mb-4">
|
|
||||||
<motion.div
|
|
||||||
animate={{ rotate: [0, -8, 8, -4, 4, 0] }}
|
|
||||||
transition={{ duration: 2.4, repeat: Infinity, ease: 'easeInOut' }}
|
|
||||||
className="absolute inset-0 rounded-3xl bg-gradient-to-br from-amber-50 to-orange-50 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<Construction size={36} className="text-amber-500" strokeWidth={2.2} />
|
|
||||||
</motion.div>
|
|
||||||
<motion.div
|
|
||||||
animate={{ rotate: [0, 18, -10, 0], y: [0, -2, 1, 0] }}
|
|
||||||
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
|
|
||||||
className="absolute -top-1 -right-1 w-9 h-9 rounded-2xl bg-white border border-amber-100 shadow-sm flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<Hammer size={16} className="text-amber-500" strokeWidth={2.2} />
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-base font-black text-slate-800 mb-1.5">ETC 模块建设中</div>
|
|
||||||
<div className="text-[12px] text-slate-500 font-bold leading-relaxed max-w-[280px]">
|
|
||||||
通行费明细、按车按月统计、运营成本拆分
|
|
||||||
<br />
|
|
||||||
这些数据都在路上啦
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 简单的里程碑进度感 */}
|
|
||||||
<div className="mt-6 w-full max-w-xs space-y-2">
|
|
||||||
{[
|
|
||||||
{ label: '需求评审', done: true },
|
|
||||||
{ label: '数据对接', done: true },
|
|
||||||
{ label: '页面开发', done: false, current: true },
|
|
||||||
{ label: '正式上线', done: false },
|
|
||||||
].map((m, i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
initial={{ opacity: 0, x: -8 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ delay: 0.1 + i * 0.08, duration: 0.3 }}
|
|
||||||
className="flex items-center gap-2.5 text-[11px]"
|
|
||||||
>
|
|
||||||
<span className={`w-3 h-3 rounded-full flex-shrink-0 ${
|
|
||||||
m.done ? 'bg-emerald-400'
|
|
||||||
: m.current ? 'bg-amber-400 ring-4 ring-amber-100 animate-pulse'
|
|
||||||
: 'bg-slate-200'
|
|
||||||
}`} />
|
|
||||||
<span className={`font-bold ${m.done ? 'text-slate-500' : m.current ? 'text-amber-600' : 'text-slate-300'}`}>
|
|
||||||
{m.label}
|
|
||||||
</span>
|
</span>
|
||||||
{m.done && <span className="text-[10px] text-emerald-500 font-bold ml-auto">已完成</span>}
|
<div className="min-w-0 flex-1">
|
||||||
{m.current && <span className="text-[10px] text-amber-500 font-bold ml-auto">进行中</span>}
|
<div className="text-xs font-black text-slate-800">
|
||||||
</motion.div>
|
{integration.providerConfigured ? 'ETC 数据提供方已配置' : 'ETC 数据提供方尚未配置'}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
<div className="mt-1 truncate text-[10px] font-bold text-slate-400">
|
||||||
|
{integration.providerConfigured
|
||||||
|
? `${integration.providerType || '已配置提供方'} · ${syncStatusLabel(integration.lastSyncStatus)}`
|
||||||
|
: '数据库结构已准备,等待配置同步账户并完成首次同步'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void load(true)}
|
||||||
|
disabled={refreshing}
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 hover:text-blue-600 disabled:opacity-50"
|
||||||
|
aria-label="刷新 ETC 状态"
|
||||||
|
title="刷新 ETC 状态"
|
||||||
|
>
|
||||||
|
<RefreshCw size={15} className={refreshing ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</SurfaceCard>
|
||||||
|
|
||||||
<RotatingFooterHint hints={ETC_HINTS} />
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
|
<DrilldownMetricButton label={`查看 ETC 通行记录,总费用 ${fmtMoney(kpi.totalAmount)}`} title="查看通行记录" onClick={() => openDetailView('records')}>
|
||||||
|
<MetricTile icon={ReceiptText} label="ETC 总费用" value={fmtMoney(kpi.totalAmount)} helper={`通行费 ${fmtMoney(kpi.tollAmount)} · 服务费 ${fmtMoney(kpi.serviceFee)}`} />
|
||||||
|
</DrilldownMetricButton>
|
||||||
|
<DrilldownMetricButton label={`查看 ETC 通行记录,共 ${kpi.passageCount} 次`} title="查看通行记录" onClick={() => openDetailView('records')}>
|
||||||
|
<MetricTile icon={Route} label="通行次数" value={kpi.passageCount} unit="次" helper={kpi.latestTransactionTime ? `最新 ${kpi.latestTransactionTime}` : '暂无通行记录'} tone="emerald" />
|
||||||
|
</DrilldownMetricButton>
|
||||||
|
<DrilldownMetricButton label={`查看 ETC 通行记录,共 ${kpi.vehicleCount} 辆车`} title="查看通行记录" onClick={() => openDetailView('records')}>
|
||||||
|
<MetricTile icon={Truck} label="通行车辆" value={kpi.vehicleCount} unit="辆" helper="按车牌去重" tone="amber" />
|
||||||
|
</DrilldownMetricButton>
|
||||||
|
<DrilldownMetricButton label={`查看 ETC 结算账单,应收 ${fmtMoney(kpi.receivableAmount)}`} title="查看结算账单" onClick={() => openDetailView('bills')}>
|
||||||
|
<MetricTile icon={Database} label="账单应收" value={fmtMoney(kpi.receivableAmount)} helper={`${kpi.billCount} 账单 · 已收 ${fmtMoney(kpi.paidAmount)} · ${collectionRate.toFixed(1)}%`} tone="slate" />
|
||||||
|
</DrilldownMetricButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<SurfaceCard className="p-3">
|
||||||
|
<div className="text-[10px] font-black text-slate-400">提供方配置</div>
|
||||||
|
<div className={`mt-1 text-sm font-black ${integration.providerConfigured ? 'text-emerald-600' : 'text-amber-600'}`}>
|
||||||
|
{integration.providerConfigured ? '已配置' : '未配置'}
|
||||||
|
</div>
|
||||||
|
</SurfaceCard>
|
||||||
|
<SurfaceCard className="p-3">
|
||||||
|
<div className="text-[10px] font-black text-slate-400">自动同步</div>
|
||||||
|
<div className={`mt-1 text-sm font-black ${integration.syncEnabled ? 'text-emerald-600' : 'text-slate-500'}`}>
|
||||||
|
{integration.syncEnabled ? '已启用' : '未启用'}
|
||||||
|
</div>
|
||||||
|
</SurfaceCard>
|
||||||
|
<SurfaceCard className="p-3">
|
||||||
|
<div className="flex items-center gap-1 text-[10px] font-black text-slate-400"><Clock3 size={11} />最近同步</div>
|
||||||
|
<div className="mt-1 text-sm font-black text-slate-700">{syncStatusLabel(integration.lastSyncStatus)}</div>
|
||||||
|
<div className="mt-1 text-[10px] font-bold text-slate-400">{integration.lastSyncAt || '无同步记录'}</div>
|
||||||
|
</SurfaceCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ETCDetails context={detailContext} overviewKpi={kpi} onContextChange={commitDetailContext} />
|
||||||
|
|
||||||
|
{error && <ErrorState message={error} />}
|
||||||
</div>
|
</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">
|
|
||||||
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
|
||||||
<span className="block text-[10px] font-black text-slate-400">开始日期</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={dateRange.start}
|
|
||||||
onChange={e => updateDateRange('start', e.target.value)}
|
|
||||||
onInput={e => updateDateRange('start', e.currentTarget.value)}
|
|
||||||
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
|
|
||||||
/>
|
/>
|
||||||
</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">
|
<AnalysisScopeSwitch
|
||||||
{(['lingniu', 'external'] as const).map(c => (
|
ariaLabel="电能车辆范围"
|
||||||
<button
|
value={vehicleScope}
|
||||||
key={c}
|
options={VEHICLE_SCOPE_OPTIONS}
|
||||||
onClick={() => setCustomer(c)}
|
onChange={updateVehicleScope}
|
||||||
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
|
icon={Truck}
|
||||||
customer === c ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
className="mt-2"
|
||||||
}`}
|
/>
|
||||||
>
|
|
||||||
<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"
|
||||||
|
onClick={() => toggleDate(d.date)}
|
||||||
|
className={`grid w-full grid-cols-[minmax(0,1fr)_120px_88px] gap-3 px-3 py-2 pl-9 text-left md:grid-cols-[minmax(0,1fr)_160px_120px] ${abnormalBg}`}
|
||||||
|
aria-expanded={selectedDate === d.date}
|
||||||
|
aria-controls={`electric-orders-${d.date}`}
|
||||||
>
|
>
|
||||||
<span className="text-[12px] text-slate-600">{d.date.slice(5)}</span>
|
<span className="flex items-center gap-1 text-[12px] font-bold text-slate-600">
|
||||||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
<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 })}
|
{d.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-right"><TrendBadge value={d.chainPct} /></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}>
|
||||||
|
<div
|
||||||
|
id={`electric-view-panel-${sub}`}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={`electric-view-tab-${sub}`}
|
||||||
|
>
|
||||||
<ElectricView sub={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">
|
|
||||||
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
|
||||||
<span className="block text-[10px] font-black text-slate-400">开始日期</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={dateRange.start}
|
|
||||||
onChange={e => updateDateRange('start', e.target.value)}
|
|
||||||
onInput={e => updateDateRange('start', e.currentTarget.value)}
|
|
||||||
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
|
|
||||||
/>
|
/>
|
||||||
</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">
|
<AnalysisScopeSwitch
|
||||||
{(['lingniu', 'external'] as const).map(c => (
|
ariaLabel="氢能车辆范围"
|
||||||
<button
|
value={vehicleScope}
|
||||||
key={c}
|
options={VEHICLE_SCOPE_OPTIONS}
|
||||||
onClick={() => setCustomer(c)}
|
onChange={updateVehicleScope}
|
||||||
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
|
icon={Truck}
|
||||||
customer === c ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
className="mt-2"
|
||||||
}`}
|
/>
|
||||||
>
|
|
||||||
<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}`}>
|
||||||
|
<div className={`grid w-full grid-cols-[minmax(0,1fr)_72px_64px] gap-2 px-3 py-2.5 text-left transition-colors hover:bg-slate-50/60 md:grid-cols-[minmax(0,1fr)_140px_120px_104px] md:gap-3 ${selectedDate === r.date ? 'bg-blue-50/70' : ''}`}>
|
||||||
|
<span className="flex min-w-0 items-center gap-1">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => toggle(r.date)}
|
onClick={() => toggle(r.date)}
|
||||||
className="w-full grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2.5 text-left hover:bg-slate-50/60 transition-colors"
|
className="flex min-w-0 items-center gap-1 text-[12px] font-bold text-slate-700"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={`${open ? '收起' : '展开'} ${r.date} 加氢站明细`}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-1 text-[12px] text-slate-700 font-bold">
|
<ChevronRight size={14} className={`shrink-0 transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
|
||||||
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
|
<span className="truncate">{r.date}</span>
|
||||||
{r.date}
|
</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}>
|
||||||
|
<div
|
||||||
|
id={`hydrogen-view-panel-${sub}`}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={`hydrogen-view-tab-${sub}`}
|
||||||
|
>
|
||||||
<HydrogenView sub={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';
|
||||||
|
|
||||||
|
function fmtKm(value: number): string {
|
||||||
|
if (value >= 10000) return `${(value / 10000).toFixed(2)}万`;
|
||||||
|
return value.toLocaleString(undefined, { maximumFractionDigits: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(value: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function VehicleRow({
|
||||||
|
vehicle,
|
||||||
|
mode,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
vehicle: DailyReportVehicle;
|
||||||
|
mode: 'top' | 'risk';
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="grid w-full grid-cols-[minmax(0,1fr)_auto_18px] items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="truncate font-mono text-xs font-black text-slate-800"><Blur>{vehicle.plate}</Blur></span>
|
||||||
|
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${vehicle.isOnline ? 'bg-emerald-500' : 'bg-slate-300'}`} />
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block truncate text-[9px] font-bold text-slate-400">
|
||||||
|
{vehicle.model} · {vehicle.status} · <Blur>{vehicle.customer}</Blur>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-right">
|
||||||
|
{mode === 'top' ? (
|
||||||
|
<>
|
||||||
|
<span className="block text-xs font-black text-blue-600">{fmtKm(vehicle.today)} km</span>
|
||||||
|
<span className="block text-[9px] font-bold text-slate-400">当日里程</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="block text-xs font-black text-rose-600">{fmtKm(vehicle.shortfall)} km</span>
|
||||||
|
<span className="block text-[9px] font-bold text-slate-400">任务缺口</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={14} className="text-slate-300" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function DailyReportView() {
|
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 (
|
return (
|
||||||
<SurfaceCard className="min-h-[360px]">
|
<SurfaceCard className="min-h-[360px]">
|
||||||
<div className="flex min-h-[320px] flex-col items-center justify-center px-6 py-10 text-center">
|
<div className="flex min-h-[320px] items-center justify-center gap-2 text-xs font-bold text-slate-400">
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
|
<RefreshCw size={16} className="animate-spin" />
|
||||||
<FileText size={26} />
|
正在生成里程日报
|
||||||
</div>
|
|
||||||
<div className="mt-4 text-base font-black text-slate-800">每日汇报接入中</div>
|
|
||||||
<div className="mt-2 max-w-md text-xs font-bold leading-relaxed text-slate-400">
|
|
||||||
日报数据口径正在整理,完成后将接入数据库统计与导出能力。
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</SurfaceCard>
|
</SurfaceCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
return (
|
||||||
|
<SurfaceCard className="min-h-[360px]">
|
||||||
|
<div className="flex min-h-[320px] flex-col items-center justify-center gap-3 px-6 text-center">
|
||||||
|
<AlertTriangle size={24} className="text-rose-500" />
|
||||||
|
<div className="text-sm font-black text-slate-800">日报暂不可用</div>
|
||||||
|
<div className="max-w-md text-xs font-bold text-slate-400">{error || '数据接口未返回有效结果'}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void load(true)}
|
||||||
|
className="rounded-lg bg-slate-900 px-4 py-2 text-xs font-black text-white"
|
||||||
|
>
|
||||||
|
重新加载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</SurfaceCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 pb-4">
|
||||||
|
<section className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-50 text-blue-600 ring-1 ring-blue-100">
|
||||||
|
<CalendarDays size={17} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-black text-slate-900">{report.reportDate} 里程日报</h2>
|
||||||
|
<p className="mt-0.5 text-[10px] font-bold text-slate-400">统计时间 {fmtTime(report.updatedAt)} · 仪表数据</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void load(true)}
|
||||||
|
disabled={loading || trendLoading}
|
||||||
|
className="inline-flex h-9 items-center justify-center gap-2 rounded-lg border border-blue-100 bg-blue-50 px-3 text-xs font-black text-blue-600 transition-colors hover:bg-blue-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className={loading || trendLoading ? 'animate-spin' : ''} />
|
||||||
|
刷新日报
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||||
|
<MetricTile
|
||||||
|
label="考核车辆当日里程"
|
||||||
|
value={fmtKm(report.totalToday)}
|
||||||
|
unit="km"
|
||||||
|
helper={`${report.activeCount}/${report.assessmentVehicleCount} 台产生里程`}
|
||||||
|
icon={Route}
|
||||||
|
tone="blue"
|
||||||
|
/>
|
||||||
|
<MetricTile
|
||||||
|
label="当日任务缺口"
|
||||||
|
value={fmtKm(report.shortfall)}
|
||||||
|
unit="km"
|
||||||
|
helper={`任务基准 ${fmtKm(report.dailyNeed)} km`}
|
||||||
|
icon={AlertTriangle}
|
||||||
|
tone={report.shortfall > 0 ? 'rose' : 'emerald'}
|
||||||
|
/>
|
||||||
|
<MetricTile
|
||||||
|
label="零里程车辆"
|
||||||
|
value={report.zeroCount}
|
||||||
|
unit="台"
|
||||||
|
helper={`考核车辆共 ${report.assessmentVehicleCount} 台`}
|
||||||
|
icon={Truck}
|
||||||
|
tone={report.zeroCount > 0 ? 'amber' : 'emerald'}
|
||||||
|
/>
|
||||||
|
<MetricTile
|
||||||
|
label="考核加权完成率"
|
||||||
|
value={report.completionRate.toFixed(1)}
|
||||||
|
unit="%"
|
||||||
|
helper={`${report.qualifiedCount} 台达标 · ${report.halfQualifiedCount} 台过半`}
|
||||||
|
icon={CircleGauge}
|
||||||
|
tone="emerald"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.65fr)]">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<SurfaceCard
|
||||||
|
title="全量监控车辆 7 日趋势"
|
||||||
|
subtitle={`当前监控范围 ${report.monitoringVehicleCount} 台,趋势不等同于考核车辆范围`}
|
||||||
|
>
|
||||||
|
<div className="h-[250px] px-2 py-3">
|
||||||
|
{report.trend.length === 0 && trendLoading ? (
|
||||||
|
<div className="flex h-full items-center justify-center gap-2 text-xs font-bold text-slate-400">
|
||||||
|
<RefreshCw size={14} className="animate-spin" />
|
||||||
|
正在加载趋势
|
||||||
|
</div>
|
||||||
|
) : report.trend.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-xs font-bold text-slate-400">
|
||||||
|
暂无趋势数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={report.trend} margin={{ top: 12, right: 12, left: -12, bottom: 0 }}>
|
||||||
|
<CartesianGrid vertical={false} stroke="#e2e8f0" strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
|
||||||
|
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} tickFormatter={fmtKm} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => [`${fmtKm(Number(value))} km`, '总里程']}
|
||||||
|
contentStyle={{ borderRadius: 8, border: '1px solid #e2e8f0', fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="value" fill="#2563eb" radius={[4, 4, 0, 0]} maxBarSize={30} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SurfaceCard>
|
||||||
|
|
||||||
|
<SurfaceCard title="考核目标表现" subtitle="按当日任务缺口排序,点击进入考核统计">
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{report.models.map((model, index) => (
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
key={model.id}
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.025 }}
|
||||||
|
onClick={() => openTarget(model.id)}
|
||||||
|
className="grid w-full grid-cols-[minmax(0,1fr)_72px_78px_20px] items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-xs font-black text-slate-800">{model.name}</span>
|
||||||
|
<span className="mt-0.5 block text-[9px] font-bold text-slate-400">
|
||||||
|
{model.active}/{model.count} 台有里程 · {model.zero} 台零里程
|
||||||
|
{model.missingDailyTaskCount > 0 ? ` · ${model.missingDailyTaskCount} 台缺任务值` : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-right">
|
||||||
|
<span className="block text-[11px] font-black text-blue-600">{fmtKm(model.today)}</span>
|
||||||
|
<span className="block text-[9px] font-bold text-slate-400">当日 km</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-right">
|
||||||
|
<span className={`block text-[11px] font-black ${model.shortfall > 0 ? 'text-rose-600' : 'text-emerald-600'}`}>
|
||||||
|
{fmtKm(model.shortfall)}
|
||||||
|
</span>
|
||||||
|
<span className="block text-[9px] font-bold text-slate-400">缺口 km</span>
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={14} className="text-slate-300" />
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SurfaceCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<SurfaceCard title="当日里程 TOP 5" subtitle="考核车辆范围">
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{report.topVehicles.map(vehicle => (
|
||||||
|
<VehicleRow key={vehicle.plate} vehicle={vehicle} mode="top" onClick={() => openVehicle(vehicle)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SurfaceCard>
|
||||||
|
|
||||||
|
<SurfaceCard title="任务缺口车辆" subtitle="按车辆当日任务缺口排序,仅含已返回任务值的车辆">
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{report.riskVehicles.map(vehicle => (
|
||||||
|
<VehicleRow key={vehicle.plate} vehicle={vehicle} mode="risk" onClick={() => openVehicle(vehicle)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SurfaceCard>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Database size={16} className="mt-0.5 shrink-0 text-slate-400" />
|
||||||
|
<div className="min-w-0 text-[10px] font-bold leading-relaxed text-slate-500">
|
||||||
|
<div>考核车辆 {report.assessmentVehicleCount} 台 · 监控车辆 {report.monitoringVehicleCount} 台</div>
|
||||||
|
<div className="mt-1">重复考核归属 {report.duplicateAssignmentCount} 条</div>
|
||||||
|
<div className={report.missingDailyTaskCount > 0 ? 'mt-1 text-amber-600' : 'mt-1'}>
|
||||||
|
缺少车辆级任务值 {report.missingDailyTaskCount} 台
|
||||||
|
</div>
|
||||||
|
{error && <div className="mt-1 text-rose-600">最近刷新失败:{error}</div>}
|
||||||
|
</div>
|
||||||
|
<Activity size={14} className="ml-auto shrink-0 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { AlertTriangle, Building2, ChevronRight, Users } from 'lucide-react';
|
||||||
|
import type { TargetVehicle } from './types';
|
||||||
|
import {
|
||||||
|
buildMileageAttribution,
|
||||||
|
reconcileMileageAttribution,
|
||||||
|
type AttributionDimension,
|
||||||
|
} from './attribution';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
vehicles: TargetVehicle[];
|
||||||
|
dimension: AttributionDimension;
|
||||||
|
selectedValue?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
targetMileagePerVehicle: number;
|
||||||
|
expectedShortfallMileage?: number;
|
||||||
|
assessmentLabel?: string;
|
||||||
|
onDimensionChange: (dimension: AttributionDimension) => void;
|
||||||
|
onSelect: (value: string) => 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,
|
||||||
|
targetMileagePerVehicle,
|
||||||
|
expectedShortfallMileage,
|
||||||
|
assessmentLabel,
|
||||||
|
onDimensionChange,
|
||||||
|
onSelect,
|
||||||
|
}: 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} · ` : ''}{totalBehind} 台未达标,累计缺口 {fmtKm(totalShortfall)} km
|
||||||
|
</p>
|
||||||
|
{!loading && 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>
|
||||||
|
) : 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,14 +28,26 @@ 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}>
|
||||||
|
<div
|
||||||
|
id={`mileage-view-panel-${activeSubTab}`}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={`mileage-view-tab-${activeSubTab}`}
|
||||||
|
>
|
||||||
{activeSubTab === 'monitoring' ? (
|
{activeSubTab === 'monitoring' ? (
|
||||||
<MonitoringView />
|
<MonitoringView />
|
||||||
) : activeSubTab === 'statistics' ? (
|
) : activeSubTab === 'statistics' ? (
|
||||||
@@ -42,6 +55,7 @@ export default function MileageModule() {
|
|||||||
) : (
|
) : (
|
||||||
<DailyReportView />
|
<DailyReportView />
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
<RotatingFooterHint />
|
<RotatingFooterHint />
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ 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';
|
||||||
|
|
||||||
const HIGH_MILEAGE_ALERT_TARGETS = new Set([
|
const HIGH_MILEAGE_ALERT_TARGETS = new Set([
|
||||||
'交投40辆4.5T普货',
|
'交投40辆4.5T普货',
|
||||||
@@ -444,6 +450,9 @@ const BatchMultiSelect = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function MonitoringView() {
|
export default function MonitoringView() {
|
||||||
|
const [drillContext, setDrillContext] = useState<MileageDrillContext>(() => (
|
||||||
|
parseMileageDrillContext(window.location.search)
|
||||||
|
));
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [filterDept, setFilterDept] = useState('All');
|
const [filterDept, setFilterDept] = useState('All');
|
||||||
const [sortBy, setSortBy] = useState<'today' | 'total' | 'statisticTime'>('today');
|
const [sortBy, setSortBy] = useState<'today' | 'total' | 'statisticTime'>('today');
|
||||||
@@ -456,7 +465,9 @@ export default function MonitoringView() {
|
|||||||
const [fullscreenLoading, setFullscreenLoading] = useState(false);
|
const [fullscreenLoading, setFullscreenLoading] = useState(false);
|
||||||
|
|
||||||
// New filters from image
|
// New filters from image
|
||||||
const [filterPlates, setFilterPlates] = useState<string[]>([]);
|
const [filterPlates, setFilterPlates] = useState<string[]>(() => (
|
||||||
|
drillContext.level === 'vehicle' && drillContext.plate ? [drillContext.plate] : []
|
||||||
|
));
|
||||||
const [filterCustomer, setFilterCustomer] = useState('All');
|
const [filterCustomer, setFilterCustomer] = useState('All');
|
||||||
const [filterProject, setFilterProject] = useState('All');
|
const [filterProject, setFilterProject] = useState('All');
|
||||||
const [filterEntity, setFilterEntity] = useState('All');
|
const [filterEntity, setFilterEntity] = useState('All');
|
||||||
@@ -468,10 +479,9 @@ export default function MonitoringView() {
|
|||||||
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
||||||
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
|
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
|
const [rangeStart, setRangeStart] = useState(() => drillContext.startDate || defaultMileageDate());
|
||||||
const [rangeStart, setRangeStart] = useState(defaultMileageDate);
|
const [rangeEnd, setRangeEnd] = useState(() => drillContext.endDate || defaultMileageDate());
|
||||||
const [rangeEnd, setRangeEnd] = useState(defaultMileageDate);
|
const [sourcePriority, setSourcePriority] = useState<MileageSourceGroup[]>(drillContext.sourcePriority);
|
||||||
const [sourcePriority, setSourcePriority] = useState<MileageSourceGroup[]>(['instrument']);
|
|
||||||
|
|
||||||
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,17 +493,59 @@ 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 [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 PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
const detailVehicle = drillContext.level === 'vehicle' && drillContext.plate
|
||||||
|
? vehicles.find(vehicle => vehicle.plate === drillContext.plate) || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
commitDrillContext({
|
||||||
|
level: 'vehicle',
|
||||||
|
plate: vehicle.plate,
|
||||||
|
startDate: effectiveRange.start,
|
||||||
|
endDate: effectiveRange.end,
|
||||||
|
sourcePriority,
|
||||||
|
}, 'push');
|
||||||
|
}, [commitDrillContext, effectiveRange.end, effectiveRange.start, sourcePriority]);
|
||||||
|
|
||||||
|
const closeVehicleDetail = useCallback(() => {
|
||||||
|
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);
|
||||||
|
setDrillContext(next);
|
||||||
|
if (next.startDate) setRangeStart(next.startDate);
|
||||||
|
if (next.endDate) setRangeEnd(next.endDate);
|
||||||
|
setSourcePriority(next.sourcePriority);
|
||||||
|
if (next.level === 'vehicle' && next.plate) setFilterPlates([next.plate]);
|
||||||
|
};
|
||||||
|
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 rangeLabel = normalizeRangeLabel(effectiveRange.start, effectiveRange.end);
|
||||||
@@ -514,8 +566,9 @@ export default function MonitoringView() {
|
|||||||
}, [filterTargetNames]);
|
}, [filterTargetNames]);
|
||||||
|
|
||||||
// 加载首页数据
|
// 加载首页数据
|
||||||
const loadFirstPage = useCallback((showPageLoading = true) => {
|
const loadFirstPage = useCallback((showPageLoading = true, force = false) => {
|
||||||
if (showPageLoading) setPageLoading(true);
|
if (showPageLoading) setPageLoading(true);
|
||||||
|
setPageError(null);
|
||||||
return fetchMonitoring({
|
return fetchMonitoring({
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
@@ -537,6 +590,7 @@ export default function MonitoringView() {
|
|||||||
endDate: rangeEnd || undefined,
|
endDate: rangeEnd || undefined,
|
||||||
brands: filterBrands.length > 0 ? filterBrands : undefined,
|
brands: filterBrands.length > 0 ? filterBrands : undefined,
|
||||||
sourcePriority,
|
sourcePriority,
|
||||||
|
force,
|
||||||
}).then(d => {
|
}).then(d => {
|
||||||
setVehicles(d.vehicles);
|
setVehicles(d.vehicles);
|
||||||
setStats(d.stats);
|
setStats(d.stats);
|
||||||
@@ -546,8 +600,13 @@ 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(() => {
|
}).catch(error => {
|
||||||
|
setPageError({
|
||||||
|
message: error instanceof Error ? error.message : '里程数据加载失败,请稍后重试',
|
||||||
|
scope: 'first',
|
||||||
|
});
|
||||||
|
}).finally(() => {
|
||||||
if (showPageLoading) setPageLoading(false);
|
if (showPageLoading) setPageLoading(false);
|
||||||
});
|
});
|
||||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]);
|
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]);
|
||||||
@@ -556,7 +615,7 @@ export default function MonitoringView() {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
@@ -567,6 +626,7 @@ export default function MonitoringView() {
|
|||||||
if (loadingMore || !hasMore) return;
|
if (loadingMore || !hasMore) return;
|
||||||
const nextPage = page + 1;
|
const nextPage = page + 1;
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
|
setPageError(null);
|
||||||
fetchMonitoring({
|
fetchMonitoring({
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
@@ -592,7 +652,13 @@ export default function MonitoringView() {
|
|||||||
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);
|
||||||
|
}).catch(error => {
|
||||||
|
setPageError({
|
||||||
|
message: error instanceof Error ? error.message : '更多车辆加载失败,请稍后重试',
|
||||||
|
scope: 'more',
|
||||||
|
});
|
||||||
|
}).finally(() => setLoadingMore(false));
|
||||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, page, loadingMore, hasMore, filterBrands, sourcePriority]);
|
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, page, loadingMore, hasMore, filterBrands, sourcePriority]);
|
||||||
|
|
||||||
// 筛选/排序变化时重新加载
|
// 筛选/排序变化时重新加载
|
||||||
@@ -649,26 +715,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(() => {
|
||||||
@@ -718,6 +764,7 @@ export default function MonitoringView() {
|
|||||||
setFullscreenVehicles(d.vehicles);
|
setFullscreenVehicles(d.vehicles);
|
||||||
setFullscreenStats(d.stats);
|
setFullscreenStats(d.stats);
|
||||||
setFilterOptions(d.filters);
|
setFilterOptions(d.filters);
|
||||||
|
setDataUpdatedAt(d.updatedAt);
|
||||||
}).catch(() => {}).finally(() => setFullscreenLoading(false));
|
}).catch(() => {}).finally(() => setFullscreenLoading(false));
|
||||||
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, fullscreenRefresh, filterBrands, sourcePriority]);
|
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, fullscreenRefresh, filterBrands, sourcePriority]);
|
||||||
|
|
||||||
@@ -1034,14 +1081,19 @@ export default function MonitoringView() {
|
|||||||
</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(dataUpdatedAt)}`}
|
||||||
|
>
|
||||||
|
OneOS 分钟级快照 · 截至 {formatMileageSnapshotTime(dataUpdatedAt)}
|
||||||
|
</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(dataUpdatedAt)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1450,7 +1502,9 @@ export default function MonitoringView() {
|
|||||||
</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">
|
||||||
|
已加载 {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>
|
||||||
@@ -1494,7 +1548,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,16 +1619,40 @@ export default function MonitoringView() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredVehicles.length === 0 && !loadingMore && (
|
{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 && (
|
||||||
{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={() => pageError.scope === 'more' ? loadMore() : loadFirstPage()}
|
||||||
|
disabled={pageLoading || loadingMore}
|
||||||
|
className="mt-2 inline-flex h-8 items-center gap-1.5 rounded-lg border border-rose-200 bg-white px-3 text-[10px] font-black text-rose-700 transition-colors hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} className={pageLoading || loadingMore ? 'animate-spin' : ''} />
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasMore && !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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!hasMore && filteredVehicles.length > 0 && (
|
{!hasMore && filteredVehicles.length > 0 && (
|
||||||
@@ -1582,13 +1660,11 @@ export default function MonitoringView() {
|
|||||||
<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
|
||||||
vehicle={detailVehicle}
|
vehicle={detailVehicle}
|
||||||
onClose={() => setDetailVehicle(null)}
|
onClose={closeVehicleDetail}
|
||||||
sourcePriority={sourcePriority}
|
sourcePriority={sourcePriority}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -12,6 +12,18 @@ import {
|
|||||||
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 { weightedCompletionRate } from '../../shared/analytics/metrics';
|
||||||
|
import { formatMileageSnapshotDateTime } from './data-freshness';
|
||||||
|
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 +31,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 +45,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 {
|
||||||
@@ -59,50 +64,180 @@ function shortTargetName(name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
||||||
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
|
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
|
||||||
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(null);
|
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(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 [viewAllHistoricalVehicles, setViewAllHistoricalVehicles] = useState<TargetVehicle[] | 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 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;
|
||||||
|
|
||||||
|
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 loadCurrentTargetVehicles = useCallback((targetId: number) => {
|
||||||
|
if (targetVehicleRequestsRef.current.has(targetId)) return;
|
||||||
|
targetVehicleRequestsRef.current.add(targetId);
|
||||||
|
fetchTargetVehicles(targetId).then(vehicles => {
|
||||||
|
setTargetVehiclesMap(prev => ({ ...prev, [targetId]: vehicles }));
|
||||||
|
}).catch(() => {}).finally(() => {
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
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
|
// Load targets on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTargets().then(data => {
|
fetchTargets().then(data => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -112,9 +247,7 @@ export default function StatisticsView() {
|
|||||||
if (selectedTargetId === null) return;
|
if (selectedTargetId === null) return;
|
||||||
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([]));
|
fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
|
||||||
}, [selectedTargetId]);
|
}, [selectedTargetId]);
|
||||||
@@ -124,10 +257,52 @@ export default function StatisticsView() {
|
|||||||
if (viewAllTargetId === null) return;
|
if (viewAllTargetId === null) return;
|
||||||
setViewAllLoading(true);
|
setViewAllLoading(true);
|
||||||
fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => {
|
fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => {
|
||||||
setTargetVehiclesMap(prev => ({ ...prev, [viewAllTargetId]: data }));
|
setViewAllHistoricalVehicles(data);
|
||||||
}).catch(() => {}).finally(() => setViewAllLoading(false));
|
}).catch(() => {}).finally(() => setViewAllLoading(false));
|
||||||
}, [viewAllTargetId, viewAllDate]);
|
}, [viewAllTargetId, viewAllDate]);
|
||||||
|
|
||||||
|
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 viewAllVehicles = useMemo(() => {
|
||||||
|
const vehicles = viewAllHistoricalVehicles
|
||||||
|
|| (viewAllTargetId === null ? [] : targetVehiclesMap[viewAllTargetId] || []);
|
||||||
|
if (
|
||||||
|
drillContext.level === 'breakdown'
|
||||||
|
&& drillContext.dimensionValue
|
||||||
|
&& (drillContext.dimension === 'department' || drillContext.dimension === 'customer')
|
||||||
|
) {
|
||||||
|
return filterAttributionVehicles(vehicles, drillContext.dimension, drillContext.dimensionValue);
|
||||||
|
}
|
||||||
|
return vehicles;
|
||||||
|
}, [drillContext, targetVehiclesMap, viewAllHistoricalVehicles, viewAllTargetId]);
|
||||||
|
|
||||||
|
const searchedViewAllVehicles = useMemo(() => (
|
||||||
|
viewAllVehicles.filter(vehicle => (
|
||||||
|
vehicle.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase())
|
||||||
|
))
|
||||||
|
), [viewAllSearch, viewAllVehicles]);
|
||||||
|
|
||||||
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' }}>
|
||||||
{/* Project Selector */}
|
{/* Project Selector */}
|
||||||
@@ -135,10 +310,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'
|
||||||
@@ -189,6 +361,23 @@ export default function StatisticsView() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedTarget && (
|
||||||
|
<MileageAttributionView
|
||||||
|
vehicles={selectedTargetVehicles}
|
||||||
|
dimension={attributionDimension}
|
||||||
|
selectedValue={selectedBreakdownValue}
|
||||||
|
loading={!targetVehiclesMap[selectedTarget.id]}
|
||||||
|
targetMileagePerVehicle={selectedTarget.annualMileagePerVehicle * (selectedAssessmentYear || 1)}
|
||||||
|
expectedShortfallMileage={selectedAssessment?.remaining}
|
||||||
|
assessmentLabel={selectedAssessment?.label}
|
||||||
|
onDimensionChange={(dimension) => {
|
||||||
|
setAttributionDimension(dimension);
|
||||||
|
if (drillContext.level === 'breakdown') closeVehiclePanel();
|
||||||
|
}}
|
||||||
|
onSelect={openAttributionGroup}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<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">
|
||||||
@@ -327,11 +516,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 +575,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 +631,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 +670,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,7 +729,7 @@ 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">
|
||||||
@@ -643,7 +831,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,14 +843,18 @@ 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} />
|
||||||
@@ -694,9 +886,8 @@ export default function StatisticsView() {
|
|||||||
<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()));
|
const totalKm = searchedViewAllVehicles.reduce((sum, vehicle) => sum + (vehicle.todayMileage || 0), 0);
|
||||||
const totalKm = filtered.reduce((sum, tv) => sum + (tv.todayMileage || 0), 0);
|
return `${searchedViewAllVehicles.length} 辆 · 合计 ${fmtKm(totalKm)} km`;
|
||||||
return `${filtered.length} 辆 · 合计 ${fmtKm(totalKm)} km`;
|
|
||||||
})()}
|
})()}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -710,14 +901,17 @@ 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 =>
|
{[...searchedViewAllVehicles].sort((a, b) => {
|
||||||
tv.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase())
|
|
||||||
).sort((a, b) => {
|
|
||||||
const valA = a.todayMileage || 0;
|
const valA = a.todayMileage || 0;
|
||||||
const valB = b.todayMileage || 0;
|
const valB = b.todayMileage || 0;
|
||||||
return viewAllSort === 'desc' ? valB - valA : valA - valB;
|
return viewAllSort === 'desc' ? valB - valA : valA - valB;
|
||||||
}).map(tv => (
|
}).map(tv => (
|
||||||
<div key={tv.plateNumber} className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between hover:border-blue-200 transition-all">
|
<button
|
||||||
|
type="button"
|
||||||
|
key={tv.plateNumber}
|
||||||
|
onClick={() => openVehicleDiagnostic(tv)}
|
||||||
|
className="flex w-full items-center justify-between rounded-xl border border-slate-100 bg-white px-3 py-2 text-left shadow-sm transition-all hover:border-blue-200 hover:shadow-md"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-3 overflow-hidden flex-1">
|
<div className="flex items-center gap-3 overflow-hidden flex-1">
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
|
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
|
||||||
@@ -742,13 +936,13 @@ export default function StatisticsView() {
|
|||||||
<div className="text-sm font-black text-blue-600">{tv.todayMileage.toLocaleString()} <span className="text-[8px] text-slate-400">KM</span></div>
|
<div className="text-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 className="text-[9px] font-bold text-slate-300 mt-0.5">累计: {fmtKm(tv.totalMileage || 0)} km</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</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"
|
||||||
>
|
>
|
||||||
关闭明细
|
关闭明细
|
||||||
|
|||||||
+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>();
|
|
||||||
for (const target of targets) {
|
|
||||||
const vehicles = targetVehiclesMap.get(target.id) ?? [];
|
|
||||||
for (const vehicle of vehicles) targetNameByPlate.set(vehicle.plateNumber, compactTargetName(target.targetName));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const topVehicles: DailyReportVehicle[] = topMonitoring.vehicles.map(vehicle => ({
|
export async function fetchDailyReport(force = false): Promise<DailyReportData> {
|
||||||
plate: vehicle.plate,
|
if (!force && dailyReportCache && dailyReportCache.expiresAt > Date.now()) {
|
||||||
model: targetNameByPlate.get(vehicle.plate) || vehicle.project || '未归入考核',
|
return dailyReportCache.data;
|
||||||
status: normalizeStatus(vehicle.rentStatus),
|
}
|
||||||
today: vehicle.dailyKm,
|
if (!force && dailyReportRequest) return dailyReportRequest;
|
||||||
customer: vehicle.customer || '未绑定客户',
|
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 zeroRisk = targetVehiclesEntries
|
let dailyReportTrendRequest: Promise<TrendPoint[]> | null = null;
|
||||||
.flatMap(([targetId, vehicles]) => {
|
let dailyReportTrendCache: { data: TrendPoint[]; expiresAt: number } | null = null;
|
||||||
const target = targets.find(item => item.id === targetId);
|
|
||||||
const model = target ? compactTargetName(target.targetName) : '未归入考核';
|
|
||||||
return vehicles
|
|
||||||
.filter(vehicle => vehicle.todayMileage <= 0 && ['自营', '租赁'].includes(normalizeStatus(vehicle.rentStatus)))
|
|
||||||
.map(vehicle => ({
|
|
||||||
plate: vehicle.plateNumber,
|
|
||||||
model,
|
|
||||||
status: normalizeStatus(vehicle.rentStatus),
|
|
||||||
customer: vehicle.customer || '未绑定客户',
|
|
||||||
completion: vehicle.completionRate,
|
|
||||||
}));
|
|
||||||
})
|
|
||||||
.sort((a, b) => (b.completion ?? 0) - (a.completion ?? 0))
|
|
||||||
.slice(0, 5);
|
|
||||||
|
|
||||||
return {
|
export async function fetchDailyReportTrend(force = false): Promise<TrendPoint[]> {
|
||||||
reportDate: reportDateFromUpdatedAt(monitoring.updatedAt),
|
if (!force && dailyReportTrendCache && dailyReportTrendCache.expiresAt > Date.now()) {
|
||||||
updatedAt: monitoring.updatedAt,
|
return dailyReportTrendCache.data;
|
||||||
models,
|
}
|
||||||
trend: trend.map(item => ({ date: item.date, value: item.mileage })),
|
if (!force && dailyReportTrendRequest) return dailyReportTrendRequest;
|
||||||
topVehicles,
|
const request = fetchTrend(undefined, 7);
|
||||||
zeroRisk,
|
dailyReportTrendRequest = request;
|
||||||
qualifiedCount: targets.reduce((sum, target) => sum + target.yearQualifiedCount, 0),
|
try {
|
||||||
halfQualifiedCount: targets.reduce((sum, target) => sum + target.halfQualifiedCount, 0),
|
const data = await request;
|
||||||
};
|
dailyReportTrendCache = { data, expiresAt: Date.now() + DAILY_REPORT_CACHE_MS };
|
||||||
|
return data;
|
||||||
|
} finally {
|
||||||
|
if (dailyReportTrendRequest === request) dailyReportTrendRequest = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -64,6 +64,7 @@ export interface MonitoringData {
|
|||||||
export interface TargetSummary {
|
export interface TargetSummary {
|
||||||
id: number;
|
id: number;
|
||||||
targetName: string;
|
targetName: string;
|
||||||
|
snapshotUpdatedAt: string | null;
|
||||||
vehicleCount: number;
|
vehicleCount: number;
|
||||||
totalMileagePerVehicle: number;
|
totalMileagePerVehicle: number;
|
||||||
annualMileagePerVehicle: number;
|
annualMileagePerVehicle: number;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+14
-11
@@ -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',
|
hydrogenPool = mysql.createPool({
|
||||||
password: process.env.HYDROGEN_DB_PASSWORD || 'lnMysql.',
|
host: requireEnv('HYDROGEN_DB_HOST'),
|
||||||
database: process.env.HYDROGEN_DB_NAME || 'ln_asset_management',
|
port: envPort('HYDROGEN_DB_PORT', 3306),
|
||||||
|
user: requireEnv('HYDROGEN_DB_USER'),
|
||||||
|
password: requireEnv('HYDROGEN_DB_PASSWORD'),
|
||||||
|
database: requireEnv('HYDROGEN_DB_NAME'),
|
||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
connectionLimit: 5,
|
connectionLimit: 5,
|
||||||
queueLimit: 0,
|
queueLimit: 0,
|
||||||
});
|
});
|
||||||
|
return hydrogenPool;
|
||||||
export default 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' }));
|
||||||
|
|||||||
+14
-11
@@ -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',
|
mileagePool = mysql.createPool({
|
||||||
password: process.env.MILEAGE_DB_PASSWORD || 'bi_reader_02_Pass',
|
host: requireEnv('MILEAGE_DB_HOST'),
|
||||||
database: process.env.MILEAGE_DB_NAME || 'hydrogen_energy',
|
port: envPort('MILEAGE_DB_PORT', 3306),
|
||||||
|
user: requireEnv('MILEAGE_DB_USER'),
|
||||||
|
password: requireEnv('MILEAGE_DB_PASSWORD'),
|
||||||
|
database: requireEnv('MILEAGE_DB_NAME'),
|
||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
connectionLimit: 5,
|
connectionLimit: 5,
|
||||||
queueLimit: 0,
|
queueLimit: 0,
|
||||||
});
|
});
|
||||||
|
return mileagePool;
|
||||||
export default 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,62 @@
|
|||||||
|
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, 6);
|
||||||
|
assert.deepEqual(payload.domains, ['mileage']);
|
||||||
|
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'mileage.assessment_completion_rate'));
|
||||||
|
});
|
||||||
|
|
||||||
|
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, 6);
|
||||||
|
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, 6);
|
||||||
|
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,16 @@ 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';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -115,6 +125,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,18 +147,67 @@ 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' = 'bypass';
|
||||||
|
|
||||||
if (range) {
|
if (range) {
|
||||||
|
const cache = getCache();
|
||||||
|
const today = shanghaiDate();
|
||||||
|
const snapshotScope = isMonitoringSnapshotScope({
|
||||||
|
startDate: range.start,
|
||||||
|
endDate: range.end,
|
||||||
|
today,
|
||||||
|
sourcePriority,
|
||||||
|
});
|
||||||
|
const cacheEligible = isMonitoringCacheEligible({
|
||||||
|
startDate: range.start,
|
||||||
|
endDate: range.end,
|
||||||
|
today,
|
||||||
|
sourcePriority,
|
||||||
|
force,
|
||||||
|
});
|
||||||
|
const manualSnapshot = cacheEligible
|
||||||
|
? getManualMonitoringSnapshot(range.start, range.end)
|
||||||
|
: null;
|
||||||
|
if (manualSnapshot) {
|
||||||
|
cacheStatus = 'manual-hit';
|
||||||
|
allVehicles = manualSnapshot.result.vehicles;
|
||||||
|
rangeDailyTotals = manualSnapshot.result.dailyTotals;
|
||||||
|
dateRange = { start: manualSnapshot.result.start, end: manualSnapshot.result.end };
|
||||||
|
dataUpdatedAt = manualSnapshot.updatedAt;
|
||||||
|
filters = buildDateFilters(allVehicles);
|
||||||
|
} else if (cacheEligible && cache) {
|
||||||
|
cacheStatus = 'hit';
|
||||||
|
allVehicles = cache.vehicles.map(vehicle => ({
|
||||||
|
...vehicle,
|
||||||
|
dailyMileage: { [range.start]: vehicle.dailyKm },
|
||||||
|
dailySourceProtocols: { [range.start]: vehicle.sourceProtocol },
|
||||||
|
}));
|
||||||
|
rangeDailyTotals = [{
|
||||||
|
date: range.start,
|
||||||
|
totalKm: Math.round(allVehicles.reduce((sum, vehicle) => sum + vehicle.dailyKm, 0)),
|
||||||
|
}];
|
||||||
|
dateRange = { ...range };
|
||||||
|
dataUpdatedAt = cache.updatedAt;
|
||||||
|
filters = cache.filters;
|
||||||
|
} else {
|
||||||
|
cacheStatus = cacheEligible ? 'miss' : 'bypass';
|
||||||
try {
|
try {
|
||||||
const result = await queryRangeMileage(range.start, range.end, protocolPriority);
|
const result = await queryRangeMileage(range.start, range.end, protocolPriority);
|
||||||
allVehicles = result.vehicles;
|
allVehicles = result.vehicles;
|
||||||
rangeDailyTotals = result.dailyTotals;
|
rangeDailyTotals = result.dailyTotals;
|
||||||
dateRange = { start: result.start, end: result.end };
|
dateRange = { start: result.start, end: result.end };
|
||||||
filters = buildDateFilters(allVehicles);
|
filters = buildDateFilters(allVehicles);
|
||||||
|
if (force && snapshotScope) {
|
||||||
|
const stored = storeManualMonitoringSnapshot(result);
|
||||||
|
cacheStatus = 'refresh';
|
||||||
|
dataUpdatedAt = stored.updatedAt;
|
||||||
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error('monitoring range query error:', e);
|
console.error('monitoring range query error:', e);
|
||||||
return c.json(EMPTY_RESPONSE, 500);
|
return c.json(EMPTY_RESPONSE, 500);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else if (date) {
|
} else if (date) {
|
||||||
try {
|
try {
|
||||||
allVehicles = await queryDateMileage(date, protocolPriority);
|
allVehicles = await queryDateMileage(date, protocolPriority);
|
||||||
@@ -186,9 +246,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 +278,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 +288,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,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,6 +200,7 @@ app.get('/', async (c) => {
|
|||||||
return {
|
return {
|
||||||
id: t.id,
|
id: t.id,
|
||||||
targetName: t.target_name,
|
targetName: t.target_name,
|
||||||
|
snapshotUpdatedAt: s.snapshot_updated_at || null,
|
||||||
vehicleCount: Number(s.total) || t.vehicle_count,
|
vehicleCount: Number(s.total) || t.vehicle_count,
|
||||||
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),
|
||||||
@@ -252,11 +256,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;
|
||||||
|
|||||||
@@ -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,67 @@
|
|||||||
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
export type MetricDomain = 'mileage' | 'hydrogen' | 'electric' | 'etc';
|
||||||
|
export type MetricUnit = 'km' | 'kg' | 'kwh' | 'cny' | 'cny-per-kwh' | 'percent' | 'vehicle' | 'station' | 'order' | 'passage' | 'timestamp';
|
||||||
|
export type MetricAggregation = 'sum' | 'count' | 'derived' | 'weighted-ratio' | 'distinct-count' | 'latest';
|
||||||
|
export type MetricTimeSemantics = 'flow' | 'snapshot' | 'freshness';
|
||||||
|
|
||||||
|
export interface MetricDefinition {
|
||||||
|
id: string;
|
||||||
|
domain: MetricDomain;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
unit: MetricUnit;
|
||||||
|
aggregation: MetricAggregation;
|
||||||
|
timeSemantics: MetricTimeSemantics;
|
||||||
|
formula: string;
|
||||||
|
sources: readonly string[];
|
||||||
|
dimensions: readonly string[];
|
||||||
|
drillEntity: 'vehicle' | 'assessment-target' | 'hydrogen-station' | 'hydrogen-customer' | 'hydrogen-order' | 'electric-charge-order' | 'etc-toll-record' | 'etc-bill';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const METRIC_CATALOG_VERSION = 6;
|
||||||
|
|
||||||
|
const MILEAGE_METRICS: readonly MetricDefinition[] = [
|
||||||
|
{
|
||||||
|
id: 'mileage.daily_total',
|
||||||
|
domain: 'mileage',
|
||||||
|
label: '当日总里程',
|
||||||
|
description: '所选自然日内有效车辆日里程之和。',
|
||||||
|
unit: 'km',
|
||||||
|
aggregation: 'sum',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: 'SUM(vehicle_daily_mileage_km)',
|
||||||
|
sources: ['OneOS mileage API'],
|
||||||
|
dimensions: ['date', 'department', 'region', 'customer', 'vehicle-model', 'plate'],
|
||||||
|
drillEntity: 'vehicle',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mileage.period_total',
|
||||||
|
domain: 'mileage',
|
||||||
|
label: '区间总里程',
|
||||||
|
description: '所选自然日区间内有效车辆日里程之和。',
|
||||||
|
unit: 'km',
|
||||||
|
aggregation: 'sum',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: 'SUM(vehicle_daily_mileage_km) OVER selected_date_range',
|
||||||
|
sources: ['OneOS mileage range API'],
|
||||||
|
dimensions: ['date', 'department', 'region', 'customer', 'vehicle-model', 'plate'],
|
||||||
|
drillEntity: 'vehicle',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mileage.vehicle_count',
|
||||||
|
domain: 'mileage',
|
||||||
|
label: '监控车辆数',
|
||||||
|
description: '当前权限和筛选范围内的去重车辆数量。',
|
||||||
|
unit: 'vehicle',
|
||||||
|
aggregation: 'distinct-count',
|
||||||
|
timeSemantics: 'snapshot',
|
||||||
|
formula: 'COUNT(DISTINCT vehicle_id)',
|
||||||
|
sources: ['Asset database'],
|
||||||
|
dimensions: ['department', 'region', 'customer', 'vehicle-model'],
|
||||||
|
drillEntity: 'vehicle',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mileage.assessment_completion_rate',
|
||||||
|
domain: 'mileage',
|
||||||
|
label: '考核完成率',
|
||||||
|
description: '按目标里程加权汇总,不平均车辆或考核组的完成率。',
|
||||||
|
unit: 'percent',
|
||||||
|
aggregation: 'weighted-ratio',
|
||||||
|
timeSemantics: 'snapshot',
|
||||||
|
formula: 'SUM(completed_mileage_km) / NULLIF(SUM(target_mileage_km), 0) * 100',
|
||||||
|
sources: ['Mileage assessment database'],
|
||||||
|
dimensions: ['assessment-year', 'assessment-target', 'department', 'customer', 'plate'],
|
||||||
|
drillEntity: 'assessment-target',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mileage.cumulative_odometer_sum',
|
||||||
|
domain: 'mileage',
|
||||||
|
label: '累计里程合计',
|
||||||
|
description: '当前筛选范围内每辆车最新累计里程表值之和,是快照指标,不能跨日期累加。',
|
||||||
|
unit: 'km',
|
||||||
|
aggregation: 'sum',
|
||||||
|
timeSemantics: 'snapshot',
|
||||||
|
formula: 'ROUND(SUM(latest_vehicle_total_mileage_km), 1)',
|
||||||
|
sources: ['OneOS mileage API'],
|
||||||
|
dimensions: ['department', 'region', 'customer', 'vehicle-model', 'plate'],
|
||||||
|
drillEntity: 'vehicle',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mileage.data_freshness',
|
||||||
|
domain: 'mileage',
|
||||||
|
label: '数据统计时间',
|
||||||
|
description: '当前选源协议最后一条有效里程数据的业务时间。',
|
||||||
|
unit: 'timestamp',
|
||||||
|
aggregation: 'latest',
|
||||||
|
timeSemantics: 'freshness',
|
||||||
|
formula: 'COALESCE(data_time, updated_at, calculated_at)',
|
||||||
|
sources: ['OneOS mileage API'],
|
||||||
|
dimensions: ['source-protocol', 'plate'],
|
||||||
|
drillEntity: 'vehicle',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const HYDROGEN_METRICS: readonly MetricDefinition[] = [
|
||||||
|
{
|
||||||
|
id: 'hydrogen.refuel_mass',
|
||||||
|
domain: 'hydrogen',
|
||||||
|
label: '加氢量',
|
||||||
|
description: '有效加氢账本订单的实际加注质量之和。',
|
||||||
|
unit: 'kg',
|
||||||
|
aggregation: 'sum',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: "SUM(amount_kg) WHERE del_flag = '0'",
|
||||||
|
sources: ['hydrogen_fuel_ledger'],
|
||||||
|
dimensions: ['date', 'station', 'region', 'customer', 'payer', 'plate'],
|
||||||
|
drillEntity: 'hydrogen-order',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hydrogen.refuel_cost',
|
||||||
|
domain: 'hydrogen',
|
||||||
|
label: '加氢成本',
|
||||||
|
description: '有效加氢订单记录的站点结算成本之和,不代表客户应收。',
|
||||||
|
unit: 'cny',
|
||||||
|
aggregation: 'sum',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: "SUM(cost_total) WHERE del_flag = '0'",
|
||||||
|
sources: ['hydrogen_fuel_ledger'],
|
||||||
|
dimensions: ['date', 'station', 'region', 'customer', 'payer', 'plate'],
|
||||||
|
drillEntity: 'hydrogen-order',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hydrogen.customer_revenue',
|
||||||
|
domain: 'hydrogen',
|
||||||
|
label: '客户收入',
|
||||||
|
description: '有效加氢订单账本中客户应收金额 fee_total 之和。',
|
||||||
|
unit: 'cny',
|
||||||
|
aggregation: 'sum',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: "SUM(fee_total) WHERE del_flag = '0'",
|
||||||
|
sources: ['hydrogen_fuel_ledger'],
|
||||||
|
dimensions: ['date', 'station', 'region', 'customer', 'plate'],
|
||||||
|
drillEntity: 'hydrogen-customer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hydrogen.customer_order_cost',
|
||||||
|
domain: 'hydrogen',
|
||||||
|
label: '客户单成本',
|
||||||
|
description: '存在客户单价或客户应收金额的订单,其站点结算成本之和。',
|
||||||
|
unit: 'cny',
|
||||||
|
aggregation: 'sum',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: "SUM(cost_total) WHERE del_flag = '0' AND (customer_price > 0 OR fee_total > 0)",
|
||||||
|
sources: ['hydrogen_fuel_ledger'],
|
||||||
|
dimensions: ['date', 'station', 'region', 'customer', 'plate'],
|
||||||
|
drillEntity: 'hydrogen-customer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hydrogen.customer_order_gross_profit',
|
||||||
|
domain: 'hydrogen',
|
||||||
|
label: '客户单毛利',
|
||||||
|
description: '客户收入减去客户单对应的站点结算成本,不包含公司承担订单及其他经营费用。',
|
||||||
|
unit: 'cny',
|
||||||
|
aggregation: 'derived',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: 'hydrogen.customer_revenue - hydrogen.customer_order_cost',
|
||||||
|
sources: ['hydrogen_fuel_ledger'],
|
||||||
|
dimensions: ['date', 'station', 'region', 'customer'],
|
||||||
|
drillEntity: 'hydrogen-customer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hydrogen.refuel_order_count',
|
||||||
|
domain: 'hydrogen',
|
||||||
|
label: '加氢订单数',
|
||||||
|
description: '有效加氢账本订单数量。',
|
||||||
|
unit: 'order',
|
||||||
|
aggregation: 'count',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: "COUNT(*) WHERE del_flag = '0'",
|
||||||
|
sources: ['hydrogen_fuel_ledger'],
|
||||||
|
dimensions: ['date', 'station', 'region', 'customer', 'payer', 'plate'],
|
||||||
|
drillEntity: 'hydrogen-order',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hydrogen.station_count',
|
||||||
|
domain: 'hydrogen',
|
||||||
|
label: '有加注站点数',
|
||||||
|
description: '所选时间和筛选范围内产生有效加氢订单的去重站点数量。',
|
||||||
|
unit: 'station',
|
||||||
|
aggregation: 'distinct-count',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: "COUNT(DISTINCT station_id) WHERE del_flag = '0'",
|
||||||
|
sources: ['hydrogen_fuel_ledger', 'hydrogen_station'],
|
||||||
|
dimensions: ['date', 'region', 'payer'],
|
||||||
|
drillEntity: 'hydrogen-station',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hydrogen.vehicle_count',
|
||||||
|
domain: 'hydrogen',
|
||||||
|
label: '加氢车辆数',
|
||||||
|
description: '所选时间和筛选范围内产生有效加氢订单的去重车牌数量。',
|
||||||
|
unit: 'vehicle',
|
||||||
|
aggregation: 'distinct-count',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: "COUNT(DISTINCT plate_number) WHERE del_flag = '0'",
|
||||||
|
sources: ['hydrogen_fuel_ledger'],
|
||||||
|
dimensions: ['date', 'station', 'region', 'customer', 'payer'],
|
||||||
|
drillEntity: 'vehicle',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ELECTRIC_METRICS: readonly MetricDefinition[] = [
|
||||||
|
{
|
||||||
|
id: 'electric.charge_energy',
|
||||||
|
domain: 'electric',
|
||||||
|
label: '充电量',
|
||||||
|
description: '所选时间和车辆归属范围内,去重导入充电订单的电量之和。',
|
||||||
|
unit: 'kwh',
|
||||||
|
aggregation: 'sum',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: 'SUM(kwh)',
|
||||||
|
sources: ['bi_ele_charge_record'],
|
||||||
|
dimensions: ['date', 'station', 'region', 'operating-company', 'vehicle-kind', 'customer', 'plate', 'order-status'],
|
||||||
|
drillEntity: 'electric-charge-order',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'electric.charge_total_fee',
|
||||||
|
domain: 'electric',
|
||||||
|
label: '充电费用',
|
||||||
|
description: '导入账单的充电费用之和;当前数据中等于充电电费与充电服务费之和,零费用订单仍参与统计。',
|
||||||
|
unit: 'cny',
|
||||||
|
aggregation: 'sum',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: 'SUM(fee)',
|
||||||
|
sources: ['bi_ele_charge_record'],
|
||||||
|
dimensions: ['date', 'station', 'region', 'operating-company', 'vehicle-kind', 'customer', 'plate', 'order-status'],
|
||||||
|
drillEntity: 'electric-charge-order',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'electric.electricity_fee',
|
||||||
|
domain: 'electric',
|
||||||
|
label: '充电电费',
|
||||||
|
description: '导入账单中充电电费字段 e_fee 之和,不含服务费。',
|
||||||
|
unit: 'cny',
|
||||||
|
aggregation: 'sum',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: 'SUM(e_fee)',
|
||||||
|
sources: ['bi_ele_charge_record'],
|
||||||
|
dimensions: ['date', 'station', 'region', 'operating-company', 'vehicle-kind', 'customer', 'plate'],
|
||||||
|
drillEntity: 'electric-charge-order',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'electric.service_fee',
|
||||||
|
domain: 'electric',
|
||||||
|
label: '充电服务费',
|
||||||
|
description: '导入账单中充电服务费字段 service_fee 之和。',
|
||||||
|
unit: 'cny',
|
||||||
|
aggregation: 'sum',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: 'SUM(service_fee)',
|
||||||
|
sources: ['bi_ele_charge_record'],
|
||||||
|
dimensions: ['date', 'station', 'region', 'operating-company', 'vehicle-kind', 'customer', 'plate'],
|
||||||
|
drillEntity: 'electric-charge-order',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'electric.blended_cost_intensity',
|
||||||
|
domain: 'electric',
|
||||||
|
label: '综合费用强度',
|
||||||
|
description: '充电费用除以充电量;包含零费用订单,用于观察综合支出强度,不等同于合同电价或单笔订单单价。',
|
||||||
|
unit: 'cny-per-kwh',
|
||||||
|
aggregation: 'weighted-ratio',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: 'SUM(fee) / NULLIF(SUM(kwh), 0)',
|
||||||
|
sources: ['bi_ele_charge_record'],
|
||||||
|
dimensions: ['date', 'station', 'region', 'operating-company', 'vehicle-kind', 'customer'],
|
||||||
|
drillEntity: 'electric-charge-order',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'electric.charge_order_count',
|
||||||
|
domain: 'electric',
|
||||||
|
label: '充电订单数',
|
||||||
|
description: '导入充电账单中按订单编号去重后的订单数量。',
|
||||||
|
unit: 'order',
|
||||||
|
aggregation: 'distinct-count',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: 'COUNT(DISTINCT order_no)',
|
||||||
|
sources: ['bi_ele_charge_record'],
|
||||||
|
dimensions: ['date', 'station', 'region', 'operating-company', 'vehicle-kind', 'customer', 'plate', 'order-status'],
|
||||||
|
drillEntity: 'electric-charge-order',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'electric.data_freshness',
|
||||||
|
domain: 'electric',
|
||||||
|
label: '电能数据截至时间',
|
||||||
|
description: '已导入充电订单中最后一条订单的业务开始时间,用于区分当期零业务与数据尚未更新。',
|
||||||
|
unit: 'timestamp',
|
||||||
|
aggregation: 'latest',
|
||||||
|
timeSemantics: 'freshness',
|
||||||
|
formula: 'MAX(start_time)',
|
||||||
|
sources: ['bi_ele_charge_record'],
|
||||||
|
dimensions: ['station', 'vehicle-kind'],
|
||||||
|
drillEntity: 'electric-charge-order',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ETC_METRICS: readonly MetricDefinition[] = [
|
||||||
|
{
|
||||||
|
id: 'etc.toll_amount',
|
||||||
|
domain: 'etc',
|
||||||
|
label: '通行费',
|
||||||
|
description: '有效 ETC 通行记录中的道路通行费之和,不含服务费。',
|
||||||
|
unit: 'cny',
|
||||||
|
aggregation: 'sum',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: "SUM(toll_amount) WHERE del_flag = '0'",
|
||||||
|
sources: ['etc_toll_record'],
|
||||||
|
dimensions: ['date', 'plate', 'customer', 'route', 'cost-type', 'payment-mode', 'contract-match'],
|
||||||
|
drillEntity: 'etc-toll-record',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'etc.service_fee',
|
||||||
|
domain: 'etc',
|
||||||
|
label: 'ETC 服务费',
|
||||||
|
description: '有效 ETC 通行记录中的服务费之和。',
|
||||||
|
unit: 'cny',
|
||||||
|
aggregation: 'sum',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: "SUM(service_fee) WHERE del_flag = '0'",
|
||||||
|
sources: ['etc_toll_record'],
|
||||||
|
dimensions: ['date', 'plate', 'customer', 'route', 'cost-type', 'payment-mode'],
|
||||||
|
drillEntity: 'etc-toll-record',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'etc.total_amount',
|
||||||
|
domain: 'etc',
|
||||||
|
label: 'ETC 总费用',
|
||||||
|
description: '有效 ETC 通行记录的总金额之和,业务定义为通行费加服务费。',
|
||||||
|
unit: 'cny',
|
||||||
|
aggregation: 'sum',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: "SUM(total_amount) WHERE del_flag = '0'",
|
||||||
|
sources: ['etc_toll_record'],
|
||||||
|
dimensions: ['date', 'plate', 'customer', 'route', 'cost-type', 'payment-mode', 'deduction-status'],
|
||||||
|
drillEntity: 'etc-toll-record',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'etc.passage_count',
|
||||||
|
domain: 'etc',
|
||||||
|
label: '通行次数',
|
||||||
|
description: '有效 ETC 通行记录按记录编码去重后的通行次数。',
|
||||||
|
unit: 'passage',
|
||||||
|
aggregation: 'distinct-count',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: "COUNT(DISTINCT record_code) WHERE del_flag = '0'",
|
||||||
|
sources: ['etc_toll_record'],
|
||||||
|
dimensions: ['date', 'plate', 'customer', 'route', 'cost-type', 'payment-mode'],
|
||||||
|
drillEntity: 'etc-toll-record',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'etc.vehicle_count',
|
||||||
|
domain: 'etc',
|
||||||
|
label: '通行车辆数',
|
||||||
|
description: '所选范围内产生有效 ETC 通行记录的去重车牌数量。',
|
||||||
|
unit: 'vehicle',
|
||||||
|
aggregation: 'distinct-count',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: "COUNT(DISTINCT plate_number) WHERE del_flag = '0'",
|
||||||
|
sources: ['etc_toll_record'],
|
||||||
|
dimensions: ['date', 'customer', 'route', 'cost-type'],
|
||||||
|
drillEntity: 'vehicle',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'etc.company_borne_amount',
|
||||||
|
domain: 'etc',
|
||||||
|
label: '我方承担 ETC 费用',
|
||||||
|
description: '费用承担方 cost_type=2 的有效 ETC 总费用。',
|
||||||
|
unit: 'cny',
|
||||||
|
aggregation: 'sum',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: "SUM(total_amount) WHERE del_flag = '0' AND cost_type = 2",
|
||||||
|
sources: ['etc_toll_record'],
|
||||||
|
dimensions: ['date', 'plate', 'customer', 'route', 'payment-mode'],
|
||||||
|
drillEntity: 'etc-toll-record',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'etc.customer_borne_amount',
|
||||||
|
domain: 'etc',
|
||||||
|
label: '客户承担 ETC 费用',
|
||||||
|
description: '费用承担方 cost_type=1 的有效 ETC 总费用。',
|
||||||
|
unit: 'cny',
|
||||||
|
aggregation: 'sum',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: "SUM(total_amount) WHERE del_flag = '0' AND cost_type = 1",
|
||||||
|
sources: ['etc_toll_record'],
|
||||||
|
dimensions: ['date', 'plate', 'customer', 'route', 'payment-mode'],
|
||||||
|
drillEntity: 'etc-toll-record',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'etc.bill_receivable',
|
||||||
|
domain: 'etc',
|
||||||
|
label: 'ETC 账单应收',
|
||||||
|
description: '有效 ETC 账单的应收金额之和,与通行记录费用属于不同结算粒度。',
|
||||||
|
unit: 'cny',
|
||||||
|
aggregation: 'sum',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: "SUM(receivable_amount) WHERE del_flag = '0'",
|
||||||
|
sources: ['energy_etc_bill'],
|
||||||
|
dimensions: ['bill-period', 'customer', 'payment-status', 'review-status', 'finance-status'],
|
||||||
|
drillEntity: 'etc-bill',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'etc.bill_paid_amount',
|
||||||
|
domain: 'etc',
|
||||||
|
label: 'ETC 账单已收',
|
||||||
|
description: '有效 ETC 账单的已支付金额之和。',
|
||||||
|
unit: 'cny',
|
||||||
|
aggregation: 'sum',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: "SUM(paid_amount) WHERE del_flag = '0'",
|
||||||
|
sources: ['energy_etc_bill'],
|
||||||
|
dimensions: ['bill-period', 'customer', 'payment-status', 'review-status', 'finance-status'],
|
||||||
|
drillEntity: 'etc-bill',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const METRIC_CATALOG: readonly MetricDefinition[] = [
|
||||||
|
...MILEAGE_METRICS,
|
||||||
|
...HYDROGEN_METRICS,
|
||||||
|
...ELECTRIC_METRICS,
|
||||||
|
...ETC_METRICS,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isMetricDomain(value: string): value is MetricDomain {
|
||||||
|
return METRIC_CATALOG.some(metric => metric.domain === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listMetricDefinitions(domain?: MetricDomain): MetricDefinition[] {
|
||||||
|
return METRIC_CATALOG
|
||||||
|
.filter(metric => !domain || metric.domain === domain)
|
||||||
|
.map(metric => ({ ...metric }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { weightedCompletionRate } from './metrics.js';
|
||||||
|
|
||||||
|
test('weights completion by target mileage', () => {
|
||||||
|
const result = weightedCompletionRate([
|
||||||
|
{ completed: 100, target: 100 },
|
||||||
|
{ completed: 0, target: 900 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(result, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows completion above 100 percent', () => {
|
||||||
|
const result = weightedCompletionRate([
|
||||||
|
{ completed: 120, target: 100 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(result, 120);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores invalid and zero targets', () => {
|
||||||
|
const result = weightedCompletionRate([
|
||||||
|
{ completed: 50, target: 0 },
|
||||||
|
{ completed: Number.NaN, target: 100 },
|
||||||
|
{ completed: 30, target: 60 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(result, 50);
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export interface ProgressMeasure {
|
||||||
|
completed: number;
|
||||||
|
target: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function weightedCompletionRate(measures: ProgressMeasure[]): number {
|
||||||
|
let completed = 0;
|
||||||
|
let target = 0;
|
||||||
|
|
||||||
|
for (const measure of measures) {
|
||||||
|
if (!Number.isFinite(measure.target) || measure.target <= 0) continue;
|
||||||
|
if (!Number.isFinite(measure.completed)) continue;
|
||||||
|
completed += Math.max(0, measure.completed);
|
||||||
|
target += measure.target;
|
||||||
|
}
|
||||||
|
|
||||||
|
return target > 0 ? completed / target * 100 : 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { BI_SOURCE_BY_DOMAIN } from './source-status.js';
|
||||||
|
|
||||||
|
test('maps every published BI domain to one observable data source', () => {
|
||||||
|
assert.deepEqual(Object.keys(BI_SOURCE_BY_DOMAIN).sort(), ['electric', 'etc', 'hydrogen', 'mileage']);
|
||||||
|
assert.equal(new Set(Object.values(BI_SOURCE_BY_DOMAIN).map(item => item.source)).size, 4);
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { MetricDomain } from './catalog.js';
|
||||||
|
|
||||||
|
export type DataSourceId = 'oneOsMileage' | 'hydrogenDatabase' | 'electricDatabase' | 'etcDatabase';
|
||||||
|
export type DataSourceState = 'available' | 'failing' | 'unobserved';
|
||||||
|
|
||||||
|
export const BI_SOURCE_BY_DOMAIN: Record<MetricDomain, {
|
||||||
|
source: DataSourceId;
|
||||||
|
label: string;
|
||||||
|
}> = {
|
||||||
|
mileage: { source: 'oneOsMileage', label: 'OneOS 里程开放平台' },
|
||||||
|
hydrogen: { source: 'hydrogenDatabase', label: '氢能业务数据库' },
|
||||||
|
electric: { source: 'electricDatabase', label: '电能业务数据库' },
|
||||||
|
etc: { source: 'etcDatabase', label: 'ETC 业务数据库' },
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user