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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:06:56 +08:00
150 changed files with 1908 additions and 16072 deletions
-9
View File
@@ -1,9 +0,0 @@
node_modules
dist
.env
.git
.codex
outputs
reports
data/geo
data/vehicle-heatmap/source.csv
-51
View File
@@ -1,51 +0,0 @@
# Core asset database
DB_HOST=
DB_PORT=3306
DB_USER=
DB_PASSWORD=
DB_NAME=
# Hydrogen ledger database
HYDROGEN_DB_HOST=
HYDROGEN_DB_PORT=3306
HYDROGEN_DB_USER=
HYDROGEN_DB_PASSWORD=
HYDROGEN_DB_NAME=
# Mileage reference database
MILEAGE_DB_HOST=
MILEAGE_DB_PORT=3306
MILEAGE_DB_USER=
MILEAGE_DB_PASSWORD=
MILEAGE_DB_NAME=
# OneOS mileage API
ONEOS_MILEAGE_API_BASE_URL=http://172.17.111.55:20310
ONEOS_MILEAGE_API_KEY=
ONEOS_MILEAGE_API_TIMEOUT_MS=20000
# Authentication and external API
EXTERNAL_API_BASE=https://lnh2e.com
JWT_SECRET=
DEV_BYPASS_AUTH=0
# Vehicle heatmap database and map SDK
HEATMAP_DB_HOST=
HEATMAP_DB_PORT=36453
HEATMAP_DB_USER=
HEATMAP_DB_PASSWORD=
HEATMAP_DB_NAME=lingniu_vehicle_data
HEATMAP_DB_SSL=false
AMAP_WEB_KEY=
AMAP_SECURITY_JS_CODE=
# Feedback attachment object storage
OSS_ENDPOINT=
OSS_REGION=oss-cn-shanghai
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
OSS_BUCKET=
OSS_BASE_DIR=/dos
OSS_HOST=
SERVER_PORT=3001
-1
View File
@@ -1,4 +1,3 @@
node_modules node_modules
dist dist
.env .env
.worktrees
+8 -36
View File
@@ -5,42 +5,14 @@ services:
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0 image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
network_mode: host network_mode: host
environment: environment:
DB_HOST: "${DB_HOST:?Set DB_HOST in the deployment environment}" DB_HOST: "47.101.148.99"
DB_PORT: "${DB_PORT:-3306}" DB_PORT: "3306"
DB_USER: "${DB_USER:?Set DB_USER in the deployment environment}" DB_USER: "root"
DB_PASSWORD: "${DB_PASSWORD:?Set DB_PASSWORD in the deployment environment}" DB_PASSWORD: "LN#Passw0rd@2026"
DB_NAME: "${DB_NAME:?Set DB_NAME in the deployment environment}" DB_NAME: "lingniu_prod"
HYDROGEN_DB_HOST: "${HYDROGEN_DB_HOST:?Set HYDROGEN_DB_HOST in the deployment environment}" SERVER_PORT: "8111"
HYDROGEN_DB_PORT: "${HYDROGEN_DB_PORT:-3306}" EXTERNAL_API_BASE: "https://lnh2e.com"
HYDROGEN_DB_USER: "${HYDROGEN_DB_USER:?Set HYDROGEN_DB_USER in the deployment environment}" JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
HYDROGEN_DB_PASSWORD: "${HYDROGEN_DB_PASSWORD:?Set HYDROGEN_DB_PASSWORD in the deployment environment}"
HYDROGEN_DB_NAME: "${HYDROGEN_DB_NAME:?Set HYDROGEN_DB_NAME in the deployment environment}"
MILEAGE_DB_HOST: "${MILEAGE_DB_HOST:?Set MILEAGE_DB_HOST in the deployment environment}"
MILEAGE_DB_PORT: "${MILEAGE_DB_PORT:-3306}"
MILEAGE_DB_USER: "${MILEAGE_DB_USER:?Set MILEAGE_DB_USER in the deployment environment}"
MILEAGE_DB_PASSWORD: "${MILEAGE_DB_PASSWORD:?Set MILEAGE_DB_PASSWORD in the deployment environment}"
MILEAGE_DB_NAME: "${MILEAGE_DB_NAME:?Set MILEAGE_DB_NAME in the deployment environment}"
ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}"
ONEOS_MILEAGE_API_KEY: "${ONEOS_MILEAGE_API_KEY:?Set ONEOS_MILEAGE_API_KEY in the deployment environment}"
ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}"
SERVER_PORT: "${SERVER_PORT:-8111}"
EXTERNAL_API_BASE: "${EXTERNAL_API_BASE:-https://lnh2e.com}"
JWT_SECRET: "${JWT_SECRET:?Set JWT_SECRET in the deployment environment}"
AMAP_WEB_KEY: "${AMAP_WEB_KEY:?Set AMAP_WEB_KEY in the deployment environment}"
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE:?Set AMAP_SECURITY_JS_CODE in the deployment environment}"
OSS_ENDPOINT: "${OSS_ENDPOINT:?Set OSS_ENDPOINT in the deployment environment}"
OSS_REGION: "${OSS_REGION:-oss-cn-shanghai}"
OSS_ACCESS_KEY_ID: "${OSS_ACCESS_KEY_ID:?Set OSS_ACCESS_KEY_ID in the deployment environment}"
OSS_ACCESS_KEY_SECRET: "${OSS_ACCESS_KEY_SECRET:?Set OSS_ACCESS_KEY_SECRET in the deployment environment}"
OSS_BUCKET: "${OSS_BUCKET:?Set OSS_BUCKET in the deployment environment}"
OSS_BASE_DIR: "${OSS_BASE_DIR:-/dos}"
OSS_HOST: "${OSS_HOST:-}"
HEATMAP_DB_HOST: "${HEATMAP_DB_HOST:?Set HEATMAP_DB_HOST in the deployment environment}"
HEATMAP_DB_PORT: "${HEATMAP_DB_PORT:-36453}"
HEATMAP_DB_USER: "${HEATMAP_DB_USER:?Set HEATMAP_DB_USER in the deployment environment}"
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD:?Set HEATMAP_DB_PASSWORD in the deployment environment}"
HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}"
HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}"
deploy: deploy:
replicas: 1 replicas: 1
restart_policy: restart_policy:
-216
View File
@@ -1,216 +0,0 @@
# BI 渐进式重构方案
更新日期:2026-08-07
## 1. 目标
本次重构不重做整套系统,采用可回滚的小批次交付,逐步解决三个核心问题:
1. UI/UX:统一信息层级、筛选方式、状态反馈和移动端行为。
2. 下钻:任何汇总指标都能沿同一统计口径进入可核对的明细。
3. 统计逻辑:指标公式、时间语义、数据来源和数据截至时间均可查询、可测试。
不在当前阶段引入新的 BI 引擎或替换现有 React、Hono、MySQL 技术栈。
## 2. 设计原则
- 先口径,后图表:没有明确公式和数据源的指标不得进入主看板。
- 父子一致:下钻前后的时间、车辆范围、组织范围和数据源必须一致。
- URL 即分析状态:日期、范围、实体和数据源写入 URL,支持刷新、返回和分享。
- 空值不等于零值:接口失败、尚未接入、没有权限和真实零业务使用不同状态。
- 汇总可对账:页面汇总应等于明细全量聚合,明细截断不能影响顶部合计。
- 数据新鲜度显式展示:业务时间与页面刷新时间分开表达。
- 渐进发布:每批只修改一个口径或一条下钻链路,通过测试和真实数据核对后提交。
## 3. 目标信息架构
每个 BI 模块统一为三级信息结构:
| 层级 | 用途 | 页面内容 |
| --- | --- | --- |
| 总览 | 判断经营状态 | 核心 KPI、趋势、结构、异常、数据截至时间 |
| 分析 | 定位变化来源 | 时间筛选、组织/车辆范围、分组排行、归因 |
| 明细 | 核对原始业务 | 车辆、订单、站点、账单或通行记录 |
页面头部统一提供模块名称、简短业务说明和“指标口径”入口。筛选条件位于内容上方,实体下钻状态紧邻筛选区展示,避免用户忘记当前分析范围。
## 4. 模块与下钻矩阵
### 4.1 里程
| 父级 | 下钻目标 | 必须继承的上下文 | 当前状态 |
| --- | --- | --- | --- |
| 实时监控汇总 | 车辆详情 | 日期区间、协议优先级、车牌 | 已完成 |
| 考核目标 | 部门/客户归因 | 考核年份、目标、归因维度 | 已完成 |
| 归因分组 | 车辆清单 | 目标、部门或客户值 | 已完成 |
| 每日汇报 | 实时监控/考核目标 | 汇报日期、目标或车牌 | 已完成 |
考核完成率使用目标里程加权:
```text
SUM(completed_mileage_km) / NULLIF(SUM(target_mileage_km), 0) * 100
```
禁止直接平均车辆完成率或考核组完成率。
### 4.2 氢能
| 父级 | 下钻目标 | 必须继承的上下文 | 当前状态 |
| --- | --- | --- | --- |
| 站点排行 | 站点每日明细 | 年份、站点、日期区间、车辆范围 | 已完成 |
| 客户排行 | 客户每日明细 | 年份、客户、日期区间、车辆范围 | 已完成 |
| 每日趋势 | 加氢订单 | 日期、车辆范围、站点或客户 | 下钻代码已完成,待数据源恢复后验收 |
成本、收入和毛利必须分开:
```text
成本 = SUM(cost_total)
客户收入 = SUM(fee_total)
客户单毛利 = 客户收入 - 客户单对应成本
```
数据库不可用时返回可识别的 503 状态,页面不得显示派生零值。
### 4.3 电能
| 父级 | 下钻目标 | 必须继承的上下文 | 当前状态 |
| --- | --- | --- | --- |
| 总览日柱 | 当日订单 | 日期、全部车辆范围 | 已完成 |
| 月份 | 日期 | 车辆范围、日期区间 | 已完成 |
| 日期 | 充电订单 | 日期、车辆范围 | 已完成 |
电能范围包括“全部车辆、羚牛车辆、外部车辆”。总览使用全部车辆口径,因此总览下钻必须进入 `all`,不能默认切换为羚牛车辆。
综合费用强度使用加权公式:
```text
SUM(fee) / NULLIF(SUM(kwh), 0)
```
零费用订单参与电量和订单数统计。当前月无数据时可以展示最近数据月,但所有月度标签必须显示实际月份,并展示 `MAX(start_time)` 数据截至时间。
### 4.4 ETC
| 父级 | 下钻目标 | 必须继承的上下文 | 当前状态 |
| --- | --- | --- | --- |
| 通行费用 | 通行记录 | 日期、车牌、客户、费用承担类型 | 下钻代码已完成,待首次真实同步验收 |
| 账单应收 | ETC 账单 | 账期、客户、支付状态 | 下钻代码已完成,待首次真实同步验收 |
供应商未配置或从未同步时展示接入状态,不使用模拟业务数据填充图表。
## 5. 指标治理
指标目录由 `/api/analytics/metrics` 发布,当前采用版本化合同。每个指标必须包含:
- 唯一且带领域前缀的指标 ID。
- 中文名称和业务解释。
- 单位、聚合方式和时间语义。
- 可审计公式与真实数据源。
- 支持的筛选维度和下钻实体。
指标变更规则:
1. 修改公式、来源或时间语义时提升目录版本。
2. 后端查询、指标目录、页面标签和测试在同一批次修改。
3. 比率类指标必须由分子、分母汇总后计算,禁止平均子级比率。
4. 金额同时标明成本、收入、应收、实收或总费用,不使用模糊的“费用”。
5. 快照、区间流量和数据新鲜度不得混在同一时间标签下。
## 6. URL 与交互约定
- 模块和子页使用 hash,例如 `#electric/overview`
- 分析上下文使用领域前缀查询参数,例如 `electricDate``hydrogenStationId``mileagePlate`
- 不删除其他领域的查询参数。
- 总览进入明细使用 `pushState`,同页筛选调整使用 `replaceState`
- 浏览器返回、前进和刷新必须恢复筛选与展开状态。
- 图表下钻同时支持鼠标和键盘,交互图形提供可读名称。
## 7. 接口与性能边界
| 类型 | 目标 |
| --- | --- |
| 热缓存汇总接口 | P95 小于 500 ms |
| 普通数据库聚合 | P95 小于 1.5 s |
| 明细查询 | 首屏小于 2 s,超过上限时分页或截断 |
| 并发同参 GET | 前端和服务端均只执行一次 |
| 外部数据源失败 | 返回结构化 503,不返回连接信息 |
查询要求:
- 日期条件使用范围比较,避免对索引列套 `DATE()``DATE_FORMAT()` 后再过滤。
- 汇总与明细可以并行查询,但必须使用相同过滤条件。
- 明细展示上限与全量汇总分离,截断时明确提示。
- 缓存键必须包含所有影响统计结果的筛选维度。
- 失败请求不得驻留在并发请求缓存中。
## 8. 分阶段路线
### 阶段 A:口径与导航基线
- 已完成版本化指标目录和四模块入口。
- 已完成里程、电能、氢能主要 URL 状态。
- 已完成 ETC 记录/账单视图、日期、搜索和分页 URL 状态,以及超范围页码自动纠正。
- 已统一共享加载、空数据和故障组件的顶层卡片与明细内联边界,消除能源明细中的嵌套状态卡片。
- 已统一电能、氢能和 ETC 日期范围输入;电能与氢能共用快捷区间算法和单一输入事件路径。
- 电能与氢能可根据 URL 日期恢复快捷区间选中态;过期区间自动归为自定义,避免标签与统计日期不一致。
- 已统一电能和氢能车辆范围 segmented control,并区分前端车辆归属状态与后端 `customer` 查询参数语义。
- 已完成并发同参 GET 去重。
### 阶段 B:核心下钻闭环
- 已完成里程考核归因、车辆详情和每日汇报跳转。
- 里程考核年度已纳入 `mileageYear` URL 状态;目标、部门/客户归因和车辆清单下钻继承同一年度,无效年度会按目标真实年度归一。
- 车辆级里程详情继承 `mileageStart``mileageEnd` 和数据源优先级;显式下钻日期严格展示所选日,预设区间继续排除尚未完整的当天数据,请求故障显示为不可用并支持重试。
- 里程实时监控的日期、来源、批次、区域、车牌、归属、高级筛选和排序已纳入独立 URL 状态;分享或刷新链接可恢复完整查询,车辆详情前进后退保留列表上下文且不把下钻车牌误写为筛选车牌。
- 已完成氢能站点、客户下钻代码。
- 已完成氢能每日趋势到加氢订单的 URL 下钻、分页和结构化故障状态;待数据源恢复后验收真实明细。
- 已为电能总览与氢能每日趋势柱图统一 Enter/Space 键盘下钻;氢能有效日期柱提供可读名称、焦点和选中反馈。
- 已完成电能全部车辆、日期和订单下钻。
- 电能每日列表打开新日期订单使用浏览器历史 `pushState`,返回/前进可恢复进入前后的分析状态;筛选调整仍使用 `replaceState`
- 已完成 ETC 通行记录与结算账单下钻代码、筛选、分页和未同步空状态;待首次真实同步后验收真实数据。
### 阶段 C:数据可信度
- 里程年度归因使用考核台账 `current_mileage` 快照,按车辆目标封顶后加总完成量与缺口,并自动核对归因合计和目标汇总;带日期的 OneOS 里程只用于车辆实时明细。
- 里程考核目标接口发布车辆台账 `MAX(update_time)`,统计页使用真实日期和时间展示数据截至点,不再用页面访问日或考核结束日代替快照时间。
- 里程考核接口区分目标声明台数与当前有效车辆数;两者不一致时页面明确标注缺少或超配数量,并说明完成率、缺口和归因的实际计算范围。
- 里程考核统计主数据已区分加载、真实空数据、首次请求故障和保留旧数据的刷新故障,并提供统一重试入口,接口失败不再显示为零值或空页面。
- 里程考核归因车辆与 7 天趋势已提供独立加载、故障、空数据和重试状态;子请求失败时停止展示“0 台未达标”和“最新日变化 +0”等伪业务结果。
- 里程考核车辆日期侧栏仅展示带目标和日期标识的成功快照;切换日期失败时保留并标注上一成功日期,首次失败不再回退为当前日数据或伪造“0 辆”。
- 里程实时监控的首页、分页、全屏和导出已共用同一组筛选参数;全屏首次故障不再显示零值,刷新故障会保留并标注上一成功快照,导出成功或失败均提供可见结果和重试入口。
- 里程实时监控主列表快照已绑定完整筛选与排序口径;新口径请求失败时隐藏旧 KPI、趋势和车辆,原口径刷新失败时保留并标注旧快照,快速筛选与分页的过期响应不会覆盖当前结果。
- 里程实时监控已将排序维度与 KPI 公式解耦:主值和平均单车始终使用当日/区间流量里程,累计仪表合计保持独立快照语义;平均单车里程已纳入版本 7 指标目录。
- 里程历史区间监控已复用权限过滤前的分钟级基础快照;筛选、排序、分页和并发同参请求不再重复拉取 OneOS 区间数据及车辆元数据,强制刷新仍可绕过已完成快照,失败请求不进入缓存。
- 已完成电能数据截至时间和实际趋势月展示。
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
- 已完成氢能结构化故障状态与重试体验。
- 已完成里程今日仪表快照复用:监控查询和考核车辆当前日明细命中同一分钟级快照,手动刷新生成一致的分页快照;历史日仍使用独立日期缓存。
- 已在各 BI 模块头部提供当前数据源的真实请求状态、失败率和最后成功/失败时间。
- 待恢复氢能数据库后进行总览、站点、客户三层对账。
- 待 ETC 同步后进行通行记录、账单和收款三层对账。
### 阶段 D:部署与安全
- 已将部署清单、氢能和里程连接中的数据库敏感值迁移为必填环境变量。
- 轮换已经进入版本历史的氢能数据库凭据。
- 已提供无敏感值的 `.env.example`;测试、生产值由各环境密钥管理。
- 已拆分进程存活检查与无敏感值的配置就绪检查。
- 已增加基于真实业务请求的数据源最后结果、最后成功时间和滚动失败率监控。
## 9. 每批验收门槛
每次提交至少满足:
1. TypeScript 检查通过。
2. 受影响的口径、URL 或查询测试通过。
3. 生产前端构建通过,服务端入口可加载。
4. `git diff --check` 通过。
5. 桌面和移动端检查无溢出、遮挡或不可操作控件。
6. 使用真实接口核对至少一个父级汇总与子级明细。
7. 不提交本地数据、临时脚本、凭据或无关工作区修改。
## 10. 当前外部依赖
- 氢能数据库连接尚未恢复,真实数据对账未完成。
- ETC 供应商未配置、未执行首次同步;记录与账单下钻代码已完成,但真实数据仍不可验收。
- 氢能连接信息已从当前版本迁出,但历史凭据仍必须在数据库侧轮换;文档不记录任何连接值。
-155
View File
@@ -1,155 +0,0 @@
# OneOS 车辆里程接口需求
当前协议选源联调阻塞项和 OneOS 修改清单见:
[OneOS 车辆里程数据源协议改造清单](./oneos-mileage-source-protocol-gaps.md)。
## 开发原则
1. 新增区间查询接口;车辆里程汇总能力在现有
`POST /api/v1/vehicles/mileage/query` 上兼容扩展。
2. 现有接口的路径、鉴权、已有请求参数、已有响应字段、字段语义和默认行为必须完全兼容。
3. 公网 `https://open.d.lnoneos.com` 与 ECS 内网 `http://172.17.111.55:20310` 使用相同路径、鉴权和数据口径。
4. 鉴权继续使用 `Authorization: Bearer APP_KEY`
5. 日期采用 `YYYY-MM-DD`,时区统一为 `Asia/Shanghai`,里程单位统一为 km。
## 1. 车辆区间日里程批量查询
```http
POST /api/v1/vehicles/mileage/range/query
```
请求:
```json
{
"startDate": "2026-07-01",
"endDate": "2026-07-23",
"plateNumbers": ["沪A00001", "沪A00002"],
"protocolPriority": ["GB32960", "MQTT", "JT808"],
"cursor": null,
"pageSize": 5000
}
```
规则:
- `startDate``endDate` 必填,最长支持 366 天;
- `plateNumbers` 可省略,省略时返回授权范围内全部车辆;
- `protocolPriority` 可省略;提供时只允许 `GB32960``MQTT``JT808`
数组顺序表示逐车逐日的数据源优先级,未列出的协议视为禁用;
- BI 只会使用以下四种组合:
- 仪表数据优先:`["GB32960", "MQTT", "JT808"]`
- GPS 数据优先:`["JT808", "GB32960", "MQTT"]`
- 仅仪表数据:`["GB32960", "MQTT"]`
- 仅 GPS 数据:`["JT808"]`
- BI 页面默认只启用“仪表数据”,即默认发送
`["GB32960", "MQTT"]`;用户主动启用 GPS 后才发送包含 `JT808` 的组合;
- 仪表数据内部优先级固定为 `GB32960 > MQTT`,不提供反向切换;
- 数据量过大时使用 `cursor` 分页,同一次查询使用相同 `snapshotId`
- 每辆车每天返回一条记录;
- 无数据返回 `status: "NO_DATA"``dailyMileageKm: null`,真实零里程返回 `status: "NORMAL"``dailyMileageKm: 0`
响应:
```json
{
"code": "SUCCESS",
"message": "success",
"data": [
{
"vin": "LMRK...",
"plateNumber": "沪A00001",
"date": "2026-07-01",
"dailyMileageKm": 182.437,
"sourceProtocol": "GB32960",
"dataTime": "2026-07-01T23:58:45+08:00",
"updatedAt": "2026-07-02T05:10:00+08:00",
"status": "NORMAL"
}
],
"snapshotId": "mileage-20260723-001",
"nextCursor": null,
"traceId": "..."
}
```
## 2. 兼容扩展现有单日车辆里程接口
用于一次获取指定日期每辆车的日里程、累计里程和数据时间。
```http
POST /api/v1/vehicles/mileage/query
```
请求:
```json
{
"date": "2026-07-23",
"plateNumbers": ["沪A00001", "沪A00002"],
"protocolPriority": ["GB32960", "MQTT", "JT808"]
}
```
规则:
- `date``plateNumbers` 沿用现有接口规则;
- `protocolPriority` 的枚举、顺序和禁用规则与区间接口完全一致;
- `plateNumbers` 省略时返回授权范围内全部车辆;
- 接口默认在现有车辆结果中返回 `totalMileageKm``dataTime``updatedAt`
- 只增加响应字段,不删除或修改任何现有字段及其语义;
- 历史日期返回该自然日最终有效结果;
- 查询当天返回当前最新结果;
- `dataTime` 必须是本次统计实际采用的最后一条车辆源数据时间,不能使用接口请求时间或响应时间代替;
- 无数据时相关里程字段返回 `null`,不能使用 0 代替;
- 接口应支持当前应用授权范围内的全部车辆。
响应:
```json
{
"code": "SUCCESS",
"message": "success",
"data": [
{
"vin": "LMRK...",
"plateNumber": "沪A00001",
"date": "2026-07-23",
"dailyMileageKm": 182.437,
"totalMileageKm": 12345.678,
"sourceProtocol": "GB32960",
"dataTime": "2026-07-23T10:35:42+08:00",
"updatedAt": "2026-07-23T10:35:46+08:00",
"status": "NORMAL"
}
],
"traceId": "..."
}
```
现有响应外层结构及已有字段保持不变,调用方无需增加任何请求参数。
## 通用要求
- `dataTime``updatedAt` 使用带 `+08:00` 的 ISO 8601 格式;
- `sourceProtocol` 必须返回本条结果实际采用的协议:
`GB32960``MQTT``JT808`;无数据时返回 `null`
- OneOS 必须按 `protocolPriority` 选择第一份有效数据,不得返回已禁用协议的数据;
- `sourceProtocol=GB32960/MQTT` 在 BI 显示为“仪表数据”,
`sourceProtocol=JT808` 显示为“GPS数据”;
- 每个响应返回 `traceId`
- 接口返回值保留原始精度;
- 负里程不得作为正常数据返回;
- 公网和 ECS 内网使用同一 AppKey 时,授权范围和查询结果保持一致;
- 区间接口及现有接口的汇总扩展不得影响现有调用方和默认行为。
## 验收
1. 现有 `/api/v1/vehicles/mileage/query` 请求方式保持不变,回归测试全部通过。
2. 区间接口不传车牌可完整查询全部授权车辆,分页无重复、无漏行。
3. 现有接口不传车牌时,默认返回全部授权车辆的日里程、累计里程和数据时间。
4. 正确区分无数据与真实零里程。
5. `dataTime` 能反映每辆车实际数据的新鲜度。
6. 四种 `protocolPriority` 组合均能按顺序选源,且响应中的
`sourceProtocol` 与实际采用协议一致。
7. 禁用某类协议后,响应不得回退到该协议。
-183
View File
@@ -1,183 +0,0 @@
# OneOS 车辆里程数据源协议改造清单
## 0. 发布后复测结论
复测日期:2026-07-23
开放平台发布后,BI 已重新连接 `https://open.d.lnoneos.com` 并完成复测:
- 单日接口和区间接口均接受 `protocolPriority`,不再返回
`INVALID_REQUEST`
- 成功响应已返回实际使用的 `sourceProtocol`
- 仪表优先、GPS 优先、仅仪表、仅 GPS 四种模式均返回 HTTP 200
- “仅仪表数据”结果未出现 `JT808`
- “仅 GPS 数据”结果未出现 `GB32960``MQTT`
- 同一车辆四天区间查询能够随优先级在 `GB32960``JT808`
之间正确切换;
- BI 页面已显示真实“仪表数据”或“GPS数据”,不再显示“来源待接口”。
复测时的单日协议分布如下,数据量会随当天车辆上报变化:
| BI 模式 | GB32960 | MQTT | JT808 | 无数据 | 禁用协议混入 |
| --- | ---: | ---: | ---: | ---: | --- |
| 仪表数据优先 | 387 | 49 | 134 | 454 | 无 |
| GPS 数据优先 | 69 | 30 | 471 | 454 | 无 |
| 仅仪表数据 | 387 | 49 | 0 | 588 | 无 |
| 仅 GPS 数据 | 0 | 0 | 471 | 553 | 无 |
结论:本清单中的协议选源阻塞项已关闭,当前接口满足 BI 对接要求。
以下内容保留为接口契约和后续回归验收依据。
## 1. 历史联调结论(发布前)
联调地址:`https://open.d.lnoneos.com`
涉及接口:
- `POST /api/v1/vehicles/mileage/query`
- `POST /api/v1/vehicles/mileage/range/query`
发布前接口不满足 BI 对车辆里程数据源切换和来源标注的要求,阻塞项如下:
| 序号 | 当前表现 | 期望表现 | 影响 |
| --- | --- | --- | --- |
| 1 | 请求增加 `protocolPriority` 后返回 HTTP 400,业务码为 `INVALID_REQUEST` | 两个里程接口均接受可选字段 `protocolPriority` | BI 无法切换仪表数据和 GPS 数据的优先级,也无法单独禁用某类数据 |
| 2 | 成功响应中没有 `sourceProtocol` | 每辆车、每天的结果返回实际采用的协议 | BI 无法准确标注“仪表数据”或“GPS数据” |
| 3 | 无法验证接口是否排除了已禁用协议 | 未列入 `protocolPriority` 的协议不得参与选源 | “仅仪表数据”和“仅 GPS 数据”无法生效 |
| 4 | 无法验证仪表数据内部的降级顺序 | 仪表数据固定按 `GB32960 > MQTT` 选源 | 32960 缺失时无法确认是否正确降级到 MQTT |
BI 保留旧请求格式的兼容回退,用于开放平台异常或版本回滚时保证现有里程查询可用。回退结果不会伪造数据来源,页面会显示“来源待接口”。正常情况下新接口已不触发该回退。
## 2. OneOS 必须修改的请求字段
两个接口都需要增加以下可选字段:
```json
{
"protocolPriority": ["GB32960", "MQTT", "JT808"]
}
```
字段规则:
1. 类型为非空字符串数组。
2. 仅允许 `GB32960``MQTT``JT808`,协议名区分以此处定义为准。
3. 数组顺序表示逐车、逐自然日的数据源优先级。
4. 未出现在数组中的协议视为禁用,不得参与选源或作为兜底数据返回。
5. `protocolPriority` 省略时保持现有接口的默认行为,确保旧调用方兼容。
6. 非法枚举、空数组或重复协议应返回 HTTP 400,并在响应中给出明确的错误信息和 `traceId`
BI 只会发送以下四种组合:
| BI 模式 | `protocolPriority` | 选源规则 |
| --- | --- | --- |
| 仪表数据优先 | `["GB32960", "MQTT", "JT808"]` | 先查 32960,再查 MQTT,最后查 JT808 |
| GPS 数据优先 | `["JT808", "GB32960", "MQTT"]` | 先查 JT808,再查 32960,最后查 MQTT |
| 仅仪表数据 | `["GB32960", "MQTT"]` | 只允许 32960 和 MQTT,禁止使用 JT808 |
| 仅 GPS 数据 | `["JT808"]` | 只允许 JT808,禁止使用 32960 和 MQTT |
BI 页面默认只启用“仪表数据”,即默认使用
`["GB32960", "MQTT"]`GPS 数据需要用户主动启用。
仪表数据内部优先级固定为 `GB32960 > MQTT`BI 不会发送 `MQTT > GB32960`
## 3. OneOS 必须增加的响应字段
两个接口的每条车辆日里程结果都需要增加:
```json
{
"sourceProtocol": "GB32960"
}
```
字段规则:
1. 有有效里程数据时,只能返回 `GB32960``MQTT``JT808`
2. 返回值必须是本条结果实际采用的协议,不能返回车辆支持的协议列表,也不能返回请求中的第一项作为固定值。
3. 无有效数据、`status="NO_DATA"` 时返回 `null`
4. `sourceProtocol` 对应关系:
- `GB32960``MQTT`BI 显示为“仪表数据”;
- `JT808`BI 显示为“GPS数据”。
5. 如果 `sourceProtocol` 不在本次请求的 `protocolPriority` 中,应视为接口错误。
响应示例:
```json
{
"code": "SUCCESS",
"message": "success",
"data": [
{
"vin": "LMRK...",
"plateNumber": "粤A00001F",
"date": "2026-07-23",
"dailyMileageKm": 182.437,
"totalMileageKm": 12345.678,
"sourceProtocol": "GB32960",
"dataTime": "2026-07-23T10:35:42+08:00",
"updatedAt": "2026-07-23T10:35:46+08:00",
"status": "NORMAL"
}
],
"traceId": "..."
}
```
## 4. 逐车逐日选源规则
OneOS 需要针对每辆车、每个自然日独立执行以下逻辑:
1.`protocolPriority` 从前到后检查数据源。
2. 找到第一份有效数据后立即使用,不再混用低优先级协议的数据。
3. 有效数据必须满足现有里程质量规则,包括里程非空、非负且数据状态有效。
4. 高优先级协议无有效数据时,才能降级到下一个已启用协议。
5. 所有已启用协议均无有效数据时,返回:
```json
{
"dailyMileageKm": null,
"sourceProtocol": null,
"status": "NO_DATA"
}
```
6. 真实零里程必须返回:
```json
{
"dailyMileageKm": 0,
"sourceProtocol": "实际采用的协议",
"status": "NORMAL"
}
```
区间查询必须逐日选源,不能先按一个协议汇总整个区间后再切换协议。
## 5. 验收用例
OneOS 修改完成后,至少提供一辆在同一天同时存在 32960、MQTT、JT808 数据的测试车辆,以及一辆只有 MQTT 数据的测试车辆。
| 用例 | 请求优先级 | 必须满足的结果 |
| --- | --- | --- |
| 仪表优先 | `GB32960, MQTT, JT808` | 同时有三种数据时采用 GB32960,`sourceProtocol="GB32960"` |
| GPS 优先 | `JT808, GB32960, MQTT` | 同时有三种数据时采用 JT808,`sourceProtocol="JT808"` |
| 仅仪表 | `GB32960, MQTT` | 不得返回 JT80832960 缺失时允许降级到 MQTT |
| 仅 GPS | `JT808` | 不得返回 GB32960 或 MQTT |
| 32960 降级 | `GB32960, MQTT` | 只有 MQTT 有效时返回 MQTT`sourceProtocol="MQTT"` |
| 禁用验证 | `JT808` | 即使仪表数据存在,也不能回退到仪表数据 |
| 无数据 | 任一合法组合 | 返回 `dailyMileageKm=null``sourceProtocol=null``status="NO_DATA"` |
| 真实零里程 | 任一合法组合 | 返回 `dailyMileageKm=0`、实际 `sourceProtocol``status="NORMAL"` |
| 单日接口 | 四种组合分别测试 | 请求成功且选源、来源字段符合规则 |
| 区间接口 | 四种组合分别测试 | 每辆车每天独立选源,分页无重复、无漏行 |
| 兼容性 | 不传 `protocolPriority` | 原有调用方式和响应语义保持不变 |
## 6. 交付和联调要求
1. 公网 `https://open.d.lnoneos.com` 和 ECS 内网地址同时发布相同能力。
2. 发布后提供接口版本或发布日期、测试车辆、测试日期及对应协议数据情况。
3. 提供四种合法组合的请求和响应样例。
4. 提供一组禁用协议的验证结果,证明未启用协议不会被回退使用。
5. 每个成功和失败响应均返回 `traceId`,便于双方定位问题。
完成标准:两个接口均支持 `protocolPriority`,每条结果准确返回 `sourceProtocol`,四种 BI 模式全部通过上述验收用例。
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>羚牛氢能</title> <title>羚牛氢能车辆资产</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+2 -160
View File
@@ -1,14 +1,13 @@
{ {
"name": "ln-bi", "name": "ln-bi",
"version": "1.1.13", "version": "1.1.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ln-bi", "name": "ln-bi",
"version": "1.1.13", "version": "1.1.5",
"dependencies": { "dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0", "@hono/node-server": "^1.13.0",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"ali-oss": "^6.23.0", "ali-oss": "^6.23.0",
@@ -18,7 +17,6 @@
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"mysql2": "^3.11.0", "mysql2": "^3.11.0",
"pg": "^8.22.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"recharts": "^3.8.1", "recharts": "^3.8.1",
@@ -29,7 +27,6 @@
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@types/ali-oss": "^6.23.3", "@types/ali-oss": "^6.23.3",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/pg": "^8.20.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
@@ -39,12 +36,6 @@
"vite": "^6.2.0" "vite": "^6.2.0"
} }
}, },
"node_modules/@amap/amap-jsapi-loader": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@amap/amap-jsapi-loader/-/amap-jsapi-loader-1.0.1.tgz",
"integrity": "sha512-nPyLKt7Ow/ThHLkSvn2etQlUzqxmTVgK7bIgwdBRTg2HK5668oN7xVxkaiRe3YZEzGzfV2XgH5Jmu2T73ljejw==",
"license": "MIT"
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -1629,18 +1620,6 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -3542,95 +3521,6 @@
"through": "~2.3" "through": "~2.3"
} }
}, },
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3686,45 +3576,6 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/process-nextick-args": { "node_modules/process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -4118,15 +3969,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/sql-escaper": { "node_modules/sql-escaper": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
+1 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "ln-bi", "name": "ln-bi",
"private": true, "private": true,
"version": "1.1.13", "version": "1.1.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", "dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
@@ -12,7 +12,6 @@
"lint": "tsc --noEmit" "lint": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0", "@hono/node-server": "^1.13.0",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"ali-oss": "^6.23.0", "ali-oss": "^6.23.0",
@@ -22,7 +21,6 @@
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"mysql2": "^3.11.0", "mysql2": "^3.11.0",
"pg": "^8.22.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"recharts": "^3.8.1", "recharts": "^3.8.1",
@@ -33,7 +31,6 @@
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@types/ali-oss": "^6.23.3", "@types/ali-oss": "^6.23.3",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/pg": "^8.20.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
+41 -182
View File
@@ -1,190 +1,67 @@
import { lazy, Suspense, useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from 'react';
import { Truck, Route, Activity, Fuel, BatteryCharging, Receipt, MapPinned } from "lucide-react"; import { Truck, Route, Activity, Zap } from 'lucide-react';
import { Shell, type ModuleConfig } from "./components/Shell"; import { Shell, type ModuleConfig } from './components/Shell';
import AuthProvider from "./auth/AuthProvider"; import AssetsModule from './modules/assets/AssetsModule';
import { useAuth } from "./auth/useAuth"; import MileageModule from './modules/mileage/MileageModule';
import UnauthorizedPage from "./auth/UnauthorizedPage"; import SchedulingModule from './modules/scheduling/SchedulingModule';
import { canAccessScheduling, canAccessEnergy } from "./shared/auth/roles"; import EnergyModule from './modules/energy/EnergyModule';
import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface"; import EleImportPage from './modules/ele/EleImportPage';
import FeedbackAdminPage from './modules/admin/FeedbackAdminPage';
import AuthProvider from './auth/AuthProvider';
import { useAuth } from './auth/useAuth';
import UnauthorizedPage from './auth/UnauthorizedPage';
import { canAccessScheduling, canAccessEnergy } from './shared/auth/roles';
const AssetsModule = lazy(() => import("./modules/assets/AssetsModule")); const BASE_MODULES: ModuleConfig[] = [
const MileageModule = lazy(() => import("./modules/mileage/MileageModule")); { id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
const SchedulingModule = lazy(() => import("./modules/scheduling/SchedulingModule")); { id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
const HydrogenModule = lazy(() => import("./modules/energy/HydrogenModule")); ];
const ElectricModule = lazy(() => import("./modules/energy/ElectricModule"));
const EtcModule = lazy(() => import("./modules/energy/EtcModule"));
const EleImportPage = lazy(() => import("./modules/ele/EleImportPage"));
const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage"));
const VehicleHeatmapModule = lazy(() => import("./modules/vehicle-heatmap/VehicleHeatmapModule"));
const HydrogenHeatmapModule = lazy(() => import("./modules/hydrogen-heatmap/HydrogenHeatmapModule"));
const ASSETS_MODULE: ModuleConfig = { const ENERGY_MODULE: ModuleConfig = {
id: "assets", id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule,
label: "资产管理",
icon: Truck,
component: AssetsModule,
};
const MILEAGE_MODULE: ModuleConfig = {
id: "mileage",
label: "里程管理",
icon: Route,
component: MileageModule,
};
const VEHICLE_HEATMAP_MODULE: ModuleConfig = {
id: "vehicle-heatmap",
label: "车辆",
icon: MapPinned,
component: VehicleHeatmapModule,
}; };
const SCHEDULING_MODULE: ModuleConfig = { const SCHEDULING_MODULE: ModuleConfig = {
id: "scheduling", id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule,
label: "智能调度",
icon: Activity,
component: SchedulingModule,
}; };
const HYDROGEN_MODULE: ModuleConfig = {
id: "hydrogen",
label: "氢能",
icon: Fuel,
component: HydrogenModule,
};
const HYDROGEN_HEATMAP_MODULE: ModuleConfig = {
id: "hydrogen-heatmap",
label: "加氢",
icon: MapPinned,
component: HydrogenHeatmapModule,
};
const ELECTRIC_MODULE: ModuleConfig = {
id: "electric",
label: "电能",
icon: BatteryCharging,
component: ElectricModule,
};
const ETC_MODULE: ModuleConfig = {
id: "etc",
label: "ETC",
icon: Receipt,
component: EtcModule,
};
/**
* 把旧路径 / 根路径归一化到 `/asset` 或 `/energy` 主路径,
* 必要时携带 hash 一段定位到具体模块。已是主路径或后台管理页则不动。
*/
function normalizePath() {
if (typeof window === "undefined") return;
const { pathname, hash, search } = window.location;
// 主路径 & 隐藏后台页保持不变
if (pathname === "/asset" || pathname === "/energy") return;
if (pathname === "/ele/import" || pathname === "/admin/feedback") return;
const legacyMap: Record<string, { path: string; hash?: string }> = {
"/": { path: "/asset" },
"/vehicle": { path: "/asset", hash: "assets" },
"/assets": { path: "/asset", hash: "assets" },
"/mileage": { path: "/asset", hash: "mileage" },
"/scheduling": { path: "/asset", hash: "scheduling" },
};
// 未知路径兜底到 /asset(保留原 hash 让 Shell 内部继续解析)
const target = legacyMap[pathname] ?? { path: "/asset" };
const finalHash = target.hash ? `#${target.hash}` : hash || "";
window.history.replaceState(null, "", `${target.path}${search}${finalHash}`);
}
normalizePath();
type PathSet = "asset" | "energy";
function getPathSet(): PathSet {
return window.location.pathname === "/energy" ? "energy" : "asset";
}
function getRouteKey(): string { function getRouteKey(): string {
if (typeof window === "undefined") return ""; if (typeof window === 'undefined') return '';
const path = window.location.pathname; const path = window.location.pathname;
const hash = window.location.hash; const hash = window.location.hash;
if ( if (path === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') return 'ele/import';
path === "/ele/import" || if (path === '/admin/feedback' || hash === '#/admin/feedback' || hash === '#admin/feedback') return 'admin/feedback';
hash === "#/ele/import" || return '';
hash === "#ele/import"
)
return "ele/import";
if (
path === "/admin/feedback" ||
hash === "#/admin/feedback" ||
hash === "#admin/feedback"
)
return "admin/feedback";
return "";
} }
function AuthGate() { function AuthGate() {
const { isLoading, isAuthenticated, error, user } = useAuth(); const { isLoading, isAuthenticated, error, user } = useAuth();
const [routeKey, setRouteKey] = useState(getRouteKey); const [routeKey, setRouteKey] = useState(getRouteKey);
const [pathSet, setPathSet] = useState<PathSet>(getPathSet);
// 监听 hashchange / popstate,让 a href="#/..." 跳转与浏览器前进后退能即时生效 // 监听 hashchange / popstate,让 a href="#/..." 跳转能即时生效
useEffect(() => { useEffect(() => {
const update = () => { const update = () => setRouteKey(getRouteKey());
setRouteKey(getRouteKey()); window.addEventListener('hashchange', update);
setPathSet(getPathSet()); window.addEventListener('popstate', update);
};
window.addEventListener("hashchange", update);
window.addEventListener("popstate", update);
return () => { return () => {
window.removeEventListener("hashchange", update); window.removeEventListener('hashchange', update);
window.removeEventListener("popstate", update); window.removeEventListener('popstate', update);
}; };
}, []); }, []);
useEffect(() => { const modules = useMemo(() => {
document.title = pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI"; const result = [...BASE_MODULES];
}, [pathSet]); if (canAccessEnergy(user?.roles)) result.push(ENERGY_MODULE);
const modules = useMemo<ModuleConfig[]>(() => {
if (pathSet === "energy") {
return [HYDROGEN_MODULE, ELECTRIC_MODULE, ETC_MODULE];
}
const heatmapChildren = canAccessEnergy(user?.roles)
? [HYDROGEN_HEATMAP_MODULE, VEHICLE_HEATMAP_MODULE]
: [VEHICLE_HEATMAP_MODULE];
const heatmapModule: ModuleConfig = {
id: "heatmap",
label: "热力图",
icon: MapPinned,
children: heatmapChildren,
};
const result: ModuleConfig[] = [ASSETS_MODULE, MILEAGE_MODULE, heatmapModule];
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE); if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
return result; return result;
}, [pathSet, user?.roles]); }, [user?.roles]);
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen bg-[var(--app-bg)] p-4 text-slate-800 md:p-8"> <div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
<div className="mx-auto flex max-w-5xl flex-col gap-4"> <div className="text-center">
<div className="rounded-2xl border border-white/70 bg-white/86 p-5 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl"> <div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
<div className="mb-3 text-[11px] font-black text-blue-600">LN BI ACCESS</div> <p className="text-xs text-slate-400 font-bold">...</p>
<div className="text-xl font-black text-slate-950"> BI</div>
<div className="mt-2 text-xs font-bold text-slate-400"></div>
</div>
<SurfaceCard className="p-4">
<div className="grid gap-3 md:grid-cols-4">
{[0, 1, 2, 3].map(item => <SkeletonBlock key={item} className="h-24" />)}
</div>
<div className="mt-4">
<LoadingState label="正在验证身份" />
</div>
</SurfaceCard>
</div> </div>
</div> </div>
); );
@@ -195,28 +72,10 @@ function AuthGate() {
} }
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现 // 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
if (routeKey === "ele/import") { if (routeKey === 'ele/import') return <EleImportPage />;
return ( if (routeKey === 'admin/feedback') return <FeedbackAdminPage />;
<Suspense fallback={<LoadingState label="正在加载导入工作台" />}>
<EleImportPage />
</Suspense>
);
}
if (routeKey === "admin/feedback") {
return (
<Suspense fallback={<LoadingState label="正在加载反馈后台" />}>
<FeedbackAdminPage />
</Suspense>
);
}
// /energy 整组按能源权限控制 return <Shell modules={modules} />;
if (pathSet === "energy" && !canAccessEnergy(user?.roles)) {
return <UnauthorizedPage message="无能源管理模块访问权限" />;
}
// key={pathSet} 让两套底栏切换时 Shell 内部 state 重置,避免残留旧 activeModule
return <Shell key={pathSet} modules={modules} />;
} }
export default function App() { export default function App() {
+13 -1
View File
@@ -82,7 +82,19 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
const jumpToken = params.get('jumpToken'); const jumpToken = params.get('jumpToken');
if (!jumpToken) { if (!jumpToken) {
setState({ isLoading: false, isAuthenticated: false, user: null, error: '请从业务系统跳转访问' }); // 临时:本地开发免登录,含智能调度权限
setState({
isLoading: false,
isAuthenticated: true,
user: {
userId: '1105261382487539712',
userName: '本地调试',
permissionLevel: 'full',
depName: '',
roles: ['BI-SCHEDULE-OPT'],
},
error: null,
});
return; return;
} }
+13 -18
View File
@@ -1,24 +1,19 @@
import { ShieldX, Monitor, Smartphone } from 'lucide-react'; import { ShieldX, Monitor, Smartphone } from 'lucide-react';
import { PageFrame, SurfaceCard } from '../components/ui/surface';
export default function UnauthorizedPage({ message }: { message?: string }) { export default function UnauthorizedPage({ message }: { message?: string }) {
return ( return (
<PageFrame <div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center p-6">
title="未授权访问" <div className="text-center max-w-sm">
subtitle={message || '获取用户认证信息失败,可能是跳转令牌已过期或无效。'} <div className="w-20 h-20 mx-auto mb-6 rounded-full bg-slate-100 flex items-center justify-center">
icon={ShieldX} <ShieldX size={36} className="text-slate-400" />
eyebrow="ACCESS CONTROL"
meta="请从授权入口重新进入"
maxWidth="max-w-xl"
>
<SurfaceCard className="p-4">
<div className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<ShieldX size={30} />
</div> </div>
<p className="mb-4 text-[10px] font-bold uppercase tracking-wider text-slate-400"></p> <h1 className="text-lg font-black text-slate-800 mb-2">访</h1>
</div> <p className="text-xs text-slate-400 mb-4">
<div className="space-y-3"> {message || '获取用户认证信息失败,可能是跳转令牌已过期或无效'}
</p>
<div className="bg-white rounded-2xl border border-slate-100 p-4 text-left space-y-3">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider text-center"></p>
<div className="flex items-start gap-3 p-2.5 rounded-xl bg-slate-50"> <div className="flex items-start gap-3 p-2.5 rounded-xl bg-slate-50">
<Monitor size={16} className="text-blue-500 flex-shrink-0 mt-0.5" /> <Monitor size={16} className="text-blue-500 flex-shrink-0 mt-0.5" />
@@ -36,7 +31,7 @@ export default function UnauthorizedPage({ message }: { message?: string }) {
</div> </div>
</div> </div>
</div> </div>
</SurfaceCard> </div>
</PageFrame> </div>
); );
} }
-99
View File
@@ -1,99 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
ApiRequestError,
_clearApiClientInflightForTests,
fetchJson,
setTokenGetter,
} from './api-client.js';
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
test('deduplicates concurrent GET requests for the same URL and token', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
let resolveFetch: ((response: Response) => void) | undefined;
globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => {
calls += 1;
assert.equal(new Headers(init?.headers).get('authorization'), 'Bearer test-token');
return new Promise<Response>(resolve => { resolveFetch = resolve; });
}) as typeof fetch;
setTokenGetter(() => 'test-token');
_clearApiClientInflightForTests();
try {
const first = fetchJson<{ value: number }>('/api/test');
const second = fetchJson<{ value: number }>('/api/test');
assert.equal(calls, 1);
resolveFetch?.(jsonResponse({ value: 42 }));
assert.deepEqual(await first, { value: 42 });
assert.deepEqual(await second, { value: 42 });
} finally {
globalThis.fetch = originalFetch;
setTokenGetter(() => null);
_clearApiClientInflightForTests();
}
});
test('does not retain a failed GET request', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = (async () => {
calls += 1;
if (calls === 1) throw new Error('network down');
return jsonResponse({ ok: true });
}) as typeof fetch;
setTokenGetter(() => null);
_clearApiClientInflightForTests();
try {
await assert.rejects(fetchJson('/api/retry'), /network down/);
assert.deepEqual(await fetchJson('/api/retry'), { ok: true });
assert.equal(calls, 2);
} finally {
globalThis.fetch = originalFetch;
_clearApiClientInflightForTests();
}
});
test('surfaces structured API errors without retaining the failed request', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = (async () => {
calls += 1;
if (calls > 1) return jsonResponse({ ok: true });
return new Response(JSON.stringify({
error: '当前无法读取氢能业务数据,请稍后重试',
code: 'HYDROGEN_SOURCE_UNAVAILABLE',
retryable: true,
}), {
status: 503,
headers: { 'content-type': 'application/json' },
});
}) as typeof fetch;
_clearApiClientInflightForTests();
try {
await assert.rejects(
fetchJson('/api/energy/hydrogen/overview'),
(error: unknown) => {
assert.ok(error instanceof ApiRequestError);
assert.equal(error.message, '当前无法读取氢能业务数据,请稍后重试');
assert.equal(error.status, 503);
assert.equal(error.code, 'HYDROGEN_SOURCE_UNAVAILABLE');
assert.equal(error.retryable, true);
return true;
},
);
assert.deepEqual(await fetchJson('/api/energy/hydrogen/overview'), { ok: true });
assert.equal(calls, 2);
} finally {
globalThis.fetch = originalFetch;
_clearApiClientInflightForTests();
}
});
+3 -62
View File
@@ -1,34 +1,13 @@
/** 全局认证 fetch 客户端 */ /** 全局认证 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;
} }
async function executeJsonRequest<T>(url: string, options: RequestInit | undefined, token: string | null): Promise<T> { export async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const token = tokenGetter();
const res = await fetch(url, { const res = await fetch(url, {
...options, ...options,
headers: { headers: {
@@ -40,44 +19,6 @@ async function executeJsonRequest<T>(url: string, options: RequestInit | undefin
window.dispatchEvent(new CustomEvent('auth:unauthorized')); window.dispatchEvent(new CustomEvent('auth:unauthorized'));
throw new Error('Unauthorized'); throw new Error('Unauthorized');
} }
if (!res.ok) { if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
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();
}
-12
View File
@@ -1,12 +0,0 @@
import type { MetricDomain } from '../shared/analytics/catalog';
import DataSourceStatusButton from './DataSourceStatusButton';
import MetricCatalogButton from './MetricCatalogButton';
export default function BiHeaderActions({ domain }: { domain: MetricDomain }) {
return (
<>
<DataSourceStatusButton domain={domain} />
<MetricCatalogButton domain={domain} />
</>
);
}
-228
View File
@@ -1,228 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Activity, AlertTriangle, CheckCircle2, Clock3, Loader2, RefreshCw, X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { fetchJson } from '../auth/api-client';
import type { MetricDomain } from '../shared/analytics/catalog';
import {
BI_SOURCE_BY_DOMAIN,
type DataSourceId,
type DataSourceState,
} from '../shared/analytics/source-status';
interface SourceTelemetry {
source: DataSourceId;
state: DataSourceState;
attempts: number;
failures: number;
failureRate: number | null;
lastSuccessAt: string | null;
lastFailureAt: string | null;
}
interface SourceTelemetryResponse {
semantics: 'observed-requests';
windowSize: number;
processStartedAt: string;
checkedAt: string;
sources: SourceTelemetry[];
}
const STATE_META = {
available: {
label: '最近请求成功',
icon: CheckCircle2,
className: 'border-emerald-200 bg-emerald-50 text-emerald-700',
},
failing: {
label: '最近请求失败',
icon: AlertTriangle,
className: 'border-rose-200 bg-rose-50 text-rose-700',
},
unobserved: {
label: '启动后尚无真实请求',
icon: Clock3,
className: 'border-slate-200 bg-slate-50 text-slate-600',
},
} as const;
function formatObservedAt(value: string | null): string {
if (!value) return '暂无';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(date);
}
export default function DataSourceStatusButton({ domain }: { domain: MetricDomain }) {
const [open, setOpen] = useState(false);
const [data, setData] = useState<SourceTelemetryResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const focusCloseButton = useCallback((node: HTMLButtonElement | null) => node?.focus(), []);
const sourceMeta = BI_SOURCE_BY_DOMAIN[domain];
const source = useMemo(
() => data?.sources.find(item => item.source === sourceMeta.source) || null,
[data, sourceMeta.source],
);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setData(await fetchJson<SourceTelemetryResponse>('/api/analytics/data-sources'));
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : String(loadError));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) void load();
}, [load, open]);
useEffect(() => {
if (!open) return undefined;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
triggerRef.current?.focus();
};
}, [open]);
const stateMeta = source ? STATE_META[source.state] : STATE_META.unobserved;
const StateIcon = stateMeta.icon;
const triggerTone = source?.state === 'failing'
? 'border-rose-200 bg-rose-50 text-rose-600'
: source?.state === 'available'
? 'border-emerald-200 bg-emerald-50 text-emerald-600'
: 'border-slate-200 bg-white text-slate-500';
return (
<>
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(true)}
className={`inline-flex h-9 w-9 items-center justify-center rounded-xl border shadow-sm transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 ${triggerTone}`}
aria-label="查看数据源运行状态"
title="数据源运行状态"
>
<Activity size={16} />
</button>
{createPortal(<AnimatePresence>
{open ? (
<div className="fixed inset-0 z-[10020]">
<motion.button
type="button"
aria-label="点击遮罩关闭数据源状态"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setOpen(false)}
className="absolute inset-0 h-full w-full bg-slate-950/35 backdrop-blur-[2px]"
/>
<motion.section
role="dialog"
aria-modal="true"
aria-labelledby={`${domain}-source-status-title`}
initial={{ opacity: 0, x: 32 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 32 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="absolute inset-y-0 right-0 flex w-full max-w-sm flex-col bg-white shadow-2xl"
>
<header className="flex shrink-0 items-center gap-3 border-b border-slate-100 px-4 py-4">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-blue-50 text-blue-600 ring-1 ring-blue-100">
<Activity size={17} />
</span>
<div className="min-w-0 flex-1">
<h2 id={`${domain}-source-status-title`} className="text-sm font-black text-slate-900"></h2>
<p className="mt-0.5 truncate text-[10px] font-bold text-slate-400">{sourceMeta.label}</p>
</div>
<button
type="button"
onClick={() => void load()}
disabled={loading}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50 disabled:opacity-50"
aria-label="刷新数据源状态"
title="刷新"
>
<RefreshCw size={15} className={loading ? 'animate-spin' : ''} />
</button>
<button
ref={focusCloseButton}
type="button"
onClick={() => setOpen(false)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50"
aria-label="关闭数据源状态"
title="关闭"
>
<X size={16} />
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
{error ? (
<div className="rounded-lg border border-rose-100 bg-rose-50 p-3 text-xs font-bold text-rose-700">{error}</div>
) : loading && !data ? (
<div className="flex items-center justify-center gap-2 py-16 text-xs font-bold text-slate-400">
<Loader2 size={16} className="animate-spin" />
</div>
) : source ? (
<>
<div className={`flex items-center gap-2 rounded-lg border px-3 py-3 ${stateMeta.className}`}>
<StateIcon size={18} />
<span className="text-sm font-black">{stateMeta.label}</span>
</div>
<dl className="mt-5 divide-y divide-slate-100 border-y border-slate-100 text-xs">
<div className="flex items-center justify-between gap-4 py-3">
<dt className="font-bold text-slate-400"></dt>
<dd className="font-black text-slate-700"> {data?.windowSize ?? 100} </dd>
</div>
<div className="flex items-center justify-between gap-4 py-3">
<dt className="font-bold text-slate-400"> / </dt>
<dd className="font-black tabular-nums text-slate-700">{source.attempts} / {source.failures}</dd>
</div>
<div className="flex items-center justify-between gap-4 py-3">
<dt className="font-bold text-slate-400"></dt>
<dd className="font-black tabular-nums text-slate-700">{source.failureRate == null ? '暂无' : `${(source.failureRate * 100).toFixed(1)}%`}</dd>
</div>
<div className="flex items-center justify-between gap-4 py-3">
<dt className="font-bold text-slate-400"></dt>
<dd className="font-black tabular-nums text-slate-700">{formatObservedAt(source.lastSuccessAt)}</dd>
</div>
<div className="flex items-center justify-between gap-4 py-3">
<dt className="font-bold text-slate-400"></dt>
<dd className="font-black tabular-nums text-slate-700">{formatObservedAt(source.lastFailureAt)}</dd>
</div>
</dl>
<p className="mt-5 text-[10px] font-bold leading-relaxed text-slate-400">
</p>
</>
) : null}
</div>
</motion.section>
</div>
) : null}
</AnimatePresence>, document.body)}
</>
);
}
-166
View File
@@ -1,166 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { BookOpen, Database, Loader2, X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { fetchJson } from '../auth/api-client';
import type { MetricDefinition, MetricDomain } from '../shared/analytics/catalog';
interface MetricCatalogResponse {
catalogVersion: number;
domains: MetricDomain[];
metrics: MetricDefinition[];
}
const AGGREGATION_LABEL: Record<MetricDefinition['aggregation'], string> = {
sum: '求和',
count: '计数',
derived: '派生计算',
'weighted-ratio': '加权比率',
'distinct-count': '去重计数',
latest: '取最新值',
};
const TIME_LABEL: Record<MetricDefinition['timeSemantics'], string> = {
flow: '区间流量',
snapshot: '时点快照',
freshness: '数据新鲜度',
};
export default function MetricCatalogButton({ domain }: { domain: MetricDomain }) {
const [open, setOpen] = useState(false);
const [data, setData] = useState<MetricCatalogResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const setCloseRef = useCallback((node: HTMLButtonElement | null) => {
closeRef.current = node;
node?.focus();
}, []);
useEffect(() => {
if (!open || data) return undefined;
let cancelled = false;
setError(null);
fetchJson<MetricCatalogResponse>(`/api/analytics/metrics?domain=${domain}`)
.then(result => { if (!cancelled) setData(result); })
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [open, data, domain]);
useEffect(() => {
if (!open) return undefined;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
triggerRef.current?.focus();
};
}, [open]);
return (
<>
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(true)}
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600"
aria-label="查看指标口径"
title="指标口径"
>
<BookOpen size={16} />
</button>
{createPortal(<AnimatePresence>
{open && (
<div className="fixed inset-0 z-[10020]">
<motion.button
type="button"
aria-label="点击遮罩关闭指标口径"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setOpen(false)}
className="absolute inset-0 h-full w-full bg-slate-950/35 backdrop-blur-[2px]"
/>
<motion.section
role="dialog"
aria-modal="true"
aria-labelledby={`${domain}-metric-catalog-title`}
initial={{ opacity: 0, x: 32 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 32 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="absolute inset-y-0 right-0 flex w-full max-w-lg flex-col bg-white shadow-2xl"
>
<header className="flex shrink-0 items-center gap-3 border-b border-slate-100 px-4 py-4 md:px-5">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-blue-50 text-blue-600 ring-1 ring-blue-100">
<BookOpen size={17} />
</span>
<div className="min-w-0 flex-1">
<h2 id={`${domain}-metric-catalog-title`} className="text-sm font-black text-slate-900"></h2>
<p className="mt-0.5 text-[10px] font-bold text-slate-400">
{data ? `目录版本 v${data.catalogVersion} · ${data.metrics.length} 项指标` : '读取已发布指标目录'}
</p>
</div>
<button
ref={setCloseRef}
type="button"
onClick={() => setOpen(false)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50 hover:text-slate-800"
aria-label="关闭指标口径"
title="关闭"
>
<X size={16} />
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-2 md:px-5">
{error ? (
<div className="my-4 rounded-lg border border-rose-100 bg-rose-50 p-3 text-xs font-bold text-rose-700">{error}</div>
) : !data ? (
<div className="flex items-center justify-center gap-2 py-16 text-xs font-bold text-slate-400">
<Loader2 size={16} className="animate-spin" />
</div>
) : (
data.metrics.map(metric => (
<article key={metric.id} className="border-b border-slate-100 py-4 last:border-b-0">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<h3 className="text-sm font-black text-slate-900">{metric.label}</h3>
<div className="mt-1 font-mono text-[9px] font-bold text-slate-400">{metric.id}</div>
</div>
<div className="flex flex-wrap gap-1 text-[9px] font-black">
<span className="rounded bg-blue-50 px-1.5 py-1 text-blue-600">{AGGREGATION_LABEL[metric.aggregation]}</span>
<span className="rounded bg-slate-100 px-1.5 py-1 text-slate-600">{TIME_LABEL[metric.timeSemantics]}</span>
<span className="rounded bg-emerald-50 px-1.5 py-1 text-emerald-700">{metric.unit}</span>
</div>
</div>
<p className="mt-3 text-[11px] font-bold leading-relaxed text-slate-600">{metric.description}</p>
<div className="mt-3 rounded-lg bg-slate-950 px-3 py-2 font-mono text-[10px] leading-relaxed text-slate-200 break-words">
{metric.formula}
</div>
<div className="mt-3 flex items-start gap-2 text-[10px] font-bold text-slate-500">
<Database size={12} className="mt-0.5 shrink-0 text-slate-400" />
<span className="break-words">{metric.sources.join(' · ')}</span>
</div>
<div className="mt-2 flex flex-wrap gap-1">
{metric.dimensions.map(dimension => (
<span key={dimension} className="rounded border border-slate-200 px-1.5 py-0.5 text-[9px] font-bold text-slate-500">{dimension}</span>
))}
</div>
</article>
))
)}
</div>
</motion.section>
</div>
)}
</AnimatePresence>, document.body)}
</>
);
}
+47 -129
View File
@@ -1,88 +1,71 @@
import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react'; import { useState, useEffect, useMemo, type ComponentType } from 'react';
import { motion } from 'motion/react';
import { Building2, ChevronRight, ShieldCheck } from 'lucide-react';
import { useAuth } from '../auth/useAuth'; import { useAuth } from '../auth/useAuth';
import { DemoModeProvider } from './Blur'; import { DemoModeProvider } from './Blur';
import FeedbackFab from './FeedbackFab'; import FeedbackFab from './FeedbackFab';
import { cn } from '../lib/cn';
import { LoadingState } from './ui/surface';
export interface ModuleConfig { export interface ModuleConfig {
id: string; id: string;
label: string; label: string;
icon: ComponentType<{ size?: number; className?: string }>; icon: ComponentType<{ size?: number; className?: string }>;
component?: ElementType; component: ComponentType;
children?: ModuleConfig[];
} }
function flattenModules(modules: ModuleConfig[]): ModuleConfig[] { /** path 到模块 id 的映射 */
return modules.flatMap((module) => module.children ?? [module]); const PATH_MAP: Record<string, string> = {
} '/vehicle': 'assets',
'/assets': 'assets',
/** hash 一级段(`#<id>` 或 `#<id>/<sub>` 都只取 id */ '/mileage': 'mileage',
function getHashHead(): string { '/scheduling': 'scheduling',
return window.location.hash.slice(1).split('/')[0]; '/energy': 'energy',
} };
function getInitialModule(modules: ModuleConfig[]): string { function getInitialModule(modules: ModuleConfig[]): string {
const head = getHashHead(); // 优先看 hash
if (modules.some((m) => m.id === head)) return head; const hash = window.location.hash.slice(1);
if (modules.some((m) => m.id === hash)) return hash;
// 再看 pathname
const pathModule = PATH_MAP[window.location.pathname];
if (pathModule && modules.some((m) => m.id === pathModule)) return pathModule;
// 默认第一个
return modules[0]?.id ?? ''; return modules[0]?.id ?? '';
} }
function getHashModule(modules: ModuleConfig[]): string { function getHashModule(modules: ModuleConfig[]): string {
const head = getHashHead(); const hash = window.location.hash.slice(1);
return modules.some((m) => m.id === head) ? head : ''; return modules.some((m) => m.id === hash) ? hash : '';
} }
export function Shell({ modules }: { modules: ModuleConfig[] }) { export function Shell({ modules }: { modules: ModuleConfig[] }) {
const flatModules = useMemo(() => flattenModules(modules), [modules]); const [activeModule, setActiveModule] = useState(() => getInitialModule(modules));
const [activeModule, setActiveModule] = useState(() => getInitialModule(flattenModules(modules)));
const [openGroupId, setOpenGroupId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const onHashChange = () => { const onHashChange = () => {
const h = getHashModule(flatModules); const h = getHashModule(modules);
if (h) setActiveModule(h); if (h) setActiveModule(h);
}; };
window.addEventListener('hashchange', onHashChange); window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange); return () => window.removeEventListener('hashchange', onHashChange);
}, [flatModules]); }, [modules]);
useEffect(() => { useEffect(() => {
if (!openGroupId) return undefined; // 同步 hash 到当前模块:使用 replaceState 避免产生多余的 history 记录,
const handleKeyDown = (event: KeyboardEvent) => { // 否则在小程序/webview 环境下首次进入需要点两次返回才能退出
if (event.key === 'Escape') setOpenGroupId(null); if (window.location.hash.slice(1) !== activeModule) {
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [openGroupId]);
useEffect(() => {
// 同步 hash 一段到当前模块:使用 replaceState 避免产生多余的 history 记录,
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出。
// 注意只比对一级段,避免把子模块写入的 `#<id>/<sub>` 二级段抹掉。
if (getHashHead() !== activeModule) {
const { pathname, search } = window.location; const { pathname, search } = window.location;
window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`); window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`);
} }
}, [activeModule]); }, [activeModule]);
const switchModule = (id: string) => { const switchModule = (id: string) => {
setOpenGroupId(null); if (window.location.hash.slice(1) === id) return;
if (getHashHead() === id) {
setActiveModule(id);
return;
}
const { pathname, search } = window.location; const { pathname, search } = window.location;
window.history.replaceState(null, '', `${pathname}${search}#${id}`); window.history.replaceState(null, '', `${pathname}${search}#${id}`);
setActiveModule(id); setActiveModule(id);
}; };
const ActiveComponent = flatModules.find((module) => module.id === activeModule)?.component ?? flatModules[0]?.component; const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component;
const { user } = useAuth(); const { user } = useAuth();
const activeLabel = flatModules.find((module) => module.id === activeModule)?.label ?? '业务看板';
const watermarkText = useMemo(() => { const watermarkText = useMemo(() => {
const name = user?.userName || '未登录'; const name = user?.userName || '未登录';
const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-'); const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-');
@@ -91,7 +74,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
return ( return (
<DemoModeProvider enabled={false}> <DemoModeProvider enabled={false}>
<div className="enterprise-grid-bg flex min-h-screen"> <div className="flex min-h-screen">
{/* 全局水印 */} {/* 全局水印 */}
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}> <div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
<div className="absolute inset-0" style={{ <div className="absolute inset-0" style={{
@@ -100,114 +83,49 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
}} /> }} />
</div> </div>
{/* Web 侧边栏 (md 及以上) */} {/* Web 侧边栏 (md 及以上) */}
<nav className="fixed left-0 top-0 z-50 hidden h-full w-20 flex-col items-center border-r border-white/10 bg-slate-950 px-2 py-4 text-white shadow-[12px_0_40px_rgba(15,23,42,0.16)] md:flex"> <nav className="hidden md:flex flex-col items-center w-16 bg-white border-r border-gray-100 fixed top-0 left-0 h-full z-50 py-6 gap-2">
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-white text-blue-600 shadow-lg shadow-blue-950/20">
<Building2 size={22} />
</div>
<div className="flex w-full flex-1 flex-col items-center gap-2">
{modules.map((m) => { {modules.map((m) => {
const Icon = m.icon; const Icon = m.icon;
const isGroup = Boolean(m.children?.length); const isActive = m.id === activeModule;
const isActive = m.id === activeModule || Boolean(m.children?.some((child) => child.id === activeModule));
const isOpen = openGroupId === m.id;
return ( return (
<div key={m.id} className="relative">
<button <button
type="button" key={m.id}
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)} onClick={() => switchModule(m.id)}
aria-expanded={isGroup ? isOpen : undefined} className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${
aria-haspopup={isGroup ? 'menu' : undefined} isActive
className={cn( ? 'bg-blue-50 text-blue-600'
'group relative flex h-16 w-16 flex-col items-center justify-center rounded-2xl text-[10px] font-black transition-all', : 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
isActive ? 'text-white' : 'text-slate-400 hover:bg-white/8 hover:text-white', }`}
)}
title={m.label}
> >
{isActive ? ( <Icon size={22} />
<motion.span layoutId="desktop-shell-active" className="absolute inset-0 rounded-2xl bg-blue-600 shadow-lg shadow-blue-950/30" transition={{ type: 'spring', stiffness: 430, damping: 34 }} /> <span className="text-[10px] mt-1 leading-tight">{m.label}</span>
) : null}
<Icon size={21} className="relative mb-1" />
<span className="relative leading-tight">{m.label}</span>
{isGroup ? <ChevronRight size={11} className={cn('absolute right-1 top-1/2 -translate-y-1/2 transition-transform', isOpen && 'rotate-180')} /> : null}
</button>
{isGroup && isOpen ? (
<div role="menu" aria-label={`${m.label}二级菜单`} className="absolute left-[calc(100%+12px)] top-1/2 w-36 -translate-y-1/2 rounded-2xl border border-slate-700 bg-slate-900 p-2 shadow-2xl shadow-slate-950/30">
{m.children!.map((child) => {
const ChildIcon = child.icon;
const childActive = child.id === activeModule;
return (
<button key={child.id} type="button" role="menuitem" onClick={() => switchModule(child.id)} className={cn('flex h-11 w-full items-center gap-2 rounded-xl px-3 text-xs font-black transition-colors', childActive ? 'bg-blue-600 text-white' : 'text-slate-300 hover:bg-white/8 hover:text-white')}>
<ChildIcon size={16} />{child.label}
</button> </button>
); );
})} })}
</div>
) : null}
</div>
);
})}
</div>
<div className="flex w-full flex-col items-center gap-2 border-t border-white/10 pt-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-400/10 text-emerald-300 ring-1 ring-emerald-300/20" title={user?.userName || '当前用户'}>
<ShieldCheck size={18} />
</div>
<div className="max-w-16 truncate text-center text-[9px] font-bold text-slate-400">{user?.userName || '未登录'}</div>
</div>
</nav> </nav>
{/* 内容区 */} {/* 内容区 */}
<main className="min-w-0 flex-1 pb-16 md:ml-20 md:pb-0" style={{ overflowX: 'clip' }}> <main className="flex-1 md:ml-16 pb-16 md:pb-0 min-w-0" style={{ overflowX: 'clip' }}>
<div className="pointer-events-none fixed left-20 right-0 top-0 z-20 hidden h-12 items-center border-b border-white/60 bg-white/55 px-6 text-xs font-bold text-slate-400 backdrop-blur-xl md:flex">
<span className="text-slate-600"> BI</span>
<span className="mx-2 text-slate-300">/</span>
<span>{activeLabel}</span>
</div>
<Suspense fallback={<div className="p-3 md:p-6"><LoadingState label="正在加载业务模块" /></div>}>
{ActiveComponent && <ActiveComponent />} {ActiveComponent && <ActiveComponent />}
</Suspense>
<FeedbackFab module={activeModule} /> <FeedbackFab module={activeModule} />
</main> </main>
{/* 移动端底部导航 (md 以下) */} {/* 移动端底部导航 (md 以下) */}
<nav className="fixed inset-x-0 bottom-0 z-50 border-t border-white/70 bg-white/92 px-3 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 shadow-[0_-18px_40px_rgba(15,23,42,0.08)] backdrop-blur-xl md:hidden"> <nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-2 flex justify-around items-center md:hidden z-50">
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${modules.length}, minmax(0, 1fr))` }}>
{modules.map((m) => { {modules.map((m) => {
const Icon = m.icon; const Icon = m.icon;
const isGroup = Boolean(m.children?.length); const isActive = m.id === activeModule;
const isActive = m.id === activeModule || Boolean(m.children?.some((child) => child.id === activeModule));
const isOpen = openGroupId === m.id;
return ( return (
<div key={m.id} className="relative flex justify-center">
<button <button
type="button" key={m.id}
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)} onClick={() => switchModule(m.id)}
aria-expanded={isGroup ? isOpen : undefined} className={`flex flex-col items-center ${isActive ? 'text-blue-600' : 'text-gray-400'}`}
aria-haspopup={isGroup ? 'menu' : undefined}
className={cn('relative flex min-h-12 w-full flex-col items-center justify-center gap-0.5 rounded-2xl text-[10px] font-medium transition-colors', isActive ? 'text-blue-700' : 'text-slate-400')}
> >
{isActive ? ( <Icon size={20} />
<motion.span layoutId="mobile-shell-active" className="absolute inset-0 rounded-2xl bg-blue-50 ring-1 ring-blue-100" transition={{ type: 'spring', stiffness: 430, damping: 34 }} /> <span className="text-[10px] mt-1">{m.label}</span>
) : null}
<Icon size={20} className="relative" />
<span className="relative">{m.label}</span>
</button>
{isGroup && isOpen ? (
<div role="menu" aria-label={`${m.label}二级菜单`} className="absolute bottom-[calc(100%+14px)] left-1/2 w-36 -translate-x-1/2 rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl shadow-slate-900/15">
{m.children!.map((child) => {
const ChildIcon = child.icon;
const childActive = child.id === activeModule;
return (
<button key={child.id} type="button" role="menuitem" onClick={() => switchModule(child.id)} className={cn('flex h-11 w-full items-center gap-2 rounded-xl px-3 text-xs font-medium transition-colors', childActive ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-50')}>
<ChildIcon size={16} />{child.label}
</button> </button>
); );
})} })}
</div>
) : null}
</div>
);
})}
</div>
</nav> </nav>
</div> </div>
</DemoModeProvider> </DemoModeProvider>
-357
View File
@@ -1,357 +0,0 @@
import { useState, type ComponentType, type ReactNode } from 'react';
import { AlertCircle, Info, Loader2, RefreshCw, SearchX, X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { cn } from '../../lib/cn';
export type SurfaceIcon = ComponentType<{ size?: number; className?: string }>;
export function PageFrame({
title,
subtitle,
icon: Icon,
eyebrow,
meta,
actions,
children,
maxWidth = 'max-w-6xl',
compactInfo = false,
}: {
title: string;
subtitle?: string;
icon?: SurfaceIcon;
eyebrow?: string;
meta?: ReactNode;
actions?: ReactNode;
children: ReactNode;
maxWidth?: string;
compactInfo?: boolean;
}) {
const [infoOpen, setInfoOpen] = useState(false);
return (
<div className="min-h-screen bg-[var(--app-bg)] text-slate-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
<div className={cn(maxWidth, 'mx-auto flex flex-col gap-4 pb-20 md:pb-6')}>
<motion.header
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28, ease: 'easeOut' }}
className={cn(
'relative rounded-2xl border border-white/70 bg-white/86 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl',
compactInfo ? 'overflow-visible' : 'overflow-hidden',
)}
>
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-blue-600 via-cyan-400 to-emerald-400" />
{compactInfo ? (
<>
<div className="flex min-h-[48px] items-center gap-2 px-3 py-1.5 md:min-h-[54px] md:gap-3 md:px-4 md:py-2">
{Icon ? (
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100 md:h-9 md:w-9">
<Icon size={17} />
</span>
) : null}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
{eyebrow ? <span className="shrink-0 text-[11px] font-black text-blue-600">{eyebrow}</span> : null}
{meta ? <span className="hidden truncate text-[11px] font-bold text-slate-400 sm:inline">{meta}</span> : null}
</div>
<h1 className="mt-0.5 truncate text-base font-black tracking-tight text-slate-950 md:text-lg">{title}</h1>
</div>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
{subtitle ? (
<button
type="button"
onClick={() => setInfoOpen(open => !open)}
className={cn(
'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border text-slate-400 transition-colors md:h-9 md:w-9',
infoOpen ? 'border-blue-100 bg-blue-50 text-blue-600' : 'border-slate-100 bg-slate-50 hover:bg-blue-50 hover:text-blue-600',
)}
aria-label={infoOpen ? '收起页面说明' : '展开页面说明'}
title={infoOpen ? '收起说明' : '页面说明'}
>
{infoOpen ? <X size={16} /> : <Info size={16} />}
</button>
) : null}
</div>
<AnimatePresence initial={false}>
{infoOpen && subtitle ? (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="absolute left-0 right-0 top-full z-40 mt-2 overflow-hidden rounded-2xl border border-slate-100 bg-white shadow-xl md:static md:mt-0 md:rounded-none md:border-x-0 md:border-b-0 md:shadow-none"
>
<div className="px-4 py-3 text-xs font-bold leading-relaxed text-slate-500 md:text-sm">
{subtitle}
</div>
</motion.div>
) : null}
</AnimatePresence>
</>
) : (
<div className="flex flex-col gap-4 p-4 md:flex-row md:items-end md:justify-between md:p-5">
<div className="min-w-0">
<div className="mb-2 flex flex-wrap items-center gap-2">
{Icon ? (
<span className="inline-flex h-8 w-8 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
<Icon size={17} />
</span>
) : null}
{eyebrow ? <span className="text-[11px] font-black text-blue-600">{eyebrow}</span> : null}
{meta ? <span className="text-[11px] font-bold text-slate-400">{meta}</span> : null}
</div>
<h1 className="truncate text-xl font-black tracking-tight text-slate-950 md:text-2xl">{title}</h1>
{subtitle ? <p className="mt-2 max-w-3xl text-xs font-bold leading-relaxed text-slate-500 md:text-sm">{subtitle}</p> : null}
</div>
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
</div>
)}
</motion.header>
{children}
</div>
</div>
);
}
export function SurfaceCard({
title,
subtitle,
actions,
children,
className,
}: {
title?: string;
subtitle?: string;
actions?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<section className={cn('rounded-2xl border border-slate-100 bg-white shadow-sm', className)}>
{(title || subtitle || actions) && (
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-slate-100 px-4 py-3">
<div>
{title ? <h2 className="text-sm font-black text-slate-900">{title}</h2> : null}
{subtitle ? <p className="mt-1 text-[11px] font-bold text-slate-400">{subtitle}</p> : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</div>
)}
{children}
</section>
);
}
export function MetricTile({
label,
value,
unit,
helper,
icon: Icon,
tone = 'blue',
}: {
label: string;
value: ReactNode;
unit?: string;
helper?: string;
icon?: SurfaceIcon;
tone?: 'blue' | 'emerald' | 'amber' | 'rose' | 'slate';
}) {
const toneClass = {
blue: 'bg-blue-50 text-blue-600 ring-blue-100',
emerald: 'bg-emerald-50 text-emerald-600 ring-emerald-100',
amber: 'bg-amber-50 text-amber-600 ring-amber-100',
rose: 'bg-rose-50 text-rose-600 ring-rose-100',
slate: 'bg-slate-100 text-slate-600 ring-slate-200',
}[tone];
return (
<div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 text-[11px] font-black text-slate-400">{label}</div>
{Icon ? (
<span className={cn('inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1', toneClass)}>
<Icon size={18} />
</span>
) : null}
</div>
<div className="mt-3 flex min-w-0 items-end gap-1">
<span className="min-w-0 whitespace-nowrap text-[clamp(1.65rem,6vw,2rem)] font-black leading-none tracking-tight text-slate-950 tabular-nums">
{value}
</span>
{unit ? <span className="shrink-0 pb-0.5 text-[11px] font-black text-slate-400">{unit}</span> : null}
</div>
{helper ? <div className="mt-3 text-[11px] font-bold leading-relaxed text-slate-500">{helper}</div> : null}
</div>
);
}
export function SegmentedNav<T extends string>({
tabs,
active,
onChange,
idPrefix,
ariaLabel,
className,
}: {
tabs: readonly { id: T; label: string; icon?: SurfaceIcon }[];
active: T;
onChange: (id: T) => void;
idPrefix: string;
ariaLabel: string;
className?: string;
}) {
const activateFromKeyboard = (event: React.KeyboardEvent<HTMLButtonElement>, currentIndex: number) => {
let nextIndex: number | null = null;
if (event.key === 'ArrowRight') nextIndex = (currentIndex + 1) % tabs.length;
if (event.key === 'ArrowLeft') nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
if (event.key === 'Home') nextIndex = 0;
if (event.key === 'End') nextIndex = tabs.length - 1;
if (nextIndex === null) return;
event.preventDefault();
const next = tabs[nextIndex];
onChange(next.id);
window.requestAnimationFrame(() => document.getElementById(`${idPrefix}-tab-${next.id}`)?.focus());
};
return (
<div className={cn('rounded-2xl border border-white/70 bg-white/90 p-1 shadow-sm backdrop-blur-xl', className)}>
<div
role="tablist"
aria-label={ariaLabel}
className="grid gap-1"
style={{ gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))` }}
>
{tabs.map(({ id, label, icon: Icon }, index) => {
const isActive = active === id;
return (
<button
key={id}
id={`${idPrefix}-tab-${id}`}
type="button"
role="tab"
aria-selected={isActive}
aria-controls={`${idPrefix}-panel-${id}`}
tabIndex={isActive ? 0 : -1}
onClick={() => onChange(id)}
onKeyDown={event => activateFromKeyboard(event, index)}
className={cn(
'relative flex min-h-10 items-center justify-center gap-1.5 rounded-xl px-2 text-[12px] font-black transition-colors',
isActive ? 'text-blue-700' : 'text-slate-400 hover:bg-slate-50 hover:text-slate-600',
)}
>
{isActive ? (
<motion.span
layoutId="segmented-active-pill"
className="absolute inset-0 rounded-xl bg-blue-50 shadow-sm ring-1 ring-blue-100"
transition={{ type: 'spring', stiffness: 430, damping: 34 }}
/>
) : null}
{Icon ? <Icon size={15} className="relative" /> : null}
<span className="relative truncate">{label}</span>
</button>
);
})}
</div>
</div>
);
}
export function SkeletonBlock({ className }: { className?: string }) {
return <div className={cn('overflow-hidden rounded-xl bg-slate-100 shimmer', className)} />;
}
type StateVariant = 'card' | 'inline';
export function LoadingState({
label = '数据加载中',
variant = 'card',
}: {
label?: string;
variant?: StateVariant;
}) {
return (
<div className={cn(
'p-8 text-center',
variant === 'card' && 'rounded-2xl border border-slate-100 bg-white shadow-sm',
)}>
<Loader2 className="mx-auto mb-3 animate-spin text-blue-500" size={22} />
<div className="text-xs font-black text-slate-500">{label}</div>
</div>
);
}
export function EmptyState({
title = '暂无数据',
description = '换个筛选条件或稍后再试',
variant = 'card',
}: {
title?: string;
description?: string;
variant?: StateVariant;
}) {
return (
<div className={cn(
'p-8 text-center',
variant === 'card' && 'rounded-2xl border border-slate-100 bg-white shadow-sm',
)}>
<SearchX className="mx-auto mb-3 text-slate-300" size={26} />
<div className="text-sm font-black text-slate-700">{title}</div>
<div className="mt-1 text-xs font-bold text-slate-400">{description}</div>
</div>
);
}
export function ErrorState({
message,
title = '加载失败',
onRetry,
retrying = false,
variant = 'card',
}: {
message: string;
title?: string;
onRetry?: () => void;
retrying?: boolean;
variant?: StateVariant;
}) {
return (
<div className={cn(
'bg-rose-50 p-4 text-rose-700',
variant === 'card' && 'rounded-2xl border border-rose-100 shadow-sm',
)}>
<div className="flex items-start gap-2">
<AlertCircle size={18} className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-sm font-black">{title}</div>
<div className="mt-1 text-xs font-bold leading-relaxed">{message}</div>
{onRetry ? (
<button
type="button"
onClick={onRetry}
disabled={retrying}
className="mt-3 inline-flex h-8 items-center gap-1.5 rounded-lg border border-rose-200 bg-white px-3 text-xs font-black text-rose-700 transition-colors hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-60"
>
<RefreshCw size={13} className={retrying ? 'animate-spin' : ''} />
</button>
) : null}
</div>
</div>
</div>
);
}
export function FadeIn({ children, className }: { children: ReactNode; className?: string }) {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
className={cn('w-full min-w-0', className)}
>
{children}
</motion.div>
);
}
-82
View File
@@ -1,15 +1,8 @@
@import "tailwindcss"; @import "tailwindcss";
:root {
--app-bg: #f4f7fb;
--panel-bg: rgba(255, 255, 255, 0.9);
--hairline: rgba(148, 163, 184, 0.18);
}
html, body { html, body {
overscroll-behavior: none; overscroll-behavior: none;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
background: var(--app-bg);
} }
html { html {
@@ -20,79 +13,4 @@ html {
body { body {
overflow: auto; overflow: auto;
height: 100%; height: 100%;
color: #0f172a;
background: #fff;
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button, input, select {
font-family: inherit;
}
.amap-logo,
.amap-copyright {
opacity: 0.82;
}
.amap-toolbar {
border: 1px solid #e2e8f0 !important;
border-radius: 10px !important;
box-shadow: 0 8px 24px rgb(15 23 42 / 10%) !important;
overflow: hidden;
}
@media (max-width: 767px) {
.amap-logo,
.amap-copyright {
bottom: 60px !important;
}
}
@keyframes marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@keyframes floatUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@utility animate-marquee {
animation: marquee 30s linear infinite;
}
@utility no-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar { display: none; }
}
.shimmer {
position: relative;
}
.shimmer::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.72), transparent);
animation: shimmer 1.4s infinite;
}
.enterprise-grid-bg {
background:
radial-gradient(circle at 16% 0%, rgba(59, 130, 246, 0.08), transparent 28%),
radial-gradient(circle at 90% 12%, rgba(20, 184, 166, 0.08), transparent 26%),
linear-gradient(180deg, #f8fbff 0%, var(--app-bg) 42%, #f7f9fc 100%);
}
.asset-date-input::-webkit-calendar-picker-indicator {
cursor: pointer;
opacity: 0;
} }
-3
View File
@@ -1,3 +0,0 @@
export function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ');
}
+26 -26
View File
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react'; import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react';
import { fetchJson } from '../../auth/api-client'; import { fetchJson } from '../../auth/api-client';
import { EmptyState, LoadingState, PageFrame, SurfaceCard } from '../../components/ui/surface';
interface FeedbackItem { interface FeedbackItem {
id: number; id: number;
@@ -21,10 +20,10 @@ interface FeedbackItem {
} }
const TYPE_LABEL: Record<string, string> = { const TYPE_LABEL: Record<string, string> = {
dimension: '新维度', dimension: '💡 新维度',
bug: 'Bug', bug: '🐛 Bug',
ux: '体验', ux: '🎨 体验',
other: '其他', other: '📝 其他',
}; };
const STATUS_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [ const STATUS_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [
@@ -127,36 +126,36 @@ export default function FeedbackAdminPage() {
}, {}); }, {});
return ( return (
<PageFrame <div className="min-h-screen bg-[#F8F9FB] p-4 md:p-8">
title="用户反馈管理" <div className="max-w-5xl mx-auto space-y-4">
subtitle="查看、回复、跟进用户提交的建议,形成从问题发现到处理闭环的运营后台。" <header className="flex items-center justify-between">
icon={Inbox} <div className="flex items-center gap-3 min-w-0">
eyebrow="FEEDBACK OPS"
meta={`当前 ${items.length} 条反馈`}
actions={(
<div className="flex items-center gap-2">
<button <button
onClick={() => { onClick={() => {
// 优先 history.back(来自 SPA 内部跳转);否则回到主页 // 优先 history.back(来自 SPA 内部跳转);否则回到主页
if (window.history.length > 1) window.history.back(); if (window.history.length > 1) window.history.back();
else { window.location.hash = '#mileage'; } else { window.location.hash = '#mileage'; }
}} }}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-100 bg-white text-slate-500 transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600" className="w-9 h-9 rounded-xl bg-white border border-slate-100 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 text-slate-500 flex items-center justify-center transition-colors flex-shrink-0"
title="返回" title="返回"
> >
<ArrowLeft size={16} /> <ArrowLeft size={16} />
</button> </button>
<button onClick={reload} className="flex h-9 w-9 items-center justify-center rounded-xl bg-slate-900 text-white shadow-sm transition-colors hover:bg-slate-800" title="刷新"> <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center flex-shrink-0">
<Inbox size={18} className="text-white" />
</div>
<div className="min-w-0">
<h1 className="text-lg font-black text-slate-900 leading-tight"></h1>
<p className="text-[11px] font-bold text-slate-400"></p>
</div>
</div>
<button onClick={reload} className="p-2 text-slate-400 hover:text-blue-500 flex-shrink-0" title="刷新">
<RotateCcw size={16} className={loading ? 'animate-spin' : ''} /> <RotateCcw size={16} className={loading ? 'animate-spin' : ''} />
</button> </button>
</div> </header>
)}
maxWidth="max-w-5xl"
>
{/* 状态过滤 */} {/* 状态过滤 */}
<SurfaceCard className="p-2"> <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-2 flex items-center gap-1 overflow-x-auto">
<div className="flex items-center gap-1 overflow-x-auto">
<button <button
onClick={() => setStatusFilter('')} onClick={() => setStatusFilter('')}
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === '' ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`} className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === '' ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
@@ -171,7 +170,6 @@ export default function FeedbackAdminPage() {
</button> </button>
))} ))}
</div> </div>
</SurfaceCard>
{error && ( {error && (
<div className="bg-rose-50 border border-rose-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-rose-600"> <div className="bg-rose-50 border border-rose-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-rose-600">
@@ -194,9 +192,9 @@ export default function FeedbackAdminPage() {
{/* 列表 */} {/* 列表 */}
<div className="space-y-2"> <div className="space-y-2">
{loading && items.length === 0 ? ( {loading && items.length === 0 ? (
<LoadingState label="正在加载用户反馈" /> <div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold"></div>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<EmptyState title="还没有反馈" description="新的用户反馈会出现在这里" /> <div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold"></div>
) : items.map(it => { ) : items.map(it => {
const shots = parseScreenshots(it.screenshots); const shots = parseScreenshots(it.screenshots);
const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status); const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status);
@@ -220,7 +218,7 @@ export default function FeedbackAdminPage() {
{(shots.length > 0 || it.contact) && ( {(shots.length > 0 || it.contact) && (
<div className="flex items-center gap-3 mt-2 text-[10px] text-slate-400 font-bold"> <div className="flex items-center gap-3 mt-2 text-[10px] text-slate-400 font-bold">
{shots.length > 0 && <span className="flex items-center gap-0.5"><ImageIcon size={11} />{shots.length} </span>} {shots.length > 0 && <span className="flex items-center gap-0.5"><ImageIcon size={11} />{shots.length} </span>}
{it.contact && <span> {it.contact}</span>} {it.contact && <span>📞 {it.contact}</span>}
</div> </div>
)} )}
{it.reply_content && ( {it.reply_content && (
@@ -238,6 +236,8 @@ export default function FeedbackAdminPage() {
); );
})} })}
</div> </div>
</div>
{/* 详情 / 回复弹窗 */} {/* 详情 / 回复弹窗 */}
<AnimatePresence> <AnimatePresence>
{active && ( {active && (
@@ -336,6 +336,6 @@ export default function FeedbackAdminPage() {
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
</PageFrame> </div>
); );
} }
+57 -452
View File
@@ -14,12 +14,8 @@ import {
Filter, Filter,
ArrowRightLeft, ArrowRightLeft,
MapPin, MapPin,
Download,
CalendarDays,
X,
} from 'lucide-react'; } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import * as XLSX from 'xlsx';
import { import {
BarChart, BarChart,
Bar, Bar,
@@ -34,13 +30,12 @@ import {
LabelList, LabelList,
} from 'recharts'; } from 'recharts';
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types'; import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types';
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, fetchFlowStats, type SubjectOption } from './api'; import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, type SubjectOption } from './api';
import type { FlowDetailItem, FlowStatsResponse, FlowType, WeeklyDetailItem } from './api'; import type { WeeklyDetailItem } from './api';
import { SearchSelect } from '../../components/SearchSelect'; import { SearchSelect } from '../../components/SearchSelect';
import { MultiSearchSelect } from '../../components/MultiSearchSelect'; import { MultiSearchSelect } from '../../components/MultiSearchSelect';
import Blur from '../../components/Blur'; import Blur from '../../components/Blur';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { ErrorState, LoadingState, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface';
// --- Constants --- // --- Constants ---
@@ -51,79 +46,6 @@ const TABS = [
{ id: 'customer', label: '按客户' }, { id: 'customer', label: '按客户' },
]; ];
function MarqueeBanner() {
const trackRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const [overflow, setOverflow] = useState(false);
useEffect(() => {
const check = () => {
if (!trackRef.current || !innerRef.current) return;
setOverflow(innerRef.current.scrollWidth > trackRef.current.clientWidth);
};
check();
const ro = new ResizeObserver(check);
ro.observe(trackRef.current!);
return () => ro.disconnect();
}, []);
const text = '车辆资产已于 2026 年 6 月 18 日完成“运营状态”与“业务关联”校验';
return (
<div className="relative -mx-6 mb-4 bg-green-50 border-y border-green-200">
<div ref={trackRef} className="overflow-hidden">
<div className={`flex w-max py-2 ${overflow ? 'animate-marquee' : 'w-full justify-center'}`}>
<span ref={innerRef} className="inline-block whitespace-nowrap px-6 text-xs text-green-700 font-medium">
{text}
</span>
{overflow && (
<span className="inline-block whitespace-nowrap px-6 text-xs text-green-700 font-medium">
{text}
</span>
)}
</div>
</div>
</div>
);
}
function formatLocalDateTime(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const hh = String(date.getHours()).padStart(2, '0');
const mm = String(date.getMinutes()).padStart(2, '0');
const ss = String(date.getSeconds()).padStart(2, '0');
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`;
}
function formatLocalDate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function addDays(date: Date, days: number): Date {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
function getWeeklyFlowRange() {
const now = new Date();
const day = now.getDay();
const end = day === 6 ? addDays(now, -1) : day === 0 ? addDays(now, -2) : addDays(now, 5 - day);
const start = addDays(end, -6);
return { start: formatLocalDate(start), end: formatLocalDate(end) };
}
const FLOW_META: Record<FlowType, { label: string; tone: string; chip: string }> = {
delivered: { label: '交车', tone: 'text-blue-600 bg-blue-50 border-blue-100', chip: 'bg-blue-50 text-blue-700 border-blue-100' },
returned: { label: '还车', tone: 'text-orange-600 bg-orange-50 border-orange-100', chip: 'bg-orange-50 text-orange-700 border-orange-100' },
replaced: { label: '替换', tone: 'text-violet-600 bg-violet-50 border-violet-100', chip: 'bg-violet-50 text-violet-700 border-violet-100' },
};
export default function AssetsModule() { export default function AssetsModule() {
const [activeTab, setActiveTab] = useState<'overview' | 'department' | 'region' | 'customer'>('overview'); const [activeTab, setActiveTab] = useState<'overview' | 'department' | 'region' | 'customer'>('overview');
const [tabReady, setTabReady] = useState(true); const [tabReady, setTabReady] = useState(true);
@@ -170,13 +92,8 @@ export default function AssetsModule() {
const [modalWeeklyDetail, setModalWeeklyDetail] = useState<WeeklyDetailItem[]>([]); const [modalWeeklyDetail, setModalWeeklyDetail] = useState<WeeklyDetailItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [lastUpdate, setLastUpdate] = useState<string>(() => formatLocalDateTime(new Date())); const [lastUpdate, setLastUpdate] = useState<string>('');
const [modalLoading, setModalLoading] = useState(false); const [modalLoading, setModalLoading] = useState(false);
const [flowRange, setFlowRange] = useState(() => getWeeklyFlowRange());
const [flowStats, setFlowStats] = useState<FlowStatsResponse | null>(null);
const [flowLoading, setFlowLoading] = useState(false);
const [flowDailyExpanded, setFlowDailyExpanded] = useState(false);
const [selectedFlow, setSelectedFlow] = useState<{ date: string; type: FlowType } | null>(null);
// Dept/Region/Customer data // Dept/Region/Customer data
const [deptData, setDeptData] = useState<DeptGroup[]>([]); const [deptData, setDeptData] = useState<DeptGroup[]>([]);
@@ -245,7 +162,7 @@ export default function AssetsModule() {
setRegionData(region); setRegionData(region);
setCustomerData(cust); setCustomerData(cust);
setInventoryData(inv); setInventoryData(inv);
setLastUpdate(formatLocalDateTime(new Date())); setLastUpdate(new Date().toLocaleString('zh-CN'));
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : '数据加载失败'); setError(e instanceof Error ? e.message : '数据加载失败');
} finally { } finally {
@@ -259,24 +176,6 @@ export default function AssetsModule() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [loadData]); }, [loadData]);
useEffect(() => {
let cancelled = false;
setFlowLoading(true);
fetchFlowStats({ start: flowRange.start, end: flowRange.end, subject: selectedSubject })
.then((data) => {
if (!cancelled) setFlowStats(data);
})
.catch(() => {
if (!cancelled) setFlowStats(null);
})
.finally(() => {
if (!cancelled) setFlowLoading(false);
});
return () => {
cancelled = true;
};
}, [flowRange.start, flowRange.end, selectedSubject]);
// 归属公司列表(仅首次加载,公司集合相对稳定) // 归属公司列表(仅首次加载,公司集合相对稳定)
useEffect(() => { useEffect(() => {
fetchSubjects().then(setSubjects).catch(() => setSubjects([])); fetchSubjects().then(setSubjects).catch(() => setSubjects([]));
@@ -576,40 +475,6 @@ export default function AssetsModule() {
return mp; return mp;
}), [modalWeeklyDetail, modalFilters.plateNumber]); }), [modalWeeklyDetail, modalFilters.plateNumber]);
const selectedFlowDetails = useMemo(() => {
if (!flowStats || !selectedFlow) return [];
return flowStats.details.filter((item) => item.date === selectedFlow.date && item.type === selectedFlow.type);
}, [flowStats, selectedFlow]);
const exportFlowDetails = useCallback((rows?: FlowDetailItem[], title = '资产流转明细') => {
const source = rows ?? flowStats?.details ?? [];
if (source.length === 0) return;
const table = source.map((item) => ({
日期: item.date,
类型: item.typeLabel,
车牌: item.plateNumber,
流转时间: item.eventTime || '',
提交时间: item.submitTime || '',
部门: item.department || '',
业务负责人: item.manager || '',
客户: item.customerName || '',
}));
const ws = XLSX.utils.json_to_sheet(table);
ws['!cols'] = [
{ wch: 14 },
{ wch: 8 },
{ wch: 14 },
{ wch: 20 },
{ wch: 20 },
{ wch: 16 },
{ wch: 14 },
{ wch: 24 },
];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '明细');
XLSX.writeFile(wb, `${title}-${flowRange.start}-${flowRange.end}.xlsx`);
}, [flowRange.end, flowRange.start, flowStats]);
const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]); const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]);
useEffect(() => { useEffect(() => {
if (customerChartView === 'province') { if (customerChartView === 'province') {
@@ -630,60 +495,38 @@ export default function AssetsModule() {
if (loading && !summary) { if (loading && !summary) {
return ( return (
<PageFrame <div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
title="车辆资产中心" <div className="flex flex-col items-center gap-3">
subtitle="正在同步车辆、部门、区域与客户归属数据,加载完成后可继续穿透查看明细。" <Loader2 className="animate-spin text-blue-500" size={32} />
icon={Truck} <span className="text-sm text-gray-500">...</span>
eyebrow="ASSET INTELLIGENCE"
meta="数据准备中"
>
<SurfaceCard className="min-h-[360px]">
<div className="grid gap-4 md:grid-cols-4">
{[0, 1, 2, 3].map(item => (
<SkeletonBlock key={item} className="h-28" />
))}
</div> </div>
<div className="mt-8">
<LoadingState label="正在加载车辆资产数据" />
</div> </div>
</SurfaceCard>
</PageFrame>
); );
} }
if (error && !summary) { if (error && !summary) {
return ( return (
<PageFrame <div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
title="车辆资产中心" <div className="flex flex-col items-center gap-3 text-center">
subtitle="车辆资产数据暂时没有返回,请重试或稍后再看。" <div className="text-red-500 text-lg font-bold"></div>
icon={Truck} <div className="text-sm text-gray-500">{error}</div>
eyebrow="ASSET INTELLIGENCE" <button onClick={loadData} className="mt-2 px-4 py-2 bg-blue-500 text-white rounded text-sm hover:bg-blue-600">
meta="加载失败"
>
<SurfaceCard className="min-h-[360px]">
<div className="mt-6 flex flex-col items-center gap-4">
<ErrorState message={error} />
<button onClick={loadData} className="rounded-xl bg-slate-900 px-4 py-2 text-xs font-black text-white shadow-sm transition-colors hover:bg-slate-800">
</button> </button>
</div> </div>
</SurfaceCard> </div>
</PageFrame>
); );
} }
const SUMMARY = summary!; const SUMMARY = summary!;
const operatingRate = SUMMARY.totalAssets > 0 ? SUMMARY.operating.total / SUMMARY.totalAssets * 100 : 0;
const inventoryRate = SUMMARY.totalAssets > 0 ? SUMMARY.inventory.total / SUMMARY.totalAssets * 100 : 0;
const pendingRate = SUMMARY.totalAssets > 0 ? SUMMARY.pendingDelivery / SUMMARY.totalAssets * 100 : 0;
return ( return (
<div className="min-h-screen bg-[var(--app-bg)] text-gray-800 font-sans p-3 md:p-6 relative"> <div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-6 relative">
{/* Compact Header Bar */} {/* Compact Header Bar */}
<div className="sticky top-0 z-40 -mx-3 -mt-3 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80 md:-mx-6 md:-mt-6"> <div className="sticky top-0 z-40 -mx-6 -mt-6 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80">
{/* Title row */} {/* Title row */}
<div className="relative flex items-center justify-center px-4 pt-3 pb-1"> <div className="relative flex items-center justify-center px-4 pt-3 pb-1">
<h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide">-BI</h1> <h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide"></h1>
{/* Right: status + theme */} {/* Right: status + theme */}
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-2"> <div className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-2">
<div className="hidden sm:flex items-center gap-1 text-[10px] text-gray-400"> <div className="hidden sm:flex items-center gap-1 text-[10px] text-gray-400">
@@ -846,11 +689,12 @@ export default function AssetsModule() {
<span className="w-1 h-1 rounded-full bg-blue-400 inline-block" /> <span className="w-1 h-1 rounded-full bg-blue-400 inline-block" />
: {lastUpdate} : {lastUpdate}
</div> </div>
<div className="flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-green-400 animate-pulse inline-block" />
</div>
</div> </div>
</div> </div>
{/* OneOS 迁移提示滚动条 */}
<MarqueeBanner />
{/* Main Content Area */} {/* Main Content Area */}
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -864,11 +708,11 @@ export default function AssetsModule() {
{tabReady && activeTab === 'overview' && ( {tabReady && activeTab === 'overview' && (
<> <>
{/* Header Summary - Ultra Compact */} {/* Header Summary - Ultra Compact */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-2"> <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-2 mb-2">
{/* Total Assets */} {/* Total Assets */}
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md" <div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-gray-50 transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', source: 'asset', title: '资产概览' })}> onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', source: 'asset', title: '资产概览' })}>
<div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500"> <div className="w-7 h-7 rounded bg-gray-50 flex items-center justify-center text-gray-400">
<Truck size={14} /> <Truck size={14} />
</div> </div>
<div> <div>
@@ -878,9 +722,9 @@ export default function AssetsModule() {
</div> </div>
{/* Operating */} {/* Operating */}
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md" <div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-blue-50 transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Operating', source: 'asset', title: '正在运营' })}> onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Operating', source: 'asset', title: '正在运营' })}>
<div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500"> <div className="w-7 h-7 rounded bg-blue-50 flex items-center justify-center text-blue-500">
<Activity size={14} /> <Activity size={14} />
</div> </div>
<div> <div>
@@ -893,9 +737,9 @@ export default function AssetsModule() {
</div> </div>
{/* Inventory */} {/* Inventory */}
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md" <div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-gray-50 transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}> onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}>
<div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500"> <div className="w-7 h-7 rounded bg-gray-50 flex items-center justify-center text-gray-500">
<Warehouse size={14} /> <Warehouse size={14} />
</div> </div>
<div> <div>
@@ -908,9 +752,9 @@ export default function AssetsModule() {
</div> </div>
{/* Pending */} {/* Pending */}
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md" <div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-blue-50 transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset', title: '待交车' })}> onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset', title: '待交车' })}>
<div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500"> <div className="w-7 h-7 rounded bg-blue-50 flex items-center justify-center text-blue-500">
<PlusCircle size={14} /> <PlusCircle size={14} />
</div> </div>
<div> <div>
@@ -919,163 +763,41 @@ export default function AssetsModule() {
</div> </div>
</div> </div>
{/* Dynamics */}
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm col-span-2">
<div className="flex items-center justify-between mb-1.5">
<div className="text-[9px] text-gray-400 font-bold uppercase tracking-tight"></div>
<div className="text-[7px] text-gray-300 font-normal italic">-</div>
</div> </div>
<div className="flex justify-between items-center gap-1">
<div data-testid="asset-operation-ratio-strip" className="mb-3 rounded-2xl border border-slate-100 bg-white/85 px-4 py-3 shadow-sm"> <div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-green-50 py-1 rounded transition-all group">
<div className="flex items-center justify-between gap-3"> <span className="text-xs font-bold text-gray-800 group-hover:text-green-600">{SUMMARY.weeklyNew}</span>
<div className="shrink-0 text-[11px] font-black text-slate-400"></div> <span className="text-[8px] text-green-500/80 font-bold mt-0.5"></span>
<div className="grid min-w-0 flex-1 grid-cols-3 divide-x divide-slate-100 text-center">
<div className="px-2">
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div>
</div> </div>
<div className="px-2"> <div className="w-[1px] h-3 bg-gray-100"></div>
<div className="text-[10px] font-black text-slate-400"></div> <div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-blue-50 py-1 rounded transition-all group"
<div className="mt-0.5 text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div> onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Delivered', source: 'asset', title: '本周交车' })}>
<span className="text-xs font-bold text-gray-800 group-hover:text-blue-600">{SUMMARY.weeklyDelivered}</span>
<span className="text-[8px] text-blue-500/80 font-bold mt-0.5"></span>
</div> </div>
<div className="px-2"> <div className="w-[1px] h-3 bg-gray-100"></div>
<div className="text-[10px] font-black text-slate-400"></div> <div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-orange-50 py-1 rounded transition-all group"
<div className="mt-0.5 text-base font-black text-amber-600">{pendingRate.toFixed(1)}%</div> onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Returned', source: 'asset', title: '本周还车' })}>
<span className="text-xs font-bold text-gray-800 group-hover:text-orange-600">{SUMMARY.weeklyReturned}</span>
<span className="text-[8px] text-orange-500/80 font-bold mt-0.5"></span>
</div>
<div className="w-[1px] h-3 bg-gray-100"></div>
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-purple-50 py-1 rounded transition-all group"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Replaced', source: 'asset', title: '本周替换' })}>
<span className="text-xs font-bold text-gray-800 group-hover:text-purple-600">{SUMMARY.weeklyReplaced}</span>
<span className="text-[8px] text-purple-500/80 font-bold mt-0.5"></span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-3 mb-4">
<div data-testid="asset-flow-card" className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 text-[13px] font-black text-slate-700">
<CalendarDays size={14} className="text-blue-500" />
<span></span>
{flowLoading && <Loader2 size={12} className="animate-spin text-slate-400" />}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => exportFlowDetails()}
disabled={!flowStats?.details.length}
className="inline-flex h-8 items-center justify-center gap-1 rounded-xl border border-slate-200 bg-slate-50 px-2.5 text-[11px] font-black text-slate-600 transition hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
>
<Download size={13} />
</button>
</div>
</div>
<div className="mt-2 rounded-xl border border-slate-100 bg-slate-50/80 px-2 py-1.5">
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
<span className="block text-[9px] font-black text-slate-400"></span>
<span className="mt-0.5 flex items-center justify-between gap-2">
<span className="text-[12px] font-black text-slate-700">{flowRange.start.replaceAll('-', '/')}</span>
<CalendarDays size={12} className="text-slate-300" />
</span>
<input
type="date"
value={flowRange.start}
onChange={(e) => setFlowRange((prev) => ({ ...prev, start: e.target.value }))}
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</label>
<div className="rounded-full bg-slate-200/70 px-2 py-0.5 text-[9px] font-black text-slate-400"></div>
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
<span className="block text-[9px] font-black text-slate-400"></span>
<span className="mt-0.5 flex items-center justify-between gap-2">
<span className="text-[12px] font-black text-slate-700">{flowRange.end.replaceAll('-', '/')}</span>
<CalendarDays size={12} className="text-slate-300" />
</span>
<input
type="date"
value={flowRange.end}
onChange={(e) => setFlowRange((prev) => ({ ...prev, end: e.target.value }))}
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</label>
</div>
</div>
<div className="mt-2 rounded-2xl border border-slate-100 bg-slate-50/70 px-2 py-2.5">
<div className="grid grid-cols-4 items-center text-center">
<div className="px-2">
<div className="text-lg font-black leading-none text-slate-950">{flowStats?.totals.total ?? 0}</div>
<div className="mt-1 text-[10px] font-black text-slate-400"></div>
</div>
{(['delivered', 'returned', 'replaced'] as FlowType[]).map((type) => (
<div key={type} className="border-l border-slate-200/70 px-2">
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>
{flowStats?.totals[type] ?? 0}
</div>
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
</div>
))}
</div>
</div>
<button
type="button"
data-testid="asset-flow-daily-toggle"
onClick={() => setFlowDailyExpanded((prev) => !prev)}
className="mt-2 flex w-full items-center justify-between rounded-xl border border-slate-100 bg-white px-3 py-1.5 text-[12px] font-black text-slate-500 transition hover:border-blue-100 hover:bg-blue-50/50 hover:text-blue-600"
>
<span>{flowDailyExpanded ? '收起每日明细' : '展开每日明细'}</span>
<span className="flex items-center gap-2 text-[11px] text-slate-400">
{flowStats?.daily.length ?? 0}
<ChevronDown size={15} className={`transition-transform ${flowDailyExpanded ? 'rotate-180' : ''}`} />
</span>
</button>
<AnimatePresence initial={false}>
{flowDailyExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="mt-3 max-h-[280px] space-y-2 overflow-y-auto pr-1">
{flowLoading && (
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400">...</div>
)}
{!flowLoading && flowStats?.daily.map((day) => (
<div key={day.date} className="rounded-2xl border border-slate-100 bg-white px-3 py-3 shadow-sm">
<div className="flex items-center justify-between">
<div className="text-[11px] font-black text-slate-400">{day.date}</div>
<div className="text-[10px] font-bold italic text-slate-300"></div>
</div>
<div className="mt-2 grid grid-cols-4 items-center text-center">
<div className="px-2">
<div className="text-lg font-black leading-none text-slate-900">{day.total}</div>
<div className="mt-1 text-[10px] font-black text-slate-400"></div>
</div>
{(['delivered', 'returned', 'replaced'] as FlowType[]).map((type) => {
const count = day[type];
return (
<button
key={type}
type="button"
data-testid={`asset-flow-cell-${day.date}-${type}`}
disabled={count === 0}
onClick={() => setSelectedFlow({ date: day.date, type })}
className="border-l border-slate-100 px-2 text-center transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-35"
>
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>{count}</div>
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
</button>
);
})}
</div>
</div>
))}
{!flowLoading && !flowStats?.daily.length && (
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400"></div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Asset Summary Table */} {/* Asset Summary Table */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden mb-6"> <div className="bg-white rounded-sm border border-gray-100 shadow-sm overflow-hidden mb-6">
<div className="p-4 border-b border-gray-50 bg-gray-50/50 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3"> <div className="p-4 border-b border-gray-50 bg-gray-50/50 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
<div className="flex flex-wrap items-center gap-4 sm:gap-6"> <div className="flex flex-wrap items-center gap-4 sm:gap-6">
<h2 className="text-sm font-bold text-gray-700"></h2> <h2 className="text-sm font-bold text-gray-700"></h2>
@@ -2880,123 +2602,6 @@ export default function AssetsModule() {
)} )}
{/* Flow Detail Modal */}
<AnimatePresence>
{selectedFlow && (
<div className="fixed inset-0 z-[1000] flex items-end justify-center bg-slate-950/45 p-0 backdrop-blur-sm sm:items-center sm:p-4">
<motion.div
data-testid="asset-flow-detail-modal"
initial={{ opacity: 0, y: 28, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.98 }}
className="flex max-h-[88vh] w-full flex-col overflow-hidden rounded-t-3xl bg-white shadow-2xl sm:max-w-5xl sm:rounded-3xl"
>
<div className="border-b border-slate-100 bg-slate-950 px-4 py-4 text-white sm:px-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-black uppercase tracking-wide text-slate-400"></div>
<h3 className="mt-1 text-lg font-black">
{selectedFlow.date} · {FLOW_META[selectedFlow.type].label}
</h3>
<div className="mt-1 text-[12px] font-bold text-slate-300">
{selectedFlowDetails.length}
</div>
</div>
<button
type="button"
onClick={() => setSelectedFlow(null)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
>
<X size={18} />
</button>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => exportFlowDetails(selectedFlowDetails, `${selectedFlow.date}-${FLOW_META[selectedFlow.type].label}明细`)}
disabled={selectedFlowDetails.length === 0}
className="inline-flex h-8 items-center gap-1 rounded-xl bg-white px-3 text-[11px] font-black text-slate-900 transition hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-40"
>
<Download size={13} />
</button>
</div>
</div>
<div className="overflow-auto bg-slate-50 p-3 sm:p-4">
<div className="hidden overflow-hidden rounded-2xl border border-slate-100 bg-white lg:block">
<table className="w-full table-fixed text-left">
<thead className="bg-slate-50 text-[11px] font-black text-slate-400">
<tr>
<th className="w-28 px-3 py-3"></th>
<th className="w-40 px-3 py-3">{FLOW_META[selectedFlow.type].label}</th>
<th className="w-40 px-3 py-3"></th>
<th className="w-36 px-3 py-3"></th>
<th className="w-28 px-3 py-3"></th>
<th className="px-3 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 text-[12px] font-bold text-slate-700">
{selectedFlowDetails.map((item) => (
<tr key={item.id} className="hover:bg-blue-50/40">
<td className="px-3 py-3 font-black text-slate-950">{item.plateNumber}</td>
<td className="px-3 py-3 text-slate-500">{item.eventTime || '-'}</td>
<td className="px-3 py-3 text-blue-600">{item.submitTime || '-'}</td>
<td className="px-3 py-3">{item.department || '-'}</td>
<td className="px-3 py-3">{item.manager || '-'}</td>
<td className="px-3 py-3">{item.customerName || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="space-y-2 lg:hidden">
{selectedFlowDetails.map((item) => (
<div key={item.id} className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-base font-black text-slate-950">{item.plateNumber}</div>
<div className={`mt-1 inline-flex rounded-full border px-2 py-0.5 text-[10px] font-black ${FLOW_META[item.type].chip}`}>
{item.typeLabel}
</div>
</div>
<div className="text-right text-[10px] font-bold text-slate-400">
<div></div>
<div className="mt-0.5 text-[11px] text-blue-600">{item.submitTime?.slice(5, 16) || '-'}</div>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px]">
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400">{item.typeLabel}</div>
<div className="mt-1 font-bold text-slate-700">{item.eventTime || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400"></div>
<div className="mt-1 font-bold text-slate-700">{item.manager || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400"></div>
<div className="mt-1 font-bold text-slate-700">{item.department || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400"></div>
<div className="mt-1 line-clamp-2 font-bold text-slate-700">{item.customerName || '-'}</div>
</div>
</div>
</div>
))}
</div>
{selectedFlowDetails.length === 0 && (
<div className="rounded-2xl bg-white px-4 py-10 text-center text-sm font-bold text-slate-400"></div>
)}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
{/* Vehicle Detail Modal */} {/* Vehicle Detail Modal */}
<AnimatePresence> <AnimatePresence>
{showPlateNumbers && ( {showPlateNumbers && (
-47
View File
@@ -78,43 +78,6 @@ export interface WeeklyDetailItem {
customer_name: string | null; customer_name: string | null;
} }
export type FlowType = 'delivered' | 'returned' | 'replaced';
export interface FlowDailyPoint {
date: string;
delivered: number;
returned: number;
replaced: number;
total: number;
}
export interface FlowDetailItem {
id: string;
type: FlowType;
typeLabel: string;
date: string;
truckId: string;
plateNumber: string;
eventTime: string | null;
submitTime: string | null;
department: string;
manager: string;
customerName: string | null;
}
export interface FlowStatsResponse {
start: string;
end: string;
daily: FlowDailyPoint[];
totals: {
delivered: number;
returned: number;
replaced: number;
total: number;
};
details: FlowDetailItem[];
}
export async function fetchDeptStats(subject?: string | null): Promise<DeptGroup[]> { export async function fetchDeptStats(subject?: string | null): Promise<DeptGroup[]> {
return fetchJson<DeptGroup[]>(withSubject(`${BASE}/dept-stats`, subject)); return fetchJson<DeptGroup[]>(withSubject(`${BASE}/dept-stats`, subject));
} }
@@ -162,13 +125,3 @@ export async function fetchWeeklyDetail(
if (filters?.source) params.set('source', filters.source); if (filters?.source) params.set('source', filters.source);
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?${params.toString()}`); return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?${params.toString()}`);
} }
export async function fetchFlowStats(params: {
start: string;
end: string;
subject?: string | null;
}): Promise<FlowStatsResponse> {
const query = new URLSearchParams({ start: params.start, end: params.end });
if (params.subject) query.set('subject', params.subject);
return fetchJson<FlowStatsResponse>(`${BASE}/flow-stats?${query.toString()}`);
}
-1
View File
@@ -106,7 +106,6 @@ export interface VehicleListItem {
city: string | null; city: string | null;
status: string; status: string;
ownership: string; ownership: string;
rentCompany?: string | null;
contractNo: string | null; contractNo: string | null;
customerName: string | null; customerName: string | null;
subjectOrg: string | null; subjectOrg: string | null;
+16 -20
View File
@@ -8,7 +8,6 @@ import { fetchJson } from '../../auth/api-client';
import { useAuth } from '../../auth/useAuth'; import { useAuth } from '../../auth/useAuth';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import FeedbackFab from '../../components/FeedbackFab'; import FeedbackFab from '../../components/FeedbackFab';
import { PageFrame } from '../../components/ui/surface';
function getJwt(): string | null { function getJwt(): string | null {
return sessionStorage.getItem('bi_jwt'); return sessionStorage.getItem('bi_jwt');
@@ -136,34 +135,30 @@ export default function EleImportPage() {
const totalFee = overall.reduce((s, o) => s + Number(o.total_fee || 0), 0); const totalFee = overall.reduce((s, o) => s + Number(o.total_fee || 0), 0);
return ( return (
<PageFrame <div className="min-h-screen bg-[#F8F9FB] text-gray-800 p-4 md:p-8">
title="充电记录导入" <div className="max-w-6xl mx-auto space-y-4">
subtitle="每日上传 xlsx,按订单编号去重,并自动匹配内部/外部车辆归属。" <header className="flex items-center justify-between">
icon={Zap} <div className="flex items-center gap-3 min-w-0">
eyebrow="ELECTRIC IMPORT"
meta={user?.userName || '导入工作台'}
actions={(
<div className="flex items-center gap-2">
<button <button
onClick={() => { onClick={() => {
if (window.history.length > 1) window.history.back(); if (window.history.length > 1) window.history.back();
else { window.location.hash = '#mileage'; } else { window.location.hash = '#mileage'; }
}} }}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-100 bg-white text-slate-500 transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600" className="w-9 h-9 rounded-xl bg-white border border-slate-100 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 text-slate-500 flex items-center justify-center transition-colors flex-shrink-0"
title="返回" title="返回"
> >
<ArrowLeft size={16} /> <ArrowLeft size={16} />
</button> </button>
<button <div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center flex-shrink-0">
onClick={() => inputRef.current?.click()} <Zap size={18} className="text-white" />
className="inline-flex h-9 items-center gap-1.5 rounded-xl bg-slate-900 px-3 text-xs font-black text-white shadow-sm transition-colors hover:bg-slate-800"
>
<Upload size={14} />
</button>
</div> </div>
)} <div className="min-w-0">
> <h1 className="text-lg font-black text-slate-900 leading-tight"></h1>
<p className="text-[11px] font-bold text-slate-400"> xlsx · · </p>
</div>
</div>
<span className="text-[10px] font-bold text-slate-400 flex-shrink-0">{user?.userName || ''}</span>
</header>
{/* 上传区 */} {/* 上传区 */}
<section <section
@@ -364,8 +359,9 @@ export default function EleImportPage() {
</section> </section>
<RotatingFooterHint className="pb-4" /> <RotatingFooterHint className="pb-4" />
</div>
<FeedbackFab module="ele" /> <FeedbackFab module="ele" />
</PageFrame> </div>
); );
} }
-110
View File
@@ -1,110 +0,0 @@
import { cn } from '../../lib/cn';
import type { DateQuickPick } from './types';
import { QUICK_DATE_OPTIONS, type DateRangeMode } from './date-range';
export function DateRangeInputs({
idPrefix,
startDate,
endDate,
onChange,
ariaPrefix = '',
className,
}: {
idPrefix: string;
startDate: string;
endDate: string;
onChange: (field: 'start' | 'end', value: string) => void;
ariaPrefix?: string;
className?: string;
}) {
const prefix = ariaPrefix ? `${ariaPrefix} ` : '';
return (
<div className={cn('grid grid-cols-2 gap-2', className)}>
<label className="min-w-0 rounded-lg border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
id={`${idPrefix}-start-date`}
name={`${idPrefix}StartDate`}
type="date"
aria-label={`${prefix}开始日期`}
value={startDate}
onInput={event => onChange('start', event.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
<label className="min-w-0 rounded-lg border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
id={`${idPrefix}-end-date`}
name={`${idPrefix}EndDate`}
type="date"
aria-label={`${prefix}结束日期`}
value={endDate}
onInput={event => onChange('end', event.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
</div>
);
}
export default function AnalysisDateFilters({
idPrefix,
mode,
startDate,
endDate,
onQuickPick,
onCustom,
onDateChange,
}: {
idPrefix: string;
mode: DateRangeMode;
startDate: string;
endDate: string;
onQuickPick: (pick: DateQuickPick) => void;
onCustom: () => void;
onDateChange: (field: 'start' | 'end', value: string) => void;
}) {
return (
<>
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{QUICK_DATE_OPTIONS.map(option => (
<button
key={option.id}
type="button"
onClick={() => onQuickPick(option.id)}
aria-pressed={mode === option.id}
className={cn(
'min-h-9 shrink-0 rounded-lg border px-3 text-[12px] font-black transition-colors',
mode === option.id
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50',
)}
>
{option.label}
</button>
))}
<button
type="button"
onClick={onCustom}
aria-pressed={mode === 'custom'}
className={cn(
'min-h-9 shrink-0 rounded-lg border px-3 text-[12px] font-black transition-colors',
mode === 'custom'
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50',
)}
>
</button>
</div>
<DateRangeInputs
idPrefix={idPrefix}
startDate={startDate}
endDate={endDate}
onChange={onDateChange}
className="mt-2"
/>
</>
);
}
@@ -1,50 +0,0 @@
import type { ComponentType } from 'react';
import { cn } from '../../lib/cn';
export interface AnalysisScopeOption<T extends string> {
id: T;
label: string;
}
export default function AnalysisScopeSwitch<T extends string>({
ariaLabel,
value,
options,
onChange,
icon: Icon,
className,
}: {
ariaLabel: string;
value: T;
options: readonly AnalysisScopeOption<T>[];
onChange: (value: T) => void;
icon?: ComponentType<{ size?: number; className?: string }>;
className?: string;
}) {
return (
<div
role="group"
aria-label={ariaLabel}
className={cn('grid gap-1 rounded-lg bg-slate-100 p-1', className)}
style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
>
{options.map(option => (
<button
key={option.id}
type="button"
onClick={() => onChange(option.id)}
aria-pressed={value === option.id}
className={cn(
'flex min-h-9 min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 text-[12px] font-black transition-colors',
value === option.id
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-700',
)}
>
{Icon ? <Icon size={14} className="shrink-0" /> : null}
<span className="min-w-0 truncate">{option.label}</span>
</button>
))}
</div>
);
}
-315
View File
@@ -1,315 +0,0 @@
import { useEffect, useState } from 'react';
import { CheckCircle2, ChevronLeft, ChevronRight, ReceiptText, Route, Search, TriangleAlert } from 'lucide-react';
import Blur from '../../components/Blur';
import { ErrorState, LoadingState } from '../../components/ui/surface';
import { fetchEtcBills, fetchEtcRecords } from './api';
import type { EtcBillResponse, EtcOverviewResponse, EtcTollRecordResponse } from './types';
import type { EtcDetailView, EtcDrillContext } from './etc-drill-context';
import { DateRangeInputs } from './AnalysisDateFilters';
import { reconcileEtcBills, reconcileEtcRecords } from './etc-reconciliation';
function fmtMoney(value: number): string {
return `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function formatSigned(value: number, fractionDigits = 0): string {
const formatted = Math.abs(value).toLocaleString('zh-CN', {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
});
return `${value >= 0 ? '+' : '-'}${formatted}`;
}
function costTypeLabel(value: number): string {
if (value === 1) return '客户承担';
if (value === 2) return '我方承担';
return '承担方待确认';
}
function paymentLabel(receivable: number, paid: number): string {
if (receivable > 0 && paid >= receivable) return '已收';
if (paid > 0) return '部分收款';
return '未收';
}
export default function ETCDetails({
context,
overviewKpi,
onContextChange,
}: {
context: EtcDrillContext;
overviewKpi: EtcOverviewResponse['kpi'];
onContextChange: (context: EtcDrillContext, mode: 'push' | 'replace') => void;
}) {
const { view, startDate, endDate, search, page } = context;
const [draftSearch, setDraftSearch] = useState(search);
const [records, setRecords] = useState<EtcTollRecordResponse | null>(null);
const [bills, setBills] = useState<EtcBillResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => setDraftSearch(search), [search]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
if (view === 'records') setRecords(null);
else setBills(null);
const query = {
page,
limit: 20,
startDate: startDate || undefined,
endDate: endDate || undefined,
search: search || undefined,
};
const request = view === 'records' ? fetchEtcRecords(query) : fetchEtcBills(query);
request
.then(result => {
if (cancelled) return;
if (page > result.totalPages) {
onContextChange({ ...context, page: result.totalPages }, 'replace');
return;
}
if (view === 'records') setRecords(result as EtcTollRecordResponse);
else setBills(result as EtcBillResponse);
})
.catch(loadError => {
if (!cancelled) setError(loadError instanceof Error ? loadError.message : String(loadError));
})
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [context, endDate, onContextChange, page, search, startDate, view]);
const activeData = view === 'records' ? records : bills;
const totalPages = activeData?.totalPages || 1;
const isFullDataset = !startDate && !endDate && !search;
const reconciliation = isFullDataset
? view === 'records' && records
? reconcileEtcRecords(overviewKpi, records)
: view === 'bills' && bills
? reconcileEtcBills(overviewKpi, bills)
: null
: null;
const reconciliationDifferences = reconciliation && !reconciliation.matches
? 'passageCountDifference' in reconciliation
? [
reconciliation.passageCountDifference !== 0 && `通行次数 ${formatSigned(reconciliation.passageCountDifference)}`,
reconciliation.vehicleCountDifference !== 0 && `车辆数 ${formatSigned(reconciliation.vehicleCountDifference)}`,
reconciliation.tollAmountDifference !== 0 && `通行费 ${formatSigned(reconciliation.tollAmountDifference, 2)}`,
reconciliation.serviceFeeDifference !== 0 && `服务费 ${formatSigned(reconciliation.serviceFeeDifference, 2)}`,
reconciliation.totalAmountDifference !== 0 && `总费用 ${formatSigned(reconciliation.totalAmountDifference, 2)}`,
].filter(Boolean).join(' · ')
: [
reconciliation.billCountDifference !== 0 && `账单数 ${formatSigned(reconciliation.billCountDifference)}`,
reconciliation.receivableAmountDifference !== 0 && `应收 ${formatSigned(reconciliation.receivableAmountDifference, 2)}`,
reconciliation.paidAmountDifference !== 0 && `已收 ${formatSigned(reconciliation.paidAmountDifference, 2)}`,
].filter(Boolean).join(' · ')
: '';
const applySearch = () => {
onContextChange({ ...context, search: draftSearch.trim(), page: 1 }, 'replace');
};
const changeView = (next: EtcDetailView) => {
onContextChange(
{ ...context, view: next, page: 1 },
next === view ? 'replace' : 'push',
);
};
const updateDate = (field: 'start' | 'end', value: string) => {
let nextStart = field === 'start' ? value : startDate;
let nextEnd = field === 'end' ? value : endDate;
if (nextStart && nextEnd && nextStart > nextEnd) [nextStart, nextEnd] = [nextEnd, nextStart];
onContextChange({ ...context, startDate: nextStart, endDate: nextEnd, page: 1 }, 'replace');
};
return (
<section
id="etc-details"
tabIndex={-1}
className="scroll-mt-3 overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
>
<header className="flex flex-col gap-3 border-b border-slate-100 px-3 py-3 md:px-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-black text-slate-900">ETC </h2>
<p className="mt-1 text-[10px] font-bold text-slate-400">
{view === 'records' ? '通行流水与费用承担' : '客户账期、应收与收款'}
</p>
</div>
<div className="flex rounded-lg bg-slate-100 p-1">
<button
type="button"
onClick={() => changeView('records')}
aria-pressed={view === 'records'}
className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[10px] font-black ${view === 'records' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500'}`}
>
<Route size={13} />
</button>
<button
type="button"
onClick={() => changeView('bills')}
aria-pressed={view === 'bills'}
className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[10px] font-black ${view === 'bills' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500'}`}
>
<ReceiptText size={13} />
</button>
</div>
</div>
<div className="grid grid-cols-[minmax(0,1fr)_40px] gap-2 sm:grid-cols-[minmax(280px,1fr)_minmax(0,1fr)_40px]">
<DateRangeInputs
idPrefix="etc"
ariaPrefix="ETC"
startDate={startDate}
endDate={endDate}
onChange={updateDate}
className="col-span-2 sm:col-span-1"
/>
<label className="min-w-0 rounded-lg border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
type="search"
aria-label={view === 'records' ? '搜索车牌、客户或流水号' : '搜索客户或账单号'}
placeholder={view === 'records' ? '车牌 / 客户 / 流水号' : '客户 / 账单号'}
value={draftSearch}
maxLength={128}
onChange={event => setDraftSearch(event.target.value)}
onKeyDown={event => { if (event.key === 'Enter') applySearch(); }}
className="mt-1 h-6 w-full min-w-0 bg-transparent text-[11px] font-bold text-slate-700 outline-none placeholder:text-slate-300"
/>
</label>
<button
type="button"
onClick={applySearch}
className="flex h-full min-h-[58px] w-10 items-center justify-center rounded-lg bg-slate-900 text-white hover:bg-blue-600"
aria-label="应用 ETC 明细搜索"
title="搜索"
>
<Search size={14} />
</button>
</div>
</header>
{activeData ? (
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 bg-slate-50/70 px-3 py-2 text-[10px] font-bold text-slate-500 md:px-4">
<span>{activeData.total} </span>
{view === 'records' && records ? (
<span> {fmtMoney(records.summary.tollAmount)} · {fmtMoney(records.summary.serviceFee)} · {fmtMoney(records.summary.totalAmount)}</span>
) : view === 'bills' && bills ? (
<span> {fmtMoney(bills.summary.receivableAmount)} · {fmtMoney(bills.summary.paidAmount)}</span>
) : null}
</div>
) : null}
{reconciliation && (
<div
role={reconciliation.matches ? 'status' : 'alert'}
className={`flex items-start gap-2 border-b px-3 py-2 text-[10px] font-bold md:px-4 ${
reconciliation.matches
? 'border-emerald-100 bg-emerald-50 text-emerald-700'
: 'border-amber-200 bg-amber-50 text-amber-800'
}`}
>
{reconciliation.matches
? <CheckCircle2 size={14} className="mt-0.5 shrink-0" />
: <TriangleAlert size={14} className="mt-0.5 shrink-0" />}
<span>
{reconciliation.matches
? `口径核对通过:总览与 ${activeData?.total ?? 0}${view === 'records' ? '通行记录' : '结算账单'}全量汇总一致`
: `口径存在差异:${reconciliationDifferences}`}
</span>
</div>
)}
{error ? (
<ErrorState message={error} variant="inline" />
) : loading && !activeData ? (
<LoadingState label="正在加载 ETC 明细" variant="inline" />
) : view === 'records' && records ? (
records.items.length === 0 ? (
<div className="py-14 text-center">
<Route size={22} className="mx-auto text-slate-300" />
<div className="mt-3 text-xs font-black text-slate-500"></div>
<div className="mt-1 text-[10px] font-bold text-slate-300"></div>
</div>
) : (
<>
<div className="hidden overflow-x-auto md:block">
<table className="w-full min-w-[820px] text-left text-[11px]">
<thead className="bg-white text-[10px] font-black text-slate-400">
<tr><th className="px-4 py-2"></th><th className="px-3 py-2"> / </th><th className="px-3 py-2"> </th><th className="px-3 py-2"></th><th className="px-4 py-2 text-right"></th></tr>
</thead>
<tbody className="divide-y divide-slate-100">
{records.items.map(item => (
<tr key={item.id}>
<td className="px-4 py-3 font-bold text-slate-600">{item.transTime}</td>
<td className="px-3 py-3"><div className="font-black text-slate-800"><Blur>{item.plate}</Blur></div><div className="mt-1 text-[9px] font-bold text-slate-400"><Blur>{item.customerName || '未关联客户'}</Blur></div></td>
<td className="px-3 py-3 font-bold text-slate-600">{item.entryStation || '-'} {item.exitStation || '-'}</td>
<td className="px-3 py-3 font-bold text-slate-600">{costTypeLabel(item.costType)}</td>
<td className="px-4 py-3 text-right"><div className="font-black text-slate-800">{fmtMoney(item.totalAmount)}</div><div className="mt-1 text-[9px] font-bold text-slate-400"> {fmtMoney(item.tollAmount)} · {fmtMoney(item.serviceFee)}</div></td>
</tr>
))}
</tbody>
</table>
</div>
<div className="divide-y divide-slate-100 md:hidden">
{records.items.map(item => (
<article key={item.id} className="px-3 py-3">
<div className="flex items-start justify-between gap-3"><div><div className="font-mono text-xs font-black text-slate-800"><Blur>{item.plate}</Blur></div><div className="mt-1 text-[9px] font-bold text-slate-400">{item.transTime}</div></div><div className="text-right text-sm font-black text-slate-800">{fmtMoney(item.totalAmount)}</div></div>
<div className="mt-2 text-[10px] font-bold text-slate-500">{item.entryStation || '-'} {item.exitStation || '-'}</div>
<div className="mt-1 flex justify-between gap-2 text-[9px] font-bold text-slate-400"><Blur>{item.customerName || '未关联客户'}</Blur><span>{costTypeLabel(item.costType)}</span></div>
</article>
))}
</div>
</>
)
) : view === 'bills' && bills ? (
bills.items.length === 0 ? (
<div className="py-14 text-center">
<ReceiptText size={22} className="mx-auto text-slate-300" />
<div className="mt-3 text-xs font-black text-slate-500"></div>
<div className="mt-1 text-[10px] font-bold text-slate-300"></div>
</div>
) : (
<>
<div className="hidden overflow-x-auto md:block">
<table className="w-full min-w-[760px] text-left text-[11px]">
<thead className="text-[10px] font-black text-slate-400"><tr><th className="px-4 py-2"> / </th><th className="px-3 py-2"></th><th className="px-3 py-2"></th><th className="px-3 py-2"></th><th className="px-4 py-2 text-right"> / </th></tr></thead>
<tbody className="divide-y divide-slate-100">
{bills.items.map(item => (
<tr key={item.id}>
<td className="px-4 py-3"><div className="font-black text-slate-800">{item.billCode}</div><div className="mt-1 text-[9px] font-bold text-slate-400"><Blur>{item.customerName || '未指定客户'}</Blur></div></td>
<td className="px-3 py-3 font-bold text-slate-600">{item.periodStart} {item.periodEnd}</td>
<td className="px-3 py-3 font-bold text-slate-600">{item.tollCount} · {fmtMoney(item.tollAmount)}</td>
<td className="px-3 py-3 font-bold text-slate-600">{paymentLabel(item.receivableAmount, item.paidAmount)}</td>
<td className="px-4 py-3 text-right"><div className="font-black text-slate-800">{fmtMoney(item.receivableAmount)}</div><div className="mt-1 text-[9px] font-bold text-slate-400"> {fmtMoney(item.paidAmount)}</div></td>
</tr>
))}
</tbody>
</table>
</div>
<div className="divide-y divide-slate-100 md:hidden">
{bills.items.map(item => (
<article key={item.id} className="px-3 py-3">
<div className="flex items-start justify-between gap-3"><div><div className="text-xs font-black text-slate-800">{item.billCode}</div><div className="mt-1 text-[9px] font-bold text-slate-400"><Blur>{item.customerName || '未指定客户'}</Blur></div></div><div className="text-right"><div className="text-sm font-black text-slate-800">{fmtMoney(item.receivableAmount)}</div><div className="mt-1 text-[9px] font-bold text-slate-400"> {fmtMoney(item.paidAmount)}</div></div></div>
<div className="mt-2 text-[10px] font-bold text-slate-500">{item.periodStart} {item.periodEnd}</div>
<div className="mt-1 flex justify-between text-[9px] font-bold text-slate-400"><span>{item.tollCount} </span><span>{paymentLabel(item.receivableAmount, item.paidAmount)}</span></div>
</article>
))}
</div>
</>
)
) : null}
{activeData ? (
<footer className="flex items-center justify-between border-t border-slate-100 px-3 py-3 md:px-4">
<span className="text-[10px] font-bold text-slate-400"> {page} / {totalPages} </span>
<div className="flex gap-2">
<button type="button" onClick={() => onContextChange({ ...context, page: Math.max(1, page - 1) }, 'push')} disabled={page <= 1 || loading} className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 text-slate-500 disabled:opacity-30" aria-label="上一页" title="上一页"><ChevronLeft size={14} /></button>
<button type="button" onClick={() => onContextChange({ ...context, page: Math.min(totalPages, page + 1) }, 'push')} disabled={page >= totalPages || loading} className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 text-slate-500 disabled:opacity-30" aria-label="下一页" title="下一页"><ChevronRight size={14} /></button>
</div>
</footer>
) : null}
</section>
);
}
+66 -171
View File
@@ -1,184 +1,79 @@
import { useCallback, useEffect, useState } from 'react'; import { motion } from 'motion/react';
import type { ReactNode } from 'react'; import { Construction, Hammer } from 'lucide-react';
import { CircleCheck, CircleX, Clock3, Database, ReceiptText, RefreshCw, Route, Truck } from 'lucide-react'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { fetchEtcOverview } from './api';
import type { EtcOverviewResponse } from './types';
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
import ETCDetails from './ETCDetails';
import {
buildEtcDrillUrl,
parseEtcDrillContext,
type EtcDetailView,
type EtcDrillContext,
} from './etc-drill-context';
function fmtMoney(value: number): string { const ETC_HINTS = [
return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`; 'ETC 通行费数据正在与发卡方系统打通…',
} '工人 GG 正在搭脚手架,敬请期待 ~',
'马上能看到每月通行费明细啦',
function syncStatusLabel(status: EtcOverviewResponse['integration']['lastSyncStatus']): string { '想看哪个维度的 ETC?反馈一下嘛',
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-3"> <div className="flex flex-col gap-4">
<SurfaceCard className="flex items-center gap-3 p-3"> <motion.div
<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'}`}> initial={{ opacity: 0, y: 12 }}
{integration.providerConfigured ? <CircleCheck size={18} /> : <CircleX size={18} />} animate={{ opacity: 1, y: 0 }}
</span> transition={{ duration: 0.3 }}
<div className="min-w-0 flex-1"> className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
<div className="text-xs font-black text-slate-800">
{integration.providerConfigured ? 'ETC 数据提供方已配置' : 'ETC 数据提供方尚未配置'}
</div>
<div className="mt-1 truncate text-[10px] font-bold text-slate-400">
{integration.providerConfigured
? `${integration.providerType || '已配置提供方'} · ${syncStatusLabel(integration.lastSyncStatus)}`
: '数据库结构已准备,等待配置同步账户并完成首次同步'}
</div>
</div>
<button
type="button"
onClick={() => void load(true)}
disabled={refreshing}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 hover:text-blue-600 disabled:opacity-50"
aria-label="刷新 ETC 状态"
title="刷新 ETC 状态"
> >
<RefreshCw size={15} className={refreshing ? 'animate-spin' : ''} /> <div className="relative w-20 h-20 mb-4">
</button> <motion.div
</SurfaceCard> animate={{ rotate: [0, -8, 8, -4, 4, 0] }}
transition={{ duration: 2.4, repeat: Infinity, ease: 'easeInOut' }}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4"> className="absolute inset-0 rounded-3xl bg-gradient-to-br from-amber-50 to-orange-50 flex items-center justify-center"
<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)}`} /> <Construction size={36} className="text-amber-500" strokeWidth={2.2} />
</DrilldownMetricButton> </motion.div>
<DrilldownMetricButton label={`查看 ETC 通行记录,共 ${kpi.passageCount}`} title="查看通行记录" onClick={() => openDetailView('records')}> <motion.div
<MetricTile icon={Route} label="通行次数" value={kpi.passageCount} unit="次" helper={kpi.latestTransactionTime ? `最新 ${kpi.latestTransactionTime}` : '暂无通行记录'} tone="emerald" /> animate={{ rotate: [0, 18, -10, 0], y: [0, -2, 1, 0] }}
</DrilldownMetricButton> transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
<DrilldownMetricButton label={`查看 ETC 通行记录,共 ${kpi.vehicleCount} 辆车`} title="查看通行记录" onClick={() => openDetailView('records')}> 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"
<MetricTile icon={Truck} label="通行车辆" value={kpi.vehicleCount} unit="辆" helper="按车牌去重" tone="amber" /> >
</DrilldownMetricButton> <Hammer size={16} className="text-amber-500" strokeWidth={2.2} />
<DrilldownMetricButton label={`查看 ETC 结算账单,应收 ${fmtMoney(kpi.receivableAmount)}`} title="查看结算账单" onClick={() => openDetailView('bills')}> </motion.div>
<MetricTile icon={Database} label="账单应收" value={fmtMoney(kpi.receivableAmount)} helper={`${kpi.billCount} 账单 · 已收 ${fmtMoney(kpi.paidAmount)} · ${collectionRate.toFixed(1)}%`} tone="slate" />
</DrilldownMetricButton>
</div> </div>
<div className="grid gap-3 md:grid-cols-3"> <div className="text-base font-black text-slate-800 mb-1.5">ETC </div>
<SurfaceCard className="p-3"> <div className="text-[12px] text-slate-500 font-bold leading-relaxed max-w-[280px]">
<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'}`}> <br />
{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> </div>
<ETCDetails context={detailContext} overviewKpi={kpi} onContextChange={commitDetailContext} /> {/* 简单的里程碑进度感 */}
<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>
{m.done && <span className="text-[10px] text-emerald-500 font-bold ml-auto"></span>}
{m.current && <span className="text-[10px] text-amber-500 font-bold ml-auto"></span>}
</motion.div>
))}
</div>
</motion.div>
{error && <ErrorState message={error} />} <RotatingFooterHint hints={ETC_HINTS} />
</div> </div>
); );
} }
+52 -315
View File
@@ -1,103 +1,37 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { BatteryCharging, CalendarDays, CheckCircle2, ChevronRight, MapPin, Plug, TrendingUp, TriangleAlert, Truck, Wallet, X } from 'lucide-react'; import { ChevronRight, Plug } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import TrendBadge from './TrendBadge'; import TrendBadge from './TrendBadge';
import { fetchElectricMonthly, fetchElectricOrders } from './api'; import { fetchElectricMonthly } from './api';
import type { DateQuickPick, ElectricChargeOrderResponse, ElectricMonthGroup, ElectricVehicleScope } from './types'; import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
import {
buildElectricDrillUrl,
parseElectricDrillContext,
type ElectricDrillContext,
} from './electric-drill-context';
import AnalysisDateFilters from './AnalysisDateFilters';
import AnalysisScopeSwitch, { type AnalysisScopeOption } from './AnalysisScopeSwitch';
import {
dateRangeModeLabel,
getQuickDateRange,
inferDateRangeMode,
normalizeDateRange,
type DateRangeMode,
} from './date-range';
import { reconcileElectricDaily } from './electric-reconciliation';
const VEHICLE_SCOPE_OPTIONS: readonly AnalysisScopeOption<ElectricVehicleScope>[] = [ const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'all', label: '全部车辆' }, { id: 'thisWeek', label: '本周' },
{ id: 'lingniu', label: '羚牛车辆' }, { id: 'thisMonth', label: '本月' },
{ id: 'external', label: '外部车辆' }, { id: 'last15', label: '近 15 天' },
]; ];
export default function ElectricDaily() { export default function ElectricDaily() {
const [drillContext, setDrillContext] = useState<ElectricDrillContext>(() => ( const [customer, setCustomer] = useState<CustomerType>('lingniu');
parseElectricDrillContext(window.location.search) const [pick, setPick] = useState<DateQuickPick>('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(() => 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;
setError(null); setError(null);
const query = pick === 'custom' fetchElectricMonthly(customer, pick)
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick };
fetchElectricMonthly(vehicleScope, query)
.then(m => { .then(m => {
if (cancelled) return; if (cancelled) return;
setMonths(m); setMonths(m);
if (m.length > 0) { // 默认展开最新一个月
setOpenMonths(new Set([selectedDateRef.current?.slice(0, 7) || m[0].month])); if (m.length > 0) setOpenMonths(prev => prev.size > 0 ? prev : new Set([m[0].month]));
}
}) })
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); }); .catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [vehicleScope, pick, effectiveRange.start, effectiveRange.end]); }, [customer, pick]);
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);
@@ -106,128 +40,40 @@ export default function ElectricDaily() {
}); });
const totalKwh = useMemo(() => (months ?? []).reduce((s, m) => s + (m.kwh || 0), 0), [months]); const totalKwh = useMemo(() => (months ?? []).reduce((s, m) => s + (m.kwh || 0), 0), [months]);
const totalFee = useMemo(() => (months ?? []).reduce((s, m) => s + (m.fee || 0), 0), [months]); const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
const activeDays = useMemo(() => (months ?? []).reduce((sum, m) => sum + m.rows.filter(r => r.kwh > 0).length, 0), [months]);
const abnormalDays = useMemo(() => (months ?? []).reduce((sum, m) => sum + m.rows.filter(r => Math.abs(r.chainPct) >= 0.3).length, 0), [months]);
const avgKwh = activeDays > 0 ? totalKwh / activeDays : 0;
const avgPrice = totalKwh > 0 ? totalFee / totalKwh : 0;
const scopeLabel = dateRangeModeLabel(pick);
const rangeText = `${effectiveRange.start}${effectiveRange.end}`;
const showExternalEmpty = vehicleScope === 'external' && months !== null && totalKwh === 0;
const applyQuickPick = (nextPick: DateQuickPick) => {
const nextRange = getQuickDateRange(nextPick);
setPick(nextPick);
setDateRange(nextRange);
commitDrillContext({
vehicleScope,
startDate: nextRange.start,
endDate: nextRange.end,
});
};
const updateDateRange = (field: 'start' | 'end', value: string) => {
if (!value) return;
const nextRange = { ...dateRange, [field]: value };
const normalized = normalizeDateRange(nextRange.start, nextRange.end);
setPick('custom');
setDateRange(normalized);
commitDrillContext({
vehicleScope,
startDate: normalized.start,
endDate: normalized.end,
});
};
const updateVehicleScope = (next: ElectricVehicleScope) => {
setVehicleScope(next);
commitDrillContext({
...drillContext,
vehicleScope: next,
startDate: effectiveRange.start,
endDate: effectiveRange.end,
});
};
const toggleDate = (date: string) => {
const opening = selectedDate !== date;
if (opening && document.activeElement instanceof HTMLButtonElement) {
orderTriggerRef.current = document.activeElement;
}
commitDrillContext({
...drillContext,
vehicleScope,
startDate: effectiveRange.start,
endDate: effectiveRange.end,
selectedDate: opening ? date : undefined,
}, opening ? 'push' : 'replace');
window.requestAnimationFrame(() => {
if (opening) {
const details = document.getElementById(`electric-orders-${date}`);
details?.focus({ preventScroll: true });
details?.scrollIntoView({
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
block: 'start',
});
} else {
const trigger = orderTriggerRef.current ?? document.getElementById(`electric-date-trigger-${date}`);
if (trigger instanceof HTMLElement) trigger.focus();
}
});
};
useEffect(() => {
const handlePopState = () => {
const next = parseElectricDrillContext(window.location.search);
setDrillContext(next);
setVehicleScope(next.vehicleScope);
if (next.startDate && next.endDate) {
const normalized = normalizeDateRange(next.startDate, next.endDate);
setPick(inferDateRangeMode(normalized.start, normalized.end));
setDateRange(normalized);
} else if (next.selectedDate) {
setPick('custom');
setDateRange({ start: next.selectedDate, end: next.selectedDate });
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<SurfaceCard className="p-2 md:p-3"> {/* 日期速选 */}
<AnalysisDateFilters <div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
idPrefix="electric" {QUICK_PICK_OPTIONS.map(opt => (
mode={pick} <button
startDate={dateRange.start} key={opt.id}
endDate={dateRange.end} onClick={() => setPick(opt.id)}
onQuickPick={applyQuickPick} className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
onCustom={() => setPick('custom')} pick === opt.id
onDateChange={updateDateRange} ? 'bg-blue-50 text-blue-600 border-blue-200'
/> : 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
</div>
<AnalysisScopeSwitch {/* 客户类型 */}
ariaLabel="电能车辆范围" <div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
value={vehicleScope} {(['lingniu', 'external'] as const).map(c => (
options={VEHICLE_SCOPE_OPTIONS} <button
onChange={updateVehicleScope} key={c}
icon={Truck} onClick={() => setCustomer(c)}
className="mt-2" className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
/> customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
</SurfaceCard> }`}
>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4"> {c === 'external' ? '外部车辆' : '羚牛车辆'}
<MetricTile icon={BatteryCharging} label={`${scopeLabel}充电量`} value={totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={rangeText} /> </button>
<MetricTile ))}
icon={Wallet}
label="充电费用"
value={`¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
helper={`综合费用强度 ${avgPrice.toFixed(2)} 元/度`}
tone="emerald"
/>
<MetricTile icon={CalendarDays} label="有效天数" value={`${activeDays}`} unit="天" helper={`日均 ${avgKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} tone="amber" />
<MetricTile icon={TrendingUp} label="波动提醒" value={abnormalDays} unit="天" helper="环比超过 30% 标记" tone={abnormalDays > 0 ? 'rose' : 'slate'} />
</div> </div>
{/* 外部车辆 数据未就绪 */} {/* 外部车辆 数据未就绪 */}
@@ -260,23 +106,20 @@ export default function ElectricDaily() {
<span className="text-right"></span> <span className="text-right"></span>
</div> </div>
{error ? ( {error ? (
<ErrorState message={error} variant="inline" /> <div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">{error}</div>
) : months === null ? ( ) : months === null ? (
<LoadingState label="正在加载充电明细" variant="inline" /> <div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div>
) : months.length === 0 ? ( ) : months.length === 0 ? (
<EmptyState title="暂无充电数据" description="请切换时间范围或车辆归属" variant="inline" /> <div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div>
) : months.map(m => { ) : months.map(m => {
const open = openMonths.has(m.month); const open = openMonths.has(m.month);
return ( return (
<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`} />
@@ -290,7 +133,6 @@ 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 }}
@@ -302,121 +144,16 @@ 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 key={d.date} className="border-t border-slate-100"> <div
<button key={d.date}
id={`electric-date-trigger-${d.date}`} className={`grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2 pl-9 border-t border-slate-100 ${abnormalBg}`}
type="button"
onClick={() => toggleDate(d.date)}
className={`grid w-full grid-cols-[minmax(0,1fr)_120px_88px] gap-3 px-3 py-2 pl-9 text-left md:grid-cols-[minmax(0,1fr)_160px_120px] ${abnormalBg}`}
aria-expanded={selectedDate === d.date}
aria-controls={`electric-orders-${d.date}`}
> >
<span className="flex items-center gap-1 text-[12px] font-bold text-slate-600"> <span className="text-[12px] text-slate-600">{d.date.slice(5)}</span>
<ChevronRight size={12} className={`text-slate-400 transition-transform ${selectedDate === d.date ? 'rotate-90' : ''}`} /> <span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
{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>
); );
})} })}
-41
View File
@@ -1,41 +0,0 @@
import { LayoutDashboard, CalendarDays } from 'lucide-react';
import { AnimatePresence } from 'motion/react';
import ElectricView, { type ElectricSubTab } from './ElectricView';
import SubTabs from './SubTabs';
import { useHashSubTab } from './useHashSubTab';
import { FadeIn, PageFrame } from '../../components/ui/surface';
import BiHeaderActions from '../../components/BiHeaderActions';
const SUB_TABS = [
{ id: 'daily', label: '每日', icon: CalendarDays },
{ id: 'overview', label: '总览', icon: LayoutDashboard },
] as const satisfies readonly { id: ElectricSubTab; label: string; icon: typeof CalendarDays }[];
const SUB_IDS: readonly ElectricSubTab[] = ['daily', 'overview'];
export default function ElectricModule() {
const [sub, setSub] = useHashSubTab<ElectricSubTab>('electric', SUB_IDS);
return (
<PageFrame
title="电能成本看板"
subtitle="围绕充电量、费用、日趋势和车辆归属展示电能支出结构,辅助识别费用波动。"
icon={CalendarDays}
eyebrow="ELECTRIC BI"
meta="时间单位清晰标注 · 支持日/总览切换"
actions={<BiHeaderActions domain="electric" />}
>
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} idPrefix="electric-view" ariaLabel="电能分析视图" />
<AnimatePresence mode="wait">
<FadeIn key={sub}>
<div
id={`electric-view-panel-${sub}`}
role="tabpanel"
aria-labelledby={`electric-view-tab-${sub}`}
>
<ElectricView sub={sub} />
</div>
</FadeIn>
</AnimatePresence>
</PageFrame>
);
}
+27 -83
View File
@@ -1,11 +1,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { BatteryCharging, Gauge, Wallet, CalendarClock } from 'lucide-react'; import { Wallet, CalendarClock } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts'; import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
import { fetchElectricOverview, type ElectricOverviewResponse } from './api'; import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
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 })}`;
@@ -13,11 +10,6 @@ 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);
@@ -32,79 +24,48 @@ export default function ElectricOverview() {
}, []); }, []);
if (error) { if (error) {
return <ErrorState message={error} />; return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">{error}</div>;
} }
if (!data) { if (!data) {
return <LoadingState label="正在加载电能总览" />; return <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6 text-center text-slate-400 text-sm"></div>;
} }
const k = data.kpi; const k = data.kpi;
const trendData = data.trend; const trendData = data.trend;
// 当电能数据滞后(本月无数据走 fallback)时,柱图标题显示实际月份 // 当电能数据滞后(本月无数据走 fallback)时,柱图标题显示实际月份
const trendMonthLabel = trendData[0]?.date.slice(0, 7); const trendMonthLabel = trendData[0]?.date.slice(0, 7);
const trendMonthText = fmtMonth(trendMonthLabel); const currentMonth = new Date().toISOString().slice(0, 7);
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 trendKwh = trendData.reduce((sum, item) => sum + item.kwh, 0);
const trendFee = trendData.reduce((sum, item) => sum + item.fee, 0);
const avgDailyKwh = activeDays > 0 ? trendKwh / activeDays : 0;
const avgDailyFee = activeDays > 0 ? trendFee / activeDays : 0;
const peakDay = trendData.reduce<typeof trendData[number] | null>((best, item) => (!best || item.kwh > best.kwh ? item : best), null);
const avgPrice = k.totalKwh > 0 ? k.totalFee / k.totalKwh : 0;
const trendPrice = trendKwh > 0 ? trendFee / trendKwh : 0;
const openDay = (date: string) => {
const url = buildElectricDrillUrl(
{ pathname: window.location.pathname, search: window.location.search, hash: '#electric' },
{
vehicleScope: 'all',
startDate: date,
endDate: date,
selectedDate: date,
},
);
window.history.pushState(null, '', url);
window.dispatchEvent(new HashChangeEvent('hashchange'));
};
return ( 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">
{data.latestRecordAt ?? '暂无记录'} · · 2025-01-01
</div> </div>
{/* 横向 mini KPI 头 */} {/* 横向 mini KPI 头 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4"> <div className="grid grid-cols-2 gap-2 md:gap-3">
<MetricTile icon={Wallet} label="累计充电费" value={fmtYuan(k.totalFee)} helper={fmtKwh(k.totalKwh)} /> <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<MetricTile icon={CalendarClock} label={`${trendMonthText}充电费`} value={fmtYuan(trendFee)} helper={fmtKwh(trendKwh)} tone="emerald" /> <div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
<MetricTile icon={Gauge} label="综合费用强度" value={avgPrice.toFixed(2)} unit="元/度" helper={`${trendMonthText} ${trendPrice.toFixed(2)} 元/度 · 含零费用订单`} tone="amber" /> <Wallet size={11} className="text-blue-600" />
<MetricTile icon={BatteryCharging} label="今日充电" value={k.todayKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={`${fmtYuan(k.todayFee)} · ${k.todayChainPct >= 0 ? '+' : ''}${(k.todayChainPct * 100).toFixed(1)}%`} tone={Math.abs(k.todayChainPct) >= 0.3 ? 'rose' : 'slate'} /> </div>
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.totalFee)}</div>
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.totalKwh)}</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
<CalendarClock size={11} className="text-blue-600" />
</div>
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.monthFee)}</div>
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.monthKwh)}</div>
</div>
</div> </div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3"> {/* 本月每日充电柱图 */}
<SurfaceCard className="p-3"> <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="text-[11px] font-black text-slate-400">{trendMonthText}</div>
<div className="mt-1 text-xl font-black text-slate-900">{activeDays}<span className="ml-1 text-[11px] text-slate-400"></span></div>
<div className="mt-1 text-[11px] font-bold text-slate-500"> {avgDailyKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} </div>
</SurfaceCard>
<SurfaceCard className="p-3">
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-xl font-black text-blue-600">{peakDay ? peakDay.date.slice(5) : '—'}</div>
<div className="mt-1 text-[11px] font-bold text-slate-500">{peakDay ? `${peakDay.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度 · ${fmtYuan(peakDay.fee)}` : '暂无数据'}</div>
</SurfaceCard>
<SurfaceCard className="p-3">
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-xl font-black text-emerald-600">{k.totalFee > 0 ? (trendFee / k.totalFee * 100).toFixed(1) : '0.0'}%</div>
<div className="mt-1 text-[11px] font-bold text-slate-500">{trendMonthText} / </div>
</SurfaceCard>
</div>
{/* 当前月无数据时展示最近一个有数据的自然月 */}
<SurfaceCard className="p-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700">{chartTitle}</span> <span className="text-sm font-bold text-slate-700">{chartTitle}</span>
<span className="text-[11px] text-slate-400 font-bold"> · · 线</span> <span className="text-[11px] text-slate-400 font-bold"> </span>
</div> </div>
<ResponsiveContainer width="100%" height={160}> <ResponsiveContainer width="100%" height={160}>
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}> <BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
@@ -124,26 +85,9 @@ export default function ElectricOverview() {
contentStyle={{ borderRadius: 12, fontSize: 12 }} contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }} cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
/> />
{avgDailyFee > 0 && (
<ReferenceLine
y={avgDailyFee}
stroke="#f59e0b"
strokeDasharray="4 4"
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
/>
)}
<Bar dataKey="fee" radius={[4, 4, 0, 0]}> <Bar dataKey="fee" radius={[4, 4, 0, 0]}>
{trendData.map(item => ( {trendData.map((_, i) => (
<Cell <Cell key={i} fill="url(#electricBarGrad)" />
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>
@@ -154,7 +98,7 @@ export default function ElectricOverview() {
</defs> </defs>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</SurfaceCard> </div>
<RotatingFooterHint /> <RotatingFooterHint />
</div> </div>
); );
+86
View File
@@ -0,0 +1,86 @@
import { useState } from 'react';
import { Fuel, BatteryCharging, Receipt, LayoutDashboard, CalendarDays } from 'lucide-react';
import { motion } from 'motion/react';
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
import ElectricView, { type ElectricSubTab } from './ElectricView';
import ETCView from './ETCView';
type TopTab = 'hydrogen' | 'electric' | 'etc';
type SubTabId = HydrogenSubTab | ElectricSubTab; // 'daily' | 'overview'
const TABS: { key: TopTab; label: string; icon: typeof Fuel }[] = [
{ key: 'hydrogen', label: '氢能', icon: Fuel },
{ key: 'electric', label: '电能', icon: BatteryCharging },
{ key: 'etc', label: 'ETC', icon: Receipt },
];
const SUB_TABS: { id: SubTabId; label: string; icon: typeof LayoutDashboard }[] = [
{ id: 'daily', label: '每日', icon: CalendarDays },
{ id: 'overview', label: '总览', icon: LayoutDashboard },
];
export default function EnergyModule() {
const [activeTab, setActiveTab] = useState<TopTab>('hydrogen');
const [hydroSub, setHydroSub] = useState<HydrogenSubTab>('daily');
const [electricSub, setElectricSub] = useState<ElectricSubTab>('daily');
const showSubTabs = activeTab === 'hydrogen' || activeTab === 'electric';
const currentSub: SubTabId = activeTab === 'electric' ? electricSub : hydroSub;
const setSub = (id: SubTabId) => activeTab === 'electric' ? setElectricSub(id) : setHydroSub(id);
return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
{/* 统一 sticky 头部:top tab + (氢能时) 子 tab;同一张卡片,无间隙 */}
{/* pb-4 留一点底部缓冲,避免下方快捷选按钮在滚动时贴着 sticky 半截露脸 */}
<div className="sticky top-0 z-30 -mx-3 md:-mx-6 px-3 md:px-6 -mt-3 md:-mt-6 pt-3 md:pt-6 pb-4 bg-[#F8F9FB] shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)]">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
{/* 顶部 tab:氢能 / 电能 / ETC */}
<div className={`px-4 py-2 flex items-center gap-6 ${showSubTabs ? 'border-b border-slate-50' : ''}`}>
{TABS.map(tab => {
const Icon = tab.icon;
const active = activeTab === tab.key;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-2 py-1 transition-colors relative ${active ? 'text-blue-600' : 'text-slate-400 hover:text-slate-600'}`}
>
<Icon size={14} />
<span className="text-[11px] font-bold">{tab.label}</span>
{active && (
<motion.div layoutId="activeEnergyTopTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
);
})}
</div>
{/* 子 tab:氢能 / 电能 都显示 每日 / 总览 */}
{showSubTabs && (
<div className="p-1 flex gap-1">
{SUB_TABS.map(({ id, label, icon: Icon }) => {
const active = currentSub === id;
return (
<button
key={id}
onClick={() => setSub(id)}
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
active ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
}`}
>
<Icon size={14} />
<span>{label}</span>
</button>
);
})}
</div>
)}
</div>
</div>
{activeTab === 'hydrogen' && <HydrogenView sub={hydroSub} />}
{activeTab === 'electric' && <ElectricView sub={electricSub} />}
{activeTab === 'etc' && <ETCView />}
</div>
</div>
);
}
-19
View File
@@ -1,19 +0,0 @@
import { Receipt } from 'lucide-react';
import ETCView from './ETCView';
import { PageFrame } from '../../components/ui/surface';
import BiHeaderActions from '../../components/BiHeaderActions';
export default function EtcModule() {
return (
<PageFrame
title="ETC 通行费看板"
subtitle="区分通行记录与结算账单口径,展示通行费、服务费、车辆规模及数据同步状态。"
icon={Receipt}
eyebrow="ETC BI"
meta="真实接入状态 · 指标口径已发布"
actions={<BiHeaderActions domain="etc" />}
>
<ETCView />
</PageFrame>
);
}
+60 -366
View File
@@ -1,108 +1,37 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Building2, ChevronRight, Fuel, Plug, ReceiptText, TrendingUp, Truck, UserRound, X } from 'lucide-react'; import { ChevronRight, Plug } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts'; import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
import TrendBadge from './TrendBadge'; import TrendBadge from './TrendBadge';
import { fetchHydrogenDaily } from './api'; import { fetchHydrogenDaily } from './api';
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types'; import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
import {
buildHydrogenDrillUrl,
parseHydrogenDrillContext,
type HydrogenDrillContext,
} from './hydrogen-drill-context';
import HydrogenOrders from './HydrogenOrders';
import AnalysisDateFilters from './AnalysisDateFilters';
import AnalysisScopeSwitch, { type AnalysisScopeOption } from './AnalysisScopeSwitch';
import { activateDrillOnKeyDown } from './drill-interaction';
import {
dateRangeModeLabel,
getQuickDateRange,
inferDateRangeMode,
normalizeDateRange,
type DateRangeMode,
} from './date-range';
const VEHICLE_SCOPE_OPTIONS: readonly AnalysisScopeOption<CustomerType>[] = [ const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'lingniu', label: '羚牛车辆' }, { id: 'thisWeek', label: '本周' },
{ id: 'external', label: '外部车辆' }, { id: 'thisMonth', label: '本月' },
{ id: 'last15', label: '近 15 天' },
]; ];
const HYDROGEN_ORDERS_ID = 'hydrogen-orders';
export default function HydrogenDaily() { export default function HydrogenDaily() {
const [drillContext, setDrillContext] = useState<HydrogenDrillContext>(() => ( const [pick, setPick] = useState<DateQuickPick>('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(() => 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' fetchHydrogenDaily(pick, customer)
? { startDate: effectiveRange.start, endDate: effectiveRange.end, stationId: selectedStationId, customerName: selectedCustomerName }
: { range: pick, stationId: selectedStationId, customerName: selectedCustomerName };
fetchHydrogenDaily(query, vehicleScope)
.then(r => { if (!cancelled) setRows(r); }) .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, vehicleScope, effectiveRange.start, effectiveRange.end, selectedStationId, selectedCustomerName, retryKey]); }, [pick, customer]);
// 柱图:按日期升序,用于"从左到右时间流" // 柱图:按日期升序,用于"从左到右时间流"
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 stationCount = useMemo(() => {
const ids = new Set<number>();
(rows ?? []).forEach(r => r.stations.forEach(s => ids.add(s.stationId)));
return ids.size;
}, [rows]);
const avgKg = activeDays > 0 ? totalKg / activeDays : 0;
const scopeLabel = dateRangeModeLabel(pick);
const rangeText = `${effectiveRange.start}${effectiveRange.end}`;
const orderScopeLabel = selectedCustomerName
? `客户 ${selectedCustomerName}`
: selectedStationName
? `站点 ${selectedStationName}`
: vehicleScope === 'external' ? '外部车辆' : '羚牛车辆';
const peakDay = trendData.reduce<HydrogenDailyRow | null>((best, item) => (!best || item.totalKg > best.totalKg ? item : best), null);
const lowDay = trendData
.filter(item => item.totalKg > 0)
.reduce<HydrogenDailyRow | null>((low, item) => (!low || item.totalKg < low.totalKg ? item : low), null);
const zeroDays = (rows ?? []).filter(r => r.totalKg === 0).length;
const toggle = (date: string) => setExpanded(prev => { const toggle = (date: string) => setExpanded(prev => {
const next = new Set(prev); const next = new Set(prev);
@@ -110,198 +39,42 @@ export default function HydrogenDaily() {
return next; return next;
}); });
const applyQuickPick = (nextPick: DateQuickPick) => {
const nextRange = getQuickDateRange(nextPick);
setPick(nextPick);
setDateRange(nextRange);
commitDrillContext({
...drillContext,
vehicleScope,
startDate: nextRange.start,
endDate: nextRange.end,
selectedDate: undefined,
orderPage: undefined,
}, 'replace');
};
const updateDateRange = (field: 'start' | 'end', value: string) => {
if (!value) return;
const nextRange = { ...dateRange, [field]: value };
setPick('custom');
const normalized = normalizeDateRange(nextRange.start, nextRange.end);
setDateRange(normalized);
commitDrillContext({
...drillContext,
vehicleScope,
startDate: normalized.start,
endDate: normalized.end,
selectedDate: undefined,
orderPage: undefined,
}, 'replace');
};
const updateVehicleScope = (next: CustomerType) => {
setVehicleScope(next);
commitDrillContext({
...drillContext,
vehicleScope: next,
startDate: effectiveRange.start,
endDate: effectiveRange.end,
orderPage: undefined,
}, 'replace');
};
const clearEntity = () => {
commitDrillContext({
level: 'overview',
year: drillContext.year,
vehicleScope,
startDate: effectiveRange.start,
endDate: effectiveRange.end,
}, 'replace');
};
const openOrders = (date: string) => {
const activeElement = document.activeElement;
if (activeElement instanceof HTMLElement || activeElement instanceof SVGElement) {
orderTriggerRef.current = activeElement;
}
commitDrillContext({
...drillContext,
vehicleScope,
startDate: effectiveRange.start,
endDate: effectiveRange.end,
selectedDate: date,
orderPage: undefined,
}, selectedDate === date ? 'replace' : 'push');
window.requestAnimationFrame(() => {
const orders = document.getElementById(HYDROGEN_ORDERS_ID);
orders?.focus({ preventScroll: true });
orders?.scrollIntoView({
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
block: 'start',
});
});
};
const closeOrders = () => {
commitDrillContext({
...drillContext,
vehicleScope,
startDate: effectiveRange.start,
endDate: effectiveRange.end,
selectedDate: undefined,
orderPage: undefined,
}, 'replace');
window.requestAnimationFrame(() => orderTriggerRef.current?.focus());
};
const changeOrderPage = useCallback((page: number, mode: 'push' | 'replace') => {
commitDrillContext({
...drillContext,
orderPage: page > 1 ? page : undefined,
}, mode);
}, [commitDrillContext, drillContext]);
const retryLoad = () => {
setRows(null);
setError(null);
setRetryKey(key => key + 1);
};
useEffect(() => {
const handlePopState = () => {
const next = parseHydrogenDrillContext(window.location.search);
setDrillContext(next);
setVehicleScope(next.vehicleScope);
if (next.startDate && next.endDate) {
const normalized = normalizeDateRange(next.startDate, next.endDate);
setPick(inferDateRangeMode(normalized.start, normalized.end));
setDateRange(normalized);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<SurfaceCard className="p-2 md:p-3"> {/* 日期速选 */}
<AnalysisDateFilters <div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
idPrefix="hydrogen" {QUICK_PICK_OPTIONS.map(opt => (
mode={pick}
startDate={dateRange.start}
endDate={dateRange.end}
onQuickPick={applyQuickPick}
onCustom={() => setPick('custom')}
onDateChange={updateDateRange}
/>
<AnalysisScopeSwitch
ariaLabel="氢能车辆范围"
value={vehicleScope}
options={VEHICLE_SCOPE_OPTIONS}
onChange={updateVehicleScope}
icon={Truck}
className="mt-2"
/>
</SurfaceCard>
{selectedStationName && (
<section className="flex items-center gap-3 rounded-xl border border-blue-100 bg-blue-50 px-4 py-3 text-blue-700 shadow-sm">
<Building2 size={16} className="shrink-0" />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-black">{selectedStationName}</div>
<div className="mt-0.5 text-[10px] font-bold text-blue-500"> · {rangeText}</div>
</div>
<button <button
type="button" key={opt.id}
onClick={clearEntity} onClick={() => setPick(opt.id)}
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" className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
aria-label="清除站点筛选" pick === opt.id
title="清除站点筛选" ? 'bg-blue-50 text-blue-600 border-blue-200'
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
}`}
> >
<X size={14} /> {opt.label}
</button> </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> </div>
{/* 客户类型 segmented */}
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
{(['lingniu', 'external'] as const).map(c => (
<button <button
type="button" key={c}
onClick={clearEntity} onClick={() => setCustomer(c)}
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" className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
aria-label="清除客户筛选" customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
title="清除客户筛选" }`}
> >
<X size={14} /> {c === 'external' ? '外部车辆' : '羚牛车辆'}
</button> </button>
</section> ))}
)} </div>
{error && (
<ErrorState
title="氢能数据源暂不可用"
message={error}
onRetry={retryLoad}
/>
)}
{!error && rows !== null && <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
<MetricTile icon={Truck} label="车辆归属" value={vehicleScope === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
</div>}
{/* 外部车辆:新系统数据还没准备好 */} {/* 外部车辆:新系统数据还没准备好 */}
{!error && vehicleScope === 'external' && rows !== null && totalKg === 0 && ( {customer === '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 }}
@@ -322,35 +95,14 @@ export default function HydrogenDaily() {
)} )}
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */} {/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
{!error && !(vehicleScope === 'external' && totalKg === 0) && trendData.length > 0 && ( {!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
<SurfaceCard> <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="flex items-center justify-between px-4 pt-4 mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700"></span> <span className="text-sm font-bold text-slate-700"></span>
<span className="text-[11px] text-slate-400 font-bold"> · Kg</span> <span className="text-[11px] text-slate-400 font-bold"> Kg</span>
</div> </div>
<div className="mx-4 mb-2 grid grid-cols-3 gap-2 rounded-xl bg-slate-50 p-2"> <ResponsiveContainer width="100%" height={160}>
<div> <BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
{peakDay ? `${peakDay.date.slice(5)} · ${peakDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
</div>
</div>
<div>
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
{lowDay ? `${lowDay.date.slice(5)} · ${lowDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
</div>
</div>
<div>
<div className="text-[10px] font-black text-slate-400"></div>
<div className={`mt-0.5 text-[11px] font-black ${zeroDays > 0 ? 'text-amber-600' : 'text-emerald-600'}`}>
{zeroDays}
</div>
</div>
</div>
<div className="h-[180px] min-w-0 px-2 pb-2">
<ResponsiveContainer width="100%" height={180} minWidth={0}>
<BarChart data={trendData} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<XAxis <XAxis
dataKey="date" dataKey="date"
tickFormatter={(v: string) => v.slice(5)} tickFormatter={(v: string) => v.slice(5)}
@@ -360,45 +112,16 @@ export default function HydrogenDaily() {
interval="preserveStartEnd" interval="preserveStartEnd"
minTickGap={8} minTickGap={8}
/> />
<YAxis <YAxis hide />
width={42}
axisLine={false}
tickLine={false}
tick={{ fontSize: 9, fill: '#94a3b8' }}
tickFormatter={(v: number) => v >= 1000 ? `${Math.round(v / 1000)}k` : `${Math.round(v)}`}
/>
<Tooltip <Tooltip
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']} formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']}
labelFormatter={(d) => `日期 ${d}`} labelFormatter={(d) => `日期 ${d}`}
contentStyle={{ borderRadius: 12, fontSize: 12 }} contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }} cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
/> />
{avgKg > 0 && (
<ReferenceLine
y={avgKg}
stroke="#f59e0b"
strokeDasharray="4 4"
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
/>
)}
<Bar dataKey="totalKg" radius={[4, 4, 0, 0]}> <Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
{trendData.map(item => ( {trendData.map((_, i) => (
<Cell <Cell key={i} fill="url(#hydrogenBarGrad)" />
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>
@@ -410,44 +133,32 @@ export default function HydrogenDaily() {
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</SurfaceCard>
)}
{selectedDate && (
<HydrogenOrders
date={selectedDate}
dailyRow={selectedDailyRow}
page={drillContext.orderPage ?? 1}
vehicleScope={vehicleScope}
stationId={selectedStationId}
customerName={selectedCustomerName}
onPageChange={changeOrderPage}
onClose={closeOrders}
/>
)} )}
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */} {/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
{!error && !(vehicleScope === 'external' && rows !== null && totalKg === 0) && ( {!(customer === '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)_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"> <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">
<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)_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"> <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">
<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>
{/* 主行 + 子行 */} {/* 主行 + 子行 */}
{rows === null ? ( {error ? (
<LoadingState label="正在加载加氢明细" variant="inline" /> <div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">{error}</div>
) : rows === null ? (
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div>
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" variant="inline" /> <div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div>
) : rows.map(r => { ) : rows.map(r => {
const open = expanded.has(r.date); const open = expanded.has(r.date);
const isAbnormal = Math.abs(r.chainPct) >= 0.3; const isAbnormal = Math.abs(r.chainPct) >= 0.3;
@@ -456,37 +167,20 @@ 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="flex min-w-0 items-center gap-1 text-[12px] font-bold text-slate-700" 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"
aria-expanded={open}
aria-label={`${open ? '收起' : '展开'} ${r.date} 加氢站明细`}
> >
<ChevronRight size={14} className={`shrink-0 transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} /> <span className="flex items-center gap-1 text-[12px] text-slate-700 font-bold">
<span className="truncate">{r.date}</span> <ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
</button> {r.date}
<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>
</div> </button>
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{open && ( {open && (
<motion.div <motion.div
@@ -499,7 +193,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)_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" 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"
> >
<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">
@@ -528,7 +222,7 @@ export default function HydrogenDaily() {
})} })}
</div> </div>
)} )}
{!error && <RotatingFooterHint />} <RotatingFooterHint />
</div> </div>
); );
} }
-41
View File
@@ -1,41 +0,0 @@
import { LayoutDashboard, CalendarDays } from 'lucide-react';
import { AnimatePresence } from 'motion/react';
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
import SubTabs from './SubTabs';
import { useHashSubTab } from './useHashSubTab';
import { FadeIn, PageFrame } from '../../components/ui/surface';
import BiHeaderActions from '../../components/BiHeaderActions';
const SUB_TABS = [
{ id: 'daily', label: '每日', icon: CalendarDays },
{ id: 'overview', label: '总览', icon: LayoutDashboard },
] as const satisfies readonly { id: HydrogenSubTab; label: string; icon: typeof CalendarDays }[];
const SUB_IDS: readonly HydrogenSubTab[] = ['daily', 'overview'];
export default function HydrogenModule() {
const [sub, setSub] = useHashSubTab<HydrogenSubTab>('hydrogen', SUB_IDS);
return (
<PageFrame
title="氢能经营看板"
subtitle="按时间、车辆归属、加氢站和区域统一展示加氢量、费用、收入与异常波动。"
icon={CalendarDays}
eyebrow="ENERGY BI"
meta="数据单位清晰标注 · 支持日/总览切换"
actions={<BiHeaderActions domain="hydrogen" />}
>
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} idPrefix="hydrogen-view" ariaLabel="氢能分析视图" />
<AnimatePresence mode="wait">
<FadeIn key={sub}>
<div
id={`hydrogen-view-panel-${sub}`}
role="tabpanel"
aria-labelledby={`hydrogen-view-tab-${sub}`}
>
<HydrogenView sub={sub} />
</div>
</FadeIn>
</AnimatePresence>
</PageFrame>
);
}
-185
View File
@@ -1,185 +0,0 @@
import { useEffect, useState } from 'react';
import { CheckCircle2, ChevronLeft, ChevronRight, ReceiptText, TriangleAlert, X } from 'lucide-react';
import Blur from '../../components/Blur';
import { EmptyState, ErrorState, LoadingState } from '../../components/ui/surface';
import { fetchHydrogenOrders } from './api';
import type { CustomerType, HydrogenDailyRow, HydrogenOrderResponse } from './types';
import { reconcileHydrogenDaily } from './hydrogen-reconciliation';
function fmtMoney(value: number): string {
return `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export default function HydrogenOrders({
date,
dailyRow,
page,
vehicleScope,
stationId,
customerName,
onPageChange,
onClose,
}: {
date: string;
dailyRow?: HydrogenDailyRow;
page: number;
vehicleScope: CustomerType;
stationId?: number;
customerName?: string;
onPageChange: (page: number, mode: 'push' | 'replace') => void;
onClose: () => void;
}) {
const [data, setData] = useState<HydrogenOrderResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [retryKey, setRetryKey] = useState(0);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
setData(null);
fetchHydrogenOrders({ date, customer: vehicleScope, stationId, customerName, page, limit: 20 })
.then(result => {
if (cancelled) return;
if (page > result.totalPages) {
onPageChange(result.totalPages, 'replace');
return;
}
setData(result);
})
.catch(loadError => {
if (!cancelled) setError(loadError instanceof Error ? loadError.message : String(loadError));
})
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [vehicleScope, customerName, date, onPageChange, page, retryKey, stationId]);
const totalPages = data?.totalPages ?? 1;
const scope = customerName
? `客户:${customerName}`
: stationId !== undefined
? `站点 #${stationId}`
: vehicleScope === 'external' ? '外部车辆' : '羚牛车辆';
const reconciliation = data && dailyRow
? reconcileHydrogenDaily(dailyRow, data, vehicleScope, stationId, customerName)
: null;
const grainDifferences = reconciliation
? [
!reconciliation.dateMatches && '日期',
!reconciliation.scopeMatches && '车辆范围',
!reconciliation.entityMatches && '站点/客户范围',
].filter(Boolean).join('、')
: '';
return (
<section
id="hydrogen-orders"
tabIndex={-1}
className="scroll-mt-3 overflow-hidden rounded-lg border border-blue-100 bg-white shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
>
<header className="flex items-start justify-between gap-3 border-b border-blue-100 bg-blue-50/60 px-3 py-3 md:px-4">
<div className="min-w-0">
<div className="flex items-center gap-2 text-xs font-black text-blue-800">
<ReceiptText size={15} />{date}
</div>
<div className="mt-1 truncate text-[10px] font-bold text-blue-500">{scope} · </div>
</div>
<button
type="button"
onClick={onClose}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white text-blue-500 ring-1 ring-blue-100 hover:text-blue-700"
aria-label="关闭加氢订单明细"
title="关闭订单明细"
>
<X size={14} />
</button>
</header>
{data ? (
<>
<div className="grid grid-cols-3 gap-2 border-b border-slate-100 bg-slate-50 px-3 py-2 text-[10px] font-bold md:px-4">
<div className="min-w-0"><span className="text-slate-400"> / </span><div className="mt-1 break-words text-slate-700">{data.recordCount} · {data.totalKg.toLocaleString('zh-CN')} Kg</div></div>
<div className="min-w-0"><span className="text-slate-400"></span><div className="mt-1 break-all text-slate-700">{fmtMoney(data.totalCost)}</div></div>
<div className="min-w-0"><span className="text-slate-400"></span><div className="mt-1 break-all text-emerald-700">{fmtMoney(data.totalRevenue)}</div></div>
</div>
{reconciliation && (
<div
role={reconciliation.matches ? 'status' : 'alert'}
className={`flex items-start gap-2 border-b px-3 py-2 text-[10px] font-bold md:px-4 ${
reconciliation.matches
? 'border-emerald-100 bg-emerald-50 text-emerald-700'
: 'border-amber-200 bg-amber-50 text-amber-800'
}`}
>
{reconciliation.matches
? <CheckCircle2 size={14} className="mt-0.5 shrink-0" />
: <TriangleAlert size={14} className="mt-0.5 shrink-0" />}
<span>
{reconciliation.matches
? `口径核对通过:日汇总与 ${data.recordCount} 笔订单全量加氢量一致`
: `口径存在差异:加氢量 ${reconciliation.kgDifference >= 0 ? '+' : ''}${reconciliation.kgDifference.toFixed(2)} Kg${grainDifferences ? ` · ${grainDifferences}不一致` : ''}`}
</span>
</div>
)}
</>
) : null}
{error ? (
<ErrorState
title="订单数据暂不可用"
message={error}
onRetry={() => setRetryKey(key => key + 1)}
variant="inline"
/>
) : loading && !data ? (
<LoadingState label="正在加载加氢订单" variant="inline" />
) : data?.items.length === 0 ? (
<EmptyState title="当前筛选下无加氢订单" description="请切换日期、车辆归属或实体范围" variant="inline" />
) : data ? (
<>
<div className="hidden overflow-x-auto md:block">
<table className="w-full min-w-[820px] text-left text-[11px]">
<thead className="text-[10px] font-black text-slate-400">
<tr><th className="px-4 py-2"> / </th><th className="px-3 py-2"> / </th><th className="px-3 py-2 text-right"></th><th className="px-3 py-2 text-right"></th><th className="px-4 py-2 text-right"></th></tr>
</thead>
<tbody className="divide-y divide-slate-100">
{data.items.map(order => (
<tr key={order.rowNumber}>
<td className="px-4 py-3"><div className="font-bold text-slate-600">{order.refuelTime}</div><div className="mt-1 font-mono text-[10px] font-black text-slate-800"><Blur>{order.plate}</Blur></div></td>
<td className="px-3 py-3"><div className="font-bold text-slate-700"><Blur>{order.customerName}</Blur></div><div className="mt-1 text-[9px] font-bold text-slate-400">{order.stationName}</div></td>
<td className="px-3 py-3 text-right font-black text-slate-800">{order.amountKg.toLocaleString('zh-CN')} Kg</td>
<td className="px-3 py-3 text-right"><div className="font-black text-slate-800">{fmtMoney(order.costTotal)}</div><div className="mt-1 text-[9px] font-bold text-slate-400">{fmtMoney(order.costPrice)} / Kg</div></td>
<td className="px-4 py-3 text-right"><div className="font-black text-emerald-700">{fmtMoney(order.revenue)}</div><div className="mt-1 text-[9px] font-bold text-slate-400"> {fmtMoney(order.customerPrice)}</div></td>
</tr>
))}
</tbody>
</table>
</div>
<div className="divide-y divide-slate-100 md:hidden">
{data.items.map(order => (
<article key={order.rowNumber} className="px-3 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0"><div className="font-mono text-xs font-black text-slate-800"><Blur>{order.plate}</Blur></div><div className="mt-1 text-[9px] font-bold text-slate-400">{order.refuelTime}</div></div>
<div className="shrink-0 text-right text-sm font-black text-slate-800">{order.amountKg.toLocaleString('zh-CN')} Kg</div>
</div>
<div className="mt-2 truncate text-[10px] font-bold text-slate-600"><Blur>{order.customerName}</Blur> · {order.stationName}</div>
<div className="mt-2 flex justify-between gap-2 text-[9px] font-bold text-slate-400"><span> {fmtMoney(order.costTotal)}</span><span className="text-emerald-700"> {fmtMoney(order.revenue)}</span></div>
</article>
))}
</div>
</>
) : null}
{data ? (
<footer className="flex items-center justify-between border-t border-slate-100 px-3 py-3 md:px-4">
<span className="text-[10px] font-bold text-slate-400"> {page} / {totalPages} </span>
<div className="flex gap-2">
<button type="button" onClick={() => onPageChange(Math.max(1, page - 1), 'push')} disabled={page <= 1 || loading} className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 text-slate-500 disabled:opacity-30" aria-label="上一页加氢订单" title="上一页"><ChevronLeft size={14} /></button>
<button type="button" onClick={() => onPageChange(Math.min(totalPages, page + 1), 'push')} disabled={page >= totalPages || loading} className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 text-slate-500 disabled:opacity-30" aria-label="下一页加氢订单" title="下一页"><ChevronRight size={14} /></button>
</div>
</footer>
) : null}
</section>
);
}
+10 -152
View File
@@ -2,12 +2,10 @@ 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, ChevronRight, UserRound } from 'lucide-react'; import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw } 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',
@@ -121,14 +119,7 @@ export default function HydrogenOverview() {
}, [year, load]); }, [year, load]);
if (error && !data) { if (error && !data) {
return ( return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">{error}</div>;
<ErrorState
title="氢能数据源暂不可用"
message={error}
onRetry={() => void load(year, true)}
retrying={refreshing}
/>
);
} }
if (!data) { if (!data) {
return <HydrogenOverviewSkeleton />; return <HydrogenOverviewSkeleton />;
@@ -156,53 +147,6 @@ export default function HydrogenOverview() {
const yearRevenueFmt = fmtYuan(k.yearRevenue); const yearRevenueFmt = fmtYuan(k.yearRevenue);
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600'; const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
const monthAvgKg = monthly.length > 0 ? monthly.reduce((sum, m) => sum + m.kg, 0) / monthly.length : 0;
const bestMonth = monthly.reduce<typeof monthly[number] | null>((best, item) => (!best || item.kg > best.kg ? item : best), null);
const latestMonth = monthly[monthly.length - 1];
const prevMonth = monthly[monthly.length - 2];
const monthMomentum = latestMonth && prevMonth && prevMonth.kg > 0 ? (latestMonth.kg - prevMonth.kg) / prevMonth.kg * 100 : null;
const top5Share = top5.reduce((sum, item) => sum + item.kg, 0) / Math.max(1, k.yearKg) * 100;
const profitYield = k.yearRevenue > 0 ? k.yearProfit / k.yearRevenue * 100 : 0;
const stationAvgKg = stations.length > 0 ? k.yearKg / stations.length : 0;
const openStation = (station: typeof stations[number]) => {
const now = new Date();
const endDate = activeYear === now.getFullYear()
? `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
: `${activeYear}-12-31`;
const url = buildHydrogenDrillUrl(
{ pathname: window.location.pathname, search: window.location.search, hash: '#hydrogen' },
{
level: 'station',
year: activeYear,
stationId: station.id,
stationName: station.name,
vehicleScope: 'lingniu',
startDate: `${activeYear}-01-01`,
endDate,
},
);
window.history.pushState(null, '', url);
window.dispatchEvent(new HashChangeEvent('hashchange'));
};
const openCustomer = (customer: typeof customers[number]) => {
const now = new Date();
const endDate = activeYear === now.getFullYear()
? `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
: `${activeYear}-12-31`;
const url = buildHydrogenDrillUrl(
{ pathname: window.location.pathname, search: window.location.search, hash: '#hydrogen' },
{
level: 'customer',
year: activeYear,
customerName: customer.name,
vehicleScope: 'lingniu',
startDate: `${activeYear}-01-01`,
endDate,
},
);
window.history.pushState(null, '', url);
window.dispatchEvent(new HashChangeEvent('hashchange'));
};
// 月度收支组合数据(推算"年内每月"图) // 月度收支组合数据(推算"年内每月"图)
const monthlyDual = monthly.map(m => ({ const monthlyDual = monthly.map(m => ({
@@ -214,7 +158,7 @@ export default function HydrogenOverview() {
<div className="flex flex-col gap-3 relative"> <div className="flex flex-col gap-3 relative">
{/* 顶部说明条 + 年份切换 + 刷新按钮 */} {/* 顶部说明条 + 年份切换 + 刷新按钮 */}
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2"> <div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRefreshTime(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span> <span className="truncate">{lastRefreshAt ? `更新于 ${formatRelative(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5"> <div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
{availableYears.map(y => { {availableYears.map(y => {
@@ -261,22 +205,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: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` }, { label: '成本', value: `¥${yearFeeFmt.value} ${yearFeeFmt.unit}` },
]} ]}
/> />
<KpiCard <KpiCard
@@ -303,59 +247,6 @@ export default function HydrogenOverview() {
/> />
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 md:gap-3">
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-lg font-black text-slate-900">
{monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`}
</div>
</div>
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
<Gauge size={18} />
</span>
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
{latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'}
{bestMonth ? ` · 峰值 ${bestMonth.month}` : ''}
{monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''}
</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-lg font-black text-slate-900">Top5 {top5Share.toFixed(1)}%</div>
</div>
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-cyan-50 text-cyan-600 ring-1 ring-cyan-100">
<Building2 size={18} />
</span>
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
{stations.length} · {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit}
{top5Share >= 70 ? ' · 头部站点依赖偏高' : ' · 分布相对健康'}
</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-[11px] font-black text-slate-400"></div>
<div className={`mt-1 text-lg font-black ${profitYield >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{profitYield.toFixed(1)}%
</div>
</div>
<span className={`flex h-9 w-9 items-center justify-center rounded-xl ring-1 ${profitYield >= 0 ? 'bg-emerald-50 text-emerald-600 ring-emerald-100' : 'bg-rose-50 text-rose-600 ring-rose-100'}`}>
<AlertTriangle size={18} />
</span>
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
{yearProfitFmt.value}{yearProfitFmt.unit} · {yearRevenueFmt.value}{yearRevenueFmt.unit}
{profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'}
</div>
</div>
</div>
{/* 月度趋势:年内每月加氢量 */} {/* 月度趋势:年内每月加氢量 */}
{monthly.length > 0 && ( {monthly.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4"> <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
@@ -545,17 +436,7 @@ 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 max-w-[180px]"> <td className="py-1.5 text-slate-700 truncate max-w-[180px]">{s.name}</td>
<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>
@@ -614,18 +495,7 @@ 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 max-w-[200px]"> <td className="py-1.5 text-slate-700 truncate max-w-[200px]">{c2.name}</td>
<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>
@@ -689,18 +559,6 @@ function formatRelative(ts: number): string {
return new Date(ts).toLocaleString('zh-CN', { hour12: false }); return new Date(ts).toLocaleString('zh-CN', { hour12: false });
} }
function formatRefreshTime(ts: number): string {
const exactTime = new Date(ts).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
return `${formatRelative(ts)} · ${exactTime.replace(/\//g, '-')}`;
}
function HydrogenOverviewSkeleton() { function HydrogenOverviewSkeleton() {
return ( return (
<div className="flex flex-col gap-3 animate-pulse"> <div className="flex flex-col gap-3 animate-pulse">
-24
View File
@@ -1,24 +0,0 @@
import type { ComponentType } from 'react';
import { SegmentedNav } from '../../components/ui/surface';
interface SubTab<T extends string> {
id: T;
label: string;
icon: ComponentType<{ size?: number; className?: string }>;
}
interface Props<T extends string> {
tabs: readonly SubTab<T>[];
active: T;
onChange: (id: T) => void;
idPrefix: string;
ariaLabel: string;
}
export default function SubTabs<T extends string>({ tabs, active, onChange, idPrefix, ariaLabel }: Props<T>) {
return (
<div className="sticky top-0 z-30 -mx-3 bg-[var(--app-bg)] px-3 pb-2 pt-1 shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)] md:-mx-6 md:top-12 md:px-6">
<SegmentedNav tabs={tabs} active={active} onChange={onChange} idPrefix={idPrefix} ariaLabel={ariaLabel} />
</div>
);
}
+5 -84
View File
@@ -1,13 +1,9 @@
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,
ElectricChargeOrderResponse, CustomerType, DateQuickPick,
EtcOverviewResponse,
EtcBillResponse, EtcTollRecordResponse,
CustomerType, DateQuickPick, ElectricVehicleScope,
} from './types'; } from './types';
const BASE = '/api/energy'; const BASE = '/api/energy';
@@ -31,96 +27,21 @@ export function fetchHydrogenOverview(year?: number, force = false): Promise<Hyd
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`); return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`);
} }
export interface HydrogenDailyQuery { export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise<HydrogenDailyRow[]> {
range?: DateQuickPick; const q = new URLSearchParams({ range, customer });
startDate?: string;
endDate?: string;
stationId?: number;
customerName?: string;
}
export function fetchHydrogenDaily(query: HydrogenDailyQuery, vehicleScope: CustomerType): Promise<HydrogenDailyRow[]> {
const q = new URLSearchParams({ customer: vehicleScope });
if (query.range) q.set('range', query.range);
if (query.startDate) q.set('startDate', query.startDate);
if (query.endDate) q.set('endDate', query.endDate);
if (query.stationId !== undefined) q.set('stationId', String(query.stationId));
if (query.customerName) q.set('customerName', query.customerName);
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`); 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(vehicleScope: ElectricVehicleScope, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> { export function fetchElectricMonthly(customer: CustomerType, range: DateQuickPick = 'last15'): Promise<ElectricMonthGroup[]> {
const q = new URLSearchParams({ customer: vehicleScope }); const q = new URLSearchParams({ customer, range });
if (query.range) q.set('range', query.range);
if (query.startDate) q.set('startDate', query.startDate);
if (query.endDate) q.set('endDate', query.endDate);
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`); return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
} }
export function fetchElectricOrders(date: string, vehicleScope: ElectricVehicleScope): Promise<ElectricChargeOrderResponse> {
const q = new URLSearchParams({ date, customer: vehicleScope });
return fetchJson<ElectricChargeOrderResponse>(`${BASE}/electric/orders?${q.toString()}`);
}
export function fetchEtcOverview(force = false): Promise<EtcOverviewResponse> {
return fetchJson<EtcOverviewResponse>(`${BASE}/etc/overview${force ? '?force=1' : ''}`);
}
export interface EtcListQuery {
page?: number;
limit?: number;
startDate?: string;
endDate?: string;
search?: string;
}
function etcListParams(query: EtcListQuery): string {
const params = new URLSearchParams();
if (query.page) params.set('page', String(query.page));
if (query.limit) params.set('limit', String(query.limit));
if (query.startDate) params.set('startDate', query.startDate);
if (query.endDate) params.set('endDate', query.endDate);
if (query.search) params.set('search', query.search);
return params.toString();
}
export function fetchEtcRecords(query: EtcListQuery): Promise<EtcTollRecordResponse> {
const params = etcListParams(query);
return fetchJson<EtcTollRecordResponse>(`${BASE}/etc/records${params ? `?${params}` : ''}`);
}
export function fetchEtcBills(query: EtcListQuery): Promise<EtcBillResponse> {
const params = etcListParams(query);
return fetchJson<EtcBillResponse>(`${BASE}/etc/bills${params ? `?${params}` : ''}`);
}
-33
View File
@@ -1,33 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
dateRangeModeLabel,
getQuickDateRange,
inferDateRangeMode,
normalizeDateRange,
} from './date-range.js';
const FRIDAY = new Date(2026, 7, 7, 16, 30, 0);
test('builds shared quick date ranges from local calendar days', () => {
assert.deepEqual(getQuickDateRange('thisWeek', FRIDAY), { start: '2026-08-03', end: '2026-08-07' });
assert.deepEqual(getQuickDateRange('thisMonth', FRIDAY), { start: '2026-08-01', end: '2026-08-07' });
assert.deepEqual(getQuickDateRange('last15', FRIDAY), { start: '2026-07-24', end: '2026-08-07' });
});
test('normalizes date order and publishes consistent labels', () => {
assert.deepEqual(normalizeDateRange('2026-08-07', '2026-08-01'), { start: '2026-08-01', end: '2026-08-07' });
assert.equal(dateRangeModeLabel('thisMonth'), '本月');
assert.equal(dateRangeModeLabel('custom'), '自定义区间');
});
test('restores a quick mode only while URL dates still match that calendar range', () => {
assert.equal(inferDateRangeMode('2026-08-03', '2026-08-07', FRIDAY), 'thisWeek');
assert.equal(inferDateRangeMode('2026-08-01', '2026-08-07', FRIDAY), 'thisMonth');
assert.equal(inferDateRangeMode('2026-08-07', '2026-07-24', FRIDAY), 'last15');
assert.equal(inferDateRangeMode('2026-08-01', '2026-08-06', FRIDAY), 'custom');
assert.equal(
inferDateRangeMode('2026-08-01', '2026-08-07', new Date(2026, 7, 8, 9, 0, 0)),
'custom',
);
});
-59
View File
@@ -1,59 +0,0 @@
import type { DateQuickPick } from './types';
export type DateRangeMode = DateQuickPick | 'custom';
export interface DateRangeValue {
start: string;
end: string;
}
const LABELS: Record<DateQuickPick, string> = {
thisWeek: '本周',
thisMonth: '本月',
last15: '近 15 天',
};
function fmtYmd(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function addDays(date: Date, days: number): Date {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
export function getQuickDateRange(pick: DateQuickPick, now = new Date()): DateRangeValue {
const today = new Date(now);
today.setHours(0, 0, 0, 0);
if (pick === 'thisWeek') {
const day = today.getDay() || 7;
return { start: fmtYmd(addDays(today, -(day - 1))), end: fmtYmd(today) };
}
if (pick === 'thisMonth') {
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end: fmtYmd(today) };
}
return { start: fmtYmd(addDays(today, -14)), end: fmtYmd(today) };
}
export function normalizeDateRange(start: string, end: string): DateRangeValue {
return start <= end ? { start, end } : { start: end, end: start };
}
export function inferDateRangeMode(start: string, end: string, now = new Date()): DateRangeMode {
const range = normalizeDateRange(start, end);
const match = QUICK_DATE_OPTIONS.find(({ id }) => {
const quickRange = getQuickDateRange(id, now);
return quickRange.start === range.start && quickRange.end === range.end;
});
return match?.id ?? 'custom';
}
export function dateRangeModeLabel(mode: DateRangeMode): string {
return mode === 'custom' ? '自定义区间' : LABELS[mode];
}
export const QUICK_DATE_OPTIONS = (Object.keys(LABELS) as DateQuickPick[]).map(id => ({
id,
label: LABELS[id],
}));
@@ -1,25 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { activateDrillOnKeyDown } from './drill-interaction.js';
test('activates drilldown with Enter and Space only', () => {
for (const key of ['Enter', ' ']) {
let prevented = false;
let activations = 0;
const handled = activateDrillOnKeyDown(
{ key, preventDefault: () => { prevented = true; } },
() => { activations += 1; },
);
assert.equal(handled, true);
assert.equal(prevented, true);
assert.equal(activations, 1);
}
let activations = 0;
const handled = activateDrillOnKeyDown(
{ key: 'ArrowRight', preventDefault: () => assert.fail('must not prevent navigation keys') },
() => { activations += 1; },
);
assert.equal(handled, false);
assert.equal(activations, 0);
});
-11
View File
@@ -1,11 +0,0 @@
export interface DrillKeyEvent {
key: string;
preventDefault: () => void;
}
export function activateDrillOnKeyDown(event: DrillKeyEvent, activate: () => void): boolean {
if (event.key !== 'Enter' && event.key !== ' ') return false;
event.preventDefault();
activate();
return true;
}
@@ -1,47 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildElectricDrillUrl,
parseElectricDrillContext,
} from './electric-drill-context.js';
test('parses electric scope, range, and selected day', () => {
assert.deepEqual(
parseElectricDrillContext('?electricScope=external&electricStart=2026-07-01&electricEnd=2026-07-31&electricDate=2026-07-24'),
{
vehicleScope: 'external',
startDate: '2026-07-01',
endDate: '2026-07-31',
selectedDate: '2026-07-24',
},
);
});
test('drops invalid electric dates and defaults scope', () => {
assert.deepEqual(
parseElectricDrillContext('?electricScope=unknown&electricDate=2026-02-31'),
{ vehicleScope: 'lingniu' },
);
});
test('preserves the all-vehicle scope for overview drilldown', () => {
assert.deepEqual(
parseElectricDrillContext('?electricScope=all&electricDate=2026-07-28'),
{ vehicleScope: 'all', selectedDate: '2026-07-28' },
);
});
test('builds namespaced electric drill URL and preserves unrelated filters', () => {
assert.equal(
buildElectricDrillUrl(
{ pathname: '/energy', search: '?company=5&electricDate=2026-01-01', hash: '#electric' },
{
vehicleScope: 'lingniu',
startDate: '2026-07-24',
endDate: '2026-08-07',
selectedDate: '2026-07-24',
},
),
'/energy?company=5&electricScope=lingniu&electricStart=2026-07-24&electricEnd=2026-08-07&electricDate=2026-07-24#electric',
);
});
@@ -1,58 +0,0 @@
import type { ElectricVehicleScope } from './types';
export interface ElectricDrillContext {
vehicleScope: ElectricVehicleScope;
startDate?: string;
endDate?: string;
selectedDate?: string;
}
interface LocationParts {
pathname: string;
search: string;
hash: string;
}
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
function validDate(value: string | null): string | undefined {
if (!value || !DATE_PATTERN.test(value)) return undefined;
const [year, month, day] = value.split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
return date.getUTCFullYear() === year
&& date.getUTCMonth() === month - 1
&& date.getUTCDate() === day
? value
: undefined;
}
export function parseElectricDrillContext(search: string): ElectricDrillContext {
const params = new URLSearchParams(search);
const scope = params.get('electricScope');
const context: ElectricDrillContext = {
vehicleScope: scope === 'external' || scope === 'all' ? scope : 'lingniu',
};
const startDate = validDate(params.get('electricStart'));
const endDate = validDate(params.get('electricEnd'));
const selectedDate = validDate(params.get('electricDate'));
if (startDate) context.startDate = startDate;
if (endDate) context.endDate = endDate;
if (selectedDate) context.selectedDate = selectedDate;
return context;
}
export function buildElectricDrillUrl(
location: LocationParts,
context: ElectricDrillContext,
): string {
const params = new URLSearchParams(location.search);
for (const key of ['electricScope', 'electricStart', 'electricEnd', 'electricDate']) {
params.delete(key);
}
params.set('electricScope', context.vehicleScope);
if (context.startDate) params.set('electricStart', context.startDate);
if (context.endDate) params.set('electricEnd', context.endDate);
if (context.selectedDate) params.set('electricDate', context.selectedDate);
const query = params.toString();
return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
}
@@ -1,44 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { reconcileElectricDaily } from './electric-reconciliation.js';
import type { ElectricChargeOrderResponse, ElectricDailyRow } from './types.js';
const daily: ElectricDailyRow = {
date: '2026-07-01',
kwh: 2736.67,
fee: 552.92,
chainPct: 0,
};
const orders: ElectricChargeOrderResponse = {
date: '2026-07-01',
vehicleScope: 'all',
recordCount: 29,
totalKwh: 2736.67,
totalFee: 552.92,
truncated: false,
items: [],
};
test('matches a daily aggregate to the full order summary at hundredth precision', () => {
assert.deepEqual(reconcileElectricDaily(daily, orders, 'all'), {
matches: true,
dateMatches: true,
scopeMatches: true,
kwhDifference: 0,
feeDifference: 0,
});
});
test('reports amount, date, and scope differences without averaging detail rows', () => {
const result = reconcileElectricDaily(
daily,
{ ...orders, date: '2026-07-02', vehicleScope: 'lingniu', totalKwh: 2736.68, totalFee: 550 },
'all',
);
assert.equal(result.matches, false);
assert.equal(result.dateMatches, false);
assert.equal(result.scopeMatches, false);
assert.equal(result.kwhDifference, 0.01);
assert.equal(result.feeDifference, -2.92);
});
@@ -1,35 +0,0 @@
import type { ElectricChargeOrderResponse, ElectricDailyRow, ElectricVehicleScope } from './types';
export interface ElectricDailyReconciliation {
matches: boolean;
dateMatches: boolean;
scopeMatches: boolean;
kwhDifference: number;
feeDifference: number;
}
function hundredths(value: number): number {
return Math.round(value * 100);
}
export function reconcileElectricDaily(
daily: ElectricDailyRow,
orders: ElectricChargeOrderResponse,
expectedScope: ElectricVehicleScope,
): ElectricDailyReconciliation {
const dateMatches = daily.date === orders.date;
const scopeMatches = expectedScope === orders.vehicleScope;
const kwhDifferenceInHundredths = hundredths(orders.totalKwh) - hundredths(daily.kwh);
const feeDifferenceInHundredths = hundredths(orders.totalFee) - hundredths(daily.fee);
return {
matches: dateMatches
&& scopeMatches
&& kwhDifferenceInHundredths === 0
&& feeDifferenceInHundredths === 0,
dateMatches,
scopeMatches,
kwhDifference: kwhDifferenceInHundredths / 100,
feeDifference: feeDifferenceInHundredths / 100,
};
}
@@ -1,40 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildEtcDrillUrl, parseEtcDrillContext } from './etc-drill-context.js';
test('parses and normalizes the ETC analysis context', () => {
assert.deepEqual(
parseEtcDrillContext('?etcView=bills&etcStart=2026-08-07&etcEnd=2026-08-01&etcSearch=%20%E5%AE%A2%E6%88%B7A%20&etcPage=3'),
{
view: 'bills',
startDate: '2026-08-01',
endDate: '2026-08-07',
search: '客户A',
page: 3,
},
);
});
test('rejects invalid ETC URL state', () => {
assert.deepEqual(
parseEtcDrillContext(`?etcView=unknown&etcStart=2026-02-31&etcSearch=${'x'.repeat(129)}&etcPage=-2`),
{ view: 'records', startDate: '', endDate: '', search: '', page: 1 },
);
});
test('builds ETC state without dropping unrelated URL context', () => {
assert.equal(
buildEtcDrillUrl({
pathname: '/energy',
search: '?electricScope=all&etcView=records',
hash: '#etc',
}, {
view: 'bills',
startDate: '2026-08-01',
endDate: '2026-08-07',
search: '客户A',
page: 2,
}),
'/energy?electricScope=all&etcView=bills&etcStart=2026-08-01&etcEnd=2026-08-07&etcSearch=%E5%AE%A2%E6%88%B7A&etcPage=2#etc',
);
});
-63
View File
@@ -1,63 +0,0 @@
export type EtcDetailView = 'records' | 'bills';
export interface EtcDrillContext {
view: EtcDetailView;
startDate: string;
endDate: string;
search: string;
page: number;
}
interface LocationParts {
pathname: string;
search: string;
hash: string;
}
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
function validDate(value: string | null): string {
if (!value || !DATE_PATTERN.test(value)) return '';
const [year, month, day] = value.split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
return date.getUTCFullYear() === year
&& date.getUTCMonth() === month - 1
&& date.getUTCDate() === day
? value
: '';
}
function validPage(value: string | null): number {
if (!value || !/^\d+$/.test(value)) return 1;
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 1;
}
export function parseEtcDrillContext(search: string): EtcDrillContext {
const params = new URLSearchParams(search);
const rawSearch = params.get('etcSearch')?.trim() ?? '';
const firstDate = validDate(params.get('etcStart'));
const secondDate = validDate(params.get('etcEnd'));
const startDate = firstDate && secondDate && firstDate > secondDate ? secondDate : firstDate;
const endDate = firstDate && secondDate && firstDate > secondDate ? firstDate : secondDate;
return {
view: params.get('etcView') === 'bills' ? 'bills' : 'records',
startDate,
endDate,
search: rawSearch.length <= 128 ? rawSearch : '',
page: validPage(params.get('etcPage')),
};
}
export function buildEtcDrillUrl(location: LocationParts, context: EtcDrillContext): string {
const params = new URLSearchParams(location.search);
for (const key of ['etcView', 'etcStart', 'etcEnd', 'etcSearch', 'etcPage']) params.delete(key);
params.set('etcView', context.view);
if (context.startDate) params.set('etcStart', context.startDate);
if (context.endDate) params.set('etcEnd', context.endDate);
const search = context.search.trim();
if (search && search.length <= 128) params.set('etcSearch', search);
if (context.page > 1) params.set('etcPage', String(context.page));
const query = params.toString();
return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
}
@@ -1,69 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { reconcileEtcBills, reconcileEtcRecords } from './etc-reconciliation.js';
import type { EtcBillResponse, EtcOverviewResponse, EtcTollRecordResponse } from './types.js';
const kpi: EtcOverviewResponse['kpi'] = {
passageCount: 12,
vehicleCount: 4,
tollAmount: 100.01,
serviceFee: 5.02,
totalAmount: 105.03,
billCount: 2,
receivableAmount: 105.03,
paidAmount: 80,
latestTransactionTime: '2026-07-01 12:00:00',
};
const records: EtcTollRecordResponse = {
page: 1,
limit: 20,
total: 12,
totalPages: 1,
filters: { startDate: null, endDate: null, search: '' },
summary: { vehicleCount: 4, tollAmount: 100.01, serviceFee: 5.02, totalAmount: 105.03 },
items: [],
};
const bills: EtcBillResponse = {
page: 1,
limit: 20,
total: 2,
totalPages: 1,
filters: { startDate: null, endDate: null, search: '' },
summary: { receivableAmount: 105.03, paidAmount: 80 },
items: [],
};
test('matches ETC overview against full record and bill summaries', () => {
assert.equal(reconcileEtcRecords(kpi, records).matches, true);
assert.equal(reconcileEtcBills(kpi, bills).matches, true);
});
test('reports record count, vehicle, and fee differences at hundredth precision', () => {
assert.deepEqual(reconcileEtcRecords(kpi, {
...records,
total: 13,
summary: { vehicleCount: 3, tollAmount: 100, serviceFee: 5.04, totalAmount: 105.04 },
}), {
matches: false,
passageCountDifference: 1,
vehicleCountDifference: -1,
tollAmountDifference: -0.01,
serviceFeeDifference: 0.02,
totalAmountDifference: 0.01,
});
});
test('reports bill count and settlement differences at hundredth precision', () => {
assert.deepEqual(reconcileEtcBills(kpi, {
...bills,
total: 1,
summary: { receivableAmount: 100, paidAmount: 80.01 },
}), {
matches: false,
billCountDifference: -1,
receivableAmountDifference: -5.03,
paidAmountDifference: 0.01,
});
});
-61
View File
@@ -1,61 +0,0 @@
import type { EtcBillResponse, EtcOverviewResponse, EtcTollRecordResponse } from './types';
export interface EtcRecordReconciliation {
matches: boolean;
passageCountDifference: number;
vehicleCountDifference: number;
tollAmountDifference: number;
serviceFeeDifference: number;
totalAmountDifference: number;
}
export interface EtcBillReconciliation {
matches: boolean;
billCountDifference: number;
receivableAmountDifference: number;
paidAmountDifference: number;
}
function hundredths(value: number): number {
return Math.round(value * 100);
}
export function reconcileEtcRecords(
kpi: EtcOverviewResponse['kpi'],
records: EtcTollRecordResponse,
): EtcRecordReconciliation {
const tollDifference = hundredths(records.summary.tollAmount) - hundredths(kpi.tollAmount);
const serviceFeeDifference = hundredths(records.summary.serviceFee) - hundredths(kpi.serviceFee);
const totalDifference = hundredths(records.summary.totalAmount) - hundredths(kpi.totalAmount);
const passageCountDifference = records.total - kpi.passageCount;
const vehicleCountDifference = records.summary.vehicleCount - kpi.vehicleCount;
return {
matches: passageCountDifference === 0
&& vehicleCountDifference === 0
&& tollDifference === 0
&& serviceFeeDifference === 0
&& totalDifference === 0,
passageCountDifference,
vehicleCountDifference,
tollAmountDifference: tollDifference / 100,
serviceFeeDifference: serviceFeeDifference / 100,
totalAmountDifference: totalDifference / 100,
};
}
export function reconcileEtcBills(
kpi: EtcOverviewResponse['kpi'],
bills: EtcBillResponse,
): EtcBillReconciliation {
const receivableDifference = hundredths(bills.summary.receivableAmount) - hundredths(kpi.receivableAmount);
const paidDifference = hundredths(bills.summary.paidAmount) - hundredths(kpi.paidAmount);
const billCountDifference = bills.total - kpi.billCount;
return {
matches: billCountDifference === 0 && receivableDifference === 0 && paidDifference === 0,
billCountDifference,
receivableAmountDifference: receivableDifference / 100,
paidAmountDifference: paidDifference / 100,
};
}
@@ -1,111 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildHydrogenDrillUrl,
parseHydrogenDrillContext,
} from './hydrogen-drill-context.js';
test('parses a station drill context including station zero', () => {
assert.deepEqual(
parseHydrogenDrillContext('?hydrogenLevel=station&hydrogenStation=0&hydrogenStationName=%E6%9C%AA%E5%85%B3%E8%81%94%E7%AB%99%E7%82%B9&hydrogenScope=lingniu&hydrogenStart=2026-08-01&hydrogenEnd=2026-08-07'),
{
level: 'station',
stationId: 0,
stationName: '未关联站点',
vehicleScope: 'lingniu',
startDate: '2026-08-01',
endDate: '2026-08-07',
},
);
});
test('requires a valid entity for station and customer levels', () => {
assert.equal(parseHydrogenDrillContext('?hydrogenLevel=station&hydrogenStation=-1').level, 'overview');
assert.equal(parseHydrogenDrillContext('?hydrogenLevel=customer').level, 'overview');
assert.equal(
parseHydrogenDrillContext(`?hydrogenLevel=customer&hydrogenCustomer=${'x'.repeat(129)}`).level,
'overview',
);
});
test('parses and builds a customer drill context', () => {
const context = parseHydrogenDrillContext(
'?hydrogenLevel=customer&hydrogenCustomer=%E6%AD%A6%E6%B1%89%E5%AE%A2%E6%88%B7&hydrogenScope=lingniu&hydrogenStart=2026-01-01&hydrogenEnd=2026-08-07',
);
assert.deepEqual(context, {
level: 'customer',
customerName: '武汉客户',
vehicleScope: 'lingniu',
startDate: '2026-01-01',
endDate: '2026-08-07',
});
assert.equal(
buildHydrogenDrillUrl(
{ pathname: '/energy', search: '', hash: '#hydrogen' },
{ ...context, year: 2026 },
),
'/energy?hydrogenLevel=customer&hydrogenYear=2026&hydrogenCustomer=%E6%AD%A6%E6%B1%89%E5%AE%A2%E6%88%B7&hydrogenScope=lingniu&hydrogenStart=2026-01-01&hydrogenEnd=2026-08-07#hydrogen',
);
});
test('builds a namespaced station URL and preserves unrelated parameters', () => {
const url = buildHydrogenDrillUrl(
{ pathname: '/energy', search: '?company=5', hash: '#hydrogen' },
{
level: 'station',
year: 2026,
stationId: 18,
stationName: '测试加氢站',
vehicleScope: 'lingniu',
startDate: '2026-08-01',
endDate: '2026-08-07',
},
);
assert.equal(
url,
'/energy?company=5&hydrogenLevel=station&hydrogenYear=2026&hydrogenStation=18&hydrogenStationName=%E6%B5%8B%E8%AF%95%E5%8A%A0%E6%B0%A2%E7%AB%99&hydrogenScope=lingniu&hydrogenStart=2026-08-01&hydrogenEnd=2026-08-07#hydrogen',
);
});
test('clears station parameters when returning to overview', () => {
const url = buildHydrogenDrillUrl(
{
pathname: '/energy',
search: '?hydrogenLevel=station&hydrogenStation=18&hydrogenStationName=test&company=5',
hash: '#hydrogen',
},
{ level: 'overview', vehicleScope: 'lingniu' },
);
assert.equal(url, '/energy?company=5&hydrogenScope=lingniu#hydrogen');
});
test('preserves a selected order date and page in the hydrogen analysis URL', () => {
const context = parseHydrogenDrillContext(
'?hydrogenScope=lingniu&hydrogenStart=2026-08-01&hydrogenEnd=2026-08-07&hydrogenDate=2026-08-05&hydrogenPage=3',
);
assert.equal(context.selectedDate, '2026-08-05');
assert.equal(context.orderPage, 3);
assert.equal(
buildHydrogenDrillUrl(
{ pathname: '/energy', search: '?company=5', hash: '#hydrogen' },
context,
),
'/energy?company=5&hydrogenScope=lingniu&hydrogenStart=2026-08-01&hydrogenEnd=2026-08-07&hydrogenDate=2026-08-05&hydrogenPage=3#hydrogen',
);
assert.equal(
parseHydrogenDrillContext('?hydrogenStart=2026-08-01&hydrogenEnd=2026-08-07&hydrogenDate=2026-08-08').selectedDate,
undefined,
);
assert.equal(parseHydrogenDrillContext('?hydrogenPage=3').orderPage, undefined);
assert.equal(parseHydrogenDrillContext('?hydrogenDate=2026-08-05&hydrogenPage=0').orderPage, undefined);
assert.equal(
buildHydrogenDrillUrl(
{ pathname: '/energy', search: '', hash: '#hydrogen' },
{ level: 'overview', vehicleScope: 'lingniu', orderPage: 3 },
),
'/energy?hydrogenScope=lingniu#hydrogen',
);
});
@@ -1,123 +0,0 @@
import type { CustomerType } from './types';
export type HydrogenDrillLevel = 'overview' | 'station' | 'customer';
export interface HydrogenDrillContext {
level: HydrogenDrillLevel;
year?: number;
stationId?: number;
stationName?: string;
customerName?: string;
vehicleScope: CustomerType;
startDate?: string;
endDate?: string;
selectedDate?: string;
orderPage?: number;
}
interface LocationParts {
pathname: string;
search: string;
hash: string;
}
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
function validDate(value: string | null): string | undefined {
if (!value || !DATE_PATTERN.test(value)) return undefined;
const timestamp = Date.parse(`${value}T00:00:00+08:00`);
return Number.isFinite(timestamp) ? value : undefined;
}
function positiveInteger(value: string | null): number | undefined {
if (!value || !/^\d+$/.test(value)) return undefined;
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
}
function stationId(value: string | null): number | undefined {
if (!value || !/^\d+$/.test(value)) return undefined;
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
}
export function parseHydrogenDrillContext(search: string): HydrogenDrillContext {
const params = new URLSearchParams(search);
const requestedLevel = params.get('hydrogenLevel');
const parsedStationId = stationId(params.get('hydrogenStation'));
const parsedStationName = params.get('hydrogenStationName')?.trim() || undefined;
const rawCustomerName = params.get('hydrogenCustomer')?.trim();
const customerName = rawCustomerName && rawCustomerName.length <= 128
? rawCustomerName
: undefined;
const context: HydrogenDrillContext = {
level: requestedLevel === 'station' && parsedStationId !== undefined
? 'station'
: requestedLevel === 'customer' && customerName
? 'customer'
: 'overview',
vehicleScope: params.get('hydrogenScope') === 'external' ? 'external' : 'lingniu',
};
const year = positiveInteger(params.get('hydrogenYear'));
if (year && year >= 2000 && year <= 2100) context.year = year;
if (context.level === 'station') {
context.stationId = parsedStationId;
if (parsedStationName) context.stationName = parsedStationName;
}
if (context.level === 'customer') context.customerName = customerName;
const startDate = validDate(params.get('hydrogenStart'));
const endDate = validDate(params.get('hydrogenEnd'));
const selectedDate = validDate(params.get('hydrogenDate'));
if (startDate) context.startDate = startDate;
if (endDate) context.endDate = endDate;
const rangeStart = startDate && endDate ? (startDate <= endDate ? startDate : endDate) : undefined;
const rangeEnd = startDate && endDate ? (startDate <= endDate ? endDate : startDate) : undefined;
if (selectedDate && (!rangeStart || !rangeEnd || (selectedDate >= rangeStart && selectedDate <= rangeEnd))) {
context.selectedDate = selectedDate;
const orderPage = positiveInteger(params.get('hydrogenPage'));
if (orderPage && orderPage > 1) context.orderPage = orderPage;
}
return context;
}
export function buildHydrogenDrillUrl(
location: LocationParts,
context: HydrogenDrillContext,
): string {
const params = new URLSearchParams(location.search);
for (const key of [
'hydrogenLevel',
'hydrogenYear',
'hydrogenStation',
'hydrogenStationName',
'hydrogenCustomer',
'hydrogenScope',
'hydrogenStart',
'hydrogenEnd',
'hydrogenDate',
'hydrogenPage',
]) {
params.delete(key);
}
if (context.level !== 'overview') params.set('hydrogenLevel', context.level);
if (context.year) params.set('hydrogenYear', String(context.year));
if (context.level === 'station' && context.stationId !== undefined) {
params.set('hydrogenStation', String(context.stationId));
if (context.stationName) params.set('hydrogenStationName', context.stationName);
}
if (context.level === 'customer' && context.customerName) {
params.set('hydrogenCustomer', context.customerName);
}
params.set('hydrogenScope', context.vehicleScope);
if (context.startDate) params.set('hydrogenStart', context.startDate);
if (context.endDate) params.set('hydrogenEnd', context.endDate);
if (context.selectedDate) params.set('hydrogenDate', context.selectedDate);
if (context.selectedDate && context.orderPage && context.orderPage > 1) {
params.set('hydrogenPage', String(context.orderPage));
}
const query = params.toString();
return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
}
@@ -1,67 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { reconcileHydrogenDaily } from './hydrogen-reconciliation.js';
import type { HydrogenDailyRow, HydrogenOrderResponse } from './types.js';
const daily: HydrogenDailyRow = {
date: '2026-07-01',
totalKg: 123.45,
chainPct: 0,
customerType: 'lingniu',
stations: [],
};
const orders: HydrogenOrderResponse = {
date: '2026-07-01',
vehicleScope: 'lingniu',
stationId: 18,
customerName: null,
page: 1,
limit: 20,
recordCount: 3,
totalPages: 1,
totalKg: 123.45,
totalCost: 1000,
totalRevenue: 1200,
items: [],
};
test('matches daily hydrogen totals at the same date, scope, and entity grain', () => {
assert.deepEqual(reconcileHydrogenDaily(daily, orders, 'lingniu', 18), {
matches: true,
dateMatches: true,
scopeMatches: true,
entityMatches: true,
kgDifference: 0,
});
});
test('reports amount and filter-grain differences independently', () => {
const result = reconcileHydrogenDaily(
daily,
{
...orders,
date: '2026-07-02',
vehicleScope: 'external',
stationId: null,
customerName: '武汉客户',
totalKg: 123.44,
},
'lingniu',
18,
);
assert.equal(result.matches, false);
assert.equal(result.dateMatches, false);
assert.equal(result.scopeMatches, false);
assert.equal(result.entityMatches, false);
assert.equal(result.kgDifference, -0.01);
});
test('matches an exact customer drill without requiring a station', () => {
const customerOrders = { ...orders, stationId: null, customerName: '武汉客户' };
assert.equal(
reconcileHydrogenDaily(daily, customerOrders, 'lingniu', undefined, '武汉客户').matches,
true,
);
});
@@ -1,35 +0,0 @@
import type { CustomerType, HydrogenDailyRow, HydrogenOrderResponse } from './types';
export interface HydrogenDailyReconciliation {
matches: boolean;
dateMatches: boolean;
scopeMatches: boolean;
entityMatches: boolean;
kgDifference: number;
}
function hundredths(value: number): number {
return Math.round(value * 100);
}
export function reconcileHydrogenDaily(
daily: HydrogenDailyRow,
orders: HydrogenOrderResponse,
expectedScope: CustomerType,
expectedStationId?: number,
expectedCustomerName?: string,
): HydrogenDailyReconciliation {
const dateMatches = daily.date === orders.date;
const scopeMatches = expectedScope === orders.vehicleScope;
const entityMatches = (expectedStationId ?? null) === orders.stationId
&& (expectedCustomerName ?? null) === orders.customerName;
const kgDifferenceInHundredths = hundredths(orders.totalKg) - hundredths(daily.totalKg);
return {
matches: dateMatches && scopeMatches && entityMatches && kgDifferenceInHundredths === 0,
dateMatches,
scopeMatches,
entityMatches,
kgDifference: kgDifferenceInHundredths / 100,
};
}
-134
View File
@@ -1,5 +1,4 @@
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 {
@@ -23,7 +22,6 @@ export interface HydrogenKpi {
} }
export interface HydrogenStationTop { export interface HydrogenStationTop {
id: number;
rank: number; rank: number;
name: string; name: string;
kg: number; kg: number;
@@ -54,7 +52,6 @@ export interface HydrogenCustomerRow {
} }
export interface HydrogenStationFull { export interface HydrogenStationFull {
id: number;
name: string; name: string;
kg: number; kg: number;
revenue: number; revenue: number;
@@ -63,7 +60,6 @@ export interface HydrogenStationFull {
} }
export interface HydrogenStationRow { export interface HydrogenStationRow {
stationId: number;
name: string; name: string;
pricePerKg: number; pricePerKg: number;
kg: number; kg: number;
@@ -78,34 +74,6 @@ 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;
@@ -129,105 +97,3 @@ 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[];
}
-16
View File
@@ -1,16 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildHashSubTab, parseHashSubTab } from './useHashSubTab.js';
const tabs = ['daily', 'overview'] as const;
test('parses valid sub tabs and falls back to the module default', () => {
assert.equal(parseHashSubTab('#electric/overview', 'electric', tabs), 'overview');
assert.equal(parseHashSubTab('#electric/unknown', 'electric', tabs), 'daily');
assert.equal(parseHashSubTab('#hydrogen/overview', 'electric', tabs), 'daily');
});
test('builds a compact default hash and an explicit secondary hash', () => {
assert.equal(buildHashSubTab('electric', 'daily', 'daily'), '#electric');
assert.equal(buildHashSubTab('electric', 'overview', 'daily'), '#electric/overview');
});
-51
View File
@@ -1,51 +0,0 @@
import { useEffect, useState } from 'react';
export function parseHashSubTab<T extends string>(
hash: string,
moduleId: string,
subs: readonly T[],
): T {
const [first, second] = hash.replace(/^#/, '').split('/');
if (first !== moduleId) return subs[0];
return second && (subs as readonly string[]).includes(second) ? second as T : subs[0];
}
export function buildHashSubTab<T extends string>(moduleId: string, sub: T, defaultSub: T): string {
return sub === defaultSub ? `#${moduleId}` : `#${moduleId}/${sub}`;
}
/**
* 把模块内子 tab 状态同步到 URL hash 二级段。
* hash 形如 `#<moduleId>`= 默认 sub)或 `#<moduleId>/<sub>`。
* 默认值不写入 hash,刷新页面可恢复。
*/
export function useHashSubTab<T extends string>(
moduleId: string,
subs: readonly T[],
): [T, (sub: T) => void] {
const defaultSub = subs[0];
const parse = (): T => parseHashSubTab(window.location.hash, moduleId, subs);
const [sub, setSubState] = useState<T>(parse);
useEffect(() => {
const onChange = () => setSubState(parse());
window.addEventListener('hashchange', onChange);
window.addEventListener('popstate', onChange);
return () => {
window.removeEventListener('hashchange', onChange);
window.removeEventListener('popstate', onChange);
};
}, [moduleId]);
const setSub = (next: T) => {
if (next === sub) return;
const { pathname, search } = window.location;
const newHash = buildHashSubTab(moduleId, next, defaultSub);
window.history.pushState(null, '', `${pathname}${search}${newHash}`);
setSubState(next);
};
return [sub, setSub];
}
@@ -1,148 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { AmapConfig, HydrogenHeatmapMetric, HydrogenHeatmapPoint } from './types';
type Props = {
config: AmapConfig;
points: HydrogenHeatmapPoint[];
max: number;
metric: HydrogenHeatmapMetric;
focusQuery: string;
onMapClick: (longitude: number, latitude: number) => void;
};
type AmapInstance = {
Map: new (container: HTMLElement, options: Record<string, unknown>) => any;
HeatMap: new (map: any, options: Record<string, unknown>) => any;
ToolBar: new (options?: Record<string, unknown>) => any;
Scale: new (options?: Record<string, unknown>) => any;
Bounds: new (southWest: [number, number], northEast: [number, number]) => any;
};
declare global {
interface Window {
_AMapSecurityConfig?: { securityJsCode: string };
}
}
function getBounds(points: HydrogenHeatmapPoint[]) {
if (!points.length) return null;
return points.reduce((bounds, point) => ({
minLng: Math.min(bounds.minLng, point.lng),
maxLng: Math.max(bounds.maxLng, point.lng),
minLat: Math.min(bounds.minLat, point.lat),
maxLat: Math.max(bounds.maxLat, point.lat),
}), { minLng: points[0].lng, maxLng: points[0].lng, minLat: points[0].lat, maxLat: points[0].lat });
}
export default function HydrogenAmapCanvas({ config, points, max, metric, focusQuery, onMapClick }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<any>(null);
const heatmapRef = useRef<any>(null);
const amapRef = useRef<AmapInstance | null>(null);
const clickHandlerRef = useRef(onMapClick);
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const intensity = useMemo(() => {
const transform = metric === 'kg'
? (value: number) => Math.log1p(value)
: (value: number) => Math.sqrt(value);
return {
points: points.map((point) => ({ ...point, count: transform(point.count) })),
max: transform(Math.max(1, max)),
};
}, [max, metric, points]);
useEffect(() => {
clickHandlerRef.current = onMapClick;
}, [onMapClick]);
useEffect(() => {
let cancelled = false;
const container = containerRef.current;
if (!container) return undefined;
async function initialize() {
try {
window._AMapSecurityConfig = { securityJsCode: config.securityCode };
const loaderModule = await import('@amap/amap-jsapi-loader');
const AMap = await loaderModule.default.load({
key: config.key,
version: '2.0',
plugins: ['AMap.HeatMap', 'AMap.ToolBar', 'AMap.Scale'],
}) as unknown as AmapInstance;
if (cancelled || !container) return;
const map = new AMap.Map(container, {
viewMode: '2D',
zoom: 5,
center: [105.4, 34.4],
mapStyle: 'amap://styles/whitesmoke',
resizeEnable: true,
showLabel: true,
});
map.addControl(new AMap.ToolBar({ position: { right: '20px', bottom: '76px' } }));
map.addControl(new AMap.Scale({ position: { right: '18px', bottom: '24px' } }));
const heatmap = new AMap.HeatMap(map, {
radius: 34,
opacity: [0.14, 0.84],
gradient: {
0.1: '#2563eb',
0.3: '#0891b2',
0.5: '#16a34a',
0.68: '#eab308',
0.84: '#f97316',
1: '#dc2626',
},
});
map.on('click', (event: any) => clickHandlerRef.current(event.lnglat.getLng(), event.lnglat.getLat()));
mapRef.current = map;
heatmapRef.current = heatmap;
amapRef.current = AMap;
setStatus('ready');
} catch (error) {
console.error('Hydrogen heatmap AMap initialization failed', error);
if (!cancelled) setStatus('error');
}
}
initialize();
return () => {
cancelled = true;
heatmapRef.current?.setMap?.(null);
mapRef.current?.destroy?.();
heatmapRef.current = null;
mapRef.current = null;
amapRef.current = null;
};
}, [config.key, config.securityCode]);
useEffect(() => {
if (status !== 'ready' || !heatmapRef.current) return;
heatmapRef.current.setDataSet({ data: intensity.points, max: intensity.max });
if (!focusQuery || !points.length || !mapRef.current || !amapRef.current) return;
const bounds = getBounds(points);
if (!bounds) return;
if (points.length === 1) {
mapRef.current.setZoomAndCenter(12, [points[0].lng, points[0].lat]);
return;
}
mapRef.current.setBounds(new amapRef.current.Bounds(
[bounds.minLng, bounds.minLat],
[bounds.maxLng, bounds.maxLat],
), false, [80, 80, 80, 80]);
}, [focusQuery, intensity, points, status]);
return (
<div className="absolute inset-0 bg-[#eef3f7]">
<div ref={containerRef} className="h-full w-full" aria-label="加氢站加氢热力图地图" />
{status === 'loading' ? (
<div className="absolute inset-0 grid place-items-center bg-white/76 text-sm font-medium text-slate-500 backdrop-blur-[2px]">
<span className="flex items-center gap-2.5"><i className="h-4 w-4 animate-spin rounded-full border-2 border-cyan-600 border-t-transparent" /></span>
</div>
) : null}
{status === 'error' ? (
<div className="absolute inset-0 grid place-items-center bg-slate-50 text-center">
<div><p className="text-sm font-semibold text-slate-700"></p><p className="mt-1 text-xs text-slate-400"> Key</p></div>
</div>
) : null}
</div>
);
}
@@ -1,85 +0,0 @@
import { ChevronRight, Fuel, X } from 'lucide-react';
import type { HydrogenHeatmapMetric, HydrogenStationRank } from './types';
type Props = {
title: string;
subtitle: string;
kg: number;
refuelCount: number;
vehicleCount: number;
stationCount: number;
stations: HydrogenStationRank[];
metric: HydrogenHeatmapMetric;
selected: boolean;
loading: boolean;
coverageText: string;
onClose: () => void;
onHide: () => void;
};
const integerFormat = new Intl.NumberFormat('zh-CN');
const kgFormat = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 });
function metricLabel(metric: HydrogenHeatmapMetric) {
if (metric === 'refuels') return '按加氢次数排序';
if (metric === 'vehicles') return '按车辆数排序';
return '按加氢量排序';
}
function metricValue(station: HydrogenStationRank, metric: HydrogenHeatmapMetric) {
if (metric === 'kg') return `${kgFormat.format(station.kg)} kg`;
if (metric === 'refuels') return `${integerFormat.format(station.refuelCount)}`;
return `${integerFormat.format(station.vehicleCount)}`;
}
export default function HydrogenHeatmapDetailPanel(props: Props) {
return (
<aside className="relative z-20 flex h-full w-[310px] shrink-0 flex-col border-l border-slate-200 bg-white max-lg:absolute max-lg:bottom-0 max-lg:left-0 max-lg:right-0 max-lg:h-[43vh] max-lg:w-full max-lg:rounded-t-2xl max-lg:border-l-0 max-lg:border-t max-lg:shadow-[0_-10px_30px_rgba(15,23,42,0.14)] max-md:h-[50dvh]">
<div className="border-b border-slate-100 px-5 pb-4 pt-5 max-md:px-4 max-md:pb-3 max-md:pt-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate text-[17px] font-semibold tracking-tight text-slate-900">{props.title}</h2>
{props.loading ? <i className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-cyan-600 border-t-transparent" /> : null}
</div>
<p className="mt-1 text-[11px] text-slate-400">{props.subtitle}</p>
<p className="mt-1 text-[10px] font-medium text-emerald-600">{props.coverageText}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{props.selected ? (
<button type="button" onClick={props.onClose} aria-label="关闭加氢区域详情" className="grid h-8 w-8 place-items-center rounded-lg text-slate-400 hover:bg-slate-50 hover:text-slate-700"><X size={18} strokeWidth={1.8} /></button>
) : null}
<button type="button" onClick={props.onHide} aria-label="隐藏加氢详情" title="隐藏详情" className="grid h-8 w-8 place-items-center rounded-lg text-slate-400 transition hover:bg-cyan-50 hover:text-cyan-700 max-md:h-10 max-md:w-10"><ChevronRight size={18} strokeWidth={1.8} /></button>
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-x-4 gap-y-3 border-t border-slate-100 pt-4 max-md:mt-3 max-md:gap-y-2 max-md:pt-3">
<div><p className="text-[10px] text-slate-400"></p><p className="mt-0.5 text-[19px] font-semibold tracking-tight text-cyan-700">{kgFormat.format(props.kg)} <small className="text-[10px] font-medium">kg</small></p></div>
<div><p className="text-[10px] text-slate-400"></p><p className="mt-0.5 text-[19px] font-semibold tracking-tight text-cyan-700">{integerFormat.format(props.refuelCount)}</p></div>
<div><p className="text-[10px] text-slate-400"></p><p className="mt-0.5 text-[16px] font-semibold text-slate-700">{integerFormat.format(props.vehicleCount)}</p></div>
<div><p className="text-[10px] text-slate-400"></p><p className="mt-0.5 text-[16px] font-semibold text-slate-700">{integerFormat.format(props.stationCount)}</p></div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto px-5 py-4 max-md:px-4 max-md:py-3">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[13px] font-semibold text-slate-800"> TOP10</h3>
<span className="text-[10px] text-slate-400">{metricLabel(props.metric)}</span>
</div>
{props.stations.length ? (
<ol className="divide-y divide-slate-100">
{props.stations.map((station, index) => (
<li key={station.stationId} className="grid grid-cols-[24px_minmax(0,1fr)_68px] items-center gap-2 py-2.5">
<span className={`text-[11px] font-medium ${index < 3 ? 'text-cyan-700' : 'text-slate-400'}`}>{index + 1}</span>
<span className="min-w-0"><span className="block truncate text-[12px] font-medium text-slate-700" title={station.stationName}>{station.stationName}</span><span className="mt-0.5 block truncate text-[9px] text-slate-400" title={station.address}>{station.address || '地址未维护'}</span></span>
<span className="text-right text-[10px] tabular-nums text-slate-600">{metricValue(station, props.metric)}</span>
</li>
))}
</ol>
) : (
<div className="grid h-44 place-items-center text-center text-xs text-slate-400"><span><Fuel className="mx-auto mb-2 text-slate-300" size={22} /></span></div>
)}
</div>
</aside>
);
}
@@ -1,85 +0,0 @@
import { CalendarDays, ChevronUp, RotateCcw, Search } from 'lucide-react';
import type { HydrogenHeatmapMetric, HydrogenPayer, HydrogenStationOption } from './types';
type Props = {
startDate: string;
endDate: string;
minDate: string;
maxDate: string;
query: string;
payer: HydrogenPayer;
metric: HydrogenHeatmapMetric;
stations: HydrogenStationOption[];
onStartDateChange: (value: string) => void;
onEndDateChange: (value: string) => void;
onQueryChange: (value: string) => void;
onPayerChange: (value: HydrogenPayer) => void;
onMetricChange: (value: HydrogenHeatmapMetric) => void;
onReset: () => void;
onHide: () => void;
};
const FIELD = 'relative h-11 min-w-0 rounded-lg border border-slate-200 bg-white transition focus-within:border-cyan-400 focus-within:ring-2 focus-within:ring-cyan-100';
const LABEL = 'pointer-events-none absolute left-3 top-1 z-10 text-[9px] font-medium leading-none text-slate-400';
const CONTROL = 'h-full w-full min-w-0 rounded-lg border-0 bg-transparent px-3 pt-3 text-[12px] text-slate-700 outline-none';
export default function HydrogenHeatmapFilters(props: Props) {
return (
<div className="pointer-events-auto grid max-h-[calc(100dvh-180px)] w-full grid-cols-2 gap-2 overflow-y-auto rounded-xl border border-slate-200/80 bg-white/96 p-2 shadow-[0_8px_28px_rgba(15,23,42,0.12)] backdrop-blur-md md:flex md:w-auto md:min-w-max md:items-center md:overflow-visible">
<label className={`${FIELD} col-span-2 md:w-[320px]`}>
<span className={`${LABEL} left-9`}></span>
<span className="flex h-full min-w-0 items-center pl-8 pt-2">
<CalendarDays className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={15} strokeWidth={1.8} />
<input aria-label="加氢开始日期" type="date" min={props.minDate} max={props.endDate} value={props.startDate} onChange={(event) => props.onStartDateChange(event.target.value)} className="h-full w-[124px] min-w-0 flex-1 border-0 bg-transparent px-1 text-[12px] text-slate-700 outline-none" />
<span className="shrink-0 px-0.5 text-slate-300"></span>
<input aria-label="加氢结束日期" type="date" min={props.startDate} max={props.maxDate} value={props.endDate} onChange={(event) => props.onEndDateChange(event.target.value)} className="h-full w-[124px] min-w-0 flex-1 border-0 bg-transparent px-1 text-[12px] text-slate-700 outline-none" />
</span>
</label>
<label className={`${FIELD} col-span-2 md:w-[145px]`}>
<span className={LABEL}></span>
<span className="relative block h-full">
<input
aria-label="搜索加氢站"
value={props.query}
list="hydrogen-heatmap-stations"
onChange={(event) => props.onQueryChange(event.target.value)}
placeholder="输入站点名称"
className={`${CONTROL} pr-9`}
/>
<Search className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} strokeWidth={1.8} />
<datalist id="hydrogen-heatmap-stations">
{props.stations.map((station) => <option key={station.stationId} value={station.stationName} />)}
</datalist>
</span>
</label>
<label className={`${FIELD} md:w-[100px]`}>
<span className={LABEL}></span>
<select aria-label="费用承担" value={props.payer} onChange={(event) => props.onPayerChange(event.target.value as HydrogenPayer)} className={CONTROL}>
<option value="all"></option>
<option value="lingniu"></option>
<option value="customer"></option>
</select>
</label>
<label className={`${FIELD} md:w-[100px]`}>
<span className={LABEL}></span>
<select aria-label="加氢统计维度" value={props.metric} onChange={(event) => props.onMetricChange(event.target.value as HydrogenHeatmapMetric)} className={CONTROL}>
<option value="kg"></option>
<option value="refuels"></option>
<option value="vehicles"></option>
</select>
</label>
<div className="col-span-2 flex min-w-0 justify-end gap-2 md:w-auto">
<button type="button" onClick={props.onReset} className="flex h-11 min-w-[72px] items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 text-[12px] font-medium text-slate-600 transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700">
<RotateCcw size={14} strokeWidth={1.8} />
</button>
<button type="button" onClick={props.onHide} aria-label="隐藏加氢筛选" title="隐藏筛选" className="grid h-11 w-11 shrink-0 place-items-center rounded-lg border border-slate-200 bg-white text-slate-400 transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700 md:w-10">
<ChevronUp size={16} strokeWidth={1.8} />
</button>
</div>
</div>
);
}
@@ -1,222 +0,0 @@
import { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, ChevronLeft, Clock3, Fuel, Maximize2, Minimize2, SlidersHorizontal } from 'lucide-react';
import HydrogenAmapCanvas from './HydrogenAmapCanvas';
import HydrogenHeatmapDetailPanel from './HydrogenHeatmapDetailPanel';
import HydrogenHeatmapFilters from './HydrogenHeatmapFilters';
import { fetchAmapConfig, fetchHydrogenHeatmapMeta, fetchHydrogenHeatmapPoints, fetchNearbyHydrogenStations } from './api';
import type { AmapConfig, HydrogenHeatmapMeta, HydrogenHeatmapMetric, HydrogenHeatmapResponse, HydrogenNearbyResponse, HydrogenPayer } from './types';
const DEFAULT_START = '2026-01-01';
const DEFAULT_END = '2026-07-13';
const integerFormat = new Intl.NumberFormat('zh-CN');
const kgFormat = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 });
function Metric({ value, label }: { value: string; label: string }) {
return (
<div className="min-w-[88px] text-center max-md:min-w-[68px]">
<p className="text-[21px] font-semibold leading-none tracking-tight text-cyan-700 tabular-nums max-md:text-[16px]">{value}</p>
<p className="mt-1.5 text-[11px] text-slate-500 max-md:mt-1 max-md:text-[9px]">{label}</p>
</div>
);
}
function metricName(metric: HydrogenHeatmapMetric) {
if (metric === 'refuels') return '加氢频次';
if (metric === 'vehicles') return '车辆覆盖';
return '加氢量强度';
}
function metricDescription(metric: HydrogenHeatmapMetric) {
if (metric === 'refuels') return '每个站点累计有效加氢订单数;平方根平滑强度。';
if (metric === 'vehicles') return '每个站点按车牌去重统计车辆数;平方根平滑强度。';
return '每个站点累计有效加氢量(kg);对数平滑强度。';
}
export default function HydrogenHeatmapModule() {
const [meta, setMeta] = useState<HydrogenHeatmapMeta | null>(null);
const [config, setConfig] = useState<AmapConfig | null>(null);
const [data, setData] = useState<HydrogenHeatmapResponse | null>(null);
const [nearby, setNearby] = useState<HydrogenNearbyResponse | null>(null);
const [startDate, setStartDate] = useState(DEFAULT_START);
const [endDate, setEndDate] = useState(DEFAULT_END);
const [query, setQuery] = useState('');
const [payer, setPayer] = useState<HydrogenPayer>('all');
const [metric, setMetric] = useState<HydrogenHeatmapMetric>('kg');
const [isFullscreen, setIsFullscreen] = useState(false);
const [showFilters, setShowFilters] = useState(() => typeof window === 'undefined' || !window.matchMedia('(max-width: 767px)').matches);
const [showDetails, setShowDetails] = useState(() => typeof window === 'undefined' || !window.matchMedia('(max-width: 767px)').matches);
const [loading, setLoading] = useState(true);
const [nearbyLoading, setNearbyLoading] = useState(false);
const [error, setError] = useState('');
const deferredQuery = useDeferredValue(query.trim());
useEffect(() => {
if (!isFullscreen) return undefined;
const previousOverflow = document.body.style.overflow;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsFullscreen(false);
};
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
};
}, [isFullscreen]);
useEffect(() => {
const controller = new AbortController();
Promise.all([fetchHydrogenHeatmapMeta(controller.signal), fetchAmapConfig(controller.signal)])
.then(([nextMeta, nextConfig]) => {
setMeta(nextMeta);
setConfig(nextConfig);
setStartDate(nextMeta.startDate);
setEndDate(nextMeta.endDate);
})
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('加氢热力图基础配置加载失败');
});
return () => controller.abort();
}, []);
useEffect(() => {
const controller = new AbortController();
const timer = window.setTimeout(() => {
setLoading(true);
setError('');
fetchHydrogenHeatmapPoints({ startDate, endDate, query: deferredQuery, payer, metric }, controller.signal)
.then((response) => {
setData(response);
setNearby(null);
})
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('加氢热力图数据加载失败,请稍后重试');
})
.finally(() => setLoading(false));
}, 220);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [deferredQuery, endDate, metric, payer, startDate]);
const handleMapClick = useCallback((longitude: number, latitude: number) => {
const controller = new AbortController();
setNearbyLoading(true);
fetchNearbyHydrogenStations({ lng: longitude, lat: latitude, startDate, endDate, query: deferredQuery, payer, metric }, controller.signal)
.then(setNearby)
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('区域加氢站明细加载失败');
})
.finally(() => setNearbyLoading(false));
}, [deferredQuery, endDate, metric, payer, startDate]);
const reset = () => {
setStartDate(meta?.startDate || DEFAULT_START);
setEndDate(meta?.endDate || DEFAULT_END);
setQuery('');
setPayer('all');
setMetric('kg');
setNearby(null);
};
const coverageText = meta
? `GPS覆盖 ${(meta.gpsCoverageRate * 100).toFixed(3)}% · 已排除 ${integerFormat.format(meta.excludedRefuelCount)} 笔缺坐标数据`
: '正在核对站点 GPS 覆盖率';
const detail = useMemo(() => {
if (nearby) {
return {
title: '选中区域',
subtitle: `${nearby.center.lng.toFixed(3)}, ${nearby.center.lat.toFixed(3)} · 半径 ${nearby.radiusKm} 公里`,
kg: nearby.kg,
refuelCount: nearby.refuelCount,
vehicleCount: nearby.vehicleCount,
stationCount: nearby.stationCount,
stations: nearby.topStations,
selected: true,
};
}
return {
title: deferredQuery ? '站点筛选结果' : '全国 · 当前范围',
subtitle: `${payer === 'all' ? '' : `${payer === 'lingniu' ? '羚牛承担' : '客户承担'} · `}${startDate} ${endDate}`,
kg: data?.kg || 0,
refuelCount: data?.refuelCount || 0,
vehicleCount: data?.vehicleCount || 0,
stationCount: data?.stationCount || 0,
stations: data?.topStations || [],
selected: false,
};
}, [data, deferredQuery, endDate, nearby, payer, startDate]);
return (
<section className={`flex flex-col overflow-hidden bg-white ${isFullscreen ? 'fixed inset-0 z-[100] h-screen w-screen min-h-0' : 'h-[100dvh] min-h-[620px] max-md:h-[calc(100dvh-4rem)] max-md:min-h-[520px]'}`}>
<header className="relative z-30 flex min-h-[98px] shrink-0 items-center justify-between border-b border-slate-200 bg-white px-8 max-md:min-h-[104px] max-md:flex-wrap max-md:gap-y-2 max-md:px-3 max-md:py-3">
<div className="min-w-0">
<h1 className="truncate text-[25px] font-semibold tracking-[-0.025em] text-slate-950 max-md:text-[18px]"></h1>
<p className="mt-1.5 flex items-center gap-1.5 text-[11px] text-slate-400 max-md:mt-1 max-md:text-[9px]"><Fuel size={13} /> × GPS · </p>
</div>
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-7 max-md:order-3 max-md:static max-md:w-full max-md:translate-x-0 max-md:translate-y-0 max-md:justify-center max-md:gap-2">
<Metric value={kgFormat.format(data?.kg ?? meta?.eligibleKg ?? 0)} label="加氢量 kg" />
<span className="h-8 w-px bg-slate-200" />
<Metric value={integerFormat.format(data?.refuelCount ?? meta?.eligibleRefuelCount ?? 0)} label="加氢次数" />
<span className="h-8 w-px bg-slate-200" />
<Metric value={integerFormat.format(data?.stationCount ?? meta?.eligibleStationCount ?? 0)} label="站点" />
</div>
<div className="flex items-center gap-3">
<p className="flex items-center gap-2 text-[12px] text-slate-500 max-xl:hidden"><Clock3 size={15} strokeWidth={1.7} /> {meta?.endDate || DEFAULT_END}</p>
<button type="button" onClick={() => setIsFullscreen((current) => !current)} className="grid h-9 w-9 place-items-center rounded-lg border border-slate-200 text-slate-500 transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700" title={isFullscreen ? '退出全屏' : '地图全屏'} aria-label={isFullscreen ? '退出加氢地图全屏' : '加氢地图全屏'} aria-pressed={isFullscreen}>
{isFullscreen ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
</button>
</div>
</header>
<div className="relative flex min-h-0 flex-1">
<div className="relative z-0 min-w-0 flex-1 overflow-hidden">
{config ? <HydrogenAmapCanvas config={config} points={data?.points || []} max={data?.max || 1} metric={metric} focusQuery={deferredQuery} onMapClick={handleMapClick} /> : <div className="absolute inset-0 bg-slate-100" />}
{showFilters ? (
<div className="pointer-events-none absolute left-3 right-3 top-3 z-10 md:left-5 md:right-auto md:top-5">
<HydrogenHeatmapFilters
startDate={startDate}
endDate={endDate}
minDate={meta?.startDate || DEFAULT_START}
maxDate={meta?.endDate || DEFAULT_END}
query={query}
payer={payer}
metric={metric}
stations={meta?.stations || []}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onQueryChange={setQuery}
onPayerChange={setPayer}
onMetricChange={setMetric}
onReset={reset}
onHide={() => setShowFilters(false)}
/>
</div>
) : (
<button type="button" onClick={() => setShowFilters(true)} aria-label="显示加氢筛选" className="absolute left-3 top-3 z-20 flex h-11 items-center gap-2 rounded-lg border border-slate-200 bg-white/95 px-3 text-xs font-medium text-slate-600 shadow-lg backdrop-blur-sm transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700 md:left-5 md:top-5 md:h-10"><SlidersHorizontal size={15} strokeWidth={1.8} /></button>
)}
{!showDetails ? (
<button type="button" onClick={() => setShowDetails(true)} aria-label="显示加氢详情" className={`absolute right-3 top-3 z-20 flex h-11 items-center gap-2 rounded-lg border border-slate-200 bg-white/95 px-3 text-xs font-medium text-slate-600 shadow-lg backdrop-blur-sm transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700 md:right-5 md:top-5 md:h-10 ${showFilters ? 'max-md:hidden' : ''}`}><ChevronLeft size={16} strokeWidth={1.8} /></button>
) : null}
<div className="pointer-events-none absolute bottom-5 left-5 z-10 w-[300px] rounded-xl border border-slate-200/80 bg-white/95 p-3.5 shadow-[0_8px_30px_rgba(15,23,42,0.11)] backdrop-blur-sm max-lg:bottom-[calc(43vh+16px)] max-md:hidden">
<div className="flex items-center justify-between text-[11px] font-medium text-slate-700"><span>{metricName(metric)}</span>{loading ? <i className="h-3 w-3 animate-spin rounded-full border-2 border-cyan-600 border-t-transparent" /> : null}</div>
<p className="mt-1 text-[10px] leading-4 text-slate-500">{metricDescription(metric)}</p>
<div className="mt-2.5 h-2.5 rounded-full bg-[linear-gradient(90deg,#2563eb_0%,#0891b2_25%,#16a34a_45%,#eab308_65%,#f97316_82%,#dc2626_100%)]" />
<div className="mt-1.5 flex justify-between text-[10px] text-slate-400"><span></span><span></span></div>
</div>
{error ? <div className="absolute bottom-5 left-1/2 z-30 flex -translate-x-1/2 items-center gap-2 rounded-lg bg-slate-900 px-4 py-2.5 text-xs text-white shadow-xl"><AlertTriangle size={15} className="text-amber-300" />{error}</div> : null}
</div>
{showDetails ? (
<HydrogenHeatmapDetailPanel {...detail} metric={metric} loading={nearbyLoading} coverageText={coverageText} onClose={() => setNearby(null)} onHide={() => setShowDetails(false)} />
) : null}
</div>
</section>
);
}
-51
View File
@@ -1,51 +0,0 @@
import { fetchJson } from '../../auth/api-client';
import type {
AmapConfig,
HydrogenHeatmapMeta,
HydrogenHeatmapMetric,
HydrogenHeatmapResponse,
HydrogenNearbyResponse,
HydrogenPayer,
} from './types';
const BASE = '/api/hydrogen-heatmap';
export function fetchAmapConfig(signal?: AbortSignal) {
return fetchJson<AmapConfig>(`${BASE}/config`, { signal });
}
export function fetchHydrogenHeatmapMeta(signal?: AbortSignal) {
return fetchJson<HydrogenHeatmapMeta>(`${BASE}/meta`, { signal });
}
export function fetchHydrogenHeatmapPoints(
params: { startDate: string; endDate: string; query: string; payer: HydrogenPayer; metric: HydrogenHeatmapMetric },
signal?: AbortSignal,
) {
return fetchJson<HydrogenHeatmapResponse>(`${BASE}/points?${new URLSearchParams(params)}`, { signal });
}
export function fetchNearbyHydrogenStations(
params: {
lng: number;
lat: number;
startDate: string;
endDate: string;
query: string;
payer: HydrogenPayer;
metric: HydrogenHeatmapMetric;
},
signal?: AbortSignal,
) {
const search = new URLSearchParams({
lng: String(params.lng),
lat: String(params.lat),
startDate: params.startDate,
endDate: params.endDate,
query: params.query,
payer: params.payer,
metric: params.metric,
radiusKm: '50',
});
return fetchJson<HydrogenNearbyResponse>(`${BASE}/nearby?${search}`, { signal });
}
-72
View File
@@ -1,72 +0,0 @@
export type HydrogenHeatmapMetric = 'kg' | 'refuels' | 'vehicles';
export type HydrogenPayer = 'all' | 'lingniu' | 'customer';
export type HydrogenHeatmapPoint = {
lng: number;
lat: number;
count: number;
};
export type HydrogenStationRank = {
stationId: string;
stationName: string;
address: string;
lng: number;
lat: number;
kg: number;
refuelCount: number;
vehicleCount: number;
firstRefuel: string;
lastRefuel: string;
metricValue: number;
};
export type HydrogenStationOption = {
stationId: string;
stationName: string;
};
export type HydrogenHeatmapMeta = {
startDate: string;
endDate: string;
totalRefuelCount: number;
eligibleRefuelCount: number;
excludedRefuelCount: number;
gpsCoverageRate: number;
totalKg: number;
eligibleKg: number;
vehicleCount: number;
totalStationCount: number;
eligibleStationCount: number;
stations: HydrogenStationOption[];
};
export type HydrogenHeatmapResponse = {
startDate: string;
endDate: string;
metric: HydrogenHeatmapMetric;
payer: HydrogenPayer;
kg: number;
refuelCount: number;
vehicleCount: number;
stationCount: number;
dayCount: number;
points: HydrogenHeatmapPoint[];
max: number;
topStations: HydrogenStationRank[];
};
export type HydrogenNearbyResponse = {
center: { lng: number; lat: number };
radiusKm: number;
kg: number;
refuelCount: number;
vehicleCount: number;
stationCount: number;
topStations: HydrogenStationRank[];
};
export type AmapConfig = {
key: string;
securityCode: string;
};
+6 -341
View File
@@ -1,347 +1,12 @@
import { useCallback, useEffect, useState } from 'react'; import { FileText } from 'lucide-react';
import { motion } from 'motion/react';
import {
Activity,
AlertTriangle,
CalendarDays,
ChevronRight,
CircleGauge,
Database,
RefreshCw,
Route,
Truck,
} from 'lucide-react';
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import Blur from '../../components/Blur';
import { MetricTile, SurfaceCard } from '../../components/ui/surface';
import {
fetchDailyReport,
fetchDailyReportTrend,
type DailyReportData,
type DailyReportVehicle,
} from './api';
import { buildMileageDrillUrl } from './drill-context';
function fmtKm(value: number): string {
if (value >= 10000) return `${(value / 10000).toFixed(2)}`;
return value.toLocaleString(undefined, { maximumFractionDigits: 1 });
}
function fmtTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
function VehicleRow({
vehicle,
mode,
onClick,
}: {
vehicle: DailyReportVehicle;
mode: 'top' | 'risk';
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="grid w-full grid-cols-[minmax(0,1fr)_auto_18px] items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-slate-50"
>
<span className="min-w-0">
<span className="flex items-center gap-2">
<span className="truncate font-mono text-xs font-black text-slate-800"><Blur>{vehicle.plate}</Blur></span>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${vehicle.isOnline ? 'bg-emerald-500' : 'bg-slate-300'}`} />
</span>
<span className="mt-0.5 block truncate text-[9px] font-bold text-slate-400">
{vehicle.model} · {vehicle.status} · <Blur>{vehicle.customer}</Blur>
</span>
</span>
<span className="text-right">
{mode === 'top' ? (
<>
<span className="block text-xs font-black text-blue-600">{fmtKm(vehicle.today)} km</span>
<span className="block text-[9px] font-bold text-slate-400"></span>
</>
) : (
<>
<span className="block text-xs font-black text-rose-600">{fmtKm(vehicle.shortfall)} km</span>
<span className="block text-[9px] font-bold text-slate-400"></span>
</>
)}
</span>
<ChevronRight size={14} className="text-slate-300" />
</button>
);
}
export default function DailyReportView() { 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]"> <div className="flex items-center justify-center py-20">
<div className="flex min-h-[320px] items-center justify-center gap-2 text-xs font-bold text-slate-400"> <div className="text-center">
<RefreshCw size={16} className="animate-spin" /> <FileText size={48} className="mx-auto text-gray-300 mb-4" />
<h2 className="text-lg font-semibold text-gray-500"></h2>
</div> <p className="text-sm text-gray-400 mt-2">...</p>
</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>
</div> </div>
); );
@@ -1,141 +0,0 @@
import { AlertTriangle, Building2, ChevronRight, Users } from 'lucide-react';
import type { TargetVehicle } from './types';
import {
buildMileageAttribution,
reconcileMileageAttribution,
type AttributionDimension,
} from './attribution';
import { ErrorState } from '../../components/ui/surface';
interface Props {
vehicles: TargetVehicle[];
dimension: AttributionDimension;
selectedValue?: string;
loading?: boolean;
error?: string | null;
targetMileagePerVehicle: number;
expectedShortfallMileage?: number;
assessmentLabel?: string;
onDimensionChange: (dimension: AttributionDimension) => void;
onSelect: (value: string) => void;
onRetry?: () => void;
}
function fmtKm(value: number): string {
return value >= 10000 ? `${(value / 10000).toFixed(2)}` : value.toLocaleString(undefined, { maximumFractionDigits: 1 });
}
export default function MileageAttributionView({
vehicles,
dimension,
selectedValue,
loading = false,
error,
targetMileagePerVehicle,
expectedShortfallMileage,
assessmentLabel,
onDimensionChange,
onSelect,
onRetry,
}: Props) {
const groups = buildMileageAttribution(vehicles, dimension, targetMileagePerVehicle);
const totalShortfall = groups.reduce((sum, group) => sum + group.shortfallMileage, 0);
const totalBehind = groups.reduce((sum, group) => sum + group.behindCount, 0);
const reconciliation = expectedShortfallMileage == null
? null
: reconcileMileageAttribution(groups, expectedShortfallMileage);
return (
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex flex-col gap-3 border-b border-slate-100 px-4 py-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="flex items-center gap-2">
<span className="h-4 w-1 rounded-full bg-blue-600" />
<h2 className="text-sm font-black text-slate-800"></h2>
</div>
<p className="mt-1 text-[10px] font-bold text-slate-400">
{assessmentLabel ? `${assessmentLabel} · ` : ''}
{loading
? '正在加载归因车辆'
: error
? '归因数据暂不可用'
: `${totalBehind} 台未达标,累计缺口 ${fmtKm(totalShortfall)} km`}
</p>
{!loading && !error && reconciliation && (
<p
role={reconciliation.matches ? 'status' : 'alert'}
className={`mt-1 text-[10px] font-black ${reconciliation.matches ? 'text-emerald-600' : 'text-rose-600'}`}
>
{reconciliation.matches
? '与考核目标汇总一致'
: `与考核目标汇总相差 ${reconciliation.difference > 0 ? '+' : ''}${fmtKm(reconciliation.difference)} km`}
</p>
)}
</div>
<div className="flex rounded-lg bg-slate-100 p-1" aria-label="归因维度">
{([
['department', '部门', Building2],
['customer', '客户', Users],
] as const).map(([value, label, Icon]) => (
<button
key={value}
type="button"
onClick={() => onDimensionChange(value)}
className={`flex min-w-[72px] items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-[10px] font-black transition-colors ${
dimension === value ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-800'
}`}
>
<Icon size={12} />
{label}
</button>
))}
</div>
</div>
{loading ? (
<div className="px-4 py-8 text-center text-xs font-bold text-slate-400">...</div>
) : error ? (
<ErrorState message={error} onRetry={onRetry} variant="inline" />
) : groups.length === 0 ? (
<div className="px-4 py-8 text-center text-xs font-bold text-slate-400"></div>
) : (
<div className="divide-y divide-slate-100">
{groups.slice(0, 8).map(group => (
<button
key={group.key}
type="button"
onClick={() => onSelect(group.key)}
className={`grid w-full grid-cols-[minmax(0,1fr)_70px_82px_24px] items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-slate-50 ${
selectedValue === group.key ? 'bg-blue-50/60' : ''
}`}
>
<span className="min-w-0">
<span className="block truncate text-xs font-black text-slate-800">{group.label}</span>
<span className="mt-0.5 flex items-center gap-2 text-[9px] font-bold text-slate-400">
<span>{group.vehicleCount} </span>
<span>{group.onlineCount} 线</span>
{group.zeroMileageCount > 0 && (
<span className="flex items-center gap-0.5 text-amber-600">
<AlertTriangle size={9} /> {group.zeroMileageCount}
</span>
)}
</span>
</span>
<span className="text-right">
<span className="block text-[11px] font-black text-slate-800">{fmtKm(group.todayMileage)}</span>
<span className="block text-[9px] font-bold text-slate-400"> {group.contributionRate.toFixed(1)}%</span>
</span>
<span className="text-right">
<span className={`block text-[11px] font-black ${group.shortfallMileage > 0 ? 'text-rose-600' : 'text-emerald-600'}`}>
{fmtKm(group.shortfallMileage)}
</span>
<span className="block text-[9px] font-bold text-slate-400"> · {group.behindCount} </span>
</span>
<ChevronRight size={14} className="text-slate-300" />
</button>
))}
</div>
)}
</section>
);
}
+38 -42
View File
@@ -1,53 +1,51 @@
import { useState } from 'react';
import { LayoutDashboard, BarChart3, FileText } from 'lucide-react'; import { LayoutDashboard, BarChart3, FileText } from 'lucide-react';
import { AnimatePresence } from 'motion/react'; import { motion } from 'motion/react';
import MonitoringView from './MonitoringView'; import MonitoringView from './MonitoringView';
import StatisticsView from './StatisticsView'; import StatisticsView from './StatisticsView';
import DailyReportView from './DailyReportView'; import DailyReportView from './DailyReportView';
import { useHashSubTab } from '../energy/useHashSubTab';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { FadeIn, PageFrame, SegmentedNav } from '../../components/ui/surface';
import BiHeaderActions from '../../components/BiHeaderActions';
type MileageSubTab = 'monitoring' | 'statistics' | 'report';
const MILEAGE_TABS = [
{ id: 'monitoring', label: '实时监控', icon: LayoutDashboard },
{ id: 'statistics', label: '统计报表', icon: BarChart3 },
{ id: 'report', label: '每日汇报', icon: FileText },
] as const satisfies readonly { id: MileageSubTab; label: string; icon: typeof LayoutDashboard }[];
const MILEAGE_SUB_IDS: readonly MileageSubTab[] = ['monitoring', 'statistics', 'report'];
export default function MileageModule() { export default function MileageModule() {
const [activeSubTab, setActiveSubTab] = useHashSubTab<MileageSubTab>('mileage', MILEAGE_SUB_IDS); const [activeSubTab, setActiveSubTab] = useState<'monitoring' | 'statistics' | 'report'>('monitoring');
return ( return (
<PageFrame <div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
title="车辆里程中心" <div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 landscape:pb-0 landscape:h-full landscape:flex-1 landscape:overflow-hidden">
subtitle="统一监控车辆日里程、累计里程、考核进度与日报经营口径,突出异常车辆和任务压力。" {/* Sub-navigation — sticky */}
icon={LayoutDashboard} <div className="bg-white px-4 py-2 rounded-2xl border border-slate-100 shadow-sm flex items-center gap-6 sticky top-0 z-30">
eyebrow="MILEAGE BI" <button
meta="实时监控 · 统计报表 · 每日汇报" onClick={() => setActiveSubTab('monitoring')}
actions={<BiHeaderActions domain="mileage" />} className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'monitoring' ? 'text-blue-600' : 'text-slate-400'}`}
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"> <LayoutDashboard size={14} />
<SegmentedNav <span className="text-[11px] font-bold"></span>
tabs={MILEAGE_TABS} {activeSubTab === 'monitoring' && (
active={activeSubTab} <motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
onChange={setActiveSubTab} )}
idPrefix="mileage-view" </button>
ariaLabel="里程分析视图" <button
/> onClick={() => setActiveSubTab('statistics')}
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'statistics' ? 'text-blue-600' : 'text-slate-400'}`}
>
<BarChart3 size={14} />
<span className="text-[11px] font-bold"></span>
{activeSubTab === 'statistics' && (
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
<button
onClick={() => setActiveSubTab('report')}
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'report' ? 'text-blue-600' : 'text-slate-400'}`}
>
<FileText size={14} />
<span className="text-[11px] font-bold"></span>
{activeSubTab === 'report' && (
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
</div> </div>
<AnimatePresence mode="wait">
<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' ? (
@@ -55,10 +53,8 @@ export default function MileageModule() {
) : ( ) : (
<DailyReportView /> <DailyReportView />
)} )}
</div>
</FadeIn>
</AnimatePresence>
<RotatingFooterHint /> <RotatingFooterHint />
</PageFrame> </div>
</div>
); );
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+66 -144
View File
@@ -1,112 +1,85 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { motion, AnimatePresence, useDragControls } from 'motion/react'; import { motion, AnimatePresence, useDragControls } from 'motion/react';
import { RefreshCw, X, Truck } from 'lucide-react'; import { X, Truck } from 'lucide-react';
import { import {
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell, BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
} from 'recharts'; } from 'recharts';
import type { MileageSourceGroup, MonitoringVehicle } from './types'; import type { MonitoringVehicle } from './types';
import { fetchVehicleRecent, type VehicleRecentDay } from './api'; import { fetchVehicleRecent, type VehicleRecentDay } from './api';
import Blur from '../../components/Blur'; import Blur from '../../components/Blur';
import { EmptyState, ErrorState } from '../../components/ui/surface';
import {
includeCurrentDayInVehicleDetail,
normalizeVehicleDetailContext,
resolveVehicleDetailRange,
vehicleDetailContextLabel,
type VehicleDetailRangeKey,
} from './vehicle-detail-range';
interface Props { interface Props {
vehicle: MonitoringVehicle | null; vehicle: MonitoringVehicle | null;
onClose: () => void; onClose: () => void;
sourcePriority: MileageSourceGroup[];
initialStartDate?: string;
initialEndDate?: string;
} }
const RANGE_TABS: { key: VehicleDetailRangeKey; label: string }[] = [ type RangeKey = 'last15' | 'month' | 'quarter';
const RANGE_TABS: { key: RangeKey; label: string }[] = [
{ key: 'last15', label: '近 15 天' }, { key: 'last15', label: '近 15 天' },
{ key: 'month', label: '本月' }, { key: 'month', label: '本月' },
{ key: 'quarter', label: '本季度' }, { key: 'quarter', label: '本季度' },
]; ];
function isToday(date: string): boolean { function fmtYmd(d: Date): string {
const now = new Date(); const y = d.getFullYear();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; const m = String(d.getMonth() + 1).padStart(2, '0');
return date === today; const dd = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${dd}`;
} }
function formatLabel(date: string, key: VehicleDetailRangeKey): string { function rangeFor(key: RangeKey): { start: string; end: string; rangeLabel: string } {
const today = new Date();
today.setHours(0, 0, 0, 0);
const end = fmtYmd(today);
if (key === 'last15') {
const start = new Date(today);
start.setDate(today.getDate() - 14);
return { start: fmtYmd(start), end, rangeLabel: '近 15 天' };
}
if (key === 'month') {
const start = new Date(today.getFullYear(), today.getMonth(), 1);
return { start: fmtYmd(start), end, rangeLabel: '本月' };
}
const q = Math.floor(today.getMonth() / 3);
const start = new Date(today.getFullYear(), q * 3, 1);
return { start: fmtYmd(start), end, rangeLabel: '本季度' };
}
function isToday(date: string): boolean {
return date === fmtYmd(new Date());
}
function formatLabel(date: string, key: RangeKey): string {
// YYYY-MM-DD → MM-DD(季度时仍展示 MM-DD // YYYY-MM-DD → MM-DD(季度时仍展示 MM-DD
void key; void key;
return date.slice(5); return date.slice(5);
} }
function daySourceLabel(day: VehicleRecentDay): string { export default function VehicleDetailModal({ vehicle, onClose }: Props) {
if (day.sourceCategory === 'INSTRUMENT') return '仪表数据';
if (day.sourceCategory === 'GPS') return 'GPS数据';
return day.isDataSynced ? '来源待接口' : '无数据';
}
export default function VehicleDetailModal({
vehicle,
onClose,
sourcePriority,
initialStartDate,
initialEndDate,
}: Props) {
const contextRange = useMemo(
() => normalizeVehicleDetailContext(initialStartDate, initialEndDate),
[initialEndDate, initialStartDate],
);
const [days, setDays] = useState<VehicleRecentDay[]>([]); const [days, setDays] = useState<VehicleRecentDay[]>([]);
const [loadedRequestKey, setLoadedRequestKey] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [range, setRange] = useState<RangeKey>('last15');
const [requestVersion, setRequestVersion] = useState(0);
const [range, setRange] = useState<VehicleDetailRangeKey>(() => (
contextRange ? 'context' : 'last15'
));
const dragControls = useDragControls(); const dragControls = useDragControls();
const resolvedRange = useMemo(
() => resolveVehicleDetailRange(range, contextRange), // 切换车辆时重置区间为默认
[contextRange, range], useEffect(() => {
); if (vehicle) setRange('last15');
const sourceKey = sourcePriority.join(','); }, [vehicle?.plate]); // eslint-disable-line react-hooks/exhaustive-deps
const requestKey = vehicle
? `${vehicle.plate}|${resolvedRange.start}|${resolvedRange.end}|${sourceKey}`
: null;
const hasSnapshot = requestKey !== null && loadedRequestKey === requestKey;
const rangeTabs = useMemo(() => (
contextRange
? [{ key: 'context' as const, label: vehicleDetailContextLabel(contextRange) }, ...RANGE_TABS]
: RANGE_TABS
), [contextRange]);
// 拉取数据(车辆或区间变化) // 拉取数据(车辆或区间变化)
useEffect(() => { useEffect(() => {
if (!vehicle || !requestKey) return; if (!vehicle) return;
const { start, end } = rangeFor(range);
setLoading(true); setLoading(true);
setError(null); setDays([]);
let cancelled = false; let cancelled = false;
fetchVehicleRecent(vehicle.plate, { fetchVehicleRecent(vehicle.plate, { start, end })
start: resolvedRange.start, .then(d => { if (!cancelled) setDays(d.days); })
end: resolvedRange.end, .catch(() => { if (!cancelled) setDays([]); })
sourcePriority,
})
.then(d => {
if (cancelled) return;
setDays(d.days);
setLoadedRequestKey(requestKey);
})
.catch(cause => {
if (!cancelled) {
setError(cause instanceof Error ? cause.message : '车辆近期里程加载失败,请稍后重试。');
}
})
.finally(() => { if (!cancelled) setLoading(false); }); .finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [requestKey, requestVersion]); // eslint-disable-line react-hooks/exhaustive-deps }, [vehicle?.plate, range]); // eslint-disable-line react-hooks/exhaustive-deps
// 锁滚动 // 锁滚动
useEffect(() => { useEffect(() => {
@@ -115,11 +88,8 @@ export default function VehicleDetailModal({
return () => { document.body.style.overflow = ''; }; return () => { document.body.style.overflow = ''; };
}, [vehicle]); }, [vehicle]);
// 预设区间排除未完整的今日;显式下钻区间严格保留用户选择的日期。 // 排除"今日"列(数据未到位时易引起误读)
const historyDays = useMemo(() => { const historyDays = useMemo(() => days.filter(d => !isToday(d.date)), [days]);
if (!hasSnapshot) return [];
return includeCurrentDayInVehicleDetail(range) ? days : days.filter(d => !isToday(d.date));
}, [days, hasSnapshot, range]);
const stats = useMemo(() => { const stats = useMemo(() => {
const totalKm = historyDays.reduce((s, d) => s + d.dailyKm, 0); const totalKm = historyDays.reduce((s, d) => s + d.dailyKm, 0);
const synced = historyDays.filter(d => d.isDataSynced).length; const synced = historyDays.filter(d => d.isDataSynced).length;
@@ -130,12 +100,12 @@ export default function VehicleDetailModal({
// 骨架天数:根据区间预估 // 骨架天数:根据区间预估
const skeletonCount = useMemo(() => { const skeletonCount = useMemo(() => {
const start = new Date(`${resolvedRange.start}T00:00:00`); if (range === 'last15') return 15;
const end = new Date(`${resolvedRange.end}T00:00:00`); const { start, end } = rangeFor(range);
return Math.max(1, Math.round((end.getTime() - start.getTime()) / 86400000) + 1); const s = new Date(start);
}, [resolvedRange.end, resolvedRange.start]); const e = new Date(end);
const showInitialLoading = loading && !hasSnapshot; return Math.max(1, Math.round((e.getTime() - s.getTime()) / 86400000));
const showUnavailable = !!error && !hasSnapshot; }, [range]);
return ( return (
<AnimatePresence> <AnimatePresence>
@@ -165,9 +135,6 @@ export default function VehicleDetailModal({
}} }}
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col touch-pan-y" className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col touch-pan-y"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="vehicle-detail-title"
> >
{/* iOS 风格 drag handle —— 长按下滑可关闭 */} {/* iOS 风格 drag handle —— 长按下滑可关闭 */}
<div <div
@@ -186,7 +153,7 @@ export default function VehicleDetailModal({
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span id="vehicle-detail-title" className="text-sm font-black text-slate-900 font-mono truncate"><Blur>{vehicle.plate}</Blur></span> <span className="text-sm font-black text-slate-900 font-mono truncate"><Blur>{vehicle.plate}</Blur></span>
<span className={`text-[8px] px-1 rounded font-bold ${vehicle.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'}`}> <span className={`text-[8px] px-1 rounded font-bold ${vehicle.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'}`}>
{vehicle.isOnline ? '在线' : '离线'} {vehicle.isOnline ? '在线' : '离线'}
</span> </span>
@@ -199,22 +166,19 @@ export default function VehicleDetailModal({
</div> </div>
</div> </div>
</div> </div>
<button type="button" onClick={onClose} aria-label="关闭车辆详情" className="p-2 -mr-1 text-slate-400 hover:text-slate-700 flex-shrink-0"> <button onClick={onClose} className="p-2 -mr-1 text-slate-400 hover:text-slate-700 flex-shrink-0">
<X size={18} /> <X size={18} />
</button> </button>
</div> </div>
{/* 时间范围切换 */} {/* 时间范围切换 */}
<div className="px-4 pt-3"> <div className="px-4 pt-3">
<div className="relative inline-flex max-w-full overflow-x-auto bg-slate-100 p-0.5 rounded-lg" role="tablist" aria-label="车辆里程区间"> <div className="relative inline-flex bg-slate-100 p-0.5 rounded-lg">
{rangeTabs.map(tab => ( {RANGE_TABS.map(tab => (
<button <button
type="button"
key={tab.key} key={tab.key}
onClick={() => setRange(tab.key)} onClick={() => setRange(tab.key)}
role="tab" className={`relative px-3 py-1 text-[10px] font-bold rounded-md transition-colors ${range === tab.key ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'}`}
aria-selected={range === tab.key}
className={`relative shrink-0 px-3 py-1 text-[10px] font-bold rounded-md transition-colors ${range === tab.key ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'}`}
> >
{range === tab.key && ( {range === tab.key && (
<motion.div <motion.div
@@ -227,48 +191,28 @@ export default function VehicleDetailModal({
</button> </button>
))} ))}
</div> </div>
{loading && hasSnapshot ? (
<span className="ml-2 inline-flex items-center gap-1 text-[9px] font-bold text-slate-400">
<RefreshCw size={10} className="animate-spin" />
</span>
) : null}
</div> </div>
{error ? (
<div className="px-4 pt-3">
<ErrorState
title="车辆近期里程加载失败"
message={hasSnapshot ? `${error} 当前保留上一次成功加载的数据。` : error}
onRetry={() => setRequestVersion(version => version + 1)}
retrying={loading}
variant="inline"
/>
</div>
) : null}
{/* KPI cards */} {/* KPI cards */}
<div className="px-4 py-3 grid grid-cols-3 gap-2"> <div className="px-4 py-3 grid grid-cols-3 gap-2">
<div className="bg-slate-50 rounded-xl p-2.5"> <div className="bg-slate-50 rounded-xl p-2.5">
<div className="text-[9px] font-bold text-slate-400 uppercase"></div> <div className="text-[9px] font-bold text-slate-400 uppercase"></div>
<div className="text-base font-black text-slate-900 leading-tight"> <div className="text-base font-black text-slate-900 leading-tight">
{showInitialLoading ? <span className="inline-block h-4 w-14 bg-slate-200 rounded animate-pulse align-middle" /> {loading ? <span className="inline-block h-4 w-14 bg-slate-200 rounded animate-pulse align-middle" />
: showUnavailable ? <span className="text-slate-300"></span>
: <>{Math.round(stats.totalKm).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>} : <>{Math.round(stats.totalKm).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
</div> </div>
</div> </div>
<div className="bg-slate-50 rounded-xl p-2.5"> <div className="bg-slate-50 rounded-xl p-2.5">
<div className="text-[9px] font-bold text-slate-400 uppercase"></div> <div className="text-[9px] font-bold text-slate-400 uppercase"></div>
<div className="text-base font-black text-slate-900 leading-tight"> <div className="text-base font-black text-slate-900 leading-tight">
{showInitialLoading ? <span className="inline-block h-4 w-10 bg-slate-200 rounded animate-pulse align-middle" /> {loading ? <span className="inline-block h-4 w-10 bg-slate-200 rounded animate-pulse align-middle" />
: showUnavailable ? <span className="text-slate-300"></span>
: <>{Math.round(stats.avg).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>} : <>{Math.round(stats.avg).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
</div> </div>
</div> </div>
<div className="bg-slate-50 rounded-xl p-2.5"> <div className="bg-slate-50 rounded-xl p-2.5">
<div className="text-[9px] font-bold text-slate-400 uppercase"></div> <div className="text-[9px] font-bold text-slate-400 uppercase"></div>
<div className="text-base font-black text-slate-900 leading-tight"> <div className="text-base font-black text-slate-900 leading-tight">
{showInitialLoading ? <span className="inline-block h-4 w-12 bg-slate-200 rounded animate-pulse align-middle" /> {loading ? <span className="inline-block h-4 w-12 bg-slate-200 rounded animate-pulse align-middle" />
: showUnavailable ? <span className="text-slate-300"></span>
: <>{stats.synced}<span className="text-[9px] font-bold text-slate-400 ml-0.5">/{stats.totalDays}</span></>} : <>{stats.synced}<span className="text-[9px] font-bold text-slate-400 ml-0.5">/{stats.totalDays}</span></>}
</div> </div>
</div> </div>
@@ -282,12 +226,8 @@ export default function VehicleDetailModal({
</div> </div>
<div className="bg-white rounded-xl border border-slate-50"> <div className="bg-white rounded-xl border border-slate-50">
<div className="h-[140px]"> <div className="h-[140px]">
{showInitialLoading ? ( {loading ? (
<SkeletonBars count={Math.min(skeletonCount, 30)} /> <SkeletonBars count={Math.min(skeletonCount, 30)} />
) : showUnavailable ? (
<div className="flex h-full items-center justify-center text-[10px] font-bold text-slate-300"></div>
) : historyDays.length === 0 ? (
<EmptyState title="暂无里程数据" description="所选区间没有可展示的车辆里程" variant="inline" />
) : ( ) : (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={historyDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}> <BarChart data={historyDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
@@ -322,12 +262,8 @@ export default function VehicleDetailModal({
{/* 每日明细 */} {/* 每日明细 */}
<div className="flex-1 overflow-y-auto px-4 pb-4"> <div className="flex-1 overflow-y-auto px-4 pb-4">
<div className="text-[10px] font-bold text-slate-500 mb-1.5"></div> <div className="text-[10px] font-bold text-slate-500 mb-1.5"></div>
{showInitialLoading ? ( {loading ? (
<SkeletonList count={Math.min(skeletonCount, 15)} /> <SkeletonList count={Math.min(skeletonCount, 15)} />
) : showUnavailable ? (
<div className="py-6 text-center text-[10px] font-bold text-slate-300"></div>
) : historyDays.length === 0 ? (
<div className="py-6 text-center text-[10px] font-bold text-slate-300"></div>
) : ( ) : (
<motion.div <motion.div
key={range} key={range}
@@ -344,21 +280,7 @@ export default function VehicleDetailModal({
transition={{ delay: Math.min(i * 0.012, 0.4), duration: 0.18 }} transition={{ delay: Math.min(i * 0.012, 0.4), duration: 0.18 }}
className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-slate-50" className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-slate-50"
> >
<div className="w-[88px] flex-shrink-0"> <span className="text-[11px] font-mono font-bold text-slate-600">{d.date}</span>
<div className="text-[11px] font-mono font-bold text-slate-600">{d.date}</div>
<div
className={`text-[8px] font-bold ${
d.sourceCategory === 'INSTRUMENT'
? 'text-violet-500'
: d.sourceCategory === 'GPS'
? 'text-emerald-500'
: 'text-amber-500'
}`}
title={d.sourceProtocol || 'OneOS 当前未返回 sourceProtocol'}
>
{daySourceLabel(d)}
</div>
</div>
<div className="flex items-center gap-2 flex-1 ml-3"> <div className="flex items-center gap-2 flex-1 ml-3">
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden"> <div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
<motion.div <motion.div
+4 -114
View File
@@ -1,29 +1,11 @@
import type { import type { MonitoringData, TargetSummary, TargetVehicle, TrendPoint } from './types';
MileageSourceCategory,
MileageSourceGroup,
MileageSourceProtocol,
MonitoringData,
TargetSummary,
TargetVehicle,
TrendPoint,
} from './types';
import { fetchJson } from '../../auth/api-client'; import { fetchJson } from '../../auth/api-client';
import {
buildDailyReport,
type DailyReportData,
} from './daily-report';
export type {
DailyReportData,
DailyReportModel,
DailyReportVehicle,
} from './daily-report';
const BASE = '/api/mileage'; const BASE = '/api/mileage';
export async function fetchMonitoring(params?: { export async function fetchMonitoring(params?: {
sortBy?: 'today' | 'total' | 'statisticTime'; sortBy?: string;
sortOrder?: 'asc' | 'desc'; sortOrder?: string;
limit?: number; limit?: number;
page?: number; page?: number;
search?: string; search?: string;
@@ -34,17 +16,11 @@ export async function fetchMonitoring(params?: {
rentStatus?: string; rentStatus?: string;
platePrefix?: string; platePrefix?: string;
targetName?: string; targetName?: string;
targetNames?: string[];
region?: string; region?: string;
plate?: string; plate?: string;
mileageMin?: string; mileageMin?: string;
mileageMax?: string; mileageMax?: string;
date?: string; date?: string;
startDate?: string;
endDate?: string;
brands?: string[];
sourcePriority?: MileageSourceGroup[];
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);
@@ -59,25 +35,11 @@ export async function fetchMonitoring(params?: {
if (params?.rentStatus) query.set('rentStatus', params.rentStatus); if (params?.rentStatus) query.set('rentStatus', params.rentStatus);
if (params?.platePrefix) query.set('platePrefix', params.platePrefix); if (params?.platePrefix) query.set('platePrefix', params.platePrefix);
if (params?.targetName) query.set('targetName', params.targetName); if (params?.targetName) query.set('targetName', params.targetName);
if (params?.targetNames) {
params.targetNames.forEach(name => {
if (name) query.append('targetName', name);
});
}
if (params?.region) query.set('region', params.region); if (params?.region) query.set('region', params.region);
if (params?.brands) {
params.brands.forEach(brand => {
if (brand) query.append('brand', brand);
});
}
if (params?.plate) query.set('plate', params.plate); if (params?.plate) query.set('plate', params.plate);
if (params?.mileageMin) query.set('mileageMin', params.mileageMin); if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
if (params?.mileageMax) query.set('mileageMax', params.mileageMax); if (params?.mileageMax) query.set('mileageMax', params.mileageMax);
if (params?.date) query.set('date', params.date); if (params?.date) query.set('date', params.date);
if (params?.startDate) query.set('startDate', params.startDate);
if (params?.endDate) query.set('endDate', params.endDate);
if (params?.sourcePriority?.length) query.set('sourcePriority', params.sourcePriority.join(','));
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}` : ''}`);
} }
@@ -104,8 +66,6 @@ export interface VehicleRecentDay {
date: string; date: string;
dailyKm: number; dailyKm: number;
isDataSynced: boolean; isDataSynced: boolean;
sourceProtocol: MileageSourceProtocol | null;
sourceCategory: Exclude<MileageSourceCategory, 'MIXED'> | null;
} }
export interface VehicleRecentResponse { export interface VehicleRecentResponse {
@@ -113,87 +73,17 @@ export interface VehicleRecentResponse {
start?: string; start?: string;
end?: string; end?: string;
days: VehicleRecentDay[]; days: VehicleRecentDay[];
sourcePriority?: MileageSourceGroup[];
} }
export async function fetchVehicleRecent( export async function fetchVehicleRecent(
plate: string, plate: string,
range: { range: { days?: number; start?: string; end?: string } = { days: 15 },
days?: number;
start?: string;
end?: string;
sourcePriority?: MileageSourceGroup[];
} = { days: 15 },
): Promise<VehicleRecentResponse> { ): Promise<VehicleRecentResponse> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (range.start) params.set('start', range.start); if (range.start) params.set('start', range.start);
if (range.end) params.set('end', range.end); if (range.end) params.set('end', range.end);
if (range.days != null) params.set('days', String(range.days)); if (range.days != null) params.set('days', String(range.days));
if (range.sourcePriority?.length) params.set('sourcePriority', range.sourcePriority.join(','));
return fetchJson<VehicleRecentResponse>( return fetchJson<VehicleRecentResponse>(
`${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}` `${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}`
); );
} }
function reportDateFromUpdatedAt(updatedAt: string): string {
if (/^\d{4}-\d{2}-\d{2}$/.test(updatedAt)) return updatedAt;
const d = new Date(updatedAt);
if (!Number.isNaN(d.getTime())) return d.toISOString().slice(0, 10);
return new Date().toISOString().slice(0, 10);
}
let dailyReportRequest: Promise<DailyReportData> | null = null;
let dailyReportCache: { data: DailyReportData; expiresAt: number } | null = null;
const DAILY_REPORT_CACHE_MS = 60_000;
async function loadDailyReport(): Promise<DailyReportData> {
const [targets, monitoring] = await Promise.all([
fetchTargets(),
fetchMonitoring({ sortBy: 'today', sortOrder: 'desc', limit: 5 }),
]);
const reportDate = monitoring.dateRange?.end || reportDateFromUpdatedAt(monitoring.updatedAt);
const targetVehicles = await Promise.all(
targets.map(async target => {
const vehicles = await fetchTargetVehicles(target.id, reportDate);
return { target, vehicles };
}),
);
return buildDailyReport(targets, targetVehicles, monitoring, [], reportDate);
}
export async function fetchDailyReport(force = false): Promise<DailyReportData> {
if (!force && dailyReportCache && dailyReportCache.expiresAt > Date.now()) {
return dailyReportCache.data;
}
if (!force && dailyReportRequest) return dailyReportRequest;
const request = loadDailyReport();
dailyReportRequest = request;
try {
const data = await request;
dailyReportCache = { data, expiresAt: Date.now() + DAILY_REPORT_CACHE_MS };
return data;
} finally {
if (dailyReportRequest === request) dailyReportRequest = null;
}
}
let dailyReportTrendRequest: Promise<TrendPoint[]> | null = null;
let dailyReportTrendCache: { data: TrendPoint[]; expiresAt: number } | null = null;
export async function fetchDailyReportTrend(force = false): Promise<TrendPoint[]> {
if (!force && dailyReportTrendCache && dailyReportTrendCache.expiresAt > Date.now()) {
return dailyReportTrendCache.data;
}
if (!force && dailyReportTrendRequest) return dailyReportTrendRequest;
const request = fetchTrend(undefined, 7);
dailyReportTrendRequest = request;
try {
const data = await request;
dailyReportTrendCache = { data, expiresAt: Date.now() + DAILY_REPORT_CACHE_MS };
return data;
} finally {
if (dailyReportTrendRequest === request) dailyReportTrendRequest = null;
}
}
@@ -1,25 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { assessVehicleCoverage } from './assessment-coverage.js';
test('reports matched, missing, and excess assessment vehicles', () => {
assert.deepEqual(assessVehicleCoverage(50, 46), {
status: 'missing',
declaredCount: 50,
activeCount: 46,
difference: -4,
coverageRate: 92,
});
assert.equal(assessVehicleCoverage(40, 40).status, 'matched');
assert.equal(assessVehicleCoverage(40, 42).status, 'excess');
});
test('normalizes invalid counts without inventing configured vehicles', () => {
assert.deepEqual(assessVehicleCoverage(Number.NaN, -2), {
status: 'matched',
declaredCount: 0,
activeCount: 0,
difference: 0,
coverageRate: 100,
});
});
@@ -1,26 +0,0 @@
export type AssessmentCoverageStatus = 'matched' | 'missing' | 'excess';
export interface AssessmentCoverage {
status: AssessmentCoverageStatus;
declaredCount: number;
activeCount: number;
difference: number;
coverageRate: number;
}
function count(value: number): number {
return Math.max(0, Math.trunc(Number(value) || 0));
}
export function assessVehicleCoverage(declaredCount: number, activeCount: number): AssessmentCoverage {
const declared = count(declaredCount);
const active = count(activeCount);
const difference = active - declared;
return {
status: difference === 0 ? 'matched' : difference < 0 ? 'missing' : 'excess',
declaredCount: declared,
activeCount: active,
difference,
coverageRate: declared > 0 ? active / declared * 100 : active === 0 ? 100 : 0,
};
}
-88
View File
@@ -1,88 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildMileageAttribution,
filterAttributionVehicles,
reconcileMileageAttribution,
} from './attribution.js';
import type { TargetVehicle } from './types.js';
function vehicle(overrides: Partial<TargetVehicle>): TargetVehicle {
return {
plateNumber: '粤A00000',
todayMileage: 0,
totalMileage: 0,
completionRate: 0,
isQualified: false,
currentYearIsQualified: false,
dailyRequiredMileage: 100,
rentStatus: null,
department: null,
customer: null,
isOnline: false,
...overrides,
};
}
test('groups vehicles and calculates additive attribution metrics', () => {
const result = buildMileageAttribution([
vehicle({ plateNumber: '粤A1', department: '业务一部', todayMileage: 120, totalMileage: 1000, completionRate: 0.8, isOnline: true }),
vehicle({ plateNumber: '粤A2', department: '业务一部', todayMileage: 40, totalMileage: 500, completionRate: 0.4 }),
vehicle({ plateNumber: '粤A3', department: '业务二部', todayMileage: 40, totalMileage: 400, completionRate: 0.2 }),
], 'department', 1000);
const first = result.find(group => group.key === '业务一部');
assert.deepEqual(first, {
key: '业务一部',
label: '业务一部',
vehicleCount: 2,
onlineCount: 1,
zeroMileageCount: 0,
behindCount: 1,
todayMileage: 160,
totalMileage: 1500,
targetMileage: 2000,
completedMileage: 1500,
shortfallMileage: 500,
completionRate: 75,
contributionRate: 80,
});
});
test('keeps unassigned vehicles visible and sorts largest shortfall first', () => {
const result = buildMileageAttribution([
vehicle({ plateNumber: '粤A1', department: null, todayMileage: 0 }),
vehicle({ plateNumber: '粤A2', department: '业务一部', todayMileage: 80, totalMileage: 100 }),
], 'department', 100);
assert.equal(result[0].label, '未分配部门');
assert.equal(result[0].shortfallMileage, 100);
assert.equal(result[0].zeroMileageCount, 1);
});
test('reconciles additive group shortfall with the target summary at display precision', () => {
const groups = buildMileageAttribution([
vehicle({ plateNumber: '粤A1', department: '业务一部', totalMileage: 1200 }),
vehicle({ plateNumber: '粤A2', department: '业务二部', totalMileage: 499.96 }),
], 'department', 1000);
assert.deepEqual(reconcileMileageAttribution(groups, 500), {
matches: true,
attributedShortfall: 500,
expectedShortfall: 500,
difference: 0,
});
assert.equal(reconcileMileageAttribution(groups, 450).difference, 50);
});
test('filters a group using the same empty-value semantics as aggregation', () => {
const vehicles = [
vehicle({ plateNumber: '粤A1', customer: null }),
vehicle({ plateNumber: '粤A2', customer: '客户甲' }),
];
assert.deepEqual(
filterAttributionVehicles(vehicles, 'customer', '未绑定客户').map(item => item.plateNumber),
['粤A1'],
);
});
-121
View File
@@ -1,121 +0,0 @@
import type { TargetVehicle } from './types';
export type AttributionDimension = 'department' | 'customer';
export interface MileageAttributionGroup {
key: string;
label: string;
vehicleCount: number;
onlineCount: number;
zeroMileageCount: number;
behindCount: number;
todayMileage: number;
totalMileage: number;
targetMileage: number;
completedMileage: number;
shortfallMileage: number;
completionRate: number;
contributionRate: number;
}
export interface MileageAttributionReconciliation {
matches: boolean;
attributedShortfall: number;
expectedShortfall: number;
difference: number;
}
const EMPTY_LABELS: Record<AttributionDimension, string> = {
department: '未分配部门',
customer: '未绑定客户',
};
export function attributionValue(
vehicle: TargetVehicle,
dimension: AttributionDimension,
): string {
return vehicle[dimension]?.trim() || EMPTY_LABELS[dimension];
}
export function buildMileageAttribution(
vehicles: TargetVehicle[],
dimension: AttributionDimension,
targetMileagePerVehicle: number,
): MileageAttributionGroup[] {
const perVehicleTarget = Math.max(0, Number(targetMileagePerVehicle) || 0);
const totalTodayMileage = vehicles.reduce(
(sum, vehicle) => sum + Math.max(0, Number(vehicle.todayMileage) || 0),
0,
);
const grouped = new Map<string, TargetVehicle[]>();
for (const vehicle of vehicles) {
const key = attributionValue(vehicle, dimension);
const current = grouped.get(key) || [];
current.push(vehicle);
grouped.set(key, current);
}
return Array.from(grouped, ([key, members]) => {
const todayMileage = members.reduce((sum, vehicle) => sum + Math.max(0, Number(vehicle.todayMileage) || 0), 0);
const totalMileage = members.reduce((sum, vehicle) => sum + Math.max(0, Number(vehicle.totalMileage) || 0), 0);
const targetMileage = perVehicleTarget * members.length;
const completedMileage = members.reduce((sum, vehicle) => (
sum + Math.min(Math.max(0, Number(vehicle.totalMileage) || 0), perVehicleTarget)
), 0);
const shortfallMileage = members.reduce((sum, vehicle) => {
const completed = Math.max(0, Number(vehicle.totalMileage) || 0);
return sum + Math.max(0, perVehicleTarget - completed);
}, 0);
return {
key,
label: key,
vehicleCount: members.length,
onlineCount: members.filter(vehicle => vehicle.isOnline).length,
zeroMileageCount: members.filter(vehicle => (Number(vehicle.todayMileage) || 0) <= 0).length,
behindCount: members.filter(vehicle => (
(Number(vehicle.totalMileage) || 0) < perVehicleTarget
)).length,
todayMileage,
totalMileage,
targetMileage,
completedMileage,
shortfallMileage,
completionRate: targetMileage > 0 ? completedMileage / targetMileage * 100 : 0,
contributionRate: totalTodayMileage > 0 ? todayMileage / totalTodayMileage * 100 : 0,
};
}).sort((a, b) => (
b.shortfallMileage - a.shortfallMileage
|| b.behindCount - a.behindCount
|| b.todayMileage - a.todayMileage
|| a.label.localeCompare(b.label, 'zh-CN')
));
}
function roundMileage(value: number): number {
return Math.round((Number(value) || 0) * 10) / 10;
}
export function reconcileMileageAttribution(
groups: MileageAttributionGroup[],
expectedShortfall: number,
): MileageAttributionReconciliation {
const attributedShortfall = roundMileage(groups.reduce((sum, group) => sum + group.shortfallMileage, 0));
const normalizedExpected = roundMileage(expectedShortfall);
const difference = roundMileage(attributedShortfall - normalizedExpected);
return {
matches: difference === 0,
attributedShortfall,
expectedShortfall: normalizedExpected,
difference,
};
}
export function filterAttributionVehicles(
vehicles: TargetVehicle[],
dimension: AttributionDimension,
value: string,
): TargetVehicle[] {
return vehicles.filter(vehicle => attributionValue(vehicle, dimension) === value);
}
-98
View File
@@ -1,98 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildDailyReport } from './daily-report.js';
import type { MonitoringData, TargetSummary, TargetVehicle } from './types.js';
function target(id: number, completed: number, goal: number): TargetSummary {
return {
id,
targetName: `目标${id}`,
vehicleCount: 2,
cumulativeTotal: completed,
currentYearCompleted: completed,
currentYearTarget: goal,
yearQualifiedCount: 0,
halfQualifiedCount: 0,
} as TargetSummary;
}
function vehicle(plateNumber: string, todayMileage: number, dailyRequiredMileage: number): TargetVehicle {
return {
plateNumber,
todayMileage,
totalMileage: 1000,
completionRate: 0.5,
isQualified: false,
currentYearIsQualified: false,
dailyRequiredMileage,
rentStatus: '租赁',
department: '业务一部',
customer: '客户甲',
isOnline: true,
};
}
const monitoring = {
stats: { totalToday: 0, totalAll: 0, vehicleCount: 10, yesterdayTotal: 0 },
updatedAt: '2026-08-07T10:00:00+08:00',
} as MonitoringData;
test('builds a weighted, additive daily report', () => {
const first = target(1, 50, 100);
const second = target(2, 100, 300);
const report = buildDailyReport(
[first, second],
[
{ target: first, vehicles: [vehicle('粤A1', 120, 100), vehicle('粤A2', 0, 100)] },
{ target: second, vehicles: [vehicle('粤A3', 50, 100)] },
],
monitoring,
[{ date: '08-06', mileage: 500 }],
'2026-08-07',
);
assert.equal(report.totalToday, 170);
assert.equal(report.dailyNeed, 300);
assert.equal(report.shortfall, 150);
assert.equal(report.activeCount, 2);
assert.equal(report.zeroCount, 1);
assert.equal(report.completionRate, 37.5);
assert.equal(report.models[0].shortfall, 100);
assert.deepEqual(report.trend, [{ date: '08-06', value: 500 }]);
});
test('deduplicates vehicles assigned to multiple targets', () => {
const first = target(1, 50, 100);
const second = target(2, 100, 300);
const report = buildDailyReport(
[first, second],
[
{ target: first, vehicles: [vehicle('粤A1', 80, 100)] },
{ target: second, vehicles: [vehicle('粤A1', 80, 100)] },
],
monitoring,
[],
'2026-08-07',
);
assert.equal(report.assessmentVehicleCount, 1);
assert.equal(report.duplicateAssignmentCount, 1);
assert.equal(report.totalToday, 80);
});
test('falls back to the target daily need when vehicle tasks are missing', () => {
const item = target(5, 100, 300);
item.dailyTarget = 250;
const report = buildDailyReport(
[item],
[{ target: item, vehicles: [vehicle('粤A1', 100, 0), vehicle('粤A2', 50, 0)] }],
monitoring,
[],
'2026-08-07',
);
assert.equal(report.dailyNeed, 250);
assert.equal(report.shortfall, 100);
assert.equal(report.missingDailyTaskCount, 2);
assert.equal(report.models[0].missingDailyTaskCount, 2);
});
-200
View File
@@ -1,200 +0,0 @@
import { weightedCompletionRate } from '../../shared/analytics/metrics';
import type {
MonitoringData,
TargetSummary,
TargetVehicle,
TrendPoint,
} from './types';
export interface DailyReportModel {
id: number;
name: string;
count: number;
today: number;
total: number;
completion: number;
active: number;
zero: number;
dailyNeed: number;
shortfall: number;
missingDailyTaskCount: number;
}
export interface DailyReportVehicle {
targetId: number;
plate: string;
model: string;
status: string;
customer: string;
today: number;
completion: number;
dailyNeed: number;
shortfall: number;
isOnline: boolean;
hasDailyTask: boolean;
}
export interface DailyReportData {
reportDate: string;
updatedAt: string;
monitoringVehicleCount: number;
assessmentVehicleCount: number;
duplicateAssignmentCount: number;
missingDailyTaskCount: number;
totalToday: number;
activeCount: number;
zeroCount: number;
dailyNeed: number;
shortfall: number;
completionRate: number;
models: DailyReportModel[];
trend: { date: string; value: number }[];
topVehicles: DailyReportVehicle[];
riskVehicles: DailyReportVehicle[];
qualifiedCount: number;
halfQualifiedCount: number;
}
interface TargetVehicleEntry {
target: TargetSummary;
vehicles: TargetVehicle[];
}
function compactTargetName(name: string): string {
return name
.replace(/^羚牛/, '')
.replace(/辆/g, '台')
.replace(/4\.5T普货/g, '普货')
.replace(/4\.5T冷链车/g, '冷链车')
.replace(/4\.5T冷链/g, '冷链车');
}
function normalizeStatus(status: string | null): string {
if (!status) return '未标注';
if (status === '自营' || status === '租赁') return status;
if (/租/.test(status)) return '租赁';
if (/自/.test(status)) return '自营';
if (/库|Inventory/i.test(status)) return '在库';
return status;
}
function positive(value: number | null | undefined): number {
return Math.max(0, Number(value) || 0);
}
function vehicleReportRow(
vehicle: TargetVehicle,
targetId: number,
model: string,
targetDailyNeed: number,
): DailyReportVehicle {
const today = positive(vehicle.todayMileage);
const dailyNeed = positive(vehicle.dailyRequiredMileage);
return {
targetId,
plate: vehicle.plateNumber,
model,
status: normalizeStatus(vehicle.rentStatus),
customer: vehicle.customer || '未绑定客户',
today,
completion: positive(vehicle.completionRate) * 100,
dailyNeed,
shortfall: Math.max(0, dailyNeed - today),
isOnline: vehicle.isOnline,
hasDailyTask: dailyNeed > 0 || targetDailyNeed <= 0,
};
}
export function buildDailyReport(
targets: TargetSummary[],
targetVehicles: TargetVehicleEntry[],
monitoring: MonitoringData,
trend: TrendPoint[],
reportDate: string,
): DailyReportData {
const models = targetVehicles.map(({ target, vehicles }) => {
const today = vehicles.reduce((sum, vehicle) => sum + positive(vehicle.todayMileage), 0);
const vehicleDailyNeed = vehicles.reduce((sum, vehicle) => sum + positive(vehicle.dailyRequiredMileage), 0);
const targetDailyNeed = positive(target.dailyTarget);
const dailyNeed = Math.max(vehicleDailyNeed, targetDailyNeed);
const vehicleShortfall = vehicles.reduce((sum, vehicle) => (
sum + Math.max(0, positive(vehicle.dailyRequiredMileage) - positive(vehicle.todayMileage))
), 0);
const shortfall = Math.max(vehicleShortfall, Math.max(0, dailyNeed - today));
return {
id: target.id,
name: compactTargetName(target.targetName),
count: vehicles.length,
today,
total: positive(target.cumulativeTotal),
completion: weightedCompletionRate([{
completed: positive(target.currentYearCompleted),
target: positive(target.currentYearTarget),
}]),
active: vehicles.filter(vehicle => positive(vehicle.todayMileage) > 0).length,
zero: vehicles.filter(vehicle => positive(vehicle.todayMileage) <= 0).length,
dailyNeed,
shortfall,
missingDailyTaskCount: targetDailyNeed > 0
? vehicles.filter(vehicle => positive(vehicle.dailyRequiredMileage) <= 0).length
: 0,
};
});
const uniqueVehicles = new Map<string, DailyReportVehicle>();
let duplicateAssignmentCount = 0;
for (const { target, vehicles } of targetVehicles) {
const model = compactTargetName(target.targetName);
for (const vehicle of vehicles) {
const next = vehicleReportRow(vehicle, target.id, model, positive(target.dailyTarget));
const existing = uniqueVehicles.get(next.plate);
if (!existing) {
uniqueVehicles.set(next.plate, next);
continue;
}
duplicateAssignmentCount += 1;
uniqueVehicles.set(next.plate, {
...existing,
today: Math.max(existing.today, next.today),
completion: Math.max(existing.completion, next.completion),
dailyNeed: Math.max(existing.dailyNeed, next.dailyNeed),
shortfall: Math.max(existing.shortfall, next.shortfall),
isOnline: existing.isOnline || next.isOnline,
hasDailyTask: existing.hasDailyTask || next.hasDailyTask,
});
}
}
const vehicles = Array.from(uniqueVehicles.values());
const activeCount = vehicles.filter(vehicle => vehicle.today > 0).length;
const totalToday = vehicles.reduce((sum, vehicle) => sum + vehicle.today, 0);
const dailyNeed = models.reduce((sum, model) => sum + model.dailyNeed, 0);
const shortfall = models.reduce((sum, model) => sum + model.shortfall, 0);
return {
reportDate,
updatedAt: monitoring.updatedAt,
monitoringVehicleCount: monitoring.stats.vehicleCount,
assessmentVehicleCount: vehicles.length,
duplicateAssignmentCount,
missingDailyTaskCount: models.reduce((sum, model) => sum + model.missingDailyTaskCount, 0),
totalToday,
activeCount,
zeroCount: vehicles.length - activeCount,
dailyNeed,
shortfall,
completionRate: weightedCompletionRate(targets.map(target => ({
completed: positive(target.currentYearCompleted),
target: positive(target.currentYearTarget),
}))),
models: models.sort((a, b) => b.shortfall - a.shortfall || b.today - a.today),
trend: trend.map(item => ({ date: item.date, value: item.mileage })),
topVehicles: [...vehicles].sort((a, b) => b.today - a.today).slice(0, 5),
riskVehicles: [...vehicles]
.filter(vehicle => vehicle.shortfall > 0)
.sort((a, b) => b.shortfall - a.shortfall || a.completion - b.completion)
.slice(0, 5),
qualifiedCount: targets.reduce((sum, target) => sum + target.yearQualifiedCount, 0),
halfQualifiedCount: targets.reduce((sum, target) => sum + target.halfQualifiedCount, 0),
};
}
@@ -1,18 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { formatMileageSnapshotDateTime, formatMileageSnapshotTime } from './data-freshness.js';
test('formats snapshot instants in Asia/Shanghai', () => {
assert.equal(formatMileageSnapshotTime('2026-08-07T07:45:06.000Z'), '15:45:06');
});
test('keeps historical date snapshots explicit', () => {
assert.equal(formatMileageSnapshotTime('2026-08-06'), '2026-08-06');
assert.equal(formatMileageSnapshotTime(null), '--:--:--');
});
test('formats assessment snapshot instants with the business date and minute', () => {
assert.equal(formatMileageSnapshotDateTime('2026-08-07T09:45:01.000Z'), '2026.8.7 17:45');
assert.equal(formatMileageSnapshotDateTime(null), '--');
assert.equal(formatMileageSnapshotDateTime('not-a-date'), 'not-a-date');
});
-32
View File
@@ -1,32 +0,0 @@
export function formatMileageSnapshotTime(value: string | null): string {
if (!value) return '--:--:--';
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
hourCycle: 'h23',
}).format(date);
}
export function formatMileageSnapshotDateTime(value: string | null): string {
if (!value) return '--';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const parts = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
hourCycle: 'h23',
}).formatToParts(date);
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find(item => item.type === type)?.value || '';
return `${part('year')}.${part('month')}.${part('day')} ${part('hour')}:${part('minute')}`;
}
-76
View File
@@ -1,76 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildMileageDrillUrl, parseMileageDrillContext } from './drill-context.js';
test('parses a vehicle drill context', () => {
const context = parseMileageDrillContext(
'?mileageLevel=vehicle&mileageTarget=5&mileagePlate=%E7%B2%A4A08190F&mileageStart=2026-08-01&mileageEnd=2026-08-07&mileageSource=instrument,gps',
);
assert.deepEqual(context, {
level: 'vehicle',
targetId: 5,
plate: '粤A08190F',
startDate: '2026-08-01',
endDate: '2026-08-07',
sourcePriority: ['instrument', 'gps'],
});
});
test('requires complete context for breakdown and vehicle levels', () => {
assert.equal(parseMileageDrillContext('?mileageLevel=vehicle').level, 'overview');
assert.equal(parseMileageDrillContext('?mileageLevel=breakdown&mileageDimension=region').level, 'overview');
});
test('parses a target-scoped breakdown context', () => {
assert.deepEqual(
parseMileageDrillContext('?mileageLevel=breakdown&mileageTarget=5&mileageYear=2&mileageDimension=department&mileageValue=%E4%B8%9A%E5%8A%A1%E4%B8%80%E9%83%A8'),
{
level: 'breakdown',
targetId: 5,
assessmentYear: 2,
dimension: 'department',
dimensionValue: '业务一部',
sourcePriority: ['instrument'],
},
);
});
test('accepts assessment years only with a target', () => {
assert.equal(parseMileageDrillContext('?mileageTarget=5&mileageYear=3').assessmentYear, 3);
assert.equal(parseMileageDrillContext('?mileageYear=3').assessmentYear, undefined);
assert.equal(parseMileageDrillContext('?mileageTarget=5&mileageYear=0').assessmentYear, undefined);
});
test('builds a namespaced URL and preserves unrelated parameters', () => {
const url = buildMileageDrillUrl(
{ pathname: '/asset', search: '?company=5&legacy=1', hash: '#mileage' },
{
level: 'vehicle',
targetId: 5,
assessmentYear: 2,
plate: '粤A08190F',
startDate: '2026-08-01',
endDate: '2026-08-07',
sourcePriority: ['instrument'],
},
);
assert.equal(
url,
'/asset?company=5&legacy=1&mileageLevel=vehicle&mileageTarget=5&mileageYear=2&mileagePlate=%E7%B2%A4A08190F&mileageStart=2026-08-01&mileageEnd=2026-08-07&mileageSource=instrument#mileage',
);
});
test('clears entity drill parameters when returning to overview', () => {
const url = buildMileageDrillUrl(
{
pathname: '/asset',
search: '?mileageLevel=vehicle&mileageTarget=5&mileageYear=2&mileagePlate=%E7%B2%A4A08190F&company=5',
hash: '#mileage',
},
{ level: 'overview', sourcePriority: ['instrument'] },
);
assert.equal(url, '/asset?company=5&mileageSource=instrument#mileage');
});
-125
View File
@@ -1,125 +0,0 @@
import type { MileageSourceGroup } from './types';
export type MileageDrillLevel = 'overview' | 'breakdown' | 'vehicle';
export type MileageBreakdownDimension =
| 'department'
| 'region'
| 'customer'
| 'vehicle-model'
| 'assessment-target';
export interface MileageDrillContext {
level: MileageDrillLevel;
targetId?: number;
assessmentYear?: number;
dimension?: MileageBreakdownDimension;
dimensionValue?: string;
plate?: string;
startDate?: string;
endDate?: string;
sourcePriority: MileageSourceGroup[];
}
interface LocationParts {
pathname: string;
search: string;
hash: string;
}
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const BREAKDOWN_DIMENSIONS = new Set<MileageBreakdownDimension>([
'department',
'region',
'customer',
'vehicle-model',
'assessment-target',
]);
function validDate(value: string | null): string | undefined {
if (!value || !DATE_PATTERN.test(value)) return undefined;
const timestamp = Date.parse(`${value}T00:00:00+08:00`);
return Number.isFinite(timestamp) ? value : undefined;
}
function parseSourcePriority(value: string | null): MileageSourceGroup[] {
if (!value) return ['instrument'];
const sources = value
.split(',')
.filter((source): source is MileageSourceGroup => source === 'instrument' || source === 'gps');
return Array.from(new Set(sources)).slice(0, 2).length > 0
? Array.from(new Set(sources)).slice(0, 2)
: ['instrument'];
}
export function parseMileageDrillContext(search: string): MileageDrillContext {
const params = new URLSearchParams(search);
const rawTargetId = Number(params.get('mileageTarget'));
const targetId = Number.isInteger(rawTargetId) && rawTargetId > 0 ? rawTargetId : undefined;
const rawAssessmentYear = Number(params.get('mileageYear'));
const assessmentYear = Number.isInteger(rawAssessmentYear) && rawAssessmentYear > 0
? rawAssessmentYear
: undefined;
const plate = params.get('mileagePlate')?.trim() || undefined;
const rawDimension = params.get('mileageDimension');
const dimension = rawDimension && BREAKDOWN_DIMENSIONS.has(rawDimension as MileageBreakdownDimension)
? rawDimension as MileageBreakdownDimension
: undefined;
const dimensionValue = params.get('mileageValue')?.trim() || undefined;
const requestedLevel = params.get('mileageLevel');
let level: MileageDrillLevel = 'overview';
if (requestedLevel === 'vehicle' && plate) level = 'vehicle';
if (requestedLevel === 'breakdown' && dimension && dimensionValue) level = 'breakdown';
const context: MileageDrillContext = {
level,
sourcePriority: parseSourcePriority(params.get('mileageSource')),
};
if (targetId) context.targetId = targetId;
if (targetId && assessmentYear) context.assessmentYear = assessmentYear;
if (level === 'breakdown') {
context.dimension = dimension;
context.dimensionValue = dimensionValue;
}
if (level === 'vehicle') context.plate = plate;
const startDate = validDate(params.get('mileageStart'));
const endDate = validDate(params.get('mileageEnd'));
if (startDate) context.startDate = startDate;
if (endDate) context.endDate = endDate;
return context;
}
export function buildMileageDrillUrl(
location: LocationParts,
context: MileageDrillContext,
): string {
const params = new URLSearchParams(location.search);
for (const key of [
'mileageLevel',
'mileageTarget',
'mileageYear',
'mileageDimension',
'mileageValue',
'mileagePlate',
'mileageStart',
'mileageEnd',
'mileageSource',
]) {
params.delete(key);
}
if (context.level !== 'overview') params.set('mileageLevel', context.level);
if (context.targetId) params.set('mileageTarget', String(context.targetId));
if (context.targetId && context.assessmentYear) params.set('mileageYear', String(context.assessmentYear));
if (context.level === 'breakdown' && context.dimension && context.dimensionValue) {
params.set('mileageDimension', context.dimension);
params.set('mileageValue', context.dimensionValue);
}
if (context.level === 'vehicle' && context.plate) params.set('mileagePlate', context.plate);
if (context.startDate) params.set('mileageStart', context.startDate);
if (context.endDate) params.set('mileageEnd', context.endDate);
if (context.sourcePriority.length > 0) params.set('mileageSource', context.sourcePriority.join(','));
const query = params.toString();
return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
}
@@ -1,100 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildMileageMonitoringUrl,
parseMileageMonitoringContext,
} from './monitoring-context.js';
test('parses a complete mileage monitoring context', () => {
const context = parseMileageMonitoringContext(
'?mileageStart=2026-08-01&mileageEnd=2026-08-07&mileageSource=gps,instrument'
+ '&mileageBatch=%E4%BA%A4%E6%8A%95190%E8%BE%86&mileageBatch=%E7%BE%9A%E7%89%9B136%E8%BE%86'
+ '&mileageRegion=%E5%B9%BF%E5%B7%9E&mileageFilterPlate=%E7%B2%A4A12345&mileageDepartment=%E4%B8%9A%E5%8A%A1%E4%B8%80%E9%83%A8'
+ '&mileageCustomer=%E5%AE%A2%E6%88%B7A&mileageProject=%E9%A1%B9%E7%9B%AEA&mileageEntity=%E4%B8%BB%E4%BD%93A'
+ '&mileageRentStatus=%E7%A7%9F%E8%B5%81&mileageBrand=%E7%8E%B0%E4%BB%A3&mileagePlatePrefix=%E7%B2%A4A'
+ '&mileageSearch=123&mileageMin=10&mileageMax=500&mileageSort=total&mileageOrder=asc',
);
assert.deepEqual(context, {
startDate: '2026-08-01',
endDate: '2026-08-07',
sourcePriority: ['gps', 'instrument'],
targetNames: ['交投190辆', '羚牛136辆'],
region: '广州',
plates: ['粤A12345'],
department: '业务一部',
customer: '客户A',
project: '项目A',
entity: '主体A',
rentStatus: '租赁',
brands: ['现代'],
platePrefix: '粤A',
search: '123',
mileageMin: '10',
mileageMax: '500',
sortBy: 'total',
sortOrder: 'asc',
});
});
test('normalizes invalid monitoring URL values', () => {
const context = parseMileageMonitoringContext(
'?mileageStart=invalid&mileageSource=unknown&mileageBatch=&mileageRegion=All&mileageSort=unknown&mileageOrder=unknown',
);
assert.deepEqual(context, {
startDate: undefined,
endDate: undefined,
sourcePriority: ['instrument'],
targetNames: [],
region: undefined,
plates: [],
department: undefined,
customer: undefined,
project: undefined,
entity: undefined,
rentStatus: undefined,
brands: [],
platePrefix: undefined,
search: undefined,
mileageMin: undefined,
mileageMax: undefined,
sortBy: 'today',
sortOrder: 'desc',
});
});
test('builds monitoring state without removing drill or unrelated context', () => {
const url = buildMileageMonitoringUrl(
{
pathname: '/asset',
search: '?company=5&mileageLevel=vehicle&mileagePlate=%E7%B2%A4A08190F&mileageBatch=old',
hash: '#mileage',
},
{
startDate: '2026-08-01',
endDate: '2026-08-07',
sourcePriority: ['instrument'],
targetNames: ['交投190辆', '羚牛136辆'],
region: '广州',
plates: ['粤A12345'],
department: undefined,
customer: undefined,
project: undefined,
entity: undefined,
rentStatus: undefined,
brands: [],
platePrefix: undefined,
search: undefined,
mileageMin: undefined,
mileageMax: undefined,
sortBy: 'statisticTime',
sortOrder: 'asc',
},
);
assert.equal(
url,
'/asset?company=5&mileageLevel=vehicle&mileagePlate=%E7%B2%A4A08190F&mileageStart=2026-08-01&mileageEnd=2026-08-07&mileageSource=instrument&mileageBatch=%E4%BA%A4%E6%8A%95190%E8%BE%86&mileageBatch=%E7%BE%9A%E7%89%9B136%E8%BE%86&mileageRegion=%E5%B9%BF%E5%B7%9E&mileageFilterPlate=%E7%B2%A4A12345&mileageSort=statisticTime&mileageOrder=asc#mileage',
);
});
-136
View File
@@ -1,136 +0,0 @@
import type { MileageSourceGroup } from './types';
export type MonitoringSortBy = 'today' | 'total' | 'statisticTime';
export type MonitoringSortOrder = 'asc' | 'desc';
export interface MileageMonitoringContext {
startDate?: string;
endDate?: string;
sourcePriority: MileageSourceGroup[];
targetNames: string[];
region?: string;
plates: string[];
department?: string;
customer?: string;
project?: string;
entity?: string;
rentStatus?: string;
brands: string[];
platePrefix?: string;
search?: string;
mileageMin?: string;
mileageMax?: string;
sortBy: MonitoringSortBy;
sortOrder: MonitoringSortOrder;
}
interface LocationParts {
pathname: string;
search: string;
hash: string;
}
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const CONTEXT_KEYS = [
'mileageStart',
'mileageEnd',
'mileageSource',
'mileageBatch',
'mileageRegion',
'mileageFilterPlate',
'mileageDepartment',
'mileageCustomer',
'mileageProject',
'mileageEntity',
'mileageRentStatus',
'mileageBrand',
'mileagePlatePrefix',
'mileageSearch',
'mileageMin',
'mileageMax',
'mileageSort',
'mileageOrder',
] as const;
function validDate(value: string | null): string | undefined {
if (!value || !DATE_PATTERN.test(value)) return undefined;
const timestamp = Date.parse(`${value}T00:00:00+08:00`);
return Number.isFinite(timestamp) ? value : undefined;
}
function boundedValue(value: string | null, maxLength = 200): string | undefined {
const normalized = value?.trim();
if (!normalized || normalized === 'All') return undefined;
return normalized.slice(0, maxLength);
}
function uniqueValues(values: string[], limit = 200): string[] {
return Array.from(new Set(values.map(value => boundedValue(value)).filter((value): value is string => !!value))).slice(0, limit);
}
function parseSourcePriority(value: string | null): MileageSourceGroup[] {
const sources = value
?.split(',')
.filter((source): source is MileageSourceGroup => source === 'instrument' || source === 'gps');
return sources && sources.length > 0 ? Array.from(new Set(sources)).slice(0, 2) : ['instrument'];
}
function appendValues(params: URLSearchParams, key: string, values: string[]): void {
uniqueValues(values).forEach(value => params.append(key, value));
}
export function parseMileageMonitoringContext(search: string): MileageMonitoringContext {
const params = new URLSearchParams(search);
const rawSortBy = params.get('mileageSort');
const rawSortOrder = params.get('mileageOrder');
return {
startDate: validDate(params.get('mileageStart')),
endDate: validDate(params.get('mileageEnd')),
sourcePriority: parseSourcePriority(params.get('mileageSource')),
targetNames: uniqueValues(params.getAll('mileageBatch')),
region: boundedValue(params.get('mileageRegion')),
plates: uniqueValues(params.getAll('mileageFilterPlate')),
department: boundedValue(params.get('mileageDepartment')),
customer: boundedValue(params.get('mileageCustomer')),
project: boundedValue(params.get('mileageProject')),
entity: boundedValue(params.get('mileageEntity')),
rentStatus: boundedValue(params.get('mileageRentStatus')),
brands: uniqueValues(params.getAll('mileageBrand')),
platePrefix: boundedValue(params.get('mileagePlatePrefix')),
search: boundedValue(params.get('mileageSearch')),
mileageMin: boundedValue(params.get('mileageMin'), 32),
mileageMax: boundedValue(params.get('mileageMax'), 32),
sortBy: rawSortBy === 'total' || rawSortBy === 'statisticTime' ? rawSortBy : 'today',
sortOrder: rawSortOrder === 'asc' ? 'asc' : 'desc',
};
}
export function buildMileageMonitoringUrl(
location: LocationParts,
context: MileageMonitoringContext,
): string {
const params = new URLSearchParams(location.search);
CONTEXT_KEYS.forEach(key => params.delete(key));
if (context.startDate) params.set('mileageStart', context.startDate);
if (context.endDate) params.set('mileageEnd', context.endDate);
if (context.sourcePriority.length > 0) params.set('mileageSource', context.sourcePriority.join(','));
appendValues(params, 'mileageBatch', context.targetNames);
if (context.region) params.set('mileageRegion', context.region);
appendValues(params, 'mileageFilterPlate', context.plates);
if (context.department) params.set('mileageDepartment', context.department);
if (context.customer) params.set('mileageCustomer', context.customer);
if (context.project) params.set('mileageProject', context.project);
if (context.entity) params.set('mileageEntity', context.entity);
if (context.rentStatus) params.set('mileageRentStatus', context.rentStatus);
appendValues(params, 'mileageBrand', context.brands);
if (context.platePrefix) params.set('mileagePlatePrefix', context.platePrefix);
if (context.search) params.set('mileageSearch', context.search);
if (context.mileageMin) params.set('mileageMin', context.mileageMin);
if (context.mileageMax) params.set('mileageMax', context.mileageMax);
if (context.sortBy !== 'today') params.set('mileageSort', context.sortBy);
if (context.sortOrder !== 'desc') params.set('mileageOrder', context.sortOrder);
const query = params.toString();
return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
}
@@ -1,47 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildMonitoringKpis } from './monitoring-kpis.js';
test('builds daily monitoring KPIs from the selected-day flow metric', () => {
const model = buildMonitoringKpis(
{ totalToday: 120, totalAll: 3_000, vehicleCount: 3, yesterdayTotal: 100 },
{ start: '2026-08-07', end: '2026-08-07' },
);
assert.deepEqual(model, {
distanceMetricId: 'mileage.daily_total',
distanceLabel: '当日总里程',
distanceShortLabel: '当日',
distanceKm: 120,
averagePerVehicleKm: 40,
vehicleCount: 3,
cumulativeOdometerKm: 3_000,
});
});
test('builds range KPIs without substituting cumulative odometer values', () => {
const model = buildMonitoringKpis(
{ totalToday: 900, totalAll: 30_000, vehicleCount: 6, yesterdayTotal: 0 },
{ start: '2026-08-01', end: '2026-08-07' },
);
assert.equal(model.distanceMetricId, 'mileage.period_total');
assert.equal(model.distanceLabel, '区间总里程');
assert.equal(model.distanceKm, 900);
assert.equal(model.averagePerVehicleKm, 150);
assert.equal(model.cumulativeOdometerKm, 30_000);
});
test('normalizes invalid monitoring KPI inputs', () => {
const model = buildMonitoringKpis({
totalToday: Number.NaN,
totalAll: -10,
vehicleCount: Number.POSITIVE_INFINITY,
yesterdayTotal: 0,
});
assert.equal(model.distanceKm, 0);
assert.equal(model.averagePerVehicleKm, 0);
assert.equal(model.vehicleCount, 0);
assert.equal(model.cumulativeOdometerKm, 0);
});
-31
View File
@@ -1,31 +0,0 @@
import type { MonitoringStats } from './types';
export interface MonitoringKpiModel {
distanceMetricId: 'mileage.daily_total' | 'mileage.period_total';
distanceLabel: '当日总里程' | '区间总里程';
distanceShortLabel: '当日' | '区间';
distanceKm: number;
averagePerVehicleKm: number;
vehicleCount: number;
cumulativeOdometerKm: number;
}
export function buildMonitoringKpis(
stats: MonitoringStats,
dateRange?: { start: string; end: string },
): MonitoringKpiModel {
const isRange = !!dateRange?.start && !!dateRange?.end && dateRange.start !== dateRange.end;
const vehicleCount = Number.isFinite(stats.vehicleCount) ? Math.max(0, stats.vehicleCount) : 0;
const distanceKm = Number.isFinite(stats.totalToday) ? Math.max(0, stats.totalToday) : 0;
const cumulativeOdometerKm = Number.isFinite(stats.totalAll) ? Math.max(0, stats.totalAll) : 0;
return {
distanceMetricId: isRange ? 'mileage.period_total' : 'mileage.daily_total',
distanceLabel: isRange ? '区间总里程' : '当日总里程',
distanceShortLabel: isRange ? '区间' : '当日',
distanceKm,
averagePerVehicleKm: vehicleCount > 0 ? distanceKm / vehicleCount : 0,
vehicleCount,
cumulativeOdometerKm,
};
}
-50
View File
@@ -1,20 +1,9 @@
export type MileageSourceGroup = 'instrument' | 'gps';
export type MileageSourceCategory = 'INSTRUMENT' | 'GPS' | 'MIXED';
export type MileageSourceProtocol = 'GB32960' | 'MQTT' | 'JT808';
export interface MonitoringVehicle { export interface MonitoringVehicle {
plate: string; plate: string;
vin: string; vin: string;
dailyKm: number; dailyKm: number;
dailyMileage?: Record<string, number>;
totalKm: number | null; totalKm: number | null;
source: string; source: string;
sourceProtocol: MileageSourceProtocol | null;
sourceCategory: MileageSourceCategory | null;
dailySourceProtocols?: Record<string, MileageSourceProtocol | null>;
dataTime: string | null;
calculatedAt: string | null;
updatedAt: string | null;
isOnline: boolean; isOnline: boolean;
isDataSynced: boolean; isDataSynced: boolean;
customer: string | null; customer: string | null;
@@ -24,8 +13,6 @@ export interface MonitoringVehicle {
entity: string | null; entity: string | null;
project: string | null; project: string | null;
region: string | null; region: string | null;
brand: string | null;
targetNames: string[];
} }
export interface MonitoringStats { export interface MonitoringStats {
@@ -45,27 +32,21 @@ export interface MonitoringFilters {
platePrefixes: { prefix: string; count: number }[]; platePrefixes: { prefix: string; count: number }[];
targetNames: string[]; targetNames: string[];
regions: string[]; regions: string[];
brands: string[];
} }
export interface MonitoringData { export interface MonitoringData {
vehicles: MonitoringVehicle[]; vehicles: MonitoringVehicle[];
stats: MonitoringStats; stats: MonitoringStats;
filters: MonitoringFilters; filters: MonitoringFilters;
rangeDailyTotals?: { date: string; totalKm: number }[];
dateRange?: { start: string; end: string };
total: number; total: number;
page: number; page: number;
totalPages: number; totalPages: number;
updatedAt: string; updatedAt: string;
sourcePriority: MileageSourceGroup[];
} }
export interface TargetSummary { export interface TargetSummary {
id: number; id: number;
targetName: string; targetName: string;
snapshotUpdatedAt: string | null;
declaredVehicleCount: number;
vehicleCount: number; vehicleCount: number;
totalMileagePerVehicle: number; totalMileagePerVehicle: number;
annualMileagePerVehicle: number; annualMileagePerVehicle: number;
@@ -82,37 +63,6 @@ export interface TargetSummary {
remaining: number; remaining: number;
daysLeft: number; daysLeft: number;
dailyTarget: number; dailyTarget: number;
firstYearVehicleCount: number;
firstYearTarget: number;
firstYearCompleted: number;
firstYearRemaining: number;
firstYearCompletionRate: number;
firstYearQualifiedCount: number;
firstYearQualifiedRate: number;
firstYearHalfQualifiedCount: number;
firstYearDaysLeft: number;
firstYearDailyTarget: number;
firstYearStartDate: string | null;
firstYearEndDate: string | null;
yearlyAssessments: TargetYearlyAssessment[];
}
export interface TargetYearlyAssessment {
yearNumber: number;
label: string;
vehicleCount: number;
target: number;
completed: number;
remaining: number;
completionRate: number;
qualifiedCount: number;
qualifiedRate: number;
halfQualifiedCount: number;
daysLeft: number;
dailyTarget: number;
startDate: string | null;
endDate: string | null;
periods: string[];
} }
export interface TargetVehicle { export interface TargetVehicle {
@@ -1,39 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
includeCurrentDayInVehicleDetail,
normalizeVehicleDetailContext,
resolveVehicleDetailRange,
vehicleDetailContextLabel,
} from './vehicle-detail-range.js';
const NOW = new Date(2026, 7, 7, 12, 0, 0);
test('normalizes and preserves a selected vehicle drill range', () => {
const context = normalizeVehicleDetailContext('2026-08-07', '2026-08-05');
assert.deepEqual(context, { start: '2026-08-05', end: '2026-08-07' });
assert.deepEqual(resolveVehicleDetailRange('context', context, NOW), context);
});
test('rejects invalid context dates and resolves calendar presets', () => {
assert.equal(normalizeVehicleDetailContext('2026-02-30', '2026-03-01'), null);
assert.deepEqual(resolveVehicleDetailRange('last15', null, NOW), {
start: '2026-07-24',
end: '2026-08-07',
});
assert.deepEqual(resolveVehicleDetailRange('month', null, NOW), {
start: '2026-08-01',
end: '2026-08-07',
});
assert.deepEqual(resolveVehicleDetailRange('quarter', null, NOW), {
start: '2026-07-01',
end: '2026-08-07',
});
});
test('labels selected dates and includes today only for explicit context', () => {
const context = { start: '2026-08-07', end: '2026-08-07' };
assert.equal(vehicleDetailContextLabel(context), '8月7日');
assert.equal(includeCurrentDayInVehicleDetail('context'), true);
assert.equal(includeCurrentDayInVehicleDetail('last15'), false);
});
@@ -1,66 +0,0 @@
export type VehicleDetailRangeKey = 'context' | 'last15' | 'month' | 'quarter';
export interface VehicleDetailDateRange {
start: string;
end: string;
}
const DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
function fmtYmd(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function validDate(value?: string): string | null {
if (!value) return null;
const match = DATE_PATTERN.exec(value);
if (!match) return null;
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
return fmtYmd(date) === value ? value : null;
}
export function normalizeVehicleDetailContext(
startDate?: string,
endDate?: string,
): VehicleDetailDateRange | null {
const start = validDate(startDate);
const end = validDate(endDate);
if (!start || !end) return null;
return start <= end ? { start, end } : { start: end, end: start };
}
export function resolveVehicleDetailRange(
key: VehicleDetailRangeKey,
context: VehicleDetailDateRange | null,
now = new Date(),
): VehicleDetailDateRange {
if (key === 'context' && context) return context;
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const end = fmtYmd(today);
if (key === 'month') {
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end };
}
if (key === 'quarter') {
const quarterStartMonth = Math.floor(today.getMonth() / 3) * 3;
return { start: fmtYmd(new Date(today.getFullYear(), quarterStartMonth, 1)), end };
}
const start = new Date(today);
start.setDate(today.getDate() - 14);
return { start: fmtYmd(start), end };
}
export function vehicleDetailContextLabel(context: VehicleDetailDateRange): string {
if (context.start === context.end) {
return `${Number(context.start.slice(5, 7))}${Number(context.start.slice(8, 10))}`;
}
return '所选区间';
}
export function includeCurrentDayInVehicleDetail(key: VehicleDetailRangeKey): boolean {
return key === 'context';
}
+22 -120
View File
@@ -2,24 +2,15 @@ import * as XLSX from 'xlsx';
import type { MonitoringVehicle } from './types'; import type { MonitoringVehicle } from './types';
interface ExportContext { interface ExportContext {
date?: string; date: string;
startDate?: string; sortBy: 'today' | 'total';
endDate?: string;
sortBy: 'today' | 'total' | 'statisticTime';
} }
const BASE_HEADERS = [ const HEADERS = [
'状态', '数据来源', '车牌号', '客户', '业务部门', '项目', '租赁状态', '状态', '车牌号', '客户', '业务部门', '项目', '租赁状态',
'运营区域', '运营区域', '今日里程(km)', '累计里程(km)',
] as const; ] as const;
function sourceLabel(v: MonitoringVehicle): string {
if (v.sourceCategory === 'INSTRUMENT') return '仪表数据';
if (v.sourceCategory === 'GPS') return 'GPS数据';
if (v.sourceCategory === 'MIXED') return '仪表数据+GPS数据';
return v.isDataSynced ? '来源待接口' : '无数据';
}
function statusLabel(v: MonitoringVehicle): string { function statusLabel(v: MonitoringVehicle): string {
if (!v.isDataSynced) return '未对接'; if (!v.isDataSynced) return '未对接';
return v.isOnline ? '在线' : '离线'; return v.isOnline ? '在线' : '离线';
@@ -27,83 +18,47 @@ function statusLabel(v: MonitoringVehicle): string {
function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | number { function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | number {
if (kind === 'today') { if (kind === 'today') {
// 当日未对接但有历史累计,视作今日 0;只有完全无数据才标「未对接」
if (!v.isDataSynced && v.totalKm == null) return '未对接'; if (!v.isDataSynced && v.totalKm == null) return '未对接';
return Math.max(0, v.dailyKm || 0); return Math.max(0, Math.round(v.dailyKm || 0));
} }
return v.totalKm != null ? v.totalKm : '未对接'; return v.totalKm != null ? Math.round(v.totalKm) : '未对接';
} }
export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void { export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void {
const start = ctx.startDate || ctx.date || ''; const data: (string | number)[][] = [
const end = ctx.endDate || ctx.date || ''; [...HEADERS],
const isRange = !!start && !!end && start !== end; ...vehicles.map(v => [
const dayKeys = Array.from(
new Set(vehicles.flatMap(v => Object.keys(v.dailyMileage || {})))
).sort();
// 车辆汇总 sheet:基础列 + 每日里程列 + 区间合计 + 累计里程
const dailyHeaders = dayKeys.map(day => `${day.slice(5)}里程(km)`);
const summaryHeaders = [
...BASE_HEADERS,
...(dayKeys.length > 0 ? dailyHeaders : []),
isRange ? '区间合计(km)' : '当日里程(km)',
'累计里程(km)',
];
const summaryData: (string | number)[][] = [
summaryHeaders,
...vehicles.map(v => {
const baseRow = [
statusLabel(v), statusLabel(v),
sourceLabel(v),
v.plate, v.plate,
v.customer || '', v.customer || '',
v.department || '', v.department || '',
v.project || '', v.project || '',
v.rentStatus || '', v.rentStatus || '',
v.region || '', v.region || '',
];
const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? 0);
return [
...baseRow,
...dailyCols,
mileageCell(v, 'today'), mileageCell(v, 'today'),
mileageCell(v, 'total'), mileageCell(v, 'total'),
]; ]),
}),
]; ];
const ws = XLSX.utils.aoa_to_sheet(summaryData); const ws = XLSX.utils.aoa_to_sheet(data);
const numFixedCols = BASE_HEADERS.length; ws['!cols'] = [
const wsCols: { wch: number }[] = [
{ wch: 8 }, // 状态 { wch: 8 }, // 状态
{ wch: 16 }, // 数据来源
{ wch: 12 }, // 车牌号 { wch: 12 }, // 车牌号
{ wch: 28 }, // 客户 { wch: 28 }, // 客户
{ wch: 14 }, // 业务部门 { wch: 14 }, // 业务部门
{ wch: 16 }, // 项目 { wch: 16 }, // 项目
{ wch: 10 }, // 租赁状态 { wch: 10 }, // 租赁状态
{ wch: 12 }, // 运营区域 { wch: 12 }, // 运营区域
{ wch: 14 }, // 今日里程
{ wch: 14 }, // 累计里程
]; ];
for (let i = 0; i < dayKeys.length; i++) wsCols.push({ wch: 12 });
wsCols.push({ wch: 14 }); // 区间合计
wsCols.push({ wch: 14 }); // 累计里程
ws['!cols'] = wsCols;
// 冻结首行 + 固定列 ws['!freeze'] = { xSplit: 0, ySplit: 1 } as never;
ws['!freeze'] = { xSplit: numFixedCols, ySplit: 1 } as never;
for (let r = 1; r < summaryData.length; r++) { // 表头样式(在客户端 SheetJS 社区版仅基本样式生效)
for (let c = numFixedCols; c < summaryHeaders.length; c++) { for (let c = 0; c < HEADERS.length; c++) {
const ref = XLSX.utils.encode_cell({ r, c });
if (ws[ref]?.t === 'n') ws[ref].z = '0.##########';
}
}
// 表头样式
for (let c = 0; c < summaryHeaders.length; c++) {
const ref = XLSX.utils.encode_cell({ r: 0, c }); const ref = XLSX.utils.encode_cell({ r: 0, c });
if (ws[ref]) { if (ws[ref]) {
(ws[ref] as { s?: unknown }).s = { (ws[ref] as { s?: unknown }).s = {
@@ -115,53 +70,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
} }
const wb = XLSX.utils.book_new(); const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '车辆汇总'); XLSX.utils.book_append_sheet(wb, ws, '里程明细');
// 每日明细 sheet:保留原有格式
if (dayKeys.length > 0) {
const detailHeaders = [
'车牌号', '数据来源', '客户', '业务部门', '项目', '租赁状态', '运营区域',
...dayKeys.map(day => `${day}里程(km)`),
'区间合计(km)',
'累计里程(km)',
];
const detailData: (string | number)[][] = [
detailHeaders,
...vehicles.map(v => [
v.plate,
sourceLabel(v),
v.customer || '',
v.department || '',
v.project || '',
v.rentStatus || '',
v.region || '',
...dayKeys.map(day => v.dailyMileage?.[day] || 0),
Math.max(0, v.dailyKm || 0),
v.totalKm != null ? v.totalKm : '',
]),
];
const detailWs = XLSX.utils.aoa_to_sheet(detailData);
detailWs['!cols'] = [
{ wch: 12 },
{ wch: 16 },
{ wch: 28 },
{ wch: 14 },
{ wch: 16 },
{ wch: 10 },
{ wch: 12 },
...dayKeys.map(() => ({ wch: 14 })),
{ wch: 14 },
{ wch: 14 },
];
detailWs['!freeze'] = { xSplit: 7, ySplit: 1 } as never;
for (let r = 1; r < detailData.length; r++) {
for (let c = 7; c < detailHeaders.length; c++) {
const ref = XLSX.utils.encode_cell({ r, c });
if (detailWs[ref]?.t === 'n') detailWs[ref].z = '0.##########';
}
}
XLSX.utils.book_append_sheet(wb, detailWs, '每日明细');
}
const now = new Date(); const now = new Date();
const y = now.getFullYear(); const y = now.getFullYear();
@@ -169,14 +78,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
const d = String(now.getDate()).padStart(2, '0'); const d = String(now.getDate()).padStart(2, '0');
const hh = String(now.getHours()).padStart(2, '0'); const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0'); const mm = String(now.getMinutes()).padStart(2, '0');
const dateTag = start && end const dateTag = ctx.date ? ctx.date.replace(/-/g, '') : `${y}${m}${d}`;
? `${start.replace(/-/g, '')}-${end.replace(/-/g, '')}` const filename = `里程看板_${dateTag}_${hh}${mm}_${ctx.sortBy === 'today' ? '今日' : '累计'}.xlsx`;
: `${y}${m}${d}`;
const sortLabel = ctx.sortBy === 'total'
? '累计'
: ctx.sortBy === 'statisticTime'
? '统计时间'
: isRange ? '区间' : '今日';
const filename = `里程看板_${dateTag}_${hh}${mm}_${sortLabel}.xlsx`;
XLSX.writeFile(wb, filename); XLSX.writeFile(wb, filename);
} }
+121 -45
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Activity, AlertTriangle, CheckCircle2, Filter, RotateCcw, X, Search, ChevronDown, CheckSquare, Send, Clock, Download, SendHorizonal } from 'lucide-react'; import { Filter, RotateCcw, X, Search, ChevronDown, CheckSquare, Send, Clock, Download } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { fetchSuggestions, sendNotifyBatch } from './api'; import { fetchSuggestions, sendNotifyBatch } from './api';
import type { SchedulingResponse, SchedulingSuggestion, CandidateVehicle } from './types'; import type { SchedulingResponse, SchedulingSuggestion, CandidateVehicle } from './types';
@@ -9,7 +9,6 @@ import NotificationHistory from './NotificationHistory';
import { exportSuggestionsCsv } from './csv-export'; import { exportSuggestionsCsv } from './csv-export';
import Blur from '../../components/Blur'; import Blur from '../../components/Blur';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { MetricTile, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface';
type TypeFilter = 'all' | 'qualified' | 'hopeless'; type TypeFilter = 'all' | 'qualified' | 'hopeless';
@@ -88,43 +87,63 @@ function FilterSelect({ label, options, value, onChange, placeholder }: {
); );
} }
/** Skeleton pulse block */
function Sk({ className }: { className?: string }) { function Sk({ className }: { className?: string }) {
return <div className={`animate-pulse bg-slate-200/70 rounded ${className ?? ''}`} />; return <div className={`animate-pulse bg-slate-200/70 rounded ${className ?? ''}`} />;
} }
function SkeletonPage() { function SkeletonPage() {
return ( return (
<PageFrame <div className="min-h-screen bg-[#F0F4F8] font-sans p-3 md:p-6">
title="智能调度工作台" <div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">
subtitle="自动识别高里程可释放车辆与低里程待救援车辆,形成可登记、可追踪的运营干预建议。" {/* Cards skeleton */}
icon={Activity} <div className="grid grid-cols-3 gap-2.5">
eyebrow="SCHEDULING OPS" {[0, 1, 2].map(i => (
meta="建议生成中 · 正在计算候选车辆" <div key={i} className="p-4 rounded-2xl bg-white border border-slate-100 space-y-2.5">
> <Sk className="h-3 w-16" />
<div className="grid grid-cols-2 gap-3 md:grid-cols-4"> <Sk className="h-7 w-12" />
{Array.from({ length: 4 }).map((_, i) => <SkeletonBlock key={i} className="h-28" />)} <Sk className="h-2.5 w-24" />
</div>
))}
</div>
{/* List card skeleton */}
<div className="bg-white rounded-2xl border border-slate-200/60 overflow-hidden">
{/* Header */}
<div className="px-4 py-3 border-b border-slate-100 space-y-3">
<div className="flex items-center justify-between">
<Sk className="h-4 w-28" />
<div className="flex gap-2"><Sk className="h-6 w-6 rounded-lg" /><Sk className="h-6 w-6 rounded-lg" /></div>
</div> </div>
<SurfaceCard>
<div className="space-y-3 p-4">
<SkeletonBlock className="h-5 w-40" />
<div className="flex gap-2"> <div className="flex gap-2">
{[0, 1, 2, 3].map(i => <SkeletonBlock key={i} className="h-8 w-24 rounded-full" />)} {[0, 1, 2, 3].map(i => <Sk key={i} className="h-7 w-20 rounded-full" />)}
</div> </div>
</div>
{/* Rows */}
<div className="divide-y divide-slate-50"> <div className="divide-y divide-slate-50">
{Array.from({ length: 8 }).map((_, i) => ( {Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 py-3"> <div key={i} className="px-4 py-3 flex items-center gap-3">
<SkeletonBlock className="h-10 w-1 rounded-full" /> <Sk className="w-1 h-10 rounded-full" />
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<SkeletonBlock className="h-3.5 w-48" /> <div className="flex items-center gap-2">
<SkeletonBlock className="h-2.5 w-72 max-w-full" /> <Sk className="h-3.5 w-20" />
<Sk className="h-3 w-10 rounded-full" />
<Sk className="h-3 w-14" />
</div> </div>
<SkeletonBlock className="h-6 w-16" /> <div className="flex items-center gap-3">
<Sk className="h-2.5 w-28" />
<Sk className="h-2.5 w-16" />
<Sk className="h-2.5 w-14" />
</div>
</div>
<Sk className="h-4 w-8" />
</div> </div>
))} ))}
</div> </div>
</div> </div>
</SurfaceCard> </div>
</PageFrame> </div>
); );
} }
@@ -256,33 +275,89 @@ export default function SchedulingModule() {
if (loading && !data) return <SkeletonPage />; if (loading && !data) return <SkeletonPage />;
return ( return (
<PageFrame <div className="min-h-screen bg-[#F0F4F8] text-slate-800 font-sans p-3 md:p-6" style={{ overflowX: 'clip' }}>
title="智能调度工作台" <div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">
subtitle="自动识别高里程可释放车辆与低里程待救援车辆,形成可登记、可追踪的运营干预建议。"
icon={Activity}
eyebrow="SCHEDULING OPS"
meta={`当前 ${filteredSuggestions.length} 条建议 · ${activeFilterCount} 个筛选条件`}
actions={(
<button onClick={loadData} disabled={loading} className="inline-flex items-center gap-1.5 rounded-xl bg-slate-900 px-3 py-2 text-xs font-black text-white shadow-sm transition-colors hover:bg-slate-800 disabled:opacity-60">
<RotateCcw size={14} className={loading ? 'animate-spin' : ''} />
</button>
)}
>
{/* ===== Summary Cards ===== */} {/* ===== Summary Cards ===== */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-2.5">
<button type="button" onClick={() => setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')} className={typeFilter === 'qualified' ? 'rounded-2xl ring-2 ring-orange-400 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}> {/* 里程高·换下 — warm orange */}
<MetricTile icon={CheckCircle2} label="已完成考核目标" value={summary?.qualifiedCount ?? 0} unit="台" helper="换下,腾位给待达标车" tone="amber" /> <button
onClick={() => setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')}
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
typeFilter === 'qualified'
? 'bg-orange-500 text-white shadow-lg shadow-orange-500/25'
: 'bg-gradient-to-br from-orange-50 to-amber-50 border border-orange-200/60'
}`}
>
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'qualified' ? 'text-orange-100' : 'text-orange-600'}`}>
</div>
<div className={`text-2xl font-black ${typeFilter === 'qualified' ? 'text-white' : 'text-orange-700'}`}>
{loading && !data ? '-' : summary?.qualifiedCount ?? 0}
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'qualified' ? 'text-orange-200' : 'text-orange-400'}`}></span>
</div>
<div className={`text-[9px] mt-0.5 ${typeFilter === 'qualified' ? 'text-orange-200' : 'text-orange-400'}`}>
</div>
</button> </button>
<button type="button" onClick={() => setTypeFilter(typeFilter === 'hopeless' ? 'all' : 'hopeless')} className={typeFilter === 'hopeless' ? 'rounded-2xl ring-2 ring-blue-500 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
<MetricTile icon={AlertTriangle} label="预估无法达标" value={summary?.hopelessCount ?? 0} unit="台" helper="换走,换上快达标的车" tone="blue" /> {/* 里程低·换走 — cool blue */}
<button
onClick={() => setTypeFilter(typeFilter === 'hopeless' ? 'all' : 'hopeless')}
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
typeFilter === 'hopeless'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/25'
: 'bg-gradient-to-br from-blue-50 to-indigo-50 border border-blue-200/60'
}`}
>
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'hopeless' ? 'text-blue-100' : 'text-blue-600'}`}>
</div>
<div className={`text-2xl font-black ${typeFilter === 'hopeless' ? 'text-white' : 'text-blue-700'}`}>
{loading && !data ? '-' : summary?.hopelessCount ?? 0}
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'hopeless' ? 'text-blue-200' : 'text-blue-400'}`}></span>
</div>
<div className={`text-[9px] mt-0.5 ${typeFilter === 'hopeless' ? 'text-blue-200' : 'text-blue-400'}`}>
</div>
</button> </button>
<button type="button" onClick={() => setTypeFilter('all')} className={typeFilter === 'all' ? 'rounded-2xl ring-2 ring-slate-700 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
<MetricTile icon={Activity} label="替换建议" value={summary?.suggestionCount ?? 0} unit="条" helper={`执行后预计 +${summary?.estimatedGain ?? 0} 台达标`} tone="slate" /> {/* 替换建议 — neutral dark */}
<button
onClick={() => setTypeFilter('all')}
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
typeFilter === 'all'
? 'bg-slate-800 text-white shadow-lg shadow-slate-800/25'
: 'bg-gradient-to-br from-slate-50 to-slate-100 border border-slate-200/60'
}`}
>
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'all' ? 'text-slate-300' : 'text-slate-500'}`}>
</div>
<div className={`text-2xl font-black ${typeFilter === 'all' ? 'text-white' : 'text-slate-800'}`}>
{loading && !data ? '-' : summary?.suggestionCount ?? 0}
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'all' ? 'text-slate-400' : 'text-slate-400'}`}></span>
</div>
<div className={`text-[9px] mt-0.5 ${typeFilter === 'all' ? 'text-slate-400' : 'text-slate-400'}`}>
+{summary?.estimatedGain ?? 0}
</div>
</button> </button>
<button type="button" onClick={() => { setShowHistory(true); setHistoryRecentOnly(true); }} className="rounded-2xl">
<MetricTile icon={SendHorizonal} label="近期已干预" value={summary?.recentInterventionCount ?? 0} unit="条" helper="最近 7 天 · 点击查看" tone="emerald" /> {/* 近期已干预 — emerald */}
<button
onClick={() => { setShowHistory(true); setHistoryRecentOnly(true); }}
className="p-3.5 rounded-2xl text-left transition-all cursor-pointer bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-200/60"
>
<div className="text-[10px] font-bold mb-1 text-emerald-600">
</div>
<div className="text-2xl font-black text-emerald-700">
{loading && !data ? '-' : summary?.recentInterventionCount ?? 0}
<span className="text-[10px] font-normal ml-1 text-emerald-400"></span>
</div>
<div className="text-[9px] mt-0.5 text-emerald-400">
7 ·
</div>
</button> </button>
</div> </div>
@@ -557,7 +632,8 @@ export default function SchedulingModule() {
</motion.div> </motion.div>
</div> </div>
)} )}
</div>
<RotatingFooterHint className="pb-4" /> <RotatingFooterHint className="pb-4" />
</PageFrame> </div>
); );
} }
@@ -1,158 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { AmapConfig, HeatmapMetric, HeatmapPoint } from './types';
type Props = {
config: AmapConfig;
points: HeatmapPoint[];
max: number;
metric: HeatmapMetric;
focusQuery: string;
onMapClick: (longitude: number, latitude: number) => void;
};
type AmapInstance = {
Map: new (container: HTMLElement, options: Record<string, unknown>) => any;
HeatMap: new (map: any, options: Record<string, unknown>) => any;
ToolBar: new (options?: Record<string, unknown>) => any;
Scale: new (options?: Record<string, unknown>) => any;
Bounds: new (southWest: [number, number], northEast: [number, number]) => any;
};
declare global {
interface Window {
_AMapSecurityConfig?: { securityJsCode: string };
}
}
function getBounds(points: HeatmapPoint[]) {
if (points.length === 0) return null;
let minLng = points[0].lng;
let maxLng = points[0].lng;
let minLat = points[0].lat;
let maxLat = points[0].lat;
for (let index = 1; index < points.length; index += 1) {
const point = points[index];
minLng = Math.min(minLng, point.lng);
maxLng = Math.max(maxLng, point.lng);
minLat = Math.min(minLat, point.lat);
maxLat = Math.max(maxLat, point.lat);
}
return { minLng, maxLng, minLat, maxLat };
}
export default function AmapHeatmapCanvas({ config, points, max, metric, focusQuery, onMapClick }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<any>(null);
const heatmapRef = useRef<any>(null);
const amapRef = useRef<AmapInstance | null>(null);
const clickHandlerRef = useRef(onMapClick);
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const intensity = useMemo(() => {
const transform = metric === 'locations'
? (value: number) => Math.log1p(value)
: (value: number) => Math.sqrt(value);
return {
points: points.map((point) => ({ ...point, count: transform(point.count) })),
max: transform(Math.max(1, max)),
};
}, [max, metric, points]);
useEffect(() => {
clickHandlerRef.current = onMapClick;
}, [onMapClick]);
useEffect(() => {
let cancelled = false;
const container = containerRef.current;
if (!container) return undefined;
async function initialize() {
try {
window._AMapSecurityConfig = { securityJsCode: config.securityCode };
const loaderModule = await import('@amap/amap-jsapi-loader');
const AMap = await loaderModule.default.load({
key: config.key,
version: '2.0',
plugins: ['AMap.HeatMap', 'AMap.ToolBar', 'AMap.Scale'],
}) as unknown as AmapInstance;
if (cancelled || !container) return;
const map = new AMap.Map(container, {
viewMode: '2D',
zoom: 5,
center: [105.4, 34.4],
mapStyle: 'amap://styles/whitesmoke',
resizeEnable: true,
showLabel: true,
});
map.addControl(new AMap.ToolBar({ position: { right: '20px', bottom: '76px' } }));
map.addControl(new AMap.Scale({ position: { right: '18px', bottom: '24px' } }));
const heatmap = new AMap.HeatMap(map, {
radius: 25,
opacity: [0.12, 0.82],
gradient: {
0.1: '#2563eb',
0.3: '#0891b2',
0.5: '#16a34a',
0.68: '#eab308',
0.84: '#f97316',
1: '#dc2626',
},
});
map.on('click', (event: any) => {
clickHandlerRef.current(event.lnglat.getLng(), event.lnglat.getLat());
});
mapRef.current = map;
heatmapRef.current = heatmap;
amapRef.current = AMap;
setStatus('ready');
} catch (error) {
console.error('AMap initialization failed', error);
if (!cancelled) setStatus('error');
}
}
initialize();
return () => {
cancelled = true;
heatmapRef.current?.setMap?.(null);
mapRef.current?.destroy?.();
heatmapRef.current = null;
mapRef.current = null;
amapRef.current = null;
};
}, [config.key, config.securityCode]);
useEffect(() => {
if (status !== 'ready' || !heatmapRef.current) return;
heatmapRef.current.setDataSet({ data: intensity.points, max: intensity.max });
if (!focusQuery || points.length === 0 || !mapRef.current || !amapRef.current) return;
const bounds = getBounds(points);
if (!bounds) return;
if (points.length === 1) {
mapRef.current.setZoomAndCenter(12, [points[0].lng, points[0].lat]);
return;
}
mapRef.current.setBounds(new amapRef.current.Bounds(
[bounds.minLng, bounds.minLat],
[bounds.maxLng, bounds.maxLat],
), false, [80, 80, 80, 80]);
}, [focusQuery, intensity, points, status]);
return (
<div className="absolute inset-0 bg-[#eef3f7]">
<div ref={containerRef} className="h-full w-full" aria-label="车辆运营热力图地图" />
{status === 'loading' ? (
<div className="absolute inset-0 grid place-items-center bg-white/76 text-sm font-medium text-slate-500 backdrop-blur-[2px]">
<span className="flex items-center gap-2.5"><i className="h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" /></span>
</div>
) : null}
{status === 'error' ? (
<div className="absolute inset-0 grid place-items-center bg-slate-50 text-center">
<div><p className="text-sm font-semibold text-slate-700"></p><p className="mt-1 text-xs text-slate-400"> Key</p></div>
</div>
) : null}
</div>
);
}
@@ -1,86 +0,0 @@
import { ChevronRight, Crosshair, X } from 'lucide-react';
import type { VehicleRank } from './types';
type Props = {
title: string;
subtitle: string;
locationCount: number;
vehicleCount: number;
vehicles: VehicleRank[];
selected: boolean;
loading: boolean;
onClose: () => void;
onHide: () => void;
};
const numberFormat = new Intl.NumberFormat('zh-CN');
export default function HeatmapDetailPanel(props: Props) {
return (
<aside className="relative z-20 flex h-full w-[300px] shrink-0 flex-col border-l border-slate-200 bg-white max-lg:absolute max-lg:bottom-0 max-lg:left-0 max-lg:right-0 max-lg:h-[43vh] max-lg:w-full max-lg:rounded-t-2xl max-lg:border-l-0 max-lg:border-t max-lg:shadow-[0_-10px_30px_rgba(15,23,42,0.14)] max-md:h-[50dvh]">
<div className="border-b border-slate-100 px-5 pb-4 pt-5 max-md:px-4 max-md:pb-3 max-md:pt-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h2 className="text-[17px] font-semibold tracking-tight text-slate-900">{props.title}</h2>
{props.loading ? <i className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" /> : null}
</div>
<p className="mt-1 text-[11px] text-slate-400">{props.subtitle}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{props.selected ? (
<button type="button" onClick={props.onClose} aria-label="关闭区域详情" className="grid h-8 w-8 place-items-center rounded-lg text-slate-400 hover:bg-slate-50 hover:text-slate-700">
<X size={18} strokeWidth={1.8} />
</button>
) : null}
<button
type="button"
onClick={props.onHide}
aria-label="隐藏详情"
title="隐藏详情"
className="grid h-8 w-8 place-items-center rounded-lg text-slate-400 transition hover:bg-blue-50 hover:text-blue-600 max-md:h-10 max-md:w-10"
>
<ChevronRight size={18} strokeWidth={1.8} />
</button>
</div>
</div>
<div className="mt-5 grid grid-cols-2 divide-x divide-slate-200 border-t border-slate-100 pt-4 max-md:mt-3 max-md:pt-3">
<div>
<p className="text-[11px] text-slate-400"></p>
<p className="mt-1 text-[22px] font-semibold tracking-tight text-blue-600">{numberFormat.format(props.locationCount)}</p>
</div>
<div className="pl-5">
<p className="text-[11px] text-slate-400"></p>
<p className="mt-1 text-[22px] font-semibold tracking-tight text-blue-600">{numberFormat.format(props.vehicleCount)}</p>
</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto px-5 py-4 max-md:px-4 max-md:py-3">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[13px] font-semibold text-slate-800"> TOP10</h3>
<span className="text-[10px] text-slate-400"></span>
</div>
{props.vehicles.length ? (
<ol className="divide-y divide-slate-100">
{props.vehicles.map((vehicle, index) => (
<li key={vehicle.vin} className="grid grid-cols-[24px_minmax(0,1fr)_42px] items-center gap-2 py-2.5">
<span className={`text-[11px] font-medium ${index < 3 ? 'text-blue-600' : 'text-slate-400'}`}>{index + 1}</span>
<span className="min-w-0">
<span className="block truncate text-[12px] font-medium text-slate-700">{vehicle.plateNumber || '未绑定车牌'}</span>
<span className="mt-0.5 block truncate font-mono text-[9px] text-slate-400">{vehicle.vin}</span>
</span>
<span className="text-right text-[12px] tabular-nums text-slate-600">{vehicle.locationCount}</span>
</li>
))}
</ol>
) : (
<div className="grid h-44 place-items-center text-center text-xs text-slate-400">
<span><Crosshair className="mx-auto mb-2 text-slate-300" size={22} /></span>
</div>
)}
</div>
</aside>
);
}
@@ -1,115 +0,0 @@
import { CalendarDays, ChevronUp, RotateCcw, Search } from 'lucide-react';
import type { HeatmapMetric, VehicleOption } from './types';
type Props = {
startDate: string;
endDate: string;
minDate: string;
maxDate: string;
query: string;
batchModel: string;
batchModels: string[];
metric: HeatmapMetric;
vehicles: VehicleOption[];
onStartDateChange: (value: string) => void;
onEndDateChange: (value: string) => void;
onQueryChange: (value: string) => void;
onBatchModelChange: (value: string) => void;
onMetricChange: (value: HeatmapMetric) => void;
onReset: () => void;
onHide: () => void;
};
const FIELD = 'relative h-11 min-w-0 rounded-lg border border-slate-200 bg-white transition focus-within:border-blue-400 focus-within:ring-2 focus-within:ring-blue-100';
const LABEL = 'pointer-events-none absolute left-3 top-1 z-10 text-[9px] font-medium leading-none text-slate-400';
const CONTROL = 'h-full w-full min-w-0 rounded-lg border-0 bg-transparent px-3 pt-3 text-[12px] text-slate-700 outline-none';
export default function HeatmapFilters(props: Props) {
return (
<div className="pointer-events-auto grid max-h-[calc(100dvh-180px)] w-full grid-cols-2 gap-2 overflow-y-auto rounded-xl border border-slate-200/80 bg-white/96 p-2 shadow-[0_8px_28px_rgba(15,23,42,0.12)] backdrop-blur-md md:flex md:w-auto md:min-w-max md:items-center md:overflow-visible">
<label className={`${FIELD} col-span-2 md:w-[320px]`}>
<span className={`${LABEL} left-9`}></span>
<span className="flex h-full min-w-0 items-center pl-8 pt-2">
<CalendarDays className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={15} strokeWidth={1.8} />
<input
aria-label="开始日期"
type="date"
min={props.minDate}
max={props.endDate}
value={props.startDate}
onChange={(event) => props.onStartDateChange(event.target.value)}
className="h-full w-[124px] min-w-0 flex-1 border-0 bg-transparent px-1 text-[12px] text-slate-700 outline-none"
/>
<span className="shrink-0 px-0.5 text-slate-300"></span>
<input
aria-label="结束日期"
type="date"
min={props.startDate}
max={props.maxDate}
value={props.endDate}
onChange={(event) => props.onEndDateChange(event.target.value)}
className="h-full w-[124px] min-w-0 flex-1 border-0 bg-transparent px-1 text-[12px] text-slate-700 outline-none"
/>
</span>
</label>
<label className={`${FIELD} col-span-2 md:w-[145px]`}>
<span className={LABEL}>VIN / </span>
<span className="relative block h-full">
<input
aria-label="搜索 VIN 或车牌"
value={props.query}
list="vehicle-heatmap-options"
onChange={(event) => props.onQueryChange(event.target.value)}
placeholder="输入 VIN 或车牌"
className={`${CONTROL} pr-9`}
/>
<Search className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} strokeWidth={1.8} />
<datalist id="vehicle-heatmap-options">
{props.vehicles.map((vehicle) => (
<option key={vehicle.vin} value={vehicle.plateNumber || vehicle.vin}>{vehicle.vin}</option>
))}
</datalist>
</span>
</label>
<label className={`${FIELD} col-span-2 md:w-[150px]`}>
<span className={LABEL}> / </span>
<select
aria-label="车型 / 批次型号"
value={props.batchModel}
onChange={(event) => props.onBatchModelChange(event.target.value)}
className={CONTROL}
title={props.batchModel || '全部车型'}
>
<option value=""></option>
{props.batchModels.map((batchModel) => (
<option key={batchModel} value={batchModel}>{batchModel}</option>
))}
</select>
</label>
<label className={`${FIELD} md:w-[110px]`}>
<span className={LABEL}></span>
<select
aria-label="统计维度"
value={props.metric}
onChange={(event) => props.onMetricChange(event.target.value as HeatmapMetric)}
className={CONTROL}
>
<option value="locations"></option>
<option value="vehicles"></option>
</select>
</label>
<div className="flex min-w-0 gap-2 md:w-auto">
<button type="button" onClick={props.onReset} className="flex h-11 min-w-0 flex-1 items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 text-[12px] font-medium text-slate-600 transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 md:flex-none">
<RotateCcw size={14} strokeWidth={1.8} />
</button>
<button type="button" onClick={props.onHide} aria-label="隐藏筛选" title="隐藏筛选" className="grid h-11 w-11 shrink-0 place-items-center rounded-lg border border-slate-200 bg-white text-slate-400 transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 md:w-10">
<ChevronUp size={16} strokeWidth={1.8} />
</button>
</div>
</div>
);
}
@@ -1,249 +0,0 @@
import { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, ChevronLeft, Clock3, MapPinned, Maximize2, Minimize2, SlidersHorizontal } from 'lucide-react';
import AmapHeatmapCanvas from './AmapHeatmapCanvas';
import HeatmapDetailPanel from './HeatmapDetailPanel';
import HeatmapFilters from './HeatmapFilters';
import { fetchAmapConfig, fetchHeatmapMeta, fetchHeatmapPoints, fetchNearbyVehicles } from './api';
import type { AmapConfig, HeatmapMeta, HeatmapMetric, HeatmapResponse, NearbyResponse } from './types';
const DEFAULT_START = '2026-01-01';
const DEFAULT_END = '2026-07-13';
const numberFormat = new Intl.NumberFormat('zh-CN');
function Metric({ value, label }: { value: number; label: string }) {
return (
<div className="min-w-[82px] text-center max-md:min-w-[64px]">
<p className="text-[22px] font-semibold leading-none tracking-tight text-blue-600 tabular-nums max-md:text-[16px]">{numberFormat.format(value)}</p>
<p className="mt-1.5 text-[11px] text-slate-500 max-md:mt-1 max-md:text-[9px]">{label}</p>
</div>
);
}
export default function VehicleHeatmapModule() {
const [meta, setMeta] = useState<HeatmapMeta | null>(null);
const [config, setConfig] = useState<AmapConfig | null>(null);
const [data, setData] = useState<HeatmapResponse | null>(null);
const [nearby, setNearby] = useState<NearbyResponse | null>(null);
const [startDate, setStartDate] = useState(DEFAULT_START);
const [endDate, setEndDate] = useState(DEFAULT_END);
const [query, setQuery] = useState('');
const [batchModel, setBatchModel] = useState('');
const [metric, setMetric] = useState<HeatmapMetric>('locations');
const [isFullscreen, setIsFullscreen] = useState(false);
const [showFilters, setShowFilters] = useState(() => typeof window === 'undefined' || !window.matchMedia('(max-width: 767px)').matches);
const [showDetails, setShowDetails] = useState(() => typeof window === 'undefined' || !window.matchMedia('(max-width: 767px)').matches);
const [loading, setLoading] = useState(true);
const [nearbyLoading, setNearbyLoading] = useState(false);
const [error, setError] = useState('');
const deferredQuery = useDeferredValue(query.trim());
useEffect(() => {
if (!isFullscreen) return undefined;
const previousOverflow = document.body.style.overflow;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsFullscreen(false);
};
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
};
}, [isFullscreen]);
const toggleFullscreen = useCallback(() => {
setIsFullscreen((current) => !current);
}, []);
useEffect(() => {
const controller = new AbortController();
Promise.all([fetchHeatmapMeta(controller.signal), fetchAmapConfig(controller.signal)])
.then(([nextMeta, nextConfig]) => {
setMeta(nextMeta);
setConfig(nextConfig);
setStartDate(nextMeta.startDate);
setEndDate(nextMeta.endDate);
})
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('热力图基础配置加载失败');
});
return () => controller.abort();
}, []);
useEffect(() => {
const controller = new AbortController();
const timer = window.setTimeout(() => {
setLoading(true);
setError('');
fetchHeatmapPoints({ startDate, endDate, query: deferredQuery, batchModel, metric }, controller.signal)
.then((response) => {
setData(response);
setNearby(null);
})
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('热力图数据加载失败,请稍后重试');
})
.finally(() => setLoading(false));
}, 220);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [batchModel, deferredQuery, endDate, metric, startDate]);
const handleMapClick = useCallback((longitude: number, latitude: number) => {
const controller = new AbortController();
setNearbyLoading(true);
fetchNearbyVehicles({ lng: longitude, lat: latitude, startDate, endDate, query: deferredQuery, batchModel }, controller.signal)
.then(setNearby)
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('区域车辆明细加载失败');
})
.finally(() => setNearbyLoading(false));
}, [batchModel, deferredQuery, endDate, startDate]);
const reset = () => {
setStartDate(meta?.startDate || DEFAULT_START);
setEndDate(meta?.endDate || DEFAULT_END);
setQuery('');
setBatchModel('');
setMetric('locations');
setNearby(null);
};
const detail = useMemo(() => {
if (nearby) {
return {
title: '选中区域',
subtitle: `${nearby.center.lng.toFixed(3)}, ${nearby.center.lat.toFixed(3)} · 半径 ${nearby.radiusKm} 公里`,
locationCount: nearby.locationCount,
vehicleCount: nearby.vehicleCount,
vehicles: nearby.topVehicles,
selected: true,
};
}
return {
title: deferredQuery ? '车辆定位概览' : '全国 · 当前范围',
subtitle: `${batchModel ? `${batchModel} · ` : ''}${startDate} ${endDate}`,
locationCount: data?.locationCount || 0,
vehicleCount: data?.vehicleCount || 0,
vehicles: data?.topVehicles || [],
selected: false,
};
}, [batchModel, data, deferredQuery, endDate, nearby, startDate]);
return (
<section className={`flex flex-col overflow-hidden bg-white ${isFullscreen ? 'fixed inset-0 z-[100] h-screen w-screen min-h-0' : 'h-[100dvh] min-h-[620px] max-md:h-[calc(100dvh-4rem)] max-md:min-h-[520px]'}`}>
<header className="relative z-30 flex min-h-[98px] shrink-0 items-center justify-between border-b border-slate-200 bg-white px-8 max-md:min-h-[104px] max-md:flex-wrap max-md:gap-y-2 max-md:px-3 max-md:py-3">
<div className="min-w-0">
<h1 className="truncate text-[25px] font-semibold tracking-[-0.025em] text-slate-950 max-md:text-[18px]"></h1>
<p className="mt-1.5 flex items-center gap-1.5 text-[11px] text-slate-400 max-md:mt-1 max-md:text-[9px]"><MapPinned size={13} /> · </p>
</div>
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-7 max-md:order-3 max-md:static max-md:w-full max-md:translate-x-0 max-md:translate-y-0 max-md:justify-center max-md:gap-2">
<Metric value={data?.locationCount ?? meta?.locationCount ?? 0} label="定位点" />
<span className="h-8 w-px bg-slate-200" />
<Metric value={data?.vehicleCount ?? meta?.vehicleCount ?? 0} label="辆车" />
<span className="h-8 w-px bg-slate-200" />
<Metric value={data?.dayCount ?? meta?.dayCount ?? 0} label="天" />
</div>
<div className="flex items-center gap-3">
<p className="flex items-center gap-2 text-[12px] text-slate-500 max-xl:hidden"><Clock3 size={15} strokeWidth={1.7} /> {meta?.endDate || DEFAULT_END}</p>
<button
type="button"
onClick={toggleFullscreen}
className="grid h-9 w-9 place-items-center rounded-lg border border-slate-200 text-slate-500 transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600"
title={isFullscreen ? '退出全屏' : '地图全屏'}
aria-label={isFullscreen ? '退出地图全屏' : '地图全屏'}
aria-pressed={isFullscreen}
>
{isFullscreen ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
</button>
</div>
</header>
<div className="relative flex min-h-0 flex-1">
<div className="relative z-0 min-w-0 flex-1 overflow-hidden">
{config ? (
<AmapHeatmapCanvas
config={config}
points={data?.points || []}
max={data?.max || 1}
metric={metric}
focusQuery={deferredQuery}
onMapClick={handleMapClick}
/>
) : <div className="absolute inset-0 bg-slate-100" />}
{showFilters ? (
<div className="pointer-events-none absolute left-3 right-3 top-3 z-10 md:left-5 md:right-auto md:top-5">
<HeatmapFilters
startDate={startDate}
endDate={endDate}
minDate={meta?.startDate || DEFAULT_START}
maxDate={meta?.endDate || DEFAULT_END}
query={query}
batchModel={batchModel}
batchModels={meta?.batchModels || []}
metric={metric}
vehicles={meta?.vehicles || []}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onQueryChange={setQuery}
onBatchModelChange={setBatchModel}
onMetricChange={setMetric}
onReset={reset}
onHide={() => setShowFilters(false)}
/>
</div>
) : (
<button
type="button"
onClick={() => setShowFilters(true)}
aria-label="显示筛选"
className="absolute left-3 top-3 z-20 flex h-11 items-center gap-2 rounded-lg border border-slate-200 bg-white/95 px-3 text-xs font-medium text-slate-600 shadow-lg backdrop-blur-sm transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 md:left-5 md:top-5 md:h-10"
>
<SlidersHorizontal size={15} strokeWidth={1.8} />
</button>
)}
{!showDetails ? (
<button
type="button"
onClick={() => setShowDetails(true)}
aria-label="显示详情"
className={`absolute right-3 top-3 z-20 flex h-11 items-center gap-2 rounded-lg border border-slate-200 bg-white/95 px-3 text-xs font-medium text-slate-600 shadow-lg backdrop-blur-sm transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 md:right-5 md:top-5 md:h-10 ${showFilters ? 'max-md:hidden' : ''}`}
>
<ChevronLeft size={16} strokeWidth={1.8} />
</button>
) : null}
<div className="pointer-events-none absolute bottom-5 left-5 z-10 w-[286px] rounded-xl border border-slate-200/80 bg-white/95 p-3.5 shadow-[0_8px_30px_rgba(15,23,42,0.11)] backdrop-blur-sm max-lg:bottom-[calc(43vh+16px)] max-md:hidden">
<div className="flex items-center justify-between text-[11px] font-medium text-slate-700"><span>{metric === 'locations' ? '定位活跃度' : '车辆覆盖度'}</span>{loading ? <i className="h-3 w-3 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" /> : null}</div>
<p className="mt-1 text-[10px] leading-4 text-slate-500">
{metric === 'locations'
? '同一网格累计每日首个定位,同车跨天重复计数;对数平滑强度。'
: '同一网格按 VIN 去重,同车仅计 1 辆;平方根平滑强度。'}
</p>
<div className="mt-2.5 h-2.5 rounded-full bg-[linear-gradient(90deg,#2563eb_0%,#0891b2_25%,#16a34a_45%,#eab308_65%,#f97316_82%,#dc2626_100%)]" />
<div className="mt-1.5 flex justify-between text-[10px] text-slate-400"><span></span><span></span></div>
</div>
{error ? (
<div className="absolute bottom-5 left-1/2 z-30 flex -translate-x-1/2 items-center gap-2 rounded-lg bg-slate-900 px-4 py-2.5 text-xs text-white shadow-xl">
<AlertTriangle size={15} className="text-amber-300" />{error}
</div>
) : null}
</div>
{showDetails ? (
<HeatmapDetailPanel
{...detail}
loading={nearbyLoading}
onClose={() => setNearby(null)}
onHide={() => setShowDetails(false)}
/>
) : null}
</div>
</section>
);
}
-34
View File
@@ -1,34 +0,0 @@
import { fetchJson } from '../../auth/api-client';
import type { AmapConfig, HeatmapMeta, HeatmapMetric, HeatmapResponse, NearbyResponse } from './types';
export function fetchAmapConfig(signal?: AbortSignal) {
return fetchJson<AmapConfig>('/api/vehicle-heatmap/config', { signal });
}
export function fetchHeatmapMeta(signal?: AbortSignal) {
return fetchJson<HeatmapMeta>('/api/vehicle-heatmap/meta', { signal });
}
export function fetchHeatmapPoints(
params: { startDate: string; endDate: string; query: string; batchModel: string; metric: HeatmapMetric },
signal?: AbortSignal,
) {
const search = new URLSearchParams(params);
return fetchJson<HeatmapResponse>(`/api/vehicle-heatmap/points?${search}`, { signal });
}
export function fetchNearbyVehicles(
params: { lng: number; lat: number; startDate: string; endDate: string; query: string; batchModel: string },
signal?: AbortSignal,
) {
const search = new URLSearchParams({
lng: String(params.lng),
lat: String(params.lat),
startDate: params.startDate,
endDate: params.endDate,
query: params.query,
batchModel: params.batchModel,
radiusKm: '50',
});
return fetchJson<NearbyResponse>(`/api/vehicle-heatmap/nearby?${search}`, { signal });
}
-59
View File
@@ -1,59 +0,0 @@
export type HeatmapMetric = 'locations' | 'vehicles';
export type HeatmapPoint = {
lng: number;
lat: number;
count: number;
};
export type VehicleRank = {
vin: string;
plateNumber: string;
locationCount: number;
firstSeen: string;
lastSeen: string;
};
export type VehicleOption = {
vin: string;
plateNumber: string;
};
export type HeatmapMeta = {
startDate: string;
endDate: string;
locationCount: number;
vehicleCount: number;
dayCount: number;
totalLocationCount: number;
excludedLocationCount: number;
outsideMainlandCount: number;
tibetCount: number;
batchModels: string[];
vehicles: VehicleOption[];
};
export type HeatmapResponse = {
startDate: string;
endDate: string;
metric: HeatmapMetric;
locationCount: number;
vehicleCount: number;
dayCount: number;
points: HeatmapPoint[];
max: number;
topVehicles: VehicleRank[];
};
export type NearbyResponse = {
center: { lng: number; lat: number };
radiusKm: number;
locationCount: number;
vehicleCount: number;
topVehicles: VehicleRank[];
};
export type AmapConfig = {
key: string;
securityCode: string;
};
+1 -6
View File
@@ -1,14 +1,13 @@
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 = requireEnv('JWT_SECRET'); const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-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) => {
@@ -53,15 +52,11 @@ app.get('/exchange', async (c) => {
// 查询 depCode 对应的部门名称 // 查询 depCode 对应的部门名称
let depName = ''; let depName = '';
if (userInfo.depCode) { if (userInfo.depCode) {
try {
const [rows] = await pool.execute( const [rows] = await pool.execute(
'SELECT dep_name FROM tab_department WHERE dep_code = ? AND is_deleted = 0 LIMIT 1', 'SELECT dep_name FROM tab_department WHERE dep_code = ? AND is_deleted = 0 LIMIT 1',
[userInfo.depCode] [userInfo.depCode]
) as [{ dep_name: string }[], unknown]; ) as [{ dep_name: string }[], unknown];
depName = rows[0]?.dep_name || ''; depName = rows[0]?.dep_name || '';
} catch (e: any) {
if (e?.code !== 'ER_NO_SUCH_TABLE') throw e;
}
} }
const payload: JwtPayload = { const payload: JwtPayload = {

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