Author SHA1 Message Date
shishengliang 3ef30215e0 feat(assets): 每天23:55采集资产状态快照
ci/woodpecker/push/woodpecker Pipeline was successful
2026-10-30 16:57:33 +08:00
shishengliang e2f932232f feat(mileage): 接入区间汇总并复用查询结果
ci/woodpecker/push/woodpecker Pipeline was successful
2026-10-29 09:11:21 +08:00
64 changed files with 1464 additions and 695 deletions
+22 -12
View File
@@ -14,7 +14,7 @@
- 复用资产仓储 `MAIN_SQL`、`transformRow`、`isOperatingVehicle`、`isInventoryVehicle`。 - 复用资产仓储 `MAIN_SQL`、`transformRow`、`isOperatingVehicle`、`isInventoryVehicle`。
- 总数为未删除、未退出运营的车辆;运营为租赁/自营,库存为可运营/待运营;异动、待交车不另加。 - 总数为未删除、未退出运营的车辆;运营为租赁/自营,库存为可运营/待运营;异动、待交车不另加。
- 月末最后一天 `Asia/Shanghai` 17:00 为计划时点;只允许在 17:00:00(含)到 17:01:00(不含)内开始读取。 - 每天 `Asia/Shanghai` 23:55 为计划时点,归属当天;只允许在 23:55:00(含)到 23:56:00(不含)内开始读取。每天独立保存完整逐车状态;月度展示月末当天、年度展示12月31日快照,不累加每日存量。
- `captured_at` 是程序记录的实际开始读取时间,`scheduled_for` 是计划时点;两者不宣称完全相同。 - `captured_at` 是程序记录的实际开始读取时间,`scheduled_for` 是计划时点;两者不宣称完全相同。
- 一致性读取可在窗口内开始后继续执行;持久化失败最多重试三次,始终使用同一份已取得数据,不重新读晚到的台账。 - 一致性读取可在窗口内开始后继续执行;持久化失败最多重试三次,始终使用同一份已取得数据,不重新读晚到的台账。
- 当月在截止前显示实时点;年末前当年年度点显示实时点。截止后只能显示归档、归档中、失败或缺失。12 月快照直接用作年度年末点。 - 当月在截止前显示实时点;年末前当年年度点显示实时点。截止后只能显示归档、归档中、失败或缺失。12 月快照直接用作年度年末点。
@@ -33,9 +33,9 @@
## 存储 ## 存储
`bi_asset_history_settings` 保存首次实际启用时间。重复启用保留首次时间。 `bi_asset_history_settings.enabled_at` 保留旧采集首次启用时间;`daily_enabled_at` 保存每日采集首次实际启用时间。重复启用保留首次时间,升级前的日期不会被标记为漏采。
`bi_asset_history_batches` 保存年月、口径版本、计划及实际时间、来源、状态、失败码、全量校验计数及导入追溯信息。年月与口径版本唯一。正式归档为 `COMPLETE`,失败为 `FAILED`,错过为 `MISSED`。 `bi_asset_history_batches` 保存期间键、口径版本、计划及实际时间、来源、状态、失败码、全量校验计数及导入追溯信息。`period_key` 与口径版本唯一;新快照使用 YYYY-MM-DD,旧月度 YYYY-MM 键和明细原样保留。月度查询仅选月末日及旧月度记录;同月优先新日快照,但失败或缺失不会遮蔽旧 COMPLETE 归档。正式归档为 `COMPLETE`,失败为 `FAILED`,错过为 `MISSED`。
`bi_asset_history_vehicles` 保存车辆字符串 ID、原始运营/车辆状态、归档分类、公司、部门、负责人 ID 和冻结车辆 JSON。批次内车辆唯一;不以当前车辆表为外键。JSON 保留车牌、VIN、型号、品牌、业务归属等明细信息,排除无关的异动工作详情和待交车附件信息。 `bi_asset_history_vehicles` 保存车辆字符串 ID、原始运营/车辆状态、归档分类、公司、部门、负责人 ID 和冻结车辆 JSON。批次内车辆唯一;不以当前车辆表为外键。JSON 保留车牌、VIN、型号、品牌、业务归属等明细信息,排除无关的异动工作详情和待交车附件信息。
@@ -73,27 +73,37 @@ node --import tsx scripts/migrate-asset-history.ts --apply
如部分建表失败,修正原因后可重复执行;脚本使用 `CREATE TABLE IF NOT EXISTS`。新建空表不会伪造以前历史。 如部分建表失败,修正原因后可重复执行;脚本使用 `CREATE TABLE IF NOT EXISTS`。新建空表不会伪造以前历史。
授权启用后,将服务进程环境变量设为 `ASSET_HISTORY_CAPTURE_ENABLED=1` 并按现有流程重启服务。调度器首次成功连接时记录 `enabled_at`,启用前的时点不会追补。如果在月末 17:00 之后才首次启用,即使仍处于该分钟,也从下一个月末开始采集。 每日采集升级(2026-10-30):先停止旧版采集服务,执行基础建表迁移(已有表不变),再审阅并执行一次增量迁移:
按本需求确认日期 2026-10-19,如果在 2026-10-31 17:00 前成功启用,首个计划时点为北京时间 **2026-10-31 17:00**;实际计划以数据库首次启用时间和服务时间计算,不硬编码。 ```sh
node --import tsx scripts/migrate-asset-history.ts --daily
node --import tsx scripts/migrate-asset-history.ts --daily --apply
```
保持服务在计划时点在线。停止期间错过的 eligible 月份会在重启时记为 MISSED;已 FAILED 的记录保留诊断标记。记录到启用前没有归档,不能用重启时的实时数回填。日志前缀为 `[asset-history]`,包含年月及 captured/already-complete/locked/failed/missed 结果,不记录客户明细或连接凭据。 增量迁移把 `period_month CHAR(7)` 改为 `period_key CHAR(10)`,原唯一索引继续约束期间键和版本;新增可空 `daily_enabled_at`。这是一次性 ALTER,不能重复执行。应在目标库完成迁移后启动新版服务并设置采集开关;旧版程序不能直接使用新字段名。迁移不启动采集,也不修改旧归档的计划/实际时间。当前页面仍提供月度和年度浏览,普通日期快照保存在数据库中。
建议仅让受控服务账号写快照表;源表仍只需读权限。多实例可启用同一数据库下的同一采集配置,命名锁和数据库唯一约束共同保证幂等。启用开关为进程级配置,`enabled_at` 是历史启用记录,不能仅根据该记录判断某个进程现在是否运行调度器。
授权启用后,将服务进程环境变量设为 `ASSET_HISTORY_CAPTURE_ENABLED=1` 并按现有流程重启服务。调度器首次成功连接时记录 `daily_enabled_at`,启用前的时点不会追补。如果在当天 23:55 之后才首次启用,即使仍处于该分钟,也从次日开始采集。
按每日采集需求确认日期 2026-10-30,如果在当天 23:55 前成功启用,首个计划时点为北京时间 **2026-10-30 23:55**;实际计划以每日采集首次启用时间和服务时间计算,不硬编码。
保持服务在计划时点在线。停止期间错过的 eligible 日期会在重启时记为 MISSED;已 FAILED 的记录保留诊断标记。记录到启用前没有归档,不能用重启时的实时数回填。日志前缀为 `[asset-history]`,包含日期及 captured/already-complete/locked/failed/missed 结果,不记录客户明细或连接凭据。
建议仅让受控服务账号写快照表;源表仍只需读权限。多实例可启用同一数据库下的同一采集配置,命名锁和数据库唯一约束共同保证幂等。启用开关为进程级配置,`daily_enabled_at` 是历史启用记录,不能仅根据该记录判断某个进程现在是否运行调度器。
管理员可用以下只读 SQL 检查,不通过普通用户接口暴露全量计数: 管理员可用以下只读 SQL 检查,不通过普通用户接口暴露全量计数:
```sql ```sql
SELECT enabled_at FROM bi_asset_history_settings WHERE id=1; SELECT enabled_at FROM bi_asset_history_settings WHERE id=1;
SELECT period_month, scheduled_for, captured_at, completed_at, SELECT period_key, scheduled_for, captured_at, completed_at,
definition_version, source, status, failure_code, definition_version, source, status, failure_code,
total_count, operating_count, inventory_count total_count, operating_count, inventory_count
FROM bi_asset_history_batches ORDER BY period_month DESC LIMIT 12; FROM bi_asset_history_batches ORDER BY period_key DESC LIMIT 31;
SELECT b.period_month, b.total_count, COUNT(v.vehicle_id) AS detail_count SELECT b.period_key, b.total_count, COUNT(v.vehicle_id) AS detail_count
FROM bi_asset_history_batches b FROM bi_asset_history_batches b
LEFT JOIN bi_asset_history_vehicles v ON v.batch_id=b.id LEFT JOIN bi_asset_history_vehicles v ON v.batch_id=b.id
WHERE b.status='COMPLETE' WHERE b.status='COMPLETE'
GROUP BY b.id, b.period_month, b.total_count; GROUP BY b.id, b.period_key, b.total_count;
``` ```
## 回退 ## 回退
@@ -108,7 +118,7 @@ GROUP BY b.id, b.period_month, b.total_count;
提供仅表头模板 `docs/asset-history-import-template.csv`。使用 UTF-8 CSV,每行一辆车,同一文件只允许一个年月、一个口径版本和一个统计时点。 提供仅表头模板 `docs/asset-history-import-template.csv`。使用 UTF-8 CSV,每行一辆车,同一文件只允许一个年月、一个口径版本和一个统计时点。
必填字段:`period_month`、`scheduled_for`、`captured_at`、`definition_version`、`source_reference`、`vehicle_id`、`operation_status`。空缺车牌/VIN使用空值,不造车辆 ID。ID 均按文本读取。`scheduled_for` 必须是指定年月最后一天北京时间 17:00,`captured_at` 必须来自可信历史来源,不能把导入执行时间当作历史读取时间。 必填字段:`period_month`、`scheduled_for`、`captured_at`、`definition_version`、`source_reference`、`vehicle_id`、`operation_status`。空缺车牌/VIN使用空值,不造车辆 ID。ID 均按文本读取。旧版月度导入契约的 `scheduled_for` 是指定年月最后一天北京时间 17:00(保留旧格式,不能作为每日自动采集规则),`captured_at` 必须来自可信历史来源,不能把导入执行时间当作历史读取时间。
其余字段见模板。部门、负责人、主体缺失须显式留空,不能用今日台账补齐;部门/个人权限因此可能看不到未归属的运营车辆,这是数据缺失而非自动扩大授权的理由。展示型号、品牌、原始车辆状态按历史来源保存;历史状态映射复用现有分类函数,不接受退出运营车辆或未知运营状态作为有效存量。归档分类由导入校验程序计算,不直接信任文件中的分类列。 其余字段见模板。部门、负责人、主体缺失须显式留空,不能用今日台账补齐;部门/个人权限因此可能看不到未归属的运营车辆,这是数据缺失而非自动扩大授权的理由。展示型号、品牌、原始车辆状态按历史来源保存;历史状态映射复用现有分类函数,不接受退出运营车辆或未知运营状态作为有效存量。归档分类由导入校验程序计算,不直接信任文件中的分类列。
+56
View File
@@ -0,0 +1,56 @@
# 里程监控结果复用(2026-10-27)
本文为第二阶段历史记录。当前主列表已改用上游区间汇总;最新行为、权限趋势处理及实测见 [区间汇总接入与验证](mileage-summary-integration-2026-10-27.md)。下文的明细缓存和首次耗时描述不代表当前实现。
## 行为
同一服务进程内,按规范化起止日期和有序协议优先级缓存 OneOS 区间明细、结束日数据及对比日数据。有效期为成功获取后 5 分钟,最多保留 4 组,新增超限时淘汰最早写入项;相同在途查询合并。只在成功完成后写入缓存,失败可重试。
分页、排序、筛选、全屏和导出复用这组上游数据。车辆归属及考核关系每次从数据库读取,然后重新合并并按当前用户权限过滤。用户身份不需要进入原始里程缓存键;授权结果绝不跨用户缓存。车辆改派经理、变更部门后,下一次请求读取最新归属。
区间日趋势始终根据已授权且符合筛选条件的车辆重新计算,修正此前权限过滤后仍可能使用全量日趋势合计的问题。
点击「查询」可命中缓存;点击普通视图或全屏的刷新按钮发送 `refresh=1`,跳过已完成缓存,包括历史单日缓存。已有相同在途请求仍可共享。刷新失败时不回退旧的区间缓存。日期和搜索仍需确认后请求。
## 接口可观察性
`GET /api/mileage/monitoring?startDate=...&endDate=...`:
- 响应头 `X-Mileage-Cache`:`miss` / `hit` / `shared` / `refresh`。
- 响应字段 `queriedAt`:这组 OneOS 数据成功获取的时间。缓存命中时保持不变;前端使用它展示获取时间,避免将缓存读取误显示为重新采集。
- `refresh=1`:显式刷新。普通分页、排序和导出不附带该参数。
旧的 `date=` 单日兼容分支和不带日期的后台缓存分支保持原有机制;当前监控前端使用起止日期参数,走本次新增的复用路径。
## 限制与取舍
这是 5 分钟的进程内数据复用,不是持久化会话快照。过期、淘汰、进程重启或请求落到另一实例时会重新取数;跨过这些边界时数据可能变化,不能保证整场会话固定在同一快照。前端可随时主动刷新。没有新增 Redis、数据库表或后台清理任务。
缓存仅减少 OneOS 的重复拉取。数据库读取、权限过滤、汇总和排序仍按请求执行,保证归属及时变化;首次长区间仍受上游串行分页限制。导出接口耗时不包含浏览器生成 XLSX 文件的时间。
## 本地真实接口实测
条件:2026-10-01 至 2026-10-26,仪表来源,765 辆 BI 车辆;同一进程连续请求,包含 HTTP 传输和 JSON 读取。
| 操作 | 耗时 | 缓存状态 |
| --- | ---: | --- |
| 首次查询 50 条 | 10,723 ms | miss |
| 第 2 页 | 92 ms | hit |
| 改为升序 | 48 ms | hit |
| 导出接口返回全部 765 条 | 64 ms | hit |
| 主动刷新 | 9,869 ms | refresh |
| 刷新后再次导出 | 96 ms | hit |
前 4 次响应使用相同的 `queriedAt`;主动刷新更新该时间,再次导出复用新的结果。以上为单次样本,不是 P95。命中路径仍包含最新车辆信息 SQL 和重新聚合;首次/主动刷新仍受上游耗时影响。
## 验证
TypeScript 检查、30 项里程相关测试及生产构建通过;构建仍有已有的大资源包提示。
- 查询缓存单测覆盖命中、5 分钟过期、容量淘汰、来源顺序隔离、在途合并、刷新和失败重试。
- 路由集成测试以模拟 OneOS/SQL 验证:首页拉取 3 次上游数据,后续分页、排序、导出、筛选及权限变更均不新增上游请求;刷新再新增 3 次。归属变更后权限立即按新的数据库结果生效。
- 验证部门/个人范围的列表、筛选车牌、日趋势及空权限结果;缓存不会被某一用户的筛选污染。
- 浏览器模拟测试覆盖桌面 1440px 和手机 390px,各 15 项交互检查通过,包括刷新传参、刷新后恢复复用、未确认条件不请求。
- 实测脚本与结果保存在工作区上级 `outputs/mileage-query-cache-20261027/`。
上游首次查询建议见 [OneOS 性能优化反馈](oneos-mileage-performance-feedback-2026-10-27.md)。
@@ -0,0 +1,56 @@
# 里程监控查询交互与性能分析(2026-10-27)
当前已完成第三阶段 [区间汇总接入与验证](mileage-summary-integration-2026-10-27.md),首次查询不再拉取全量逐日明细。
本文保留第一阶段诊断与修改记录。后续已实现 OneOS 查询复用,见 [第二阶段实现与验证](mileage-query-cache-2026-10-27.md);可转交上游的独立建议见 [OneOS 性能优化反馈](oneos-mileage-performance-feedback-2026-10-27.md)。
## 本次修改
实时监控页将编辑中的筛选条件与已提交条件分开。日期输入、快捷日期、搜索选择、筛选标签清理、重置与排序修改只更新草稿,点击「查询」才提交。首次进入仍加载默认日期;主动刷新与确认后的滚动分页继续保留。移除页面每分钟自动刷新,避免编辑过程中产生后台查询。
高级筛选和全屏均提供查询按钮。草稿未提交时禁用刷新和导出,并暂停加载更多,避免使用未确认条件。日期不完整或起止倒置时不可提交。接口失败显示重试提示;数据统计使用返回结果的日期范围。保留原有权限过滤、数据来源规则及全屏参数差异。
范围:本次修改实时监控页;统计报表、每日汇报和单车详情的日期交互未改动。后端数据缓存、上游分页及实时性策略未修改。
## 测量方式
在本地开发环境中,使用已有配置调用 `queryRangeMileage`,仅执行数据读取。临时探针包裹 `fetch`(统计至 JSON 读取完毕)及数据库 `execute`,不记录凭据和车辆明细。来源为仪表数据,测量日期均为已结束的上海自然日。
这些是单次样本,不代表 P95 或高并发表现;总耗时为服务端查询函数耗时,包含网络、解析、数据库及合并计算,不包含浏览器渲染。同一进程重复查询可复用已有单日缓存。
| 区间 | 总耗时 | 区间上游记录数 | 区间分页请求数 |
| --- | ---: | ---: | ---: |
| 10 月 26 日 | 1,644 ms | 1,024 | 1 |
| 10 月 20–26 日,首次 | 3,100 ms | 7,168 | 2 |
| 10 月 20–26 日,重复 | 2,994 ms | 7,168 | 2 |
| 10 月 1–26 日,首次 | 11,123 ms | 26,624 | 6 |
| 10 月 1–26 日,重复 | 9,510 ms | 26,624 | 6 |
单日样本同时另发结束日和前一日的两个单日请求,各约 1.6 秒。26 天首次查询的 6 个游标分页分别约 2,283 / 2,114 / 1,775 / 1,762 / 1,682 / 1,466 ms,串行耗时合计约 11.08 秒,几乎占满总耗时。
车辆关联 SQL 返回 765 行,耗时 58–168 ms;考核目标关联 SQL 返回 512 行,耗时 25–125 ms。它们与上游查询并行执行。本次样本不支持把主要瓶颈归因于数据库或浏览器渲染。
## 原因与代码依据
1. **每次改条件就发请求。** `MonitoringView.tsx` 原本直接以全部输入状态构建 `monitoringQuery`,依赖它的 `loadFirstPage` 改变后由 Effect 自动调用;另有 60 秒轮询。本次已经改为显式提交。
2. **页面分页发生在全量查询之后。** `src/server/routes/mileage/monitoring.ts` 先调用 `queryRangeMileage`,然后执行权限过滤、业务筛选、排序和 `slice`。请求第 2 页或只选一辆车仍先执行全量区间查询。
3. **区间查询没有结果缓存。** `src/server/routes/mileage/oneos-api.ts` 中的 `fetchOneOsMileageDates` 只有 `rangeInflight`,完成后删除;单日查询的历史 6 小时缓存不覆盖此路径。重复查询同一个月仍发 6 次分页请求,与实测吻合。
4. **游标必须串行。** `requestRange` 每页 5,000 条,拿到 `nextCursor` 才能发下一页。不能直接并行发后续页。前端固定传 `startDate/endDate`,即使只选一天也会走区间接口,再额外取结束日单日数据。
5. **重试可能进一步拉长等待。** 本地配置每次上游调用超时 20 秒,最多尝试 3 次,中间等待 1 秒、2 秒。某一页连续超时即可约 63 秒;这是代码推导,实测样本没有出现超时。接口没有整个区间请求的总时限。
6. **开发环境首次请求有两次。** `src/main.tsx` 启用 React StrictMode,浏览器测试观察到首次挂载两次请求;用户点击查询只产生一次请求。上游相同在途请求可合并,但数据库工作仍重复。这与生产环境行为需区分,不能为消除开发重复而移除 StrictMode。
## 后续优化顺序
1. **优先让分页、排序、导出复用一次已完成查询的结果。** 采用明确的查询快照或按日期范围与数据来源缓存原始结果,再按当前用户权限过滤。需要定义刷新失效方式及允许的数据延迟;不能缓存某个用户过滤后的结果再跨用户共享。这样能去掉重复查询的主要等待,但不会缩短首次上游查询。
2. **对单日范围复用单日数据。** 避免区间与结束日两个接口重复查询同一天,验证缺失值、累计里程、来源优先级和前一日统计口径后再替换。
3. **减少首次查询传输量。** 上游若提供区间聚合结果,可直接获取每车汇总及每日总量;明确车牌查询可考虑传入车牌限制,但必须保留现有权限检查和筛选候选项语义。若继续只提供明细游标分页,长区间首次查询仍受串行页数限制。
未通过缩短超时、隐藏等待动画或并发重试来声称已解决单次查询耗时。
## 验证
- TypeScript 检查通过。
- 监控查询模型、日期范围及里程显示相关 12 项测试通过。
- Vite 生产构建通过,保留已有的大资源包提示。
- 浏览器真实接口检查:修改两个日期没有监控请求,点击查询后发送一条包含两个最终日期的请求。
- 浏览器自动化使用模拟数据验证 1440px 和 390px 两种宽度,各 11 项交互检查通过,均无页面运行时错误;运行脚本与结果位于工作区上级 `outputs/mileage-query-20261027/`。模拟测试用于验证请求触发条件,不用于性能结论。
@@ -0,0 +1,59 @@
# 区间里程汇总接入与验证(2026-10-27)
本地里程监控页已接入 `POST /api/v1/vehicles/mileage/summary/query`。依据用户提供的《BI 区间里程汇总接口对接说明》及 [OpenAPI 契约](https://open.d.lnoneos.com/open-api/openapi.yaml);沿用现有服务地址和 appKey,不新增凭据配置。
## 查询与展示
- 首次进入加载默认日期;修改日期、来源及业务筛选后点击「查询」才请求。保留手动刷新,取消定时轮询。
- 主列表每辆车使用一条区间汇总,不再先拉全部逐日明细。分页、排序、全屏及区间汇总导出复用服务器结果。
- 默认仪表来源仍为有序的 `GB32960, MQTT`;不因文档示例包含 `JT808` 而扩大来源。保留现有来源选择、366 天 BI 区间限制和上海自然日口径。
- 区间里程取 `periodMileageKm`,只合计有效日;真实零显示 0,无有效数据显示横杠。异常车辆保留其余有效日期的合计,并展示有效、缺失、异常天数及原因提示。
- 结束日累计里程取 `endDateTotalMileageKm`,独立于区间日里程质量。无读数时不回填历史累计。采集时间、源更新时间与查询时间保持区分。
- `CARRIED_FORWARD` 明细计为缺失,不冒充有效零。日趋势的 `null` 保留缺口。
- 单车详情按当前区间和来源按需取明细。导出菜单分为「导出区间汇总」和「导出逐日明细」;前者复用汇总,后者明确拉取所选车辆逐日数据,仍可能较慢。
## 权限与缓存
每次 BI 请求重新读取车辆归属和考核关系,再执行现有部门、个人等权限及业务筛选、客户名称脱敏。上游原始结果可复用,用户授权结果不跨用户缓存。单车详情也校验当前 BI 车辆权限。
上游全量授权车辆与 BI 当前可见车辆并不相同,因此不能直接把全量趋势展示给用户。当筛选范围缩小时,使用已授权且可见的车牌集合请求范围内趋势;空集合直接返回无数据趋势,不向上游传空数组(空数组在契约中代表全部)。分页、排序、全屏和导出使用 `includeDailyTrend=0`,无需重复获取趋势。
缓存键包含起止日期、有序来源及规范化车牌范围;同一在途请求合并,成功结果最多缓存 5 分钟且不超过上游快照到期时间,最多 4 项。显式刷新绕过已完成缓存;失败不回退成空数据或成功零值。401、403、429、504 等上游错误转为明确提示,保留错误码、traceId 和适用的 Retry-After。
缓存为进程内机制,过期、淘汰、重启或跨实例都会重新请求。BI 归属变化立即按最新数据库结果过滤;上游授权变化需要下次上游请求才能获知。列表与按权限重查的趋势可能来自两个生成时点,当前日或历史补报期间不保证原子一致;上游首次汇总本身也不是数据库原子快照。
## 真实数据对账
条件:2026-10-01 至 2026-10-26,仪表来源。仅执行读取,不修改上游或业务数据库。
- 旧明细接口:6 页、26,624 条记录;新汇总:1,024 辆、26 天趋势。
- 逐车对比有效里程合计、有效/缺失/异常天数:差异 0。
- 旧明细中 6,910 条 `NORMAL/CARRIED_FORWARD` 按契约计为缺失,真实正常记录 11,150 条,无数据 8,559 条,异常 5 条。
- BI 列表 765 辆,其中 761 辆匹配上游汇总,4 辆不在返回集合内,保持无可用里程。匹配车辆的区间里程、质量天数、结束日累计里程:差异 0。
- 按 BI 车辆集合重新计算的 26 天趋势与返回趋势逐日对比:差异 0。
## 性能实测
同一本地开发环境、同一日期和来源;单次样本,非 P95。HTTP 指标包含响应及 JSON 读取,不包含浏览器绘图或 XLSX 生成。
| 路径 | 耗时 |
| --- | ---: |
| 旧上游逐日明细,完整 6 页 | 10,250 ms |
| 新上游区间汇总,单次请求 | 1,869 ms |
| 新 BI 首次完整查询,缓存 miss | 3,862 ms |
| 第 2 页,缓存 hit | 68 ms |
| 升序查询,缓存 hit | 35 ms |
| 区间汇总导出数据,765 辆,缓存 hit | 39 ms |
| 主动刷新 | 3,572 ms |
| 刷新后再次导出数据,缓存 hit | 67 ms |
首次 BI 请求仍包含全量汇总、原有对比日读数、数据库查询,以及 BI 范围的趋势汇总,因此不能把上游单次 1.9 秒宣称为完整页面耗时。此前旧 BI 冷查询实测 10,723 ms,本次 3,862 ms,单样本约缩短 64%。
## 验证记录
- 38 项里程相关测试通过:覆盖契约解析、快照分页、真实零与缺失、部分异常有效合计、缓存与过期、部门/个人/空权限、归属变化、错误响应和按需明细。
- `npm run lint` 和 `npm run build` 通过;构建仍提示已有的大资源包。
- 真实浏览器 1440px / 390px:日期编辑不请求、确认后查询、765 辆和 26 天趋势、质量天数、手机查询按钮可见,均通过,无页面脚本异常。
- 实际下载并解析区间汇总 XLSX:1 张表、765 辆、质量列齐全;单车逐日 XLSX:2 张表、26 个日期列。单车详情请求日期与来源与列表一致。
本机验证脚本、脱离 Git 的原始结果及截图位于工作区父目录 `outputs/mileage-summary-20261027/`:`timings.json`、`reconciliation.json`、`browser-results.json`。原始响应含业务数据,不应直接发布到公共仓库或转发。
@@ -0,0 +1,103 @@
# OneOS 里程接口性能优化反馈
后续进展:上游已提供区间汇总接口,本地 BI 已接入并完成对账,见 [接入与验证记录](mileage-summary-integration-2026-10-27.md)。下文保留最初反馈背景。
日期:2026-10-27。本文供 BI 与 OneOS 团队对接,建议项尚未代表上游已实现或已承诺。
## 当前问题与业务影响
BI 查询一段日期的车辆里程时,先获取全部车辆逐日明细,完成后才能汇总、排序及展示首页。长区间查询的主要等待发生在 OneOS 游标分页:首次进入或主动刷新一个月的数据仍需约 10 秒。
相关现有接口:
- `POST /api/v1/vehicles/mileage/range/query`:日期范围、可选车牌、来源优先级、游标分页。
- `POST /api/v1/vehicles/mileage/query`:单日里程及累计里程。
## 可复现样本
请求条件如下,不包含任何访问凭据:
```json
{
"startDate": "2026-10-01",
"endDate": "2026-10-26",
"pageSize": 5000,
"protocolPriority": ["GB32960", "MQTT"]
}
```
后续请求携带上一页返回的 `nextCursor` 作为 `cursor`;BI 检查返回 `snapshotId` 的一致性。
在当前应用授权范围内,共返回 26,624 条逐日记录,需 6 次串行分页请求。2026-10-27 的一组测量结果如下(客户端计时至 JSON 读取完毕):
| 页 | 返回条数 | 耗时 |
| --- | ---: | ---: |
| 1 | 5,000 | 2,283 ms |
| 2 | 5,000 | 2,114 ms |
| 3 | 5,000 | 1,775 ms |
| 4 | 5,000 | 1,762 ms |
| 5 | 5,000 | 1,682 ms |
| 6 | 1,624 | 1,466 ms |
区间分页合计约 11.08 秒,BI 查询函数整体约 11.12 秒。同一进程重复查询约 9.51 秒,仍需请求 6 页。另一组 7 天查询约 3.10 秒,重复约 2.99 秒。
车辆归属 SQL 约 58–168 ms,与上游调用并行;合并后 BI 范围为 765 辆车,上游返回范围大于 BI 的有效车辆集合。
以上是单次本地实测,包含网络与 JSON 处理,不是服务端执行时间或 P95。尚无 OneOS 内部 trace,不能据此断言某条 SQL 缺索引或每个游标页都重复计算。
## BI 已做的优化
- 日期、筛选和搜索修改后,点击查询才发请求。
- 相同日期范围与来源优先级的 OneOS 结果在本地服务进程中复用 5 分钟,最多保留 4 组。
- 分页、排序、筛选和导出复用 OneOS 数据;主动刷新绕过该缓存,并重新获取相关单日数据。
- 车辆归属与考核关系仍每次读取,权限检查、筛选和汇总仍按当前用户执行,不缓存用户权限结果。
这解决重复查询,不能消除首次长区间查询的 6 次串行等待。上游优化的目标是首次查询、主动刷新及跨应用复用,而不是让 BI 延长缓存来掩盖延迟。
## 建议优先级
### P0:诊断每页耗时,并复用已生成快照
请提供一次上述查询的 `traceId` 与各页阶段耗时,至少区分:数据库读取、里程计算/协议选择、快照生成或读取、序列化、排队等待。
请确认:
1. 后续游标页是否只读取已有快照,还是重新扫描、计算整个区间?若存在重复计算,优先改为计算一次后分页读取。
2. 相同应用授权范围、日期范围、协议优先级和车牌集合,能否在短期内复用已完成快照?
3. 当前 `snapshotId` 的有效期及数据更新时间是什么?能否通过新增参数重用指定快照,而不是重新创建?
快照复用必须包含应用权限范围和有序协议优先级,车牌集合需规范化;不能仅按起止日期跨应用共享授权后的结果。需要返回有效期/数据时间,明确实时日更新、历史补数与主动刷新的失效规则。
### P1:提供按车辆的区间聚合,减少传输与调用次数
建议新增“区间汇总”能力(接口名称由 OneOS 确定,非现有协议):每辆车返回一条汇总,而非默认返回每辆车每天一条完整记录。BI 列表所需字段包括:
| 字段能力 | 用途及口径 |
| --- | --- |
| 车牌、VIN | 与 BI 车辆信息关联 |
| 区间有效日里程合计 | 不能用末日累计里程减首日累计里程替代 |
| 有效天数、缺失天数、异常状态 | 区分真实零里程和整段无数据 |
| 结束日累计里程 | 当前 BI 为它额外调用单日接口 |
| 区间开始日前一天的日里程 | 当前对比口径,若可一并提供可再减少一次调用 |
| 实际来源/混合来源、数据时间 | 保持来源优先级与数据质量可解释性 |
| 快照标识、计算/生成时间 | 后续查询与数据时间展示 |
这能将主列表传输量从约“车辆数 × 天数”降至“车辆数”。日报趋势/单车逐日明细按需读取,或通过字段投影仅返回必要的逐日值。
BI 的每日趋势必须遵循当前筛选与权限范围:不能直接采用全应用车辆的日合计。建议日趋势聚合支持同一快照和明确车牌集合,或在汇总结果中可选返回每车的精简逐日值,再由 BI 汇总。
### P2:减少单页耗时并提供稳定的调用预算
- 基于 trace 和实际执行计划,优化日期范围、车牌集合、来源优先级条件的读取路径;目前数据不足以指定具体索引。
- 核实响应压缩、连接复用及明细字段投影是否生效。调大 `pageSize` 可作为对照实验,但应同时评估响应大小、内存与单页超时;不能只靠加大页数掩盖重复计算。
- 目前 BI 每页超时 20 秒、最多尝试 3 次,退避 1 秒与 2 秒。单页连续超时可导致约 63 秒等待,这是配置推导,非本次实测异常。建议约定整个查询的期限、可重试错误码和可恢复快照,避免失效后从第一页重做。
## 建议验收方式
双方使用相同应用权限、同一车牌集合及协议优先级,对 1 / 7 / 31 天、冷查询与快照复用分别测量。记录首屏耗时、完整结果耗时、请求数、响应字节数和上游阶段耗时;有足够重复样本后再报告 P50/P95。
可讨论的目标:约 1,000 辆 × 31 天汇总首次完整返回在 3 秒内,快照复用在 300 ms 内。以上是待双方评估确认的目标,不是当前测量结果或已承诺 SLA。
结果正确性需与现有接口逐车核对:来源优先级、缺失值与零值、异常里程、累计里程、上海自然日起止边界、跨月区间及权限范围。缓存命中不能改变这些口径。
请上游反馈:阶段 trace、快照当前实现与复用能力、聚合接口可行性,以及可交付的第一阶段范围与排期。
@@ -0,0 +1,4 @@
-- Run once after 20261019_asset_history.sql; stop old capture workers before migration.
-- Existing YYYY-MM keys and archived vehicle rows remain unchanged.
ALTER TABLE bi_asset_history_batches CHANGE COLUMN period_month period_key CHAR(10) NOT NULL;
ALTER TABLE bi_asset_history_settings ADD COLUMN daily_enabled_at DATETIME(3) NULL;
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ln-bi", "name": "ln-bi",
"version": "1.1.21", "version": "1.1.19",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ln-bi", "name": "ln-bi",
"version": "1.1.21", "version": "1.1.19",
"dependencies": { "dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1", "@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0", "@hono/node-server": "^1.13.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ln-bi", "name": "ln-bi",
"private": true, "private": true,
"version": "1.1.21", "version": "1.1.19",
"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\"",
+2 -1
View File
@@ -1,5 +1,6 @@
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
const sql = await readFile(new URL('../migrations/20261019_asset_history.sql', import.meta.url), 'utf8'); const migration = process.argv.includes('--daily') ? '20261030_asset_history_daily.sql' : '20261019_asset_history.sql';
const sql = await readFile(new URL(`../migrations/${migration}`, import.meta.url), 'utf8');
if (!process.argv.includes('--apply')) { if (!process.argv.includes('--apply')) {
console.log(sql); console.log(sql);
console.log('Review only. Pass --apply only after approving the configured database migration.'); console.log('Review only. Pass --apply only after approving the configured database migration.');
-120
View File
@@ -1,120 +0,0 @@
import { useId, useRef, useState } from 'react';
import { ArrowRight, CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react';
export interface DateRange { start: string; end: string }
interface DateRangePickerProps {
value: DateRange;
onChange: (range: DateRange) => void;
quickPicks: Array<{ label: string; range: DateRange }>;
label?: string;
testId?: string;
className?: string;
maxDays?: number;
}
const weekdays = ['一', '二', '三', '四', '五', '六', '日'];
const ymd = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const parseDate = (value: string) => new Date(`${value}T12:00:00`);
const dayCount = (start: string, end: string) => Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000) + 1;
export function DateRangePicker({ value, onChange, quickPicks, label = '统计日期', testId, className = '', maxDays = Infinity }: DateRangePickerProps) {
const titleId = useId();
const dialog = useRef<HTMLDialogElement>(null);
const [draft, setDraft] = useState(value);
const [selecting, setSelecting] = useState<'start' | 'end'>('start');
const [month, setMonth] = useState(() => parseDate(value.start));
const [hovered, setHovered] = useState('');
const today = ymd(new Date());
const count = draft.end ? dayCount(draft.start, draft.end) : 0;
const valid = count > 0 && count <= maxDays;
const monthStart = new Date(month.getFullYear(), month.getMonth(), 1);
const previewEnd = draft.end || hovered || draft.start;
const rangeStart = draft.start < previewEnd ? draft.start : previewEnd;
const rangeEnd = draft.start > previewEnd ? draft.start : previewEnd;
const open = () => {
setDraft(value); setSelecting('start'); setMonth(parseDate(value.start)); setHovered('');
dialog.current?.showModal();
};
const choose = (date: string) => {
if (selecting === 'start') {
setDraft({ start: date, end: '' }); setSelecting('end');
} else {
setDraft(date < draft.start ? { start: date, end: draft.start } : { start: draft.start, end: date });
setSelecting('start');
}
setHovered('');
};
const calendar = (offset: number) => {
const current = new Date(monthStart.getFullYear(), monthStart.getMonth() + offset, 1);
const year = current.getFullYear(), m = current.getMonth();
const padding = (current.getDay() + 6) % 7;
const length = new Date(year, m + 1, 0).getDate();
return <div className={offset ? 'hidden sm:block' : ''} key={offset}>
<div className="mb-3 text-center text-sm font-bold text-slate-700">{year} 年 {m + 1} 月</div>
<div className="grid grid-cols-7 text-center">
{weekdays.map(day => <div key={day} className="py-2 text-[11px] font-medium text-slate-400">{day}</div>)}
{Array.from({ length: padding }, (_, i) => <div key={`blank-${i}`} />)}
{Array.from({ length }, (_, i) => {
const date = ymd(new Date(year, m, i + 1));
const endpoint = date === draft.start || date === draft.end;
const inRange = date >= rangeStart && date <= rangeEnd;
return <button key={date} type="button" aria-label={date} aria-pressed={endpoint}
onClick={() => choose(date)} onMouseEnter={() => { if (!draft.end) setHovered(date); }}
className={`relative my-0.5 h-10 text-xs font-semibold transition focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-blue-500 ${endpoint ? 'rounded-xl bg-blue-600 text-white shadow-sm' : inRange ? 'bg-blue-50 text-blue-700' : 'rounded-xl text-slate-600 hover:bg-slate-100'} ${date === today && !endpoint ? 'font-black text-blue-600' : ''}`}>
{i + 1}{date === today && <span className={`absolute bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full ${endpoint ? 'bg-white' : 'bg-blue-500'}`} />}
</button>;
})}
</div>
</div>;
};
return <>
<button type="button" data-testid={testId} aria-haspopup="dialog" onClick={open}
className={`flex w-full items-center gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-left shadow-sm transition hover:border-blue-300 hover:bg-blue-50/40 focus-visible:outline-2 focus-visible:outline-blue-500 ${className}`}>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600"><CalendarDays size={18} /></span>
<span className="min-w-0 flex-1">
<span className="block text-[10px] font-medium text-slate-400">{label}</span>
<span className="mt-1 flex items-center gap-2 text-[13px] font-bold text-slate-700"><span>{value.start.replaceAll('-', '/')}</span><ArrowRight size={13} className="shrink-0 text-slate-300" /><span>{value.end.replaceAll('-', '/')}</span></span>
</span>
<span className="hidden rounded-lg bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-500 sm:block">{dayCount(value.start, value.end)} 天</span>
<ChevronDown size={16} className="shrink-0 text-slate-400" />
</button>
<dialog ref={dialog} aria-labelledby={titleId} onClick={e => { if (e.target === e.currentTarget) dialog.current?.close(); }}
className="fixed inset-0 m-auto max-h-[90dvh] w-[calc(100%-24px)] max-w-[680px] overflow-y-auto rounded-3xl border-0 bg-white p-0 text-slate-700 shadow-2xl backdrop:bg-slate-950/30 backdrop:backdrop-blur-sm">
<div className="p-5 sm:p-6">
<div className="flex items-start justify-between gap-3">
<div><h3 id={titleId} className="text-base font-bold text-slate-900">选择统计日期</h3><p className="mt-1 text-xs text-slate-400">先选开始日期,再选结束日期</p></div>
<button type="button" aria-label="关闭日期选择" onClick={() => dialog.current?.close()} className="rounded-full p-2 text-slate-400 hover:bg-slate-100"><X size={18} /></button>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{quickPicks.map(pick => <button key={pick.label} type="button" onClick={() => { setDraft(pick.range); setMonth(parseDate(pick.range.start)); setSelecting('start'); setHovered(''); }}
className={`rounded-lg border px-3 py-2 text-xs font-medium transition ${draft.start === pick.range.start && draft.end === pick.range.end ? 'border-blue-200 bg-blue-50 text-blue-600' : 'border-slate-100 text-slate-500 hover:border-blue-200 hover:bg-blue-50'}`}>{pick.label}</button>)}
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
{(['start', 'end'] as const).map(field => <button key={field} type="button" onClick={() => { setSelecting(field); setHovered(''); setMonth(parseDate(draft[field] || draft.start)); }}
className={`rounded-xl border px-3 py-2.5 text-left transition ${selecting === field ? 'border-blue-400 bg-blue-50/60 ring-2 ring-blue-50' : 'border-slate-200 bg-slate-50/50'}`}>
<span className="block text-[10px] text-slate-400">{field === 'start' ? '开始日期' : '结束日期'}</span><span className="mt-1 block text-sm font-bold">{draft[field] || '请选择结束日期'}</span>
</button>)}
</div>
<div className="mt-5 flex items-center justify-between gap-2">
<button type="button" aria-label="上个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronLeft size={18} /></button>
<div className="flex gap-2">
<select aria-label="年份" value={monthStart.getFullYear()} onChange={e => setMonth(new Date(Number(e.target.value), monthStart.getMonth(), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
{Array.from({ length: Math.max(new Date().getFullYear() + 1, monthStart.getFullYear()) - Math.min(2016, monthStart.getFullYear()) + 1 }, (_, i) => Math.min(2016, monthStart.getFullYear()) + i).map(year => <option key={year} value={year}>{year} 年</option>)}
</select>
<select aria-label="月份" value={monthStart.getMonth()} onChange={e => setMonth(new Date(monthStart.getFullYear(), Number(e.target.value), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
{Array.from({ length: 12 }, (_, i) => <option key={i} value={i}>{i + 1} 月</option>)}
</select>
</div>
<button type="button" aria-label="下个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronRight size={18} /></button>
</div>
<div className="mt-3 grid gap-6 sm:grid-cols-2" onMouseLeave={() => setHovered('')}>{calendar(0)}{calendar(1)}</div>
</div>
<div className="sticky bottom-0 flex items-center justify-between gap-3 border-t border-slate-100 bg-white px-5 py-4 sm:px-6">
<span role="status" className={`text-xs ${count > maxDays ? 'text-red-500' : 'text-slate-400'}`}>{!draft.end ? '请选择结束日期' : count > maxDays ? `最多选择 ${maxDays} 天` : `已选 ${count} 天(包含首尾)`}</span>
<div className="flex shrink-0 gap-2">
<button type="button" onClick={() => dialog.current?.close()} className="rounded-xl px-3 py-2.5 text-xs font-semibold text-slate-500 hover:bg-slate-100">取消</button>
<button type="button" disabled={!valid} onClick={() => { onChange(draft); dialog.current?.close(); }} className="flex items-center gap-1 rounded-xl bg-blue-600 px-4 py-2.5 text-xs font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40"><Check size={14} />应用日期</button>
</div>
</div>
</dialog>
</>;
}
@@ -8,7 +8,6 @@ import type { OverviewViewProps } from './overview/types';
export function OverviewView(props: OverviewViewProps) { export function OverviewView(props: OverviewViewProps) {
return ( return (
<> <>
<div className="flex flex-col gap-2">
<SummaryMetrics <SummaryMetrics
summary={props.summary} summary={props.summary}
operatingRate={props.operatingRate} operatingRate={props.operatingRate}
@@ -18,7 +17,6 @@ export function OverviewView(props: OverviewViewProps) {
/> />
<AssetHistorySection subject={props.selectedSubject} onSubjects={props.onHistorySubjects} /> <AssetHistorySection subject={props.selectedSubject} onSubjects={props.onHistorySubjects} />
</div>
<FlowStatisticsCard <FlowStatisticsCard
flowRange={props.flowRange} flowRange={props.flowRange}
@@ -1,18 +1,118 @@
import { DateRangePicker } from '../../../../components/ui/DateRangePicker'; import { useRef, useState } from 'react';
import { ArrowRight, CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react';
import { getRecentFlowRange, getWeeklyFlowRange, type DateRange } from '../../model'; import { getRecentFlowRange, getWeeklyFlowRange, type DateRange } from '../../model';
const weekdays = ['一', '二', '三', '四', '五', '六', '日'];
const ymd = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; const ymd = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const parseDate = (value: string) => new Date(`${value}T12:00:00`);
const shiftDay = (date: Date, offset: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + offset); const shiftDay = (date: Date, offset: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + offset);
const dayCount = (start: string, end: string) => Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000) + 1;
export function FlowDateRangePicker({ value, onChange, dateLabel = '运维签章时间' }: { value: DateRange; onChange: (range: DateRange) => void; dateLabel?: string }) { export function FlowDateRangePicker({ value, onChange, dateLabel = '运维签章时间' }: { value: DateRange; onChange: (range: DateRange) => void; dateLabel?: string }) {
const dialog = useRef<HTMLDialogElement>(null);
const [draft, setDraft] = useState(value);
const [selecting, setSelecting] = useState<'start' | 'end'>('start');
const [month, setMonth] = useState(() => parseDate(value.start));
const [hovered, setHovered] = useState('');
const today = ymd(new Date()); const today = ymd(new Date());
return <DateRangePicker value={value} onChange={onChange} const count = draft.end ? dayCount(draft.start, draft.end) : 0;
label={`统计日期 · ${dateLabel}`} testId="asset-flow-date-range" className="mt-3" maxDays={370} const valid = count > 0 && count <= 370;
quickPicks={[ const monthStart = new Date(month.getFullYear(), month.getMonth(), 1);
const previewEnd = draft.end || hovered || draft.start;
const rangeStart = draft.start < previewEnd ? draft.start : previewEnd;
const rangeEnd = draft.start > previewEnd ? draft.start : previewEnd;
const quickPicks = [
{ label: '今天', range: { start: today, end: today } }, { label: '今天', range: { start: today, end: today } },
{ label: '昨天', range: { start: ymd(shiftDay(new Date(), -1)), end: ymd(shiftDay(new Date(), -1)) } }, { label: '昨天', range: { start: ymd(shiftDay(new Date(), -1)), end: ymd(shiftDay(new Date(), -1)) } },
{ label: '业务周', range: getWeeklyFlowRange() }, { label: '业务周', range: getWeeklyFlowRange() },
{ label: '近 7 天', range: getRecentFlowRange() }, { label: '近 7 天', range: getRecentFlowRange() },
{ label: '近 30 天', range: { start: ymd(shiftDay(new Date(), -29)), end: today } }, { label: '近 30 天', range: { start: ymd(shiftDay(new Date(), -29)), end: today } },
]} />; ];
const open = () => {
setDraft(value); setSelecting('start'); setMonth(parseDate(value.start)); setHovered('');
dialog.current?.showModal();
};
const choose = (date: string) => {
if (selecting === 'start') {
setDraft({ start: date, end: '' }); setSelecting('end');
} else {
setDraft(date < draft.start ? { start: date, end: draft.start } : { start: draft.start, end: date });
setSelecting('start');
}
setHovered('');
};
const calendar = (offset: number) => {
const current = new Date(monthStart.getFullYear(), monthStart.getMonth() + offset, 1);
const year = current.getFullYear(), m = current.getMonth();
const padding = (current.getDay() + 6) % 7;
const length = new Date(year, m + 1, 0).getDate();
return <div className={offset ? 'hidden sm:block' : ''} key={offset}>
<div className="mb-3 text-center text-sm font-bold text-slate-700">{year} 年 {m + 1} 月</div>
<div className="grid grid-cols-7 text-center">
{weekdays.map(day => <div key={day} className="py-2 text-[11px] font-medium text-slate-400">{day}</div>)}
{Array.from({ length: padding }, (_, i) => <div key={`blank-${i}`} />)}
{Array.from({ length }, (_, i) => {
const date = ymd(new Date(year, m, i + 1));
const endpoint = date === draft.start || date === draft.end;
const inRange = date >= rangeStart && date <= rangeEnd;
return <button key={date} type="button" aria-label={date} aria-pressed={endpoint}
onClick={() => choose(date)} onMouseEnter={() => { if (!draft.end) setHovered(date); }}
className={`relative my-0.5 h-10 text-xs font-semibold transition focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-blue-500 ${endpoint ? 'rounded-xl bg-blue-600 text-white shadow-sm' : inRange ? 'bg-blue-50 text-blue-700' : 'rounded-xl text-slate-600 hover:bg-slate-100'} ${date === today && !endpoint ? 'font-black text-blue-600' : ''}`}>
{i + 1}{date === today && <span className={`absolute bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full ${endpoint ? 'bg-white' : 'bg-blue-500'}`} />}
</button>;
})}
</div>
</div>;
};
return <>
<button type="button" data-testid="asset-flow-date-range" aria-haspopup="dialog" onClick={open}
className="mt-3 flex w-full items-center gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-left shadow-sm transition hover:border-blue-300 hover:bg-blue-50/40 focus-visible:outline-2 focus-visible:outline-blue-500">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600"><CalendarDays size={18} /></span>
<span className="min-w-0 flex-1">
<span className="block text-[10px] font-medium text-slate-400">统计日期 · {dateLabel}</span>
<span className="mt-1 flex items-center gap-2 text-[13px] font-bold text-slate-700"><span>{value.start.replaceAll('-', '/')}</span><ArrowRight size={13} className="shrink-0 text-slate-300" /><span>{value.end.replaceAll('-', '/')}</span></span>
</span>
<span className="hidden rounded-lg bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-500 sm:block">{dayCount(value.start, value.end)} 天</span>
<ChevronDown size={16} className="shrink-0 text-slate-400" />
</button>
<dialog ref={dialog} aria-labelledby="flow-date-title" onClick={e => { if (e.target === e.currentTarget) dialog.current?.close(); }}
className="fixed inset-0 m-auto max-h-[90dvh] w-[calc(100%-24px)] max-w-[680px] overflow-y-auto rounded-3xl border-0 bg-white p-0 text-slate-700 shadow-2xl backdrop:bg-slate-950/30 backdrop:backdrop-blur-sm">
<div className="p-5 sm:p-6">
<div className="flex items-start justify-between gap-3">
<div><h3 id="flow-date-title" className="text-base font-bold text-slate-900">选择统计日期</h3><p className="mt-1 text-xs text-slate-400">先选开始日期,再选结束日期</p></div>
<button type="button" aria-label="关闭日期选择" onClick={() => dialog.current?.close()} className="rounded-full p-2 text-slate-400 hover:bg-slate-100"><X size={18} /></button>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{quickPicks.map(pick => <button key={pick.label} type="button" onClick={() => { setDraft(pick.range); setMonth(parseDate(pick.range.start)); setSelecting('start'); setHovered(''); }}
className={`rounded-lg border px-3 py-2 text-xs font-medium transition ${draft.start === pick.range.start && draft.end === pick.range.end ? 'border-blue-200 bg-blue-50 text-blue-600' : 'border-slate-100 text-slate-500 hover:border-blue-200 hover:bg-blue-50'}`}>{pick.label}</button>)}
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
{(['start', 'end'] as const).map(field => <button key={field} type="button" onClick={() => { setSelecting(field); setHovered(''); setMonth(parseDate(draft[field] || draft.start)); }}
className={`rounded-xl border px-3 py-2.5 text-left transition ${selecting === field ? 'border-blue-400 bg-blue-50/60 ring-2 ring-blue-50' : 'border-slate-200 bg-slate-50/50'}`}>
<span className="block text-[10px] text-slate-400">{field === 'start' ? '开始日期' : '结束日期'}</span><span className="mt-1 block text-sm font-bold">{draft[field] || '请选择结束日期'}</span>
</button>)}
</div>
<div className="mt-5 flex items-center justify-between gap-2">
<button type="button" aria-label="上个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronLeft size={18} /></button>
<div className="flex gap-2">
<select aria-label="年份" value={monthStart.getFullYear()} onChange={e => setMonth(new Date(Number(e.target.value), monthStart.getMonth(), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
{Array.from({ length: Math.max(new Date().getFullYear() + 1, monthStart.getFullYear()) - Math.min(2016, monthStart.getFullYear()) + 1 }, (_, i) => Math.min(2016, monthStart.getFullYear()) + i).map(year => <option key={year} value={year}>{year} 年</option>)}
</select>
<select aria-label="月份" value={monthStart.getMonth()} onChange={e => setMonth(new Date(monthStart.getFullYear(), Number(e.target.value), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
{Array.from({ length: 12 }, (_, i) => <option key={i} value={i}>{i + 1} 月</option>)}
</select>
</div>
<button type="button" aria-label="下个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronRight size={18} /></button>
</div>
<div className="mt-3 grid gap-6 sm:grid-cols-2" onMouseLeave={() => setHovered('')}>{calendar(0)}{calendar(1)}</div>
</div>
<div className="sticky bottom-0 flex items-center justify-between gap-3 border-t border-slate-100 bg-white px-5 py-4 sm:px-6">
<span role="status" className={`text-xs ${count > 370 ? 'text-red-500' : 'text-slate-400'}`}>{!draft.end ? '请选择结束日期' : count > 370 ? '最多选择 370 天' : `已选 ${count} 天(包含首尾)`}</span>
<div className="flex shrink-0 gap-2">
<button type="button" onClick={() => dialog.current?.close()} className="rounded-xl px-3 py-2.5 text-xs font-semibold text-slate-500 hover:bg-slate-100">取消</button>
<button type="button" disabled={!valid} onClick={() => { onChange(draft); dialog.current?.close(); }} className="flex items-center gap-1 rounded-xl bg-blue-600 px-4 py-2.5 text-xs font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40"><Check size={14} />应用日期</button>
</div>
</div>
</dialog>
</>;
} }
@@ -10,7 +10,7 @@ export function SummaryMetrics({
return ( return (
<> <>
{/* Header Summary - Ultra Compact */} {/* Header Summary - Ultra Compact */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-2"> <div className="grid grid-cols-2 md:grid-cols-3 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="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"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', source: 'asset', title: '资产概览' })}> onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', source: 'asset', title: '资产概览' })}>
@@ -66,7 +66,7 @@ export function SummaryMetrics({
</div> </div>
<div data-testid="asset-operation-ratio-strip" className="rounded-xl border border-slate-100 bg-white/85 px-3 py-1 shadow-sm"> <div data-testid="asset-operation-ratio-strip" className="mb-3 rounded-2xl border border-slate-100 bg-white/85 px-3 py-2 shadow-sm">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="shrink-0 text-[11px] font-black text-slate-400">运营概览</div> <div className="shrink-0 text-[11px] font-black text-slate-400">运营概览</div>
<div className="grid min-w-0 flex-1 grid-cols-2 divide-x divide-slate-100 text-center"> <div className="grid min-w-0 flex-1 grid-cols-2 divide-x divide-slate-100 text-center">
@@ -43,7 +43,7 @@ export function FaultAnalysisModal({ metric, onClose, source }: { metric: FaultO
}, [metric, revision, source?.url]); }, [metric, revision, source?.url]);
const records = useMemo(() => data?.rows || [], [data]); const records = useMemo(() => data?.rows || [], [data]);
const handlerLabels = useMemo(() => new Map(records.flatMap(row => row.handlers.map(h => [h.value, h.label] as const))), [records]); const handlerLabels = useMemo(() => new Map(records.flatMap(row => row.handlers.map(h => [h.value, h.label] as const))), [records]);
const label = (d: FaultAnalysisDimension, value: string) => d === 'plate' ? value : d === 'faultStatus' ? FAULT_CLOSURE_LABELS[value] || value : d === 'handler' ? handlerLabels.get(value) || '未指派' : d === 'level' ? value || '未标注' : d === 'customer' ? data?.customerLabels[value] || '未关联客户' : getDimensionValueLabel(d, value); const label = (d: FaultAnalysisDimension, value: string) => d === 'plate' ? value : d === 'faultStatus' ? FAULT_CLOSURE_LABELS[value] || value : d === 'handler' ? handlerLabels.get(value) || '未指派' : d === 'customer' ? data?.customerLabels[value] || '未关联客户' : getDimensionValueLabel(d, value);
const options = available.map(key => { const options = available.map(key => {
const otherFilters = { ...filters }; delete otherFilters[key]; const otherFilters = { ...filters }; delete otherFilters[key];
const counts = new Map<string, number>(); const counts = new Map<string, number>();
@@ -78,7 +78,7 @@ export function FaultAnalysisModal({ metric, onClose, source }: { metric: FaultO
<div className="analysis-scroll"> <div className="analysis-scroll">
{error ? <div className="fault-message" role="alert">{error}<button onClick={() => setRevision(v => v + 1)}>重试</button></div> : !data ? <FaultAnalysisLoading /> : group ? <> {error ? <div className="fault-message" role="alert">{error}<button onClick={() => setRevision(v => v + 1)}>重试</button></div> : !data ? <FaultAnalysisLoading /> : group ? <>
<div className="analysis-table-tools"><button className="analysis-button" onClick={() => { setGroup(null); setLimit(50); }}><ArrowLeft size={14} />返回分组</button><span>{groupLabel(group)} · {details.length.toLocaleString()} 单</span><label className="fault-detail-search"><Search size={14} /><input aria-label="搜索故障明细" placeholder="搜索单号、车牌、描述、处置人" value={search} onChange={e => { setSearch(e.target.value); setLimit(50); }} /></label></div> <div className="analysis-table-tools"><button className="analysis-button" onClick={() => { setGroup(null); setLimit(50); }}><ArrowLeft size={14} />返回分组</button><span>{groupLabel(group)} · {details.length.toLocaleString()} 单</span><label className="fault-detail-search"><Search size={14} /><input aria-label="搜索故障明细" placeholder="搜索单号、车牌、描述、处置人" value={search} onChange={e => { setSearch(e.target.value); setLimit(50); }} /></label></div>
<div className="analysis-group-table is-merged fault-detail-table"><table><thead><tr>{['车牌 / 故障单号', '故障程度', '运维处置人', '故障描述', '状态', '提报时间', '解决时间', '运营城市', '客户 / 部门'].map(h => <th key={h}>{h}</th>)}</tr></thead><tbody>{details.slice(0, limit).map(row => <tr key={row.id}><td><strong>{row.plate || '未关联车辆'}</strong><small>{row.code || '未编号'}</small></td><td>{row.level || '未标注'}</td><td>{row.handlers.map(h => h.label).join('、') || '未指派'}</td><td>{row.description || '—'}</td><td>{row.status}</td><td>{row.reportedAt || '—'}</td><td>{row.archivedAt || '—'}</td><td>{row.city}</td><td>{row.customerLabel}<small>{row.department}</small></td></tr>)}</tbody></table></div> <div className="analysis-group-table is-merged fault-detail-table"><table><thead><tr>{['车牌 / 故障单号', '运维处置人', '故障描述', '状态', '提报时间', '解决时间', '运营城市', '客户 / 部门'].map(h => <th key={h}>{h}</th>)}</tr></thead><tbody>{details.slice(0, limit).map(row => <tr key={row.id}><td><strong>{row.plate || '未关联车辆'}</strong><small>{row.code || '未编号'}</small></td><td>{row.handlers.map(h => h.label).join('、') || '未指派'}</td><td>{row.description || '—'}</td><td>{row.status}</td><td>{row.reportedAt || '—'}</td><td>{row.archivedAt || '—'}</td><td>{row.city}</td><td>{row.customerLabel}<small>{row.department}</small></td></tr>)}</tbody></table></div>
{!details.length && <p className="fault-empty">没有符合条件的故障明细</p>} {!details.length && <p className="fault-empty">没有符合条件的故障明细</p>}
{details.length > limit && <button className="analysis-button fault-more" onClick={() => setLimit(v => v + 50)}>加载更多明细(已显示 {limit} / {details.length})</button>} {details.length > limit && <button className="analysis-button fault-more" onClick={() => setLimit(v => v + 50)}>加载更多明细(已显示 {limit} / {details.length})</button>}
</> : <> </> : <>
@@ -50,7 +50,7 @@ export default function FaultManagement(){
</section> </section>
<div className="fd-today-stats"><span>实时统计</span><button onClick={()=>drill('今日新增','reported')}>今日新增 <b>{t?.todayReported}</b></button><button onClick={()=>drill('本日解决','closed')}>本日解决 <b>{t?.todayClosed}</b></button></div> <div className="fd-today-stats"><span>实时统计</span><button onClick={()=>drill('今日新增','reported')}>今日新增 <b>{t?.todayReported}</b></button><button onClick={()=>drill('本日解决','closed')}>本日解决 <b>{t?.todayClosed}</b></button></div>
<div className="fd-insight"><Info size={18}/><strong>{data.distributions.region[0]?`本期关注:${data.distributions.region[0].label}故障量最高,共 ${data.distributions.region[0].reported} 单;当前 ${t?.overdue} 单在办故障已逾期。`:'所选条件下暂无本期故障,可调整日期或筛选范围。'}</strong><span>按数量描述,不代表故障发生率。</span><button disabled={!t?.open} onClick={showAllOpen}>查看关联故障 <ArrowUpRight size={13}/></button></div> <div className="fd-insight"><Info size={18}/><strong>{data.distributions.region[0]?`本期关注:${data.distributions.region[0].label}故障量最高,共 ${data.distributions.region[0].reported} 单;当前 ${t?.overdue} 单在办故障已逾期。`:'所选条件下暂无本期故障,可调整日期或筛选范围。'}</strong><span>按数量描述,不代表故障发生率。</span><button disabled={!t?.open} onClick={showAllOpen}>查看关联故障 <ArrowUpRight size={13}/></button></div>
{tab==='多维分析'&&<div className="fd-analysis-banner"><div><h2>组合维度,追溯故障</h2><p>支持运维处置人、车牌、故障程度、工单状态、车型、区域、客户等 13 个维度,多选筛选与明细下钻。</p></div><button className="fd-query" onClick={()=>drill('本期故障多维分析')}>打开多维分析</button></div>} {tab==='多维分析'&&<div className="fd-analysis-banner"><div><h2>组合维度,追溯故障</h2><p>支持运维处置人、车牌、工单状态、车型、区域、客户等 12 个维度,多选筛选与明细下钻。</p></div><button className="fd-query" onClick={()=>drill('本期故障多维分析')}>打开多维分析</button></div>}
<DashboardPanels data={data} drill={drill}/> <DashboardPanels data={data} drill={drill}/>
<section className="fd-panel fd-focus" aria-label="重点故障跟进"> <section className="fd-panel fd-focus" aria-label="重点故障跟进">
<div className="fd-panel-title"> <div className="fd-panel-title">
+3 -4
View File
@@ -2,10 +2,10 @@ import type { AssetAnalysisDimension } from '../../../shared/assets/analytics';
import type { FaultAnalysisRecord } from '../../../shared/assets/faults'; import type { FaultAnalysisRecord } from '../../../shared/assets/faults';
import { DIMENSION_LABELS, getAvailableDimensions } from '../analytics-model'; import { DIMENSION_LABELS, getAvailableDimensions } from '../analytics-model';
export type FaultAnalysisDimension = AssetAnalysisDimension | 'handler' | 'plate' | 'faultStatus' | 'level'; export type FaultAnalysisDimension = AssetAnalysisDimension | 'handler' | 'plate' | 'faultStatus';
export type FaultAnalysisFilters = Partial<Record<FaultAnalysisDimension, string[]>>; export type FaultAnalysisFilters = Partial<Record<FaultAnalysisDimension, string[]>>;
export const FAULT_ANALYSIS_DIMENSIONS: FaultAnalysisDimension[] = getAvailableDimensions('assets').flatMap(d => d === 'type' ? [d, 'handler', 'plate', 'faultStatus', 'level'] : [d]); export const FAULT_ANALYSIS_DIMENSIONS: FaultAnalysisDimension[] = getAvailableDimensions('assets').flatMap(d => d === 'type' ? [d, 'handler', 'plate', 'faultStatus'] : [d]);
export const FAULT_ANALYSIS_LABELS = { ...DIMENSION_LABELS, handler: '运维处置人', plate: '车牌', faultStatus: '故障工单状态', level: '故障程度' }; export const FAULT_ANALYSIS_LABELS = { ...DIMENSION_LABELS, handler: '运维处置人', plate: '车牌', faultStatus: '故障工单状态' };
export const FAULT_CLOSURE_LABELS: Record<string, string> = { closed: '已闭环', unclosed: '未闭环' }; export const FAULT_CLOSURE_LABELS: Record<string, string> = { closed: '已闭环', unclosed: '未闭环' };
export interface FaultAnalysisGroup { export interface FaultAnalysisGroup {
key: string; values: Partial<Record<FaultAnalysisDimension, string>>; key: string; values: Partial<Record<FaultAnalysisDimension, string>>;
@@ -14,7 +14,6 @@ export interface FaultAnalysisGroup {
export function faultDimensionValues(row: FaultAnalysisRecord, dimension: FaultAnalysisDimension): string[] { export function faultDimensionValues(row: FaultAnalysisRecord, dimension: FaultAnalysisDimension): string[] {
if (dimension === 'plate') return [row.plate?.trim() || '未关联车辆']; if (dimension === 'plate') return [row.plate?.trim() || '未关联车辆'];
if (dimension === 'faultStatus') return [row.status === '已归档' ? 'closed' : 'unclosed']; if (dimension === 'faultStatus') return [row.status === '已归档' ? 'closed' : 'unclosed'];
if (dimension === 'level') return [row.level?.trim() || '未标注'];
return dimension === 'handler' ? [...new Set(row.handlers.length ? row.handlers.map(h => h.value) : ['unassigned'])] : [row.cell[dimension]]; return dimension === 'handler' ? [...new Set(row.handlers.length ? row.handlers.map(h => h.value) : ['unassigned'])] : [row.cell[dimension]];
} }
export function filterFaultRecords(rows: FaultAnalysisRecord[], filters: FaultAnalysisFilters = {}): FaultAnalysisRecord[] { export function filterFaultRecords(rows: FaultAnalysisRecord[], filters: FaultAnalysisFilters = {}): FaultAnalysisRecord[] {
@@ -69,17 +69,18 @@ export function AssetHistorySection({ subject, onSubjects }: { subject: string |
<div className="asset-history-toolbar"> <div className="asset-history-toolbar">
{mode === 'monthly' && <HistorySelect label="历史年份" value={String(year)} options={(data?.years || [year]).map(y => ({ value: String(y), label: `${y}年` }))} onChange={value => { setYear(Number(value)); setDetails(null); }} />} {mode === 'monthly' && <HistorySelect label="历史年份" value={String(year)} options={(data?.years || [year]).map(y => ({ value: String(y), label: `${y}年` }))} onChange={value => { setYear(Number(value)); setDetails(null); }} />}
{data && <HistorySelect label="统计期间" value={selected || ''} onChange={value => { setSelected(value); setDetails(null); }} options={data.points.map(p => ({ value: p.period, label: p.label, detail: p.state === 'RECONSTRUCTED' ? '估算' : p.state === 'LIVE' ? '实时' : p.state === 'ARCHIVED' ? '归档' : p.state === 'FUTURE' ? '未来' : p.state === 'MISSING' ? '缺失' : HISTORY_STATE_LABELS[p.state] }))} />} {data && <HistorySelect label="统计期间" value={selected || ''} onChange={value => { setSelected(value); setDetails(null); }} options={data.points.map(p => ({ value: p.period, label: p.label, detail: p.state === 'RECONSTRUCTED' ? '估算' : p.state === 'LIVE' ? '实时' : p.state === 'ARCHIVED' ? '归档' : p.state === 'FUTURE' ? '未来' : p.state === 'MISSING' ? '缺失' : HISTORY_STATE_LABELS[p.state] }))} />}
<span className="asset-history-cutoff" title="每月最后一天北京时间17:00采集;年度复用12月快照">月末 17:00</span> <span title="每天北京时间23:55采集;月度取月末、年度取年末快照">每日 23:55</span>
<button type="button" className="asset-history-refresh" aria-label="刷新资产历史趋势" disabled={loading} onClick={() => setRetry(x => x + 1)}><RefreshCw size={15} /></button> <button type="button" className="asset-history-refresh" aria-label="刷新资产历史趋势" disabled={loading} onClick={() => setRetry(x => x + 1)}><RefreshCw size={15} /></button>
</div> </div>
</div> </div>
{loading && !data ? <p className="asset-history-empty" role="status">正在加载资产历史趋势…</p> : !data ? <p className="asset-history-empty" role="alert">{error || '暂无可用数据'} <button type="button" onClick={() => setRetry(x => x + 1)}>重试</button></p> : <> {loading && !data ? <p className="asset-history-empty" role="status">正在加载资产历史趋势…</p> : !data ? <p className="asset-history-empty" role="alert">{error || '暂无可用数据'} <button type="button" onClick={() => setRetry(x => x + 1)}>重试</button></p> : <>
{error && <p className="asset-history-warning" role="alert">{error} · 保留上次查询结果 <button type="button" onClick={() => setRetry(x => x + 1)}>重试</button></p>} {error && <p className="asset-history-warning" role="alert">{error} · 保留上次查询结果 <button type="button" onClick={() => setRetry(x => x + 1)}>重试</button></p>}
<p className="asset-history-unit">数量(台)</p>
<div className="asset-history-chart" role="img" aria-label="运营数与库存数堆叠柱,资产总数折线;精确数值和缺失状态可通过上方统计期间选择框查看"> <div className="asset-history-chart" role="img" aria-label="运营数与库存数堆叠柱,资产总数折线;精确数值和缺失状态可通过上方统计期间选择框查看">
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 480, height: 200 }}> <ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 480, height: 200 }}>
<ComposedChart data={chart} margin={{ top: 18, right: 10, bottom: 0, left: 0 }} onClick={state => { const p = chart.find(p => p.label === String(state?.activeLabel)); if (p) setSelected(p.period); }}> <ComposedChart data={chart} margin={{ top: 10, right: 14, bottom: 0, left: 0 }} onClick={state => { const p = chart.find(p => p.label === String(state?.activeLabel)); if (p) setSelected(p.period); }}>
<CartesianGrid stroke="#e9eff7" vertical={false} /><XAxis dataKey="label" tick={{ fontSize: 10, fill: '#64748b' }} axisLine={{ stroke: '#dbe4ef' }} tickLine={false} interval={mode === 'monthly' ? 0 : 'preserveStartEnd'} minTickGap={4} /> <CartesianGrid stroke="#e9eff7" vertical={false} /><XAxis dataKey="label" tick={{ fontSize: 10, fill: '#64748b' }} axisLine={{ stroke: '#dbe4ef' }} tickLine={false} interval={mode === 'monthly' ? 0 : 'preserveStartEnd'} minTickGap={4} />
<YAxis allowDecimals={false} tickCount={4} width={36} tick={{ fontSize: 11, fill: '#64748b' }} axisLine={false} tickLine={false} /> <YAxis allowDecimals={false} width={44} tick={{ fontSize: 11, fill: '#64748b' }} axisLine={false} tickLine={false} />
<Tooltip content={({ active, payload }) => { <Tooltip content={({ active, payload }) => {
const item = payload?.[0]?.payload as HistoryPoint | undefined; const item = payload?.[0]?.payload as HistoryPoint | undefined;
const values = item && displayHistoryCounts(item); const values = item && displayHistoryCounts(item);
@@ -91,19 +92,16 @@ export function AssetHistorySection({ subject, onSubjects }: { subject: string |
</ComposedChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<div className="asset-history-legend" aria-hidden="true"><span><i className="operating" />运营数</span><span><i className="inventory" />库存数</span><span><i className="total" />资产总数</span><span className="asset-history-unit">台</span></div> <div className="asset-history-legend" aria-hidden="true"><span><i className="operating" />运营数</span><span><i className="inventory" />库存数</span><span><i className="total" />资产总数</span></div>
{point && <div className="asset-history-selected" aria-live="polite"> {point && <div className="asset-history-selected" aria-live="polite">
<div className="asset-history-selected-heading"><strong>{mode === 'annual' || point.state === 'LIVE' ? point.label : `${point.label}末`}</strong><span>{HISTORY_STATE_LABELS[point.state]}</span></div> <div className="asset-history-selected-heading"><strong>{mode === 'annual' || point.state === 'LIVE' ? point.label : `${point.label}末`}</strong><span>{HISTORY_STATE_LABELS[point.state]}</span>{point.asOf && <time dateTime={point.asOf} aria-label={`截至 ${historyTime(point.asOf)}`}>{historyTime(point.asOf)}</time>}</div>
<div className="asset-history-counts">{([['total', '总数'], ['inventory', '库存'], ['operating', '运营']] as const).map(([key, label]) => <div key={key}><span aria-label={`${label}(台)`}>{label}</span><strong>{displayHistoryCounts(point)?.[key]?.toLocaleString() ?? (point.reconstruction && key === 'total' ? '待核实' : '—')}</strong></div>)}</div> <div className="asset-history-counts">{([['total', '总数'], ['inventory', '库存'], ['operating', '运营']] as const).map(([key, label]) => <div key={key}><span aria-label={`${label}(台)`}>{label}(台)</span><strong>{displayHistoryCounts(point)?.[key]?.toLocaleString() ?? (point.reconstruction && key === 'total' ? '待核实' : '—')}</strong></div>)}</div>
{hasComparison ? <div className="asset-history-comparison"><span>{point.comparisonLabel}</span><div>{(['total', 'inventory', 'operating'] as const).map(key => { {hasComparison ? <div className="asset-history-comparison"><span>{point.comparisonLabel}</span><div>{(['total', 'inventory', 'operating'] as const).map(key => {
const difference = displayHistoryDifference(point, key); const difference = displayHistoryDifference(point, key);
return <span key={key} className={difference != null && difference > 0 ? 'asset-history-increase' : difference != null && difference < 0 ? 'asset-history-decrease' : ''}>{difference != null ? signedCount(difference) : '—'}</span>; return <span key={key} className={difference != null && difference > 0 ? 'asset-history-increase' : difference != null && difference < 0 ? 'asset-history-decrease' : ''}>{difference != null ? signedCount(difference) : '—'}</span>;
})}</div></div> : null} })}</div></div> : <p>{point.comparisonLabel}</p>}
<div className="asset-history-selected-footer">
<div className="asset-history-point-meta">{point.asOf && <time dateTime={point.asOf} aria-label={`截至 ${historyTime(point.asOf)}`}>{historyTime(point.asOf)}</time>}{!hasComparison && <span>{point.comparisonLabel}</span>}</div>
{mode === 'monthly' && <button type="button" className="asset-history-detail-button" disabled={!point.detailAvailable} onClick={() => setDetails(point)}><span>{point.reconstruction ? '查看月度回溯明细' : '查看月度明细'}</span><ChevronRight size={16} aria-hidden="true" /></button>}
</div>
</div>} </div>}
{point && mode === 'monthly' && <button type="button" className="asset-history-detail-button" disabled={!point.detailAvailable} onClick={() => setDetails(point)}><span>{point.reconstruction ? '查看月度回溯明细' : '查看月度明细'}</span><ChevronRight size={18} /></button>}
<p className="asset-history-footnote">历史库存状态快照缺失,核对中,暂不展示。</p> <p className="asset-history-footnote">历史库存状态快照缺失,核对中,暂不展示。</p>
</>} </>}
{details && <HistoryDetailsDialog point={details} subject={subject} close={closeDetails} />} {details && <HistoryDetailsDialog point={details} subject={subject} close={closeDetails} />}
+61 -52
View File
@@ -1,43 +1,41 @@
.asset-history { --history-blue:#1559e8; --history-ink:#142b4e; --history-muted:#62748b; --history-border:#e3ebf5; margin:0; padding:16px; border:1px solid var(--history-border); border-radius:16px; background:#fff; color:var(--history-ink); box-shadow:0 2px 12px rgb(22 53 90 / 3%); } .asset-history { --history-blue:#1559e8; margin:8px 0 16px; padding:16px; border:1px solid #e6edf5; border-radius:20px; background:#fff; color:#142b4e; box-shadow:0 5px 24px rgb(22 53 90 / 3%); }
.asset-history button,.asset-history-dialog button { cursor:pointer; touch-action:manipulation; } .asset-history button,.asset-history-dialog button { cursor:pointer; }
.asset-history button:disabled,.asset-history-dialog button:disabled { cursor:default; opacity:.45; } .asset-history button:disabled,.asset-history-dialog button:disabled { cursor:default; opacity:.45; }
.asset-history button:focus-visible,.asset-history-dialog :is(button,input,select):focus-visible { outline:2px solid #2563eb; outline-offset:3px; } .asset-history button:focus-visible,.asset-history select:focus-visible,.asset-history-dialog :is(button,input,select):focus-visible { outline:2px solid #2563eb; outline-offset:3px; }
.asset-history-header { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px; } .asset-history-header { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; }
.asset-history-header h2 { margin:0; font-size:18px; font-weight:700; letter-spacing:-.4px; } .asset-history-header h2 { margin:0; font-size:20px; font-weight:750; letter-spacing:-.5px; }
.asset-history-modes { display:flex; gap:2px; background:#f0f4f9; border-radius:10px; padding:0 3px; } .asset-history-modes { display:flex; gap:2px; background:#eef3fa; border:1px solid #e7edf6; border-radius:11px; padding:3px; }
.asset-history-modes button { position:relative; isolation:isolate; min-width:52px; min-height:44px; padding:8px 14px; font-size:13px; font-weight:600; color:var(--history-muted); transition:color .16s; } .asset-history-modes button { padding:7px 20px; min-height:34px; border-radius:8px; font-size:13px; font-weight:600; transition:background .15s,color .15s,box-shadow .15s; color:#64748b; }
.asset-history-modes button::before { content:""; position:absolute; inset:5px 0; z-index:-1; border-radius:7px; transition:background .16s,box-shadow .16s; } .asset-history-modes button[aria-pressed=true] { background:#fff; color:#1559e8; box-shadow:0 1px 4px #193c6920, inset 0 0 0 1px #dce7f5; }
.asset-history-modes button[aria-pressed=true] { color:var(--history-blue); } .asset-history-toolbar { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin:8px 0 4px; font-size:12px; color:#64748b; }
.asset-history-modes button[aria-pressed=true]::before { background:#fff; box-shadow:0 1px 4px #193c691a,0 0 0 1px #e0e8f3; }
.asset-history-toolbar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:4px 0 0; color:var(--history-muted); font-size:12px; }
.asset-history-toolbar>span { margin-left:auto; } .asset-history-toolbar>span { margin-left:auto; }
.asset-history-refresh { display:grid; place-items:center; min-width:44px; min-height:44px; padding:8px; border-radius:8px; color:var(--history-muted); transition:background .16s,color .16s; } .asset-history-toolbar .asset-history-annual-note { margin-left:0; margin-right:auto; }
.asset-history-refresh { display:grid; place-items:center; padding:8px; border-radius:8px; color:#7c8da5; transition:background .15s,color .15s; }
.asset-history-warning { margin:8px 0; font-size:12px; line-height:1.7; color:#74613a; background:#fff9ec; padding:9px 12px; border-radius:9px; } .asset-history-warning { margin:8px 0; font-size:12px; line-height:1.7; color:#74613a; background:#fff9ec; padding:9px 12px; border-radius:9px; }
.asset-history-warning button,.asset-history-empty button { color:var(--history-blue); text-decoration:underline; padding:6px; } .asset-history-warning button,.asset-history-empty button { color:#1559e8; text-decoration:underline; padding:6px; }
.asset-history-chart { height:180px; width:100%; min-width:0; } .asset-history-unit { font-size:12px; color:#64748b; margin:6px 0 0; }
.asset-history-tooltip { background:#fff; border:1px solid var(--history-border); border-radius:10px; padding:10px 12px; box-shadow:0 4px 16px #142b4e15; font-size:12px; } .asset-history-chart { height:200px; width:100%; min-width:0; }
.asset-history-tooltip { background:#fff; border:1px solid #dce5ef; border-radius:10px; padding:10px 12px; box-shadow:0 4px 16px #142b4e15; font-size:12px; }
.asset-history-tooltip p { margin-top:4px; } .asset-history-tooltip p { margin-top:4px; }
.asset-history-legend { display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:16px; color:var(--history-muted); font-size:12px; line-height:18px; margin:2px 0 8px; } .asset-history-legend { display:flex; justify-content:center; flex-wrap:wrap; gap:22px; color:#64748b; font-size:12px; margin:6px 0; }
.asset-history-legend span { display:flex; align-items:center; gap:6px; } .asset-history-legend span { display:flex; align-items:center; gap:7px; }
.asset-history-legend i { display:inline-block; height:8px; width:8px; border-radius:2px; } .asset-history-legend i { display:inline-block; height:11px; width:11px; border-radius:2px; }
.asset-history-legend .operating { background:#347df5; }.asset-history-legend .inventory { background:#9ed1fa; } .asset-history-legend .operating { background:#347df5; }.asset-history-legend .inventory { background:#9ed1fa; }
.asset-history-legend .total { position:relative; width:16px; height:2px; background:#132e57; } .asset-history-legend .total { width:20px; height:2px; background:#132e57; }
.asset-history-legend .total::after { content:""; position:absolute; top:-2px; left:6px; width:6px; height:6px; border:2px solid #132e57; border-radius:50%; background:white; } .asset-history-selected { margin-top:8px; display:grid; grid-template-columns:90px minmax(0,1fr); align-items:center; gap:0 10px; padding:10px 14px; background:linear-gradient(115deg,#f3f7fe,#edf5ff); border:1px solid #e7effb; border-radius:12px; }
.asset-history-legend .asset-history-unit { padding-left:8px; border-left:1px solid var(--history-border); }
.asset-history-selected { display:grid; grid-template-columns:94px minmax(0,1fr); align-items:center; gap:0 8px; padding:10px 12px 0; background:#f2f7ff; border:1px solid #e4edfa; border-radius:10px; }
.asset-history-selected-heading { display:flex; flex-direction:column; align-items:flex-start; gap:3px; min-width:0; } .asset-history-selected-heading { display:flex; flex-direction:column; align-items:flex-start; gap:3px; min-width:0; }
.asset-history-selected-heading>strong { color:var(--history-blue); font-size:18px; line-height:24px; font-weight:700; } .asset-history-selected-heading>strong { color:#1559e8; font-size:17px; font-weight:750; }
.asset-history-selected-heading>span { color:var(--history-muted); font-size:12px; line-height:18px; } .asset-history-selected-heading>span,.asset-history-selected-heading>time { color:#64748b; font-size:11px; }
.asset-history-counts { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); } .asset-history-counts { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); }
.asset-history-counts>div { display:flex; flex-direction:column; align-items:center; gap:3px; min-width:0; border-left:1px solid #dce7f6; } .asset-history-counts>div { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px; min-width:0; border-right:1px solid #d4e2f4; }
.asset-history-counts>div>span { color:var(--history-muted); font-size:12px; line-height:18px; } .asset-history-counts>div:last-child { border:0; }
.asset-history-counts strong { font-size:24px; font-weight:700; line-height:28px; letter-spacing:-.4px; font-variant-numeric:tabular-nums; } .asset-history-counts>div>span { color:#64748b; font-size:12px; }
.asset-history-counts strong { font-size:23px; font-weight:750; line-height:1.2; font-variant-numeric:tabular-nums; }
.asset-history-increase { color:#bf2f30; }.asset-history-decrease { color:#14864a; } .asset-history-increase { color:#bf2f30; }.asset-history-decrease { color:#14864a; }
.asset-history-selected-footer { grid-column:1/-1; display:flex; align-items:center; justify-content:space-between; gap:8px; margin-top:8px; border-top:1px solid #e0eaf8; min-height:44px; } .asset-history-selected>p { grid-column:1 / -1; color:#64748b; font-size:11px; text-align:center; padding-top:6px; }
.asset-history-point-meta { display:flex; flex-wrap:wrap; gap:2px 12px; color:var(--history-muted); font-size:12px; line-height:18px; } .asset-history-detail-button { display:flex; width:100%; align-items:center; justify-content:space-between; min-height:44px; padding:8px 12px; margin-top:8px; border:1px solid #dce8f8; border-radius:10px; background:#f7faff; color:#18385d; font-size:14px; font-weight:600; }
.asset-history-detail-button { display:flex; flex-shrink:0; align-items:center; justify-content:space-between; gap:4px; min-height:44px; padding:8px 0 8px 8px; color:var(--history-blue); font-size:12px; font-weight:600; border-radius:6px; transition:color .16s,background .16s; } .asset-history-footnote { display:flex; align-items:center; flex-wrap:wrap; gap:6px; color:#64748b; font-size:11px; margin-top:8px; }
.asset-history-footnote { color:var(--history-muted); font-size:12px; line-height:18px; margin-top:8px; } .asset-history-empty { padding:32px 12px; text-align:center; font-size:13px; color:#64748b; }
.asset-history-empty { padding:32px 12px; text-align:center; font-size:13px; color:var(--history-muted); }
.asset-history-overlay { position:fixed; inset:0; z-index:120; background:#0c1e3b66; display:flex; justify-content:center; align-items:center; padding:24px; } .asset-history-overlay { position:fixed; inset:0; z-index:120; background:#0c1e3b66; display:flex; justify-content:center; align-items:center; padding:24px; }
.asset-history-dialog { display:flex; flex-direction:column; width:min(980px,100%); max-height:88dvh; border-radius:18px; background:white; color:#183454; box-shadow:0 24px 64px #142b4e30; overflow:hidden; outline:none; } .asset-history-dialog { display:flex; flex-direction:column; width:min(980px,100%); max-height:88dvh; border-radius:18px; background:white; color:#183454; box-shadow:0 24px 64px #142b4e30; overflow:hidden; outline:none; }
.asset-history-dialog>header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:20px 22px; border-bottom:1px solid #e6edf5; } .asset-history-dialog>header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:20px 22px; border-bottom:1px solid #e6edf5; }
@@ -60,12 +58,12 @@
.asset-history-dialog footer { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px 22px; border-top:1px solid #e6edf5; color:#64748b; font-size:12px; } .asset-history-dialog footer { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px 22px; border-top:1px solid #e6edf5; color:#64748b; font-size:12px; }
.asset-history-dialog footer>div { display:flex; gap:8px; }.asset-history-dialog footer button { padding:9px; border:1px solid #dce5f0; border-radius:7px; } .asset-history-dialog footer>div { display:flex; gap:8px; }.asset-history-dialog footer button { padding:9px; border:1px solid #dce5f0; border-radius:7px; }
@media(max-width:640px) { @media(max-width:640px) {
.asset-history { padding:12px; border-radius:14px; } .asset-history { padding:12px; border-radius:16px; }
.asset-history-header h2 { font-size:17px; } .asset-history-header h2 { font-size:18px; }.asset-history-modes button { padding:7px 15px; min-height:38px; }
.asset-history-chart { height:140px; } .asset-history-toolbar { gap:6px; }.asset-history-toolbar>span { font-size:10px; }
.asset-history-selected { grid-template-columns:84px minmax(0,1fr); padding:8px 10px 0; gap:0 6px; } .asset-history-chart { height:170px; }
.asset-history-counts strong { font-size:22px; line-height:26px; } .asset-history-selected { grid-template-columns:72px minmax(0,1fr); gap:0 6px; padding:8px 10px; }.asset-history-selected-heading { gap:2px; }.asset-history-selected-heading>span,.asset-history-selected-heading>time { font-size:10px; }
.asset-history-point-meta { flex-direction:column; gap:0; } .asset-history-counts strong { font-size:21px; }.asset-history-counts>div>span { font-size:11px; }
.asset-history-overlay { padding:10px; }.asset-history-dialog { max-height:94dvh; border-radius:14px; } .asset-history-overlay { padding:10px; }.asset-history-dialog { max-height:94dvh; border-radius:14px; }
.asset-history-dialog>header { padding:14px; }.asset-history-dialog h2 { font-size:16px; }.asset-history-detail-tools { padding:12px 14px; } .asset-history-dialog>header { padding:14px; }.asset-history-dialog h2 { font-size:16px; }.asset-history-detail-tools { padding:12px 14px; }
.asset-history-detail-note { padding:10px 14px; }.asset-history-dialog :is(input,select) { font-size:16px; }.asset-history-dialog footer { padding:12px 14px; } .asset-history-detail-note { padding:10px 14px; }.asset-history-dialog :is(input,select) { font-size:16px; }.asset-history-dialog footer { padding:12px 14px; }
@@ -81,18 +79,31 @@
} }
@media(min-width:900px) { @media(min-width:900px) {
.asset-history-controls { display:flex; align-items:center; gap:24px; flex-wrap:wrap; } .asset-history-controls { display:flex; align-items:center; flex-wrap:wrap; gap:8px 24px; }
.asset-history-header { flex:1; gap:16px; } .asset-history-header { flex:1; gap:16px; }
.asset-history-toolbar { margin:0; } .asset-history-toolbar { margin:0; }
.asset-history-selected { grid-template-columns:minmax(150px,1fr) 3fr; gap:0 16px; } .asset-history-selected { display:grid; grid-template-columns:minmax(150px,1fr) 3fr; align-items:center; gap:0 18px; }
.asset-history-selected-heading { flex-direction:row; align-items:center; gap:12px; } .asset-history-selected-heading { display:grid; grid-template-columns:auto 1fr; align-items:center; gap:2px 12px; margin:0; }
.asset-history-selected-heading>strong { grid-row:span 2; }
.asset-history-selected-heading>span,.asset-history-selected-heading>time { grid-column:2; }
.asset-history-selected>p { grid-column:1 / -1; }
}
@media(max-width:640px) {
.asset-history-modes button,.asset-history-refresh { min-height:44px; }
.asset-history-refresh { min-width:44px; }
.asset-history-toolbar { gap:4px; }
} }
.asset-history-comparison { grid-column:1/-1; display:grid; grid-template-columns:94px minmax(0,1fr); gap:8px; align-items:center; margin-top:8px; padding-top:6px; border-top:1px solid #dce7f6; font-size:12px; color:var(--history-muted); }
.asset-history-comparison>div { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); text-align:center; font-size:13px; font-weight:600; }
@media(min-width:900px) { .asset-history-comparison { grid-template-columns:minmax(150px,1fr) 3fr; gap:16px; } }
@media(max-width:640px) { .asset-history-comparison { grid-template-columns:84px minmax(0,1fr); gap:6px; } }
.asset-history-select { display:inline-flex; align-items:center; justify-content:space-between; gap:12px; min-width:76px; min-height:44px; padding:7px 11px; border:1px solid #dce5f1; border-radius:8px; background:#fff; color:#254365; font-size:13px; font-weight:550; box-shadow:0 1px 2px #173c6310; transition:border-color .15s,box-shadow .15s; } .asset-history-comparison { grid-column:1 / -1; display:grid; grid-template-columns:90px minmax(0,1fr); gap:10px; align-items:center; margin-top:6px; padding-top:6px; border-top:1px solid #d4e2f4; font-size:11px; color:#64748b; }
.asset-history-comparison>div { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); text-align:center; font-size:13px; font-weight:600; }
@media(min-width:900px) {
.asset-history-comparison { grid-template-columns:minmax(150px,1fr) 3fr; gap:18px; }
}
@media(max-width:640px) {
.asset-history-comparison { grid-template-columns:72px minmax(0,1fr); gap:6px; }
}
.asset-history-select { display:inline-flex; align-items:center; justify-content:space-between; gap:12px; min-width:76px; min-height:36px; padding:7px 11px; border:1px solid #dce5f1; border-radius:9px; background:#fff; color:#254365; font-size:13px; font-weight:550; box-shadow:0 1px 2px #173c6310; transition:border-color .15s,box-shadow .15s; }
.asset-history-select[data-state=open] { border-color:#8fb4f2; box-shadow:0 0 0 3px #dbeaff80; } .asset-history-select[data-state=open] { border-color:#8fb4f2; box-shadow:0 0 0 3px #dbeaff80; }
.asset-history-select-icon { color:#8b9db5; display:flex; transition:transform .15s; } .asset-history-select-icon { color:#8b9db5; display:flex; transition:transform .15s; }
.asset-history-select[data-state=open] .asset-history-select-icon { transform:rotate(180deg); } .asset-history-select[data-state=open] .asset-history-select-icon { transform:rotate(180deg); }
@@ -104,19 +115,17 @@
.asset-history-option-detail { margin-left:auto; color:#7b8da5; font-size:11px; font-weight:400; } .asset-history-option-detail { margin-left:auto; color:#7b8da5; font-size:11px; font-weight:400; }
.asset-history-option-check { position:absolute; right:9px; display:flex; color:#2563eb; } .asset-history-option-check { position:absolute; right:9px; display:flex; color:#2563eb; }
.asset-history-select-scroll { display:flex; align-items:center; justify-content:center; height:24px; color:#7b8da5; } .asset-history-select-scroll { display:flex; align-items:center; justify-content:center; height:24px; color:#7b8da5; }
.asset-history-detail-button>svg { flex-shrink:0; } .asset-history-detail-button>svg { padding:3px; width:24px; height:24px; border-radius:7px; background:#eaf2ff; color:#527eb7; }
@media(hover:hover) { @media(hover:hover) {
.asset-history-modes button:not([aria-pressed=true]):hover::before { background:#e5edf9; } .asset-history-modes button:not([aria-pressed=true]):hover { color:#1559e8; background:#e5edf9; }
.asset-history-select:hover { border-color:#adc5e8; } .asset-history-select:hover { border-color:#adc5e8; }
.asset-history-refresh:hover { color:#2563eb; background:#eef5ff; } .asset-history-refresh:hover { color:#2563eb; background:#eef5ff; }
.asset-history-detail-button:hover { color:#103eaa; background:#e9f1ff; } .asset-history-detail-button:hover { background:#eef5ff; border-color:#bcd3f3; }
} }
@media(max-width:640px) { @media(max-width:640px) {
.asset-history-select { min-height:44px; padding:8px 10px; font-size:12px; gap:10px; } .asset-history-select { min-height:44px; padding:8px 10px; font-size:12px; gap:10px; }
.asset-history-select-option { min-height:44px; } .asset-history-select-option { min-height:44px; }
} }
@media(prefers-reduced-motion:reduce) { @media(prefers-reduced-motion:reduce) {
.asset-history-modes button,.asset-history-modes button::before,.asset-history-select,.asset-history-select-icon,.asset-history-refresh,.asset-history-detail-button { transition:none; } .asset-history-modes button,.asset-history-select,.asset-history-select-icon,.asset-history-refresh { transition:none; }
} }
.asset-history :is(.asset-history-select,.asset-history-refresh,.asset-history-detail-button):active { background:#e6effe; }
+35 -88
View File
@@ -1,12 +1,11 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Check, ChevronRight, Fuel, Layers, Plug, Search, TrendingUp, Truck, X } from 'lucide-react'; import { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts'; import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
import TrendBadge from './TrendBadge'; import TrendBadge from './TrendBadge';
import { fetchHydrogenDaily } from './api'; import { fetchHydrogenDaily } from './api';
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types'; import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
import { import {
filterHydrogenRows,
getQuickRange, getQuickRange,
getRangeModeLabel, getRangeModeLabel,
normalizeRange, normalizeRange,
@@ -14,14 +13,12 @@ import {
type RangeMode, type RangeMode,
} from './hydrogen-daily/model'; } from './hydrogen-daily/model';
import DailyRangeControls from './daily-range/DailyRangeControls'; import DailyRangeControls from './daily-range/DailyRangeControls';
import { DateRangePicker } from '../../components/ui/DateRangePicker';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface'; import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
export default function HydrogenDaily() { export default function HydrogenDaily() {
const [pick, setPick] = useState<RangeMode>('last15'); const [pick, setPick] = useState<RangeMode>('last15');
const [dateRange, setDateRange] = useState(() => getQuickRange('last15')); const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
const [customer, setCustomer] = useState<CustomerType>('all'); const [customer, setCustomer] = useState<CustomerType>('lingniu');
const [stationSearch, setStationSearch] = useState('');
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);
@@ -31,7 +28,6 @@ export default function HydrogenDaily() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setError(null); setError(null);
setRows(null);
const query = pick === 'custom' const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end } ? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick }; : { range: pick };
@@ -41,8 +37,6 @@ export default function HydrogenDaily() {
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [pick, customer, effectiveRange.start, effectiveRange.end]); }, [pick, customer, effectiveRange.start, effectiveRange.end]);
const filteredRows = useMemo(() => filterHydrogenRows(rows, stationSearch), [rows, stationSearch]);
const { const {
trendData, trendData,
totalKg, totalKg,
@@ -52,7 +46,7 @@ export default function HydrogenDaily() {
peakDay, peakDay,
lowDay, lowDay,
zeroDays, zeroDays,
} = useMemo(() => summarizeHydrogenRows(filteredRows), [filteredRows]); } = useMemo(() => summarizeHydrogenRows(rows), [rows]);
const scopeLabel = getRangeModeLabel(pick); const scopeLabel = getRangeModeLabel(pick);
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`; const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
@@ -83,87 +77,38 @@ export default function HydrogenDaily() {
onCustomPick={() => setPick('custom')} onCustomPick={() => setPick('custom')}
onDateRangeChange={updateDateRange} onDateRangeChange={updateDateRange}
onCustomerChange={setCustomer} onCustomerChange={setCustomer}
dateControl={
<DateRangePicker
value={effectiveRange}
label="加氢日期"
testId="hydrogen-date-range"
quickPicks={([
{ id: 'thisWeek', label: '本周' },
{ id: 'thisMonth', label: '本月' },
{ id: 'last15', label: '近 15 天' },
] as const).map(option => ({ label: option.label, range: getQuickRange(option.id) }))}
onChange={range => {
setDateRange(range);
const matching = (['thisWeek', 'thisMonth', 'last15'] as const).find(option => {
const preset = getQuickRange(option);
return preset.start === range.start && preset.end === range.end;
});
setPick(matching ?? 'custom');
}}
/> />
}
>
<div className="mt-3 space-y-3">
<fieldset>
<legend className="mb-2 px-1 text-[11px] font-semibold text-slate-500">车辆归属</legend>
<div className="grid grid-cols-3 gap-2">
{([
{ value: 'all', label: '全部车辆', description: '内外部汇总', icon: Layers },
{ value: 'lingniu', label: '内部车辆', description: '羚牛车辆', icon: Truck },
{ value: 'external', label: '外部车辆', description: '佛山站外部订单', icon: Truck },
] as const).map(option => {
const Icon = option.icon;
return (
<label key={option.value} className="relative min-w-0 cursor-pointer">
<input type="radio" name="hydrogen-customer" aria-label={option.label} value={option.value} checked={customer === option.value}
onChange={() => setCustomer(option.value)} className="peer sr-only" />
<span className="flex h-full min-h-[94px] flex-col gap-2 rounded-2xl border border-slate-200 bg-white p-3 transition hover:border-blue-300 hover:bg-blue-50/40 peer-checked:border-blue-400 peer-checked:bg-blue-50/70 peer-checked:shadow-sm peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-blue-500">
<span className="flex items-center justify-between">
<Icon size={17} aria-hidden="true" className={customer === option.value ? 'text-blue-600' : 'text-slate-400'} />
<span aria-hidden="true" className={`flex h-4 w-4 items-center justify-center rounded-full ${customer === option.value ? 'bg-blue-600 text-white' : 'border border-slate-200 bg-white'}`}>
{customer === option.value && <Check size={11} strokeWidth={3} />}
</span>
</span>
<span className={`text-[12px] font-bold ${customer === option.value ? 'text-blue-700' : 'text-slate-700'}`}>{option.label}</span>
<span className="text-[10px] leading-tight text-slate-500">{option.description}</span>
</span>
</label>
);
})}
</div>
</fieldset>
<div className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2 focus-within:ring-2 focus-within:ring-blue-200">
<label htmlFor="hydrogen-station-search" className="block text-[10px] font-black text-slate-400">搜索加氢站</label>
<div className="mt-1 flex h-7 items-center gap-2">
<Search size={15} className="shrink-0 text-slate-400" aria-hidden="true" />
<input
id="hydrogen-station-search"
type="search"
value={stationSearch}
onChange={event => setStationSearch(event.target.value)}
placeholder="输入加氢站名称,例如:佛山"
className="min-w-0 flex-1 bg-transparent text-[12px] font-bold text-slate-800 outline-none placeholder:font-normal placeholder:text-slate-400 [&::-webkit-search-cancel-button]:hidden"
/>
{stationSearch && (
<button type="button" aria-label="清除加氢站搜索" onClick={() => setStationSearch('')} className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-slate-400 hover:bg-slate-200 hover:text-slate-700">
<X size={14} />
</button>
)}
</div>
</div>
</div>
</DailyRangeControls>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4"> <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} /> <MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
<MetricTile icon={Truck} label="车辆归属" value={{ all: '全部', lingniu: '内部', external: '外部' }[customer]} helper={customer === 'external' ? '佛山南海羚牛加氢站 · 未关联车辆的订单' : '当前筛选口径'} tone="emerald" /> <MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" /> <MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper={stationSearch.trim() ? `搜索:${stationSearch.trim()}` : '按明细站点去重'} tone="slate" /> <MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
</div> </div>
{/* 时段加氢量柱图 */} {/* 外部车辆:新系统数据还没准备好 */}
{!error && trendData.length > 0 && totalKg > 0 && ( {customer === 'external' && rows !== null && totalKg === 0 && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
>
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3 relative">
<Plug size={22} className="text-blue-500" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
</div>
<div className="text-sm font-bold text-slate-700 mb-1">外部车辆 · 数据未就绪</div>
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
新系统的外部车辆加氢数据还在准备中
<br />
上线后此处将展示完整明细
</div>
</motion.div>
)}
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
<SurfaceCard> <SurfaceCard>
<div className="flex items-center justify-between px-4 pt-4 mb-2"> <div className="flex items-center justify-between px-4 pt-4 mb-2">
<span className="text-sm font-bold text-slate-700">每日加氢量</span> <span className="text-sm font-bold text-slate-700">每日加氢量</span>
@@ -239,7 +184,8 @@ export default function HydrogenDaily() {
</SurfaceCard> </SurfaceCard>
)} )}
{/* 日明细:加载失败、无订单与正常数据分别展示。 */} {/* 表格(外部车辆 + 全 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)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500"> <div className="grid grid-cols-[minmax(0,1fr)_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">
@@ -258,11 +204,11 @@ export default function HydrogenDaily() {
{/* 主行 + 子行 */} {/* 主行 + 子行 */}
{error ? ( {error ? (
<div className="p-3"><ErrorState message={error} /></div> <div className="p-3"><ErrorState message={error} /></div>
) : filteredRows === null ? ( ) : rows === null ? (
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div> <div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
) : filteredRows.length === 0 || totalKg === 0 ? ( ) : rows.length === 0 ? (
<div className="p-3"><EmptyState title="所选时段暂无加氢数据" description={stationSearch.trim() ? '当前时段和车辆归属下没有匹配的加氢站数据,请调整关键词或清除搜索' : customer === 'external' ? '佛山南海羚牛加氢站在该时段没有未关联车辆的加氢订单,请切换时间范围' : '请切换时间范围或车辆归属'} /></div> <div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div>
) : filteredRows.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;
const abnormalBg = isAbnormal const abnormalBg = isAbnormal
@@ -324,6 +270,7 @@ export default function HydrogenDaily() {
); );
})} })}
</div> </div>
)}
</div> </div>
); );
@@ -1,5 +1,4 @@
import { Truck } from 'lucide-react'; import { Truck } from 'lucide-react';
import type { ReactNode } from 'react';
import { SurfaceCard } from '../../../components/ui/surface'; import { SurfaceCard } from '../../../components/ui/surface';
import type { CustomerType, DateQuickPick } from '../types'; import type { CustomerType, DateQuickPick } from '../types';
import { QUICK_PICK_OPTIONS, type RangeMode } from './model'; import { QUICK_PICK_OPTIONS, type RangeMode } from './model';
@@ -12,8 +11,6 @@ interface DailyRangeControlsProps {
onCustomPick: () => void; onCustomPick: () => void;
onDateRangeChange: (field: 'start' | 'end', value: string) => void; onDateRangeChange: (field: 'start' | 'end', value: string) => void;
onCustomerChange: (customer: CustomerType) => void; onCustomerChange: (customer: CustomerType) => void;
children?: ReactNode;
dateControl?: ReactNode;
} }
export default function DailyRangeControls({ export default function DailyRangeControls({
@@ -24,12 +21,9 @@ export default function DailyRangeControls({
onCustomPick, onCustomPick,
onDateRangeChange, onDateRangeChange,
onCustomerChange, onCustomerChange,
children,
dateControl,
}: DailyRangeControlsProps) { }: DailyRangeControlsProps) {
return ( return (
<SurfaceCard className="p-2 md:p-3"> <SurfaceCard className="p-2 md:p-3">
{dateControl ?? <>
<div className="flex items-center gap-2 overflow-x-auto pb-1"> <div className="flex items-center gap-2 overflow-x-auto pb-1">
{QUICK_PICK_OPTIONS.map(opt => ( {QUICK_PICK_OPTIONS.map(opt => (
<button <button
@@ -79,9 +73,7 @@ export default function DailyRangeControls({
</label> </label>
</div> </div>
</>} <div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
{children ?? <div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
{(['lingniu', 'external'] as const).map(option => ( {(['lingniu', 'external'] as const).map(option => (
<button <button
key={option} key={option}
@@ -94,7 +86,7 @@ export default function DailyRangeControls({
{option === 'external' ? '外部车辆' : '羚牛车辆'} {option === 'external' ? '外部车辆' : '羚牛车辆'}
</button> </button>
))} ))}
</div>} </div>
</SurfaceCard> </SurfaceCard>
); );
} }
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import type { HydrogenDailyRow } from '../types.js'; import type { HydrogenDailyRow } from '../types.js';
import { import {
filterHydrogenRows,
getQuickRange, getQuickRange,
getRangeModeLabel, getRangeModeLabel,
normalizeRange, normalizeRange,
@@ -83,53 +82,3 @@ test('空数据保持零值且没有峰谷日', () => {
zeroDays: 0, zeroDays: 0,
}); });
}); });
const searchRows: HydrogenDailyRow[] = [
{ date: '2026-09-17', totalKg: 50, chainPct: -0.75, customerType: 'all', stations: [
{ name: '嘉兴站', kg: 50, pricePerKg: 30, chainPct: 0 },
] },
{ date: '2026-09-16', totalKg: 200, chainPct: 1, customerType: 'all', stations: [
{ name: '佛山南海羚牛加氢站', kg: 30, pricePerKg: 38, chainPct: 2 },
{ name: '佛山豪汇石油加氢站', kg: 10, pricePerKg: 35, chainPct: 0 },
{ name: '嘉兴站', kg: 160, pricePerKg: 30, chainPct: 1 },
] },
{ date: '2026-09-15', totalKg: 100, chainPct: 0, customerType: 'all', stations: [
{ name: '佛山南海羚牛加氢站', kg: 10, pricePerKg: 38, chainPct: 0 },
{ name: '嘉兴站', kg: 90, pricePerKg: 30, chainPct: 0 },
] },
];
test('站名搜索同步更新日明细、总量、趋势和环比,保留完整日期及原始数据', () => {
const before = structuredClone(searchRows);
const filtered = filterHydrogenRows(searchRows, ' 佛山 ')!;
assert.deepEqual(filtered.map(row => row.date), searchRows.map(row => row.date));
assert.deepEqual(filtered.map(row => row.totalKg), [0, 40, 10]);
assert.deepEqual(filtered.map(row => row.chainPct), [-1, 3, 0]);
assert.equal(filtered[1].stations.length, 2);
assert.equal(filtered[1].stations[0].pricePerKg, 38);
assert.equal(filtered[1].stations[0].chainPct, 2);
const summary = summarizeHydrogenRows(filtered);
assert.equal(summary.totalKg, 50);
assert.equal(summary.stationCount, 2);
assert.equal(summary.activeDays, 2);
assert.equal(summary.avgKg, 25);
assert.equal(summary.zeroDays, 1);
assert.deepEqual(summary.trendData.map(row => row.totalKg), [10, 40, 0]);
assert.deepEqual(searchRows, before);
});
test('无匹配关键词显示零结果,清除或空白搜索恢复全量,加载中保持加载状态', () => {
const missing = filterHydrogenRows(searchRows, '不存在的站点')!;
assert.equal(missing.length, 3);
assert.ok(missing.every(row => row.totalKg === 0 && row.chainPct === 0 && row.stations.length === 0));
assert.strictEqual(filterHydrogenRows(searchRows, ''), searchRows);
assert.strictEqual(filterHydrogenRows(searchRows, ' '), searchRows);
assert.equal(summarizeHydrogenRows(filterHydrogenRows(searchRows, '')).totalKg, 350);
assert.equal(filterHydrogenRows(null, '佛山'), null);
assert.deepEqual(filterHydrogenRows([], '佛山'), []);
});
test('完整站名可定位单站,搜索将特殊字符按普通文字匹配', () => {
assert.equal(summarizeHydrogenRows(filterHydrogenRows(searchRows, '佛山南海羚牛加氢站')).totalKg, 40);
assert.equal(summarizeHydrogenRows(filterHydrogenRows(searchRows, '%')).totalKg, 0);
});
@@ -9,28 +9,6 @@ export {
type RangeMode, type RangeMode,
} from '../daily-range/model'; } from '../daily-range/model';
// 接口已返回所选车辆范围内的全部日/站点数据;搜索后统一重算日合计与环比。
export function filterHydrogenRows(rows: HydrogenDailyRow[] | null, stationSearch: string): HydrogenDailyRow[] | null {
const keyword = stationSearch.trim().toLocaleLowerCase();
if (!rows || !keyword) return rows;
const filtered = rows.map(row => {
const stations = row.stations.filter(station => station.name.toLocaleLowerCase().includes(keyword));
return {
...row,
stations,
totalKg: Math.round(stations.reduce((sum, station) => sum + station.kg, 0) * 100) / 100,
chainPct: 0,
};
});
let previousKg = 0;
for (const row of [...filtered].sort((a, b) => a.date.localeCompare(b.date))) {
row.chainPct = previousKg > 0 ? (row.totalKg - previousKg) / previousKg : 0;
previousKg = row.totalKg;
}
return filtered;
}
export function summarizeHydrogenRows(rows: HydrogenDailyRow[] | null) { export function summarizeHydrogenRows(rows: HydrogenDailyRow[] | null) {
const source = rows ?? []; const source = rows ?? [];
// 图表固定按日期升序;复制数组避免改变接口返回及表格原始顺序。 // 图表固定按日期升序;复制数组避免改变接口返回及表格原始顺序。
+1 -1
View File
@@ -1,4 +1,4 @@
export type CustomerType = 'external' | 'lingniu' | 'all'; export type CustomerType = 'external' | 'lingniu';
export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15'; export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15';
export interface HydrogenKpi { export interface HydrogenKpi {
+72 -71
View File
@@ -48,7 +48,6 @@ export default function MonitoringView() {
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [fullscreenVehicles, setFullscreenVehicles] = useState<MonitoringVehicle[]>([]); const [fullscreenVehicles, setFullscreenVehicles] = useState<MonitoringVehicle[]>([]);
const [fullscreenStats, setFullscreenStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 }); const [fullscreenStats, setFullscreenStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
const [fullscreenRefresh, setFullscreenRefresh] = useState(0);
const [fullscreenLoading, setFullscreenLoading] = useState(false); const [fullscreenLoading, setFullscreenLoading] = useState(false);
// New filters from image // New filters from image
@@ -62,7 +61,6 @@ export default function MonitoringView() {
const [filterRegion, setFilterRegion] = useState('All'); const [filterRegion, setFilterRegion] = useState('All');
const [filterBrands, setFilterBrands] = useState<string[]>([]); const [filterBrands, setFilterBrands] = useState<string[]>([]);
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' }); const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null); const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
const [rangeStart, setRangeStart] = useState(defaultMileageDate); const [rangeStart, setRangeStart] = useState(defaultMileageDate);
@@ -72,7 +70,7 @@ export default function MonitoringView() {
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]); const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 }); const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [], brands: [] }); const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [], brands: [] });
const [rangeDailyTotals, setRangeDailyTotals] = useState<{ date: string; totalKm: number }[]>([]); const [rangeDailyTotals, setRangeDailyTotals] = useState<{ date: string; totalKm: number | null }[]>([]);
const [effectiveRange, setEffectiveRange] = useState<{ start: string; end: string }>(() => ({ start: defaultMileageDate(), end: defaultMileageDate() })); const [effectiveRange, setEffectiveRange] = useState<{ start: string; end: string }>(() => ({ start: defaultMileageDate(), end: defaultMileageDate() }));
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -85,7 +83,7 @@ export default function MonitoringView() {
const [relativeNow, setRelativeNow] = useState(() => Date.now()); const [relativeNow, setRelativeNow] = useState(() => Date.now());
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
const monitoringQuery = useMemo<MonitoringQueryModel>(() => ({ const draftQuery = useMemo<MonitoringQueryModel>(() => ({
sortBy, sortBy,
sortOrder, sortOrder,
searchTerm, searchTerm,
@@ -98,35 +96,24 @@ export default function MonitoringView() {
targetNames: filterTargetNames, targetNames: filterTargetNames,
region: filterRegion, region: filterRegion,
plates: filterPlates, plates: filterPlates,
mileageRange: appliedMileageRange, mileageRange: filterMileageRange,
rangeStart, rangeStart,
rangeEnd, rangeEnd,
brands: filterBrands, brands: filterBrands,
sourcePriority, sourcePriority,
}), [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]); }), [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, filterMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]);
// Keep this model and dependency list separate: fullscreen historically omits const [monitoringQuery, setMonitoringQuery] = useState(draftQuery);
// project, entity and mileage-range, so adding those states would change both const [queryError, setQueryError] = useState('');
// its request parameters and refresh triggers. const hasPendingChanges = JSON.stringify(draftQuery) !== JSON.stringify(monitoringQuery);
const fullscreenQuery = useMemo<MonitoringQueryModel>(() => ({ const queryDisabled = pageLoading || manualRefreshing || fullscreenLoading || !rangeStart || !rangeEnd || rangeStart > rangeEnd;
sortBy,
sortOrder, const handleQuery = () => {
searchTerm, if (queryDisabled) return;
department: filterDept, setQueryError('');
customer: filterCustomer, setMonitoringQuery({ ...draftQuery });
project: 'All', setIsFilterOpen(false);
entity: 'All', };
rentStatus: filterRentStatus,
platePrefix: filterPlatePrefix,
targetNames: filterTargetNames,
region: filterRegion,
plates: filterPlates,
mileageRange: { min: '', max: '' },
rangeStart,
rangeEnd,
brands: filterBrands,
sourcePriority,
}), [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, filterBrands, sourcePriority]);
useEffect(() => { useEffect(() => {
const timer = setInterval(() => setRelativeNow(Date.now()), 5000); const timer = setInterval(() => setRelativeNow(Date.now()), 5000);
@@ -145,45 +132,48 @@ export default function MonitoringView() {
const isHighMileageAlert = useCallback((v: MonitoringVehicle) => { const isHighMileageAlert = useCallback((v: MonitoringVehicle) => {
const inAlertTarget = v.targetNames?.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name)) const inAlertTarget = v.targetNames?.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name))
|| filterTargetNames.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name)); || monitoringQuery.targetNames.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name));
return inAlertTarget && Math.max(0, v.dailyKm || 0) >= HIGH_MILEAGE_ALERT_KM; return inAlertTarget && Math.max(0, v.dailyKm || 0) >= HIGH_MILEAGE_ALERT_KM;
}, [filterTargetNames]); }, [monitoringQuery.targetNames]);
const queryRequestRef = useRef(0); const queryRequestRef = useRef(0);
// 加载首页数据 // 加载首页数据
const loadFirstPage = useCallback((showPageLoading = true) => { const loadFirstPage = useCallback((showPageLoading = true, refresh = false) => {
const requestId = ++queryRequestRef.current; const requestId = ++queryRequestRef.current;
setQueryError('');
if (showPageLoading) setPageLoading(true); if (showPageLoading) setPageLoading(true);
return fetchMonitoring(buildInitialMonitoringQuery(monitoringQuery, PAGE_SIZE)).then(d => { return fetchMonitoring({ ...buildInitialMonitoringQuery(monitoringQuery, PAGE_SIZE), refresh }).then(d => {
if (requestId !== queryRequestRef.current) return; if (requestId !== queryRequestRef.current) return;
setVehicles(d.vehicles); setVehicles(d.vehicles);
setStats(d.stats); setStats(d.stats);
setFilterOptions(d.filters); setFilterOptions(d.filters);
setRangeDailyTotals(d.rangeDailyTotals || []); setRangeDailyTotals(d.rangeDailyTotals || []);
setEffectiveRange(d.dateRange || { start: rangeStart, end: rangeEnd }); setEffectiveRange(d.dateRange || { start: monitoringQuery.rangeStart, end: monitoringQuery.rangeEnd });
setTotal(d.total); setTotal(d.total);
setPage(1); setPage(1);
setHasMore(d.page < d.totalPages); setHasMore(d.page < d.totalPages);
setLastRefreshedAt(new Date()); setLastRefreshedAt(d.queriedAt ? new Date(d.queriedAt) : new Date());
}).catch(() => {}).finally(() => { }).catch(error => {
if (requestId === queryRequestRef.current) setQueryError(error instanceof Error ? error.message : '里程查询失败,请点击查询重试');
}).finally(() => {
if (requestId === queryRequestRef.current) setPageLoading(false); if (requestId === queryRequestRef.current) setPageLoading(false);
}); });
}, [monitoringQuery, rangeStart, rangeEnd]); }, [monitoringQuery]);
const handleManualRefresh = useCallback(async () => { const handleManualRefresh = useCallback(async () => {
if (manualRefreshing || pageLoading) return; if (manualRefreshing || pageLoading || hasPendingChanges) return;
setManualRefreshing(true); setManualRefreshing(true);
try { try {
await loadFirstPage(false); await loadFirstPage(false, true);
} finally { } finally {
setManualRefreshing(false); setManualRefreshing(false);
} }
}, [loadFirstPage, manualRefreshing, pageLoading]); }, [loadFirstPage, manualRefreshing, pageLoading, hasPendingChanges]);
// 加载更多 // 加载更多
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
if (pageLoading || loadingMore || !hasMore) return; if (pageLoading || manualRefreshing || loadingMore || !hasMore || hasPendingChanges || queryError) return;
const requestId = queryRequestRef.current; const requestId = queryRequestRef.current;
const nextPage = page + 1; const nextPage = page + 1;
setLoadingMore(true); setLoadingMore(true);
@@ -192,15 +182,17 @@ export default function MonitoringView() {
setVehicles(prev => [...prev, ...d.vehicles]); setVehicles(prev => [...prev, ...d.vehicles]);
setPage(nextPage); setPage(nextPage);
setHasMore(nextPage < d.totalPages); setHasMore(nextPage < d.totalPages);
}).catch(() => {}).finally(() => setLoadingMore(false)); }).catch(error => {
}, [monitoringQuery, page, pageLoading, loadingMore, hasMore]); if (requestId === queryRequestRef.current) setQueryError(error instanceof Error ? error.message : '加载更多失败,请重试');
}).finally(() => setLoadingMore(false));
}, [monitoringQuery, page, pageLoading, manualRefreshing, loadingMore, hasMore, hasPendingChanges, queryError]);
// 筛选/排序变化时重新加载 // 首次进入和确认查询后加载;编辑筛选条件不触发请求。
useEffect(() => { useEffect(() => {
loadFirstPage(); loadFirstPage();
}, [loadFirstPage]); }, [loadFirstPage]);
// 区域级联:plate 选项收窄后,剔除已选但已不属于该区域的车牌 // 保留区域级联清理;清理后的草稿也必须确认后才能请求。
useEffect(() => { useEffect(() => {
if (filterPlates.length === 0) return; if (filterPlates.length === 0) return;
const valid = new Set(filterOptions.plates); const valid = new Set(filterOptions.plates);
@@ -209,24 +201,18 @@ export default function MonitoringView() {
}, [filterOptions.plates, filterPlates]); }, [filterOptions.plates, filterPlates]);
// 下载当前筛选结果为 xlsx // 下载当前筛选结果为 xlsx
const handleDownload = useCallback(async () => { const handleDownload = useCallback(async (dailyDetails = false) => {
if (exporting) return; if (exporting || hasPendingChanges || pageLoading || queryError) return;
setExporting(true); setExporting(true);
try { try {
const d = await fetchMonitoring(buildExportMonitoringQuery(monitoringQuery)); const d = await fetchMonitoring({ ...buildExportMonitoringQuery(monitoringQuery), dailyDetails });
exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy }); exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || monitoringQuery.rangeStart, endDate: d.dateRange?.end || monitoringQuery.rangeEnd, sortBy: monitoringQuery.sortBy });
} catch (err) { } catch (err) {
console.error('export failed', err); setQueryError(err instanceof Error ? err.message : '导出失败,请重试');
} finally { } finally {
setExporting(false); setExporting(false);
} }
}, [exporting, monitoringQuery, rangeStart, rangeEnd, sortBy]); }, [exporting, monitoringQuery, hasPendingChanges, pageLoading, queryError]);
// 每分钟自动刷新
useEffect(() => {
const timer = setInterval(() => { void loadFirstPage(false); }, 60 * 1000);
return () => clearInterval(timer);
}, [loadFirstPage]);
// 触底检测:用 IntersectionObserver 监听哨兵元素 // 触底检测:用 IntersectionObserver 监听哨兵元素
const loadMoreRef = useRef(loadMore); const loadMoreRef = useRef(loadMore);
@@ -272,16 +258,20 @@ export default function MonitoringView() {
setIsFullscreen(!isFullscreen); setIsFullscreen(!isFullscreen);
}; };
// 全屏时加载全部数据(无分页),筛选变化时重新加载 // 全屏使用已确认条件,编辑筛选后仍需点击查询。
useEffect(() => { const loadFullscreen = useCallback((refresh = false) => {
if (!isFullscreen) return; setQueryError('');
setFullscreenLoading(true); setFullscreenLoading(true);
fetchMonitoring(buildFullscreenMonitoringQuery(fullscreenQuery)).then(d => { return fetchMonitoring({ ...buildFullscreenMonitoringQuery(monitoringQuery), refresh }).then(d => {
setFullscreenVehicles(d.vehicles); setFullscreenVehicles(d.vehicles);
setFullscreenStats(d.stats); setFullscreenStats(d.stats);
setFilterOptions(d.filters); setFilterOptions(d.filters);
}).catch(() => {}).finally(() => setFullscreenLoading(false)); }).catch(error => setQueryError(error instanceof Error ? error.message : '全屏查询失败')).finally(() => setFullscreenLoading(false));
}, [isFullscreen, fullscreenQuery, fullscreenRefresh]); }, [monitoringQuery]);
useEffect(() => {
if (isFullscreen) void loadFullscreen();
}, [isFullscreen, loadFullscreen]);
// 全屏时禁止背景滚动 // 全屏时禁止背景滚动
useEffect(() => { useEffect(() => {
@@ -322,9 +312,13 @@ export default function MonitoringView() {
forceLandscape={forceLandscape} forceLandscape={forceLandscape}
fullscreenStats={fullscreenStats} fullscreenStats={fullscreenStats}
sortBy={sortBy} sortBy={sortBy}
sourcePriority={sourcePriority} sourcePriority={monitoringQuery.sourcePriority}
onRefresh={() => { setFullscreenRefresh(n => n + 1); }} onQuery={handleQuery}
queryDisabled={queryDisabled}
hasPendingChanges={hasPendingChanges}
onRefresh={() => { void loadFullscreen(true); }}
fullscreenLoading={fullscreenLoading} fullscreenLoading={fullscreenLoading}
error={queryError}
onToggleFullscreen={toggleFullscreen} onToggleFullscreen={toggleFullscreen}
filterTargetNames={filterTargetNames} filterTargetNames={filterTargetNames}
onFilterTargetNamesChange={setFilterTargetNames} onFilterTargetNamesChange={setFilterTargetNames}
@@ -351,8 +345,13 @@ export default function MonitoringView() {
<MonitoringToolbar <MonitoringToolbar
onToggleFullscreen={toggleFullscreen} onToggleFullscreen={toggleFullscreen}
onDownload={handleDownload} onDownload={() => { void handleDownload(); }}
onDownloadDetails={() => { void handleDownload(true); }}
exporting={exporting} exporting={exporting}
onQuery={handleQuery}
queryDisabled={queryDisabled}
hasPendingChanges={hasPendingChanges}
resultsDisabled={pageLoading || hasPendingChanges || !!queryError}
lastRefreshedAt={lastRefreshedAt} lastRefreshedAt={lastRefreshedAt}
onManualRefresh={handleManualRefresh} onManualRefresh={handleManualRefresh}
manualRefreshing={manualRefreshing} manualRefreshing={manualRefreshing}
@@ -415,8 +414,8 @@ export default function MonitoringView() {
onFilterPlatesChange={setFilterPlates} onFilterPlatesChange={setFilterPlates}
onFilterTargetNamesChange={setFilterTargetNames} onFilterTargetNamesChange={setFilterTargetNames}
onFilterRegionChange={setFilterRegion} onFilterRegionChange={setFilterRegion}
onAppliedMileageRangeChange={setAppliedMileageRange} onQuery={handleQuery}
onFilterOpenChange={setIsFilterOpen} queryDisabled={queryDisabled}
/> />
)} )}
</AnimatePresence> </AnimatePresence>
@@ -445,8 +444,7 @@ export default function MonitoringView() {
searchTerm={searchTerm} searchTerm={searchTerm}
onSearchTermChange={setSearchTerm} onSearchTermChange={setSearchTerm}
onFilterMileageRangeChange={setFilterMileageRange} onFilterMileageRangeChange={setFilterMileageRange}
appliedMileageRange={appliedMileageRange} mileageRange={filterMileageRange}
onAppliedMileageRangeChange={setAppliedMileageRange}
filterPlatePrefix={filterPlatePrefix} filterPlatePrefix={filterPlatePrefix}
onFilterPlatePrefixChange={setFilterPlatePrefix} onFilterPlatePrefixChange={setFilterPlatePrefix}
rangeStart={rangeStart} rangeStart={rangeStart}
@@ -455,10 +453,12 @@ export default function MonitoringView() {
onRangeEndChange={setRangeEnd} onRangeEndChange={setRangeEnd}
/> />
{queryError && <div role="alert" className="rounded-xl bg-red-50 p-3 text-sm text-red-700">{queryError}</div>}
{/* Sticky header: KPI + 清单标题 */} {/* Sticky header: KPI + 清单标题 */}
<MonitoringSummary <MonitoringSummary
pageLoading={pageLoading} pageLoading={pageLoading}
sortBy={sortBy} sortBy={monitoringQuery.sortBy}
isRangeMode={isRangeMode} isRangeMode={isRangeMode}
stats={stats} stats={stats}
rangeLabel={rangeLabel} rangeLabel={rangeLabel}
@@ -482,8 +482,9 @@ export default function MonitoringView() {
<VehicleDetailModal <VehicleDetailModal
vehicle={detailVehicle} vehicle={detailVehicle}
selectedRange={effectiveRange}
onClose={() => setDetailVehicle(null)} onClose={() => setDetailVehicle(null)}
sourcePriority={sourcePriority} sourcePriority={monitoringQuery.sourcePriority}
/> />
{/* 回到顶部按钮 */} {/* 回到顶部按钮 */}
+17 -16
View File
@@ -13,11 +13,13 @@ interface Props {
vehicle: MonitoringVehicle | null; vehicle: MonitoringVehicle | null;
onClose: () => void; onClose: () => void;
sourcePriority: MileageSourceGroup[]; sourcePriority: MileageSourceGroup[];
selectedRange: { start: string; end: string };
} }
type RangeKey = 'last15' | 'month' | 'quarter'; type RangeKey = 'selected' | 'last15' | 'month' | 'quarter';
const RANGE_TABS: { key: RangeKey; label: string }[] = [ const RANGE_TABS: { key: RangeKey; label: string }[] = [
{ key: 'selected', label: '当前区间' },
{ key: 'last15', label: '近 15 天' }, { key: 'last15', label: '近 15 天' },
{ key: 'month', label: '本月' }, { key: 'month', label: '本月' },
{ key: 'quarter', label: '本季度' }, { key: 'quarter', label: '本季度' },
@@ -48,10 +50,6 @@ function rangeFor(key: RangeKey): { start: string; end: string; rangeLabel: stri
return { start: fmtYmd(start), end, rangeLabel: '本季度' }; return { start: fmtYmd(start), end, rangeLabel: '本季度' };
} }
function isToday(date: string): boolean {
return date === fmtYmd(new Date());
}
function formatLabel(date: string, key: RangeKey): string { 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;
@@ -64,30 +62,32 @@ function daySourceLabel(day: VehicleRecentDay): string {
return day.isDataSynced ? '来源待接口' : '无数据'; return day.isDataSynced ? '来源待接口' : '无数据';
} }
export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: Props) { export default function VehicleDetailModal({ vehicle, onClose, sourcePriority, selectedRange }: Props) {
const [days, setDays] = useState<VehicleRecentDay[]>([]); const [days, setDays] = useState<VehicleRecentDay[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [range, setRange] = useState<RangeKey>('last15'); const [error, setError] = useState('');
const [range, setRange] = useState<RangeKey>('selected');
const dragControls = useDragControls(); const dragControls = useDragControls();
// 切换车辆时重置区间为默认 // 切换车辆时重置区间为默认
useEffect(() => { useEffect(() => {
if (vehicle) setRange('last15'); if (vehicle) setRange('selected');
}, [vehicle?.plate]); // eslint-disable-line react-hooks/exhaustive-deps }, [vehicle?.plate]); // eslint-disable-line react-hooks/exhaustive-deps
// 拉取数据(车辆或区间变化) // 拉取数据(车辆或区间变化)
useEffect(() => { useEffect(() => {
if (!vehicle) return; if (!vehicle) return;
const { start, end } = rangeFor(range); const { start, end } = range === 'selected' ? selectedRange : rangeFor(range);
setLoading(true); setLoading(true);
setError('');
setDays([]); setDays([]);
let cancelled = false; let cancelled = false;
fetchVehicleRecent(vehicle.plate, { start, end, sourcePriority }) fetchVehicleRecent(vehicle.plate, { start, end, sourcePriority })
.then(d => { if (!cancelled) setDays(d.days); }) .then(d => { if (!cancelled) setDays(d.days); })
.catch(() => { if (!cancelled) setDays([]); }) .catch(() => { if (!cancelled) setError('单车里程加载失败,请重新打开详情重试'); })
.finally(() => { if (!cancelled) setLoading(false); }); .finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [vehicle?.plate, range, sourcePriority]); // eslint-disable-line react-hooks/exhaustive-deps }, [vehicle?.plate, range, sourcePriority, selectedRange.start, selectedRange.end]); // eslint-disable-line react-hooks/exhaustive-deps
// 锁滚动 // 锁滚动
useEffect(() => { useEffect(() => {
@@ -96,8 +96,8 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
return () => { document.body.style.overflow = ''; }; return () => { document.body.style.overflow = ''; };
}, [vehicle]); }, [vehicle]);
// 排除"今日"列(数据未到位时易引起误读) // 与主列表区间一致;缺失日保留缺口,真实零保留为零。
const historyDays = useMemo(() => days.filter(d => !isToday(d.date)), [days]); const historyDays = useMemo(() => days, [days]);
const stats = useMemo(() => recentMileageStats(historyDays), [historyDays]); const stats = useMemo(() => recentMileageStats(historyDays), [historyDays]);
const chartDays = useMemo(() => historyDays.map(day => ({ const chartDays = useMemo(() => historyDays.map(day => ({
...day, dailyKm: hasDailyMileage(day) ? day.dailyKm : null, ...day, dailyKm: hasDailyMileage(day) ? day.dailyKm : null,
@@ -106,11 +106,11 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
// 骨架天数:根据区间预估 // 骨架天数:根据区间预估
const skeletonCount = useMemo(() => { const skeletonCount = useMemo(() => {
if (range === 'last15') return 15; if (range === 'last15') return 15;
const { start, end } = rangeFor(range); const { start, end } = range === 'selected' ? selectedRange : rangeFor(range);
const s = new Date(start); const s = new Date(start);
const e = new Date(end); const e = new Date(end);
return Math.max(1, Math.round((e.getTime() - s.getTime()) / 86400000)); return Math.max(1, Math.round((e.getTime() - s.getTime()) / 86400000));
}, [range]); }, [range, selectedRange.start, selectedRange.end]);
return ( return (
<AnimatePresence> <AnimatePresence>
@@ -171,11 +171,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
</div> </div>
</div> </div>
</div> </div>
<button onClick={onClose} className="p-2 -mr-1 text-slate-400 hover:text-slate-700 flex-shrink-0"> <button aria-label="关闭车辆详情" 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>
{error && <div role="alert" className="mx-4 mt-2 rounded-lg bg-red-50 p-2 text-xs text-red-700">{error}</div>}
{/* 时间范围切换 */} {/* 时间范围切换 */}
<div className="px-4 pt-3"> <div className="px-4 pt-3">
<div className="relative inline-flex bg-slate-100 p-0.5 rounded-lg"> <div className="relative inline-flex bg-slate-100 p-0.5 rounded-lg">
+11 -2
View File
@@ -7,7 +7,7 @@ import type {
TargetVehicle, TargetVehicle,
TrendPoint, TrendPoint,
} from './types'; } from './types';
import { fetchJson } from '../../auth/api-client'; import { fetchJson, fetchAuthenticated } from '../../auth/api-client';
import type { import type {
DailyMileageReport, DailyMileageReport,
MileageReportHistoryResponse, MileageReportHistoryResponse,
@@ -26,6 +26,9 @@ export type {
const BASE = '/api/mileage'; const BASE = '/api/mileage';
export async function fetchMonitoring(params?: { export async function fetchMonitoring(params?: {
refresh?: boolean;
includeDailyTrend?: boolean;
dailyDetails?: boolean;
sortBy?: 'today' | 'total' | 'statisticTime'; sortBy?: 'today' | 'total' | 'statisticTime';
sortOrder?: 'asc' | 'desc'; sortOrder?: 'asc' | 'desc';
limit?: number; limit?: number;
@@ -50,6 +53,9 @@ export async function fetchMonitoring(params?: {
sourcePriority?: MileageSourceGroup[]; sourcePriority?: MileageSourceGroup[];
}): Promise<MonitoringData> { }): Promise<MonitoringData> {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params?.refresh) query.set('refresh', '1');
if (params?.includeDailyTrend === false) query.set('includeDailyTrend', '0');
if (params?.dailyDetails) query.set('dailyDetails', '1');
if (params?.sortBy) query.set('sortBy', params.sortBy); if (params?.sortBy) query.set('sortBy', params.sortBy);
if (params?.sortOrder) query.set('sortOrder', params.sortOrder); if (params?.sortOrder) query.set('sortOrder', params.sortOrder);
if (params?.limit) query.set('limit', String(params.limit)); if (params?.limit) query.set('limit', String(params.limit));
@@ -81,7 +87,10 @@ export async function fetchMonitoring(params?: {
if (params?.endDate) query.set('endDate', params.endDate); if (params?.endDate) query.set('endDate', params.endDate);
if (params?.sourcePriority?.length) query.set('sourcePriority', params.sourcePriority.join(',')); if (params?.sourcePriority?.length) query.set('sourcePriority', params.sourcePriority.join(','));
const qs = query.toString(); const qs = query.toString();
return fetchJson<MonitoringData>(`${BASE}/monitoring${qs ? `?${qs}` : ''}`); const response = await fetchAuthenticated(`${BASE}/monitoring${qs ? `?${qs}` : ''}`);
const data = await response.json();
if (!response.ok) throw new Error(data.error || '里程查询失败,请重试');
return data as MonitoringData;
} }
export async function fetchTargets(): Promise<TargetSummary[]> { export async function fetchTargets(): Promise<TargetSummary[]> {
@@ -21,3 +21,10 @@ test('列表全区间缺失时显示横杠,即使存在累计里程', () => {
assert.equal(monitoringMileageValue({ ...vehicle, isDataSynced: true }), 0); assert.equal(monitoringMileageValue({ ...vehicle, isDataSynced: true }), 0);
assert.equal(monitoringMileageValue({ ...vehicle, isDataSynced: true, dailyKm: 5417 }), 5417); assert.equal(monitoringMileageValue({ ...vehicle, isDataSynced: true, dailyKm: 5417 }), 5417);
}); });
test('summary partial anomalies retain valid mileage; missing stays distinct from zero', () => {
const v = { periodMileageKm: 0, dailyKm: 0, mileageAnomaly: 'BASELINE_MISSING', isDataSynced: true } as MonitoringVehicle;
assert.equal(monitoringMileageValue(v),0);
assert.equal(monitoringMileageValue({...v,periodMileageKm:20}),20);
assert.equal(monitoringMileageValue({...v,periodMileageKm:null}),'—');
});
+6
View File
@@ -2,6 +2,7 @@ import type { MonitoringVehicle } from './types';
import type { VehicleRecentDay } from './api'; import type { VehicleRecentDay } from './api';
export function monitoringMileageValue(vehicle: MonitoringVehicle): number | string { export function monitoringMileageValue(vehicle: MonitoringVehicle): number | string {
if (vehicle.periodMileageKm !== undefined) return vehicle.periodMileageKm ?? '—';
if (vehicle.mileageAnomaly) return '数据异常'; if (vehicle.mileageAnomaly) return '数据异常';
if (!vehicle.isDataSynced) return '—'; if (!vehicle.isDataSynced) return '—';
return Math.max(0, vehicle.dailyKm || 0); return Math.max(0, vehicle.dailyKm || 0);
@@ -22,3 +23,8 @@ export function recentMileageStats(days: VehicleRecentDay[]) {
totalDays: days.length, totalDays: days.length,
}; };
} }
export function mileageQualityLabel(vehicle: MonitoringVehicle): string | null {
if (!vehicle.qualityStatus) return null;
return `${vehicle.validDays ?? 0}天有效 · ${vehicle.missingDays ?? 0}天缺失 · ${vehicle.anomalyDays ?? 0}天异常`;
}
@@ -27,8 +27,7 @@ export default function ActiveFilterTags({
searchTerm, searchTerm,
onSearchTermChange, onSearchTermChange,
onFilterMileageRangeChange, onFilterMileageRangeChange,
appliedMileageRange, mileageRange,
onAppliedMileageRangeChange,
filterPlatePrefix, filterPlatePrefix,
onFilterPlatePrefixChange, onFilterPlatePrefixChange,
rangeStart, rangeStart,
@@ -58,8 +57,7 @@ export default function ActiveFilterTags({
searchTerm: string; searchTerm: string;
onSearchTermChange: (value: string) => void; onSearchTermChange: (value: string) => void;
onFilterMileageRangeChange: Dispatch<SetStateAction<MileageRange>>; onFilterMileageRangeChange: Dispatch<SetStateAction<MileageRange>>;
appliedMileageRange: MileageRange; mileageRange: MileageRange;
onAppliedMileageRangeChange: Dispatch<SetStateAction<MileageRange>>;
filterPlatePrefix: string; filterPlatePrefix: string;
onFilterPlatePrefixChange: (value: string) => void; onFilterPlatePrefixChange: (value: string) => void;
rangeStart: string; rangeStart: string;
@@ -84,8 +82,8 @@ export default function ActiveFilterTags({
if (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => onFilterProjectChange('All') }); if (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => onFilterProjectChange('All') });
if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => onFilterEntityChange('All') }); if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => onFilterEntityChange('All') });
if (searchTerm) tags.push({ label: `搜索: ${searchTerm}`, onClear: () => onSearchTermChange('') }); if (searchTerm) tags.push({ label: `搜索: ${searchTerm}`, onClear: () => onSearchTermChange('') });
if (appliedMileageRange.min) tags.push({ label: `里程≥${appliedMileageRange.min}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, min: '' })); onAppliedMileageRangeChange(prev => ({ ...prev, min: '' })); } }); if (mileageRange.min) tags.push({ label: `里程≥${mileageRange.min}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, min: '' })); } });
if (appliedMileageRange.max) tags.push({ label: `里程≤${appliedMileageRange.max}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, max: '' })); onAppliedMileageRangeChange(prev => ({ ...prev, max: '' })); } }); if (mileageRange.max) tags.push({ label: `里程≤${mileageRange.max}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, max: '' })); } });
if (filterPlatePrefix !== 'All') tags.push({ label: `车牌段: ${filterPlatePrefix}`, onClear: () => onFilterPlatePrefixChange('All') }); if (filterPlatePrefix !== 'All') tags.push({ label: `车牌段: ${filterPlatePrefix}`, onClear: () => onFilterPlatePrefixChange('All') });
if (rangeStart || rangeEnd) tags.push({ if (rangeStart || rangeEnd) tags.push({
label: `区间: ${normalizeRangeLabel(rangeStart, rangeEnd)}`, label: `区间: ${normalizeRangeLabel(rangeStart, rangeEnd)}`,
@@ -99,7 +97,7 @@ export default function ActiveFilterTags({
const clearAll = () => { const clearAll = () => {
onFilterDeptChange('All'); onFilterCustomerChange('All'); onFilterRentStatusChange('All'); onFilterProjectChange('All'); onFilterEntityChange('All'); onFilterDeptChange('All'); onFilterCustomerChange('All'); onFilterRentStatusChange('All'); onFilterProjectChange('All'); onFilterEntityChange('All');
onFilterPlatesChange([]); onSearchTermChange(''); onFilterPlatePrefixChange('All'); onFilterTargetNamesChange([]); onFilterRegionChange('All'); onFilterPlatesChange([]); onSearchTermChange(''); onFilterPlatePrefixChange('All'); onFilterTargetNamesChange([]); onFilterRegionChange('All');
onFilterMileageRangeChange({ min: '', max: '' }); onAppliedMileageRangeChange({ min: '', max: '' }); onFilterMileageRangeChange({ min: '', max: '' });
onFilterBrandsChange([]); onFilterBrandsChange([]);
const today = defaultMileageDate(); const today = defaultMileageDate();
onRangeStartChange(today); onRangeEndChange(today); onRangeStartChange(today); onRangeEndChange(today);
@@ -35,8 +35,8 @@ export default function FilterPanel({
onFilterPlatesChange, onFilterPlatesChange,
onFilterTargetNamesChange, onFilterTargetNamesChange,
onFilterRegionChange, onFilterRegionChange,
onAppliedMileageRangeChange, onQuery,
onFilterOpenChange, queryDisabled,
}: { }: {
rangeStart: string; rangeStart: string;
rangeEnd: string; rangeEnd: string;
@@ -65,8 +65,8 @@ export default function FilterPanel({
onFilterPlatesChange: (value: string[]) => void; onFilterPlatesChange: (value: string[]) => void;
onFilterTargetNamesChange: (value: string[]) => void; onFilterTargetNamesChange: (value: string[]) => void;
onFilterRegionChange: (value: string) => void; onFilterRegionChange: (value: string) => void;
onAppliedMileageRangeChange: (value: MileageRange) => void; onQuery: () => void;
onFilterOpenChange: (value: boolean) => void; queryDisabled: boolean;
}) { }) {
return ( return (
<motion.div <motion.div
@@ -82,6 +82,7 @@ export default function FilterPanel({
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2"> <div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<input <input
type="date" type="date"
aria-label="开始日期"
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none" className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={rangeStart} value={rangeStart}
onChange={(e) => onRangeStartChange(e.target.value)} onChange={(e) => onRangeStartChange(e.target.value)}
@@ -89,11 +90,13 @@ export default function FilterPanel({
<span className="text-[10px] font-bold text-slate-300">至</span> <span className="text-[10px] font-bold text-slate-300">至</span>
<input <input
type="date" type="date"
aria-label="结束日期"
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none" className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={rangeEnd} value={rangeEnd}
onChange={(e) => onRangeEndChange(e.target.value)} onChange={(e) => onRangeEndChange(e.target.value)}
/> />
</div> </div>
{rangeStart && rangeEnd && rangeStart > rangeEnd && <p role="alert" className="text-xs text-red-600">开始日期不能晚于结束日期</p>}
<div className="flex items-center gap-1.5 pt-1"> <div className="flex items-center gap-1.5 pt-1">
{([ {([
['today', '今天'], ['today', '今天'],
@@ -240,13 +243,13 @@ export default function FilterPanel({
onFilterDeptChange('All'); onFilterDeptChange('All');
onFilterPlatesChange([]); onFilterPlatesChange([]);
onFilterCustomerChange('All'); onFilterCustomerChange('All');
onFilterRentStatusChange('All');
onFilterProjectChange('All'); onFilterProjectChange('All');
onFilterEntityChange('All'); onFilterEntityChange('All');
onFilterPlatePrefixChange('All'); onFilterPlatePrefixChange('All');
onFilterTargetNamesChange([]); onFilterTargetNamesChange([]);
onFilterRegionChange('All'); onFilterRegionChange('All');
onFilterMileageRangeChange({ min: '', max: '' }); onFilterMileageRangeChange({ min: '', max: '' });
onAppliedMileageRangeChange({ min: '', max: '' });
onFilterBrandsChange([]); onFilterBrandsChange([]);
const today = defaultMileageDate(); const today = defaultMileageDate();
onRangeStartChange(today); onRangeStartChange(today);
@@ -257,13 +260,11 @@ export default function FilterPanel({
重置所有 重置所有
</button> </button>
<button <button
onClick={() => { onClick={onQuery}
onAppliedMileageRangeChange({ ...filterMileageRange }); disabled={queryDisabled}
onFilterOpenChange(false); className="bg-blue-600 text-white px-6 py-2 rounded-xl text-xs font-bold shadow-lg shadow-blue-100 disabled:opacity-50"
}}
className="bg-blue-600 text-white px-6 py-2 rounded-xl text-xs font-bold shadow-lg shadow-blue-100"
> >
完成筛选 查询
</button> </button>
</div> </div>
</div> </div>
@@ -1,4 +1,4 @@
import { monitoringMileageValue } from '../../mileage-display'; import { monitoringMileageValue, mileageQualityLabel } from '../../mileage-display';
import type { Dispatch, SetStateAction } from 'react'; import type { Dispatch, SetStateAction } from 'react';
import { ArrowDown, ArrowUp, Minimize2, RotateCcw } from 'lucide-react'; import { ArrowDown, ArrowUp, Minimize2, RotateCcw } from 'lucide-react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
@@ -15,8 +15,12 @@ export default function FullscreenMonitoring({
fullscreenStats, fullscreenStats,
sortBy, sortBy,
sourcePriority, sourcePriority,
onQuery,
queryDisabled,
hasPendingChanges,
onRefresh, onRefresh,
fullscreenLoading, fullscreenLoading,
error,
onToggleFullscreen, onToggleFullscreen,
filterTargetNames, filterTargetNames,
onFilterTargetNamesChange, onFilterTargetNamesChange,
@@ -42,8 +46,12 @@ export default function FullscreenMonitoring({
fullscreenStats: MonitoringStats; fullscreenStats: MonitoringStats;
sortBy: SortBy; sortBy: SortBy;
sourcePriority: MileageSourceGroup[]; sourcePriority: MileageSourceGroup[];
onQuery: () => void;
queryDisabled: boolean;
hasPendingChanges: boolean;
onRefresh: () => void; onRefresh: () => void;
fullscreenLoading: boolean; fullscreenLoading: boolean;
error: string;
onToggleFullscreen: () => void; onToggleFullscreen: () => void;
filterTargetNames: string[]; filterTargetNames: string[];
onFilterTargetNamesChange: Dispatch<SetStateAction<string[]>>; onFilterTargetNamesChange: Dispatch<SetStateAction<string[]>>;
@@ -93,13 +101,13 @@ export default function FullscreenMonitoring({
<h2 className="text-white font-bold text-xs">全屏监控</h2> <h2 className="text-white font-bold text-xs">全屏监控</h2>
</div> </div>
<div className="flex items-center gap-3 text-[10px]"> <div className="flex items-center gap-3 text-[10px]">
<span className="text-slate-500">区间 <span className="text-white font-black">{Math.round(fullscreenStats.totalToday).toLocaleString()}</span> <span className="text-blue-400">km</span></span> <span className="text-slate-500">区间 <span className="text-white font-black">{fullscreenStats.validVehicleCount === 0 ? '—' : Math.round(fullscreenStats.totalToday).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span> <span className="text-slate-700">|</span>
<span className="text-slate-500">累计 <span className="text-white font-black">{Math.round(fullscreenStats.totalAll).toLocaleString()}</span> <span className="text-blue-400">km</span></span> <span className="text-slate-500">累计 <span className="text-white font-black">{fullscreenStats.totalMileageVehicleCount === 0 ? '—' : Math.round(fullscreenStats.totalAll).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span> <span className="text-slate-700">|</span>
<span className="text-slate-500">车辆 <span className="text-white font-black">{fullscreenStats.vehicleCount}</span> 台</span> <span className="text-slate-500">车辆 <span className="text-white font-black">{fullscreenStats.vehicleCount}</span> 台</span>
<span className="text-slate-700">|</span> <span className="text-slate-700">|</span>
<span className="text-slate-500">均 <span className="text-white font-black">{(fullscreenStats.vehicleCount > 0 ? (sortBy === 'total' ? fullscreenStats.totalAll : fullscreenStats.totalToday) / fullscreenStats.vehicleCount : 0).toFixed(0)}</span> <span className="text-blue-400">km</span></span> <span className="text-slate-500">均 <span className="text-white font-black">{(sortBy === 'total' ? fullscreenStats.totalMileageVehicleCount : fullscreenStats.validVehicleCount) === 0 ? '—' : (fullscreenStats.vehicleCount > 0 ? (sortBy === 'total' ? fullscreenStats.totalAll : fullscreenStats.totalToday) / fullscreenStats.vehicleCount : 0).toFixed(0)}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span> <span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-slate-500">
来源 <span className="text-white font-black"> 来源 <span className="text-white font-black">
@@ -109,7 +117,11 @@ export default function FullscreenMonitoring({
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button onClick={onQuery} disabled={queryDisabled} className="rounded bg-blue-600 px-3 py-1 text-xs text-white disabled:opacity-50">查询</button>
{hasPendingChanges && <span role="status" className="text-xs text-amber-400">待查询</span>}
<button <button
aria-label="刷新全屏数据"
disabled={fullscreenLoading || hasPendingChanges}
onClick={onRefresh} onClick={onRefresh}
className={`p-1.5 text-slate-500 hover:text-blue-400 transition-colors ${fullscreenLoading ? 'animate-spin' : ''}`} className={`p-1.5 text-slate-500 hover:text-blue-400 transition-colors ${fullscreenLoading ? 'animate-spin' : ''}`}
> >
@@ -121,6 +133,7 @@ export default function FullscreenMonitoring({
</div> </div>
</div> </div>
{error && <div role="alert" className="bg-red-950 px-3 py-2 text-sm text-red-200">{error}</div>}
{/* Batch selector + legend */} {/* Batch selector + legend */}
<div className="flex-shrink-0 px-3 py-1 border-b border-slate-800/60 flex items-center justify-between"> <div className="flex-shrink-0 px-3 py-1 border-b border-slate-800/60 flex items-center justify-between">
<div className="flex items-center gap-1 overflow-x-auto no-scrollbar"> <div className="flex items-center gap-1 overflow-x-auto no-scrollbar">
@@ -298,7 +311,7 @@ export default function FullscreenMonitoring({
<div className="text-xs font-bold text-white"><Blur>{v.plate}</Blur></div> <div className="text-xs font-bold text-white"><Blur>{v.plate}</Blur></div>
<span <span
className={`inline-flex shrink-0 rounded px-1.5 py-0.5 text-[7px] font-black ${sourceDisplay.className}`} className={`inline-flex shrink-0 rounded px-1.5 py-0.5 text-[7px] font-black ${sourceDisplay.className}`}
title={sourceDisplay.title} title={[sourceDisplay.title, mileageQualityLabel(v), v.qualityReasons?.join(', ')].filter(Boolean).join(' · ')}
> >
{sourceDisplay.label} {sourceDisplay.label}
</span> </span>
@@ -16,12 +16,12 @@ export default function MonitoringSummary({
isRangeMode: boolean; isRangeMode: boolean;
stats: MonitoringStats; stats: MonitoringStats;
rangeLabel: string; rangeLabel: string;
rangeDailyTotals: { date: string; totalKm: number }[]; rangeDailyTotals: { date: string; totalKm: number | null }[];
total: number; total: number;
}) { }) {
const averageDailyKm = rangeDailyTotals.length > 0 const hasMileage = (sortBy === 'total' ? stats.totalMileageVehicleCount : stats.validVehicleCount) !== 0;
? rangeDailyTotals.reduce((sum, item) => sum + item.totalKm, 0) / rangeDailyTotals.length const validDays = rangeDailyTotals.filter(item => item.totalKm != null);
: 0; const averageDailyKm = validDays.length ? validDays.reduce((sum, item) => sum + item.totalKm!, 0) / validDays.length : null;
return ( return (
<div className="sticky top-[44px] z-20 bg-[var(--app-bg)] pt-1 pb-1 space-y-2"> <div className="sticky top-[44px] z-20 bg-[var(--app-bg)] pt-1 pb-1 space-y-2">
@@ -29,13 +29,13 @@ export default function MonitoringSummary({
<div className="relative col-span-2 flex min-h-[68px] flex-col justify-center overflow-hidden rounded-xl bg-slate-900 p-2.5 text-white"> <div className="relative col-span-2 flex min-h-[68px] flex-col justify-center overflow-hidden rounded-xl bg-slate-900 p-2.5 text-white">
<div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'total' ? '累计' : (isRangeMode ? '区间' : '当日')}总里程</div> <div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'total' ? '累计' : (isRangeMode ? '区间' : '当日')}总里程</div>
<div className="text-lg font-black tracking-tighter leading-tight flex items-baseline gap-1"> <div className="text-lg font-black tracking-tighter leading-tight flex items-baseline gap-1">
{pageLoading ? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div> : <>{Math.round(sortBy === 'total' ? stats.totalAll : stats.totalToday).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></>} {pageLoading ? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div> : <>{hasMileage ? Math.round(sortBy === 'total' ? stats.totalAll : stats.totalToday).toLocaleString() : '—'} <span className="text-[8px] text-slate-400">km</span></>}
</div> </div>
<div className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div> <div className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div>
</div> </div>
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm"> <div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm">
<div className="text-[7px] font-bold text-slate-400 uppercase">平均单车</div> <div className="text-[7px] font-bold text-slate-400 uppercase">平均单车</div>
<div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : (stats.vehicleCount > 0 ? (sortBy === 'total' ? stats.totalAll : stats.totalToday) / stats.vehicleCount : 0).toFixed(0)}</div> <div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : hasMileage ? (stats.vehicleCount > 0 ? (sortBy === 'total' ? stats.totalAll : stats.totalToday) / stats.vehicleCount : 0).toFixed(0) : '—'}</div>
<div className="text-[7px] text-slate-400">km/台</div> <div className="text-[7px] text-slate-400">km/台</div>
</div> </div>
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm"> <div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm">
@@ -57,27 +57,27 @@ export default function MonitoringSummary({
<div className="mt-1 text-[9px] font-bold text-slate-400">{rangeDailyTotals.length} 天 · km</div> <div className="mt-1 text-[9px] font-bold text-slate-400">{rangeDailyTotals.length} 天 · km</div>
</div> </div>
<div className="h-[58px] min-w-0"> <div className="h-[58px] min-w-0">
{rangeDailyTotals.length === 0 ? ( {rangeDailyTotals.length === 0 || validDays.length === 0 ? (
<div className="flex h-full items-center justify-center rounded-lg bg-slate-50 text-[10px] font-bold text-slate-300">暂无趋势</div> <div className="flex h-full items-center justify-center rounded-lg bg-slate-50 text-[10px] font-bold text-slate-300">无有效里程数据</div>
) : ( ) : (
<ResponsiveContainer width="100%" height={58} minWidth={0}> <ResponsiveContainer width="100%" height={58} minWidth={0}>
<BarChart data={rangeDailyTotals} margin={{ top: 4, right: 2, bottom: 0, left: 2 }}> <BarChart data={rangeDailyTotals} margin={{ top: 4, right: 2, bottom: 0, left: 2 }}>
<XAxis dataKey="date" hide /> <XAxis dataKey="date" hide />
<Tooltip <Tooltip
formatter={(value) => [`${Number(value ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} km`, '当日里程']} formatter={(value) => value == null ? ['无有效数据', '当日里程'] : [`${Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} km`, '当日里程']}
labelFormatter={(label) => `日期 ${String(label)}`} labelFormatter={(label) => `日期 ${String(label)}`}
contentStyle={{ borderRadius: 10, borderColor: '#e2e8f0', fontSize: 11 }} contentStyle={{ borderRadius: 10, borderColor: '#e2e8f0', fontSize: 11 }}
cursor={{ fill: 'rgba(37, 99, 235, 0.06)' }} cursor={{ fill: 'rgba(37, 99, 235, 0.06)' }}
/> />
{averageDailyKm > 0 && <ReferenceLine y={averageDailyKm} stroke="#f59e0b" strokeDasharray="3 3" />} {averageDailyKm != null && averageDailyKm > 0 && <ReferenceLine y={averageDailyKm} stroke="#f59e0b" strokeDasharray="3 3" />}
<Bar dataKey="totalKm" radius={[3, 3, 0, 0]} fill="#38bdf8" /> <Bar dataKey="totalKm" radius={[3, 3, 0, 0]} fill="#38bdf8" />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
)} )}
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-[9px] font-black text-slate-400">日均</div> <div className="text-[9px] font-black text-slate-400">有效日均</div>
<div className="text-xs font-black text-slate-800">{Math.round(averageDailyKm).toLocaleString()}</div> <div className="text-xs font-black text-slate-800">{averageDailyKm == null ? '—' : Math.round(averageDailyKm).toLocaleString()}</div>
<div className="text-[9px] font-bold text-slate-400">km</div> <div className="text-[9px] font-bold text-slate-400">km</div>
</div> </div>
</div> </div>
@@ -92,11 +92,11 @@ export default function MonitoringSummary({
</div> </div>
<div className="flex flex-col justify-center rounded-lg bg-blue-50 px-2 py-1.5 text-right"> <div className="flex flex-col justify-center rounded-lg bg-blue-50 px-2 py-1.5 text-right">
<div className="text-[9px] font-black text-blue-400">当日总计</div> <div className="text-[9px] font-black text-blue-400">当日总计</div>
<div className="mt-1 text-xs font-black tabular-nums text-blue-700">{Math.round(stats.totalToday).toLocaleString()}</div> <div className="mt-1 text-xs font-black tabular-nums text-blue-700">{stats.validVehicleCount === 0 ? '—' : Math.round(stats.totalToday).toLocaleString()}</div>
</div> </div>
<div className="flex flex-col justify-center rounded-lg bg-slate-50 px-2 py-1.5 text-right"> <div className="flex flex-col justify-center rounded-lg bg-slate-50 px-2 py-1.5 text-right">
<div className="text-[9px] font-black text-slate-400">日均单车</div> <div className="text-[9px] font-black text-slate-400">日均单车</div>
<div className="mt-1 text-xs font-black tabular-nums text-slate-800">{stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}</div> <div className="mt-1 text-xs font-black tabular-nums text-slate-800">{stats.validVehicleCount === 0 ? '—' : stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}</div>
</div> </div>
</div> </div>
)} )}
@@ -11,8 +11,13 @@ type SortBy = 'today' | 'total' | 'statisticTime';
type SortOrder = 'asc' | 'desc'; type SortOrder = 'asc' | 'desc';
export default function MonitoringToolbar({ export default function MonitoringToolbar({
onQuery,
queryDisabled,
hasPendingChanges,
resultsDisabled,
onToggleFullscreen, onToggleFullscreen,
onDownload, onDownload,
onDownloadDetails,
exporting, exporting,
lastRefreshedAt, lastRefreshedAt,
onManualRefresh, onManualRefresh,
@@ -44,8 +49,13 @@ export default function MonitoringToolbar({
onApplyRangePreset, onApplyRangePreset,
rangeLabel, rangeLabel,
}: { }: {
onQuery: () => void;
queryDisabled: boolean;
hasPendingChanges: boolean;
resultsDisabled: boolean;
onToggleFullscreen: () => void; onToggleFullscreen: () => void;
onDownload: () => void; onDownload: () => void;
onDownloadDetails: () => void;
exporting: boolean; exporting: boolean;
lastRefreshedAt: Date | null; lastRefreshedAt: Date | null;
onManualRefresh: () => void; onManualRefresh: () => void;
@@ -88,9 +98,13 @@ export default function MonitoringToolbar({
<button onClick={onToggleFullscreen} className="p-1 text-slate-300 transition-colors hover:text-blue-600" title="全屏视图"> <button onClick={onToggleFullscreen} className="p-1 text-slate-300 transition-colors hover:text-blue-600" title="全屏视图">
<Maximize2 size={14} /> <Maximize2 size={14} />
</button> </button>
<button onClick={onDownload} disabled={exporting} className="p-1 text-slate-300 transition-colors hover:text-blue-600 disabled:text-slate-200" title="下载当前筛选结果"> <details className="relative">
{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download size={14} />} <summary aria-label="导出" className="cursor-pointer list-none p-1 text-slate-400">{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download size={14} />}</summary>
</button> <div className="absolute left-0 top-full z-40 w-36 rounded-lg border border-slate-200 bg-white p-1 shadow-lg">
<button onClick={onDownload} disabled={exporting || resultsDisabled} className="block w-full rounded p-2 text-left text-xs hover:bg-slate-50 disabled:opacity-50">导出区间汇总</button>
<button onClick={onDownloadDetails} disabled={exporting || resultsDisabled} className="block w-full rounded p-2 text-left text-xs hover:bg-slate-50 disabled:opacity-50">导出逐日明细</button>
</div>
</details>
</div> </div>
<div className="mt-1 flex items-center gap-1.5"> <div className="mt-1 flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" /> <span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
@@ -100,15 +114,15 @@ export default function MonitoringToolbar({
</div> </div>
<div className="flex flex-shrink-0 items-center gap-1.5"> <div className="flex flex-shrink-0 items-center gap-1.5">
<span className="hidden text-[9px] font-bold tabular-nums text-slate-400 sm:inline"> <span title="查询结果获取时间" className="hidden text-[9px] font-bold tabular-nums text-slate-400 sm:inline">
{lastRefreshedAt ? lastRefreshedAt.toLocaleTimeString('zh-CN', { hour12: false }) : '--:--:--'} {lastRefreshedAt ? lastRefreshedAt.toLocaleTimeString('zh-CN', { hour12: false }) : '--:--:--'}
</span> </span>
<button <button
type="button" type="button"
onClick={onManualRefresh} onClick={onManualRefresh}
disabled={manualRefreshing} disabled={manualRefreshing || resultsDisabled}
className="flex h-7 items-center gap-1 whitespace-nowrap rounded-lg border border-blue-100 bg-blue-50 px-2 text-[9px] font-bold text-blue-600 disabled:cursor-wait disabled:opacity-60" className="flex h-7 items-center gap-1 whitespace-nowrap rounded-lg border border-blue-100 bg-blue-50 px-2 text-[9px] font-bold text-blue-600 disabled:cursor-wait disabled:opacity-60"
title="局部刷新当前筛选数据" title="重新获取当前筛选数据"
> >
<RefreshCw size={11} className={manualRefreshing ? 'animate-spin' : ''} /> <RefreshCw size={11} className={manualRefreshing ? 'animate-spin' : ''} />
<span>{manualRefreshing ? '刷新中' : '刷新数据'}</span> <span>{manualRefreshing ? '刷新中' : '刷新数据'}</span>
@@ -145,7 +159,8 @@ export default function MonitoringToolbar({
</button> </button>
</div> </div>
<div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar"> <div className="flex items-center gap-1.5">
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto no-scrollbar">
{([ {([
['today', '今天'], ['today', '今天'],
['yesterday', '昨天'], ['yesterday', '昨天'],
@@ -166,7 +181,10 @@ export default function MonitoringToolbar({
</button> </button>
); );
})} })}
<span className="ml-auto shrink-0 rounded-lg bg-slate-50 px-2 py-1 text-[10px] font-bold text-slate-400">{rangeLabel}</span> </div>
<button type="button" onClick={onQuery} disabled={queryDisabled} className="shrink-0 rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-bold text-white disabled:opacity-50">查询</button>
{hasPendingChanges && <span role="status" className="shrink-0 text-[10px] text-amber-700">待查询</span>}
<span className="hidden shrink-0 rounded-lg sm:block bg-slate-50 px-2 py-1 text-[10px] font-bold text-slate-400">{rangeLabel}</span>
</div> </div>
</div> </div>
); );
@@ -1,4 +1,4 @@
import { monitoringMileageValue } from '../../mileage-display'; import { monitoringMileageValue, mileageQualityLabel } from '../../mileage-display';
import type { RefObject } from 'react'; import type { RefObject } from 'react';
import { Truck } from 'lucide-react'; import { Truck } from 'lucide-react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
@@ -87,7 +87,8 @@ export default function VehicleList({
{sourceDisplay.label} {sourceDisplay.label}
</span> </span>
</div> </div>
{v.mileageAnomaly && <div className="text-[9px] font-bold text-amber-600" title={v.mileageAnomaly}>{v.mileageAnomaly === 'ODOMETER_SOURCE_CHANGED' ? '里程来源变化' : '上游里程异常'}</div>} {v.qualityStatus && <div className="text-[9px] text-slate-500" title={v.qualityReasons?.join(', ')}>{mileageQualityLabel(v)}</div>}
{!v.qualityStatus && v.mileageAnomaly && <div className="text-[9px] font-bold text-amber-600" title={v.mileageAnomaly}>{v.mileageAnomaly === 'ODOMETER_SOURCE_CHANGED' ? '里程来源变化' : '上游里程异常'}</div>}
<div className={`text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}> <div className={`text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
{statisticTime.label} {statisticTime.label}
</div> </div>
@@ -135,10 +135,10 @@ test('four monitoring query builders preserve pagination and fullscreen differen
assert.equal(initial.plate, '粤A00001,粤A00002'); assert.equal(initial.plate, '粤A00001,粤A00002');
const nextPage = buildNextPageMonitoringQuery(queryModel, 50, 3); const nextPage = buildNextPageMonitoringQuery(queryModel, 50, 3);
assert.deepEqual(nextPage, { ...initial, page: 3 }); assert.deepEqual(nextPage, { ...initial, page: 3, includeDailyTrend: false });
const exported = buildExportMonitoringQuery(queryModel); const exported = buildExportMonitoringQuery(queryModel);
assert.deepEqual(exported, { ...initial, limit: 9999 }); assert.deepEqual(exported, { ...initial, limit: 9999, includeDailyTrend: false });
const fullscreen = buildFullscreenMonitoringQuery(queryModel); const fullscreen = buildFullscreenMonitoringQuery(queryModel);
assert.equal(fullscreen.limit, 9999); assert.equal(fullscreen.limit, 9999);
@@ -82,15 +82,15 @@ export function buildNextPageMonitoringQuery(
pageSize: number, pageSize: number,
page: number, page: number,
): MonitoringRequestParams { ): MonitoringRequestParams {
return buildPagedQuery(model, pageSize, page); return { ...buildPagedQuery(model, pageSize, page), includeDailyTrend: false };
} }
export function buildExportMonitoringQuery(model: MonitoringQueryModel): MonitoringRequestParams { export function buildExportMonitoringQuery(model: MonitoringQueryModel): MonitoringRequestParams {
return buildPagedQuery(model, 9999, 1); return { ...buildPagedQuery(model, 9999, 1), includeDailyTrend: false };
} }
export function buildFullscreenMonitoringQuery(model: MonitoringQueryModel): MonitoringRequestParams { export function buildFullscreenMonitoringQuery(model: MonitoringQueryModel): MonitoringRequestParams {
// Characterization boundary: fullscreen historically omits these four filters. // Characterization boundary: fullscreen historically omits these four filters.
// Keep project, entity, mileageMin and mileageMax out until a separate bug fix is approved. // Keep project, entity, mileageMin and mileageMax out until a separate bug fix is approved.
return buildSharedQuery(model, 9999, 1); return { ...buildSharedQuery(model, 9999, 1), includeDailyTrend: false };
} }
+11 -1
View File
@@ -6,6 +6,13 @@ export interface MonitoringVehicle {
plate: string; plate: string;
vin: string; vin: string;
dailyKm: number; dailyKm: number;
periodMileageKm?: number | null;
validDays?: number;
missingDays?: number;
anomalyDays?: number;
qualityStatus?: 'NORMAL' | 'PARTIAL_DATA' | 'NO_DATA' | 'DATA_ANOMALY';
qualityReasons?: string[];
sourceProtocols?: ('GB32960' | 'MQTT' | 'JT808')[];
mileageAnomaly?: string | null; mileageAnomaly?: string | null;
dailyMileage?: Record<string, number | null>; dailyMileage?: Record<string, number | null>;
totalKm: number | null; totalKm: number | null;
@@ -35,6 +42,8 @@ export interface MonitoringStats {
totalAll: number; totalAll: number;
vehicleCount: number; vehicleCount: number;
yesterdayTotal: number; yesterdayTotal: number;
validVehicleCount?: number;
totalMileageVehicleCount?: number;
} }
export interface MonitoringFilters { export interface MonitoringFilters {
@@ -54,11 +63,12 @@ export interface MonitoringData {
vehicles: MonitoringVehicle[]; vehicles: MonitoringVehicle[];
stats: MonitoringStats; stats: MonitoringStats;
filters: MonitoringFilters; filters: MonitoringFilters;
rangeDailyTotals?: { date: string; totalKm: number }[]; rangeDailyTotals?: { date: string; totalKm: number | null }[];
dateRange?: { start: string; end: string }; dateRange?: { start: string; end: string };
total: number; total: number;
page: number; page: number;
totalPages: number; totalPages: number;
queriedAt?: string;
updatedAt: string; updatedAt: string;
sourcePriority: MileageSourceGroup[]; sourcePriority: MileageSourceGroup[];
} }
+6 -4
View File
@@ -11,7 +11,7 @@ interface ExportContext {
const BASE_HEADERS = [ const BASE_HEADERS = [
'状态', '数据来源', '车牌号', '客户', '业务部门', '项目', '租赁状态', '状态', '数据来源', '车牌号', '客户', '业务部门', '项目', '租赁状态',
'运营区域', '运营区域', '有效天数', '缺失天数', '异常天数', '质量说明',
] as const; ] as const;
function sourceLabel(v: MonitoringVehicle): string { function sourceLabel(v: MonitoringVehicle): string {
@@ -22,6 +22,7 @@ function sourceLabel(v: MonitoringVehicle): string {
} }
function statusLabel(v: MonitoringVehicle): string { function statusLabel(v: MonitoringVehicle): string {
if (v.qualityStatus) return { NORMAL: '有效', PARTIAL_DATA: '部分缺失', NO_DATA: '无数据', DATA_ANOMALY: '部分异常' }[v.qualityStatus];
if (!v.isDataSynced) return '未对接'; if (!v.isDataSynced) return '未对接';
return v.isOnline ? '在线' : '离线'; return v.isOnline ? '在线' : '离线';
} }
@@ -30,7 +31,7 @@ function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | nu
if (kind === 'today') { if (kind === 'today') {
return monitoringMileageValue(v); return monitoringMileageValue(v);
} }
return v.totalKm != null ? v.totalKm : '未对接'; return v.totalKm != null ? v.totalKm : '—';
} }
export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void { export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void {
@@ -62,7 +63,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
v.department || '', v.department || '',
v.project || '', v.project || '',
v.rentStatus || '', v.rentStatus || '',
v.region || '', v.region || '', v.validDays ?? '', v.missingDays ?? '', v.anomalyDays ?? '', v.qualityReasons?.join(', ') || '',
]; ];
const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? '—'); const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? '—');
return [ return [
@@ -86,6 +87,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
{ wch: 16 }, // 项目 { wch: 16 }, // 项目
{ wch: 10 }, // 租赁状态 { wch: 10 }, // 租赁状态
{ wch: 12 }, // 运营区域 { wch: 12 }, // 运营区域
{ wch: 10 }, { wch: 10 }, { wch: 10 }, { wch: 36 },
]; ];
for (let i = 0; i < dayKeys.length; i++) wsCols.push({ wch: 12 }); for (let i = 0; i < dayKeys.length; i++) wsCols.push({ wch: 12 });
wsCols.push({ wch: 14 }); // 区间合计 wsCols.push({ wch: 14 }); // 区间合计
@@ -177,6 +179,6 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
: ctx.sortBy === 'statisticTime' : ctx.sortBy === 'statisticTime'
? '统计时间' ? '统计时间'
: isRange ? '区间' : '今日'; : isRange ? '区间' : '今日';
const filename = `里程看板_${dateTag}_${hh}${mm}_${sortLabel}.xlsx`; const filename = `${dayKeys.length ? '里程逐日明细' : '里程区间汇总'}_${dateTag}_${hh}${mm}_${sortLabel}.xlsx`;
XLSX.writeFile(wb, filename); XLSX.writeFile(wb, filename);
} }
+22 -8
View File
@@ -1,17 +1,17 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { monthCutoff, nextCapture, shanghaiMonth, shanghaiTimestamp } from '../../../shared/assets/history.js'; import { dayCutoff, shiftDay, monthCutoff, nextCapture, shanghaiMonth, shanghaiTimestamp } from '../../../shared/assets/history.js';
import { counts, freezeRows, makePoint, freezeLive } from './model.js'; import { counts, freezeRows, makePoint, freezeLive } from './model.js';
import { testBatch, testRow, testVehicle } from './fixtures.js'; import { testBatch, testRow, testVehicle } from './fixtures.js';
test('Shanghai month-end cutoff covers leap years, short/long months and cross-year boundaries', () => { test('Shanghai month-end cutoff covers leap years, short/long months and cross-year boundaries', () => {
for (const [month, expected] of [['2024-02','2024-02-29'], ['2026-02','2026-02-28'], ['2026-04','2026-04-30'], ['2026-10','2026-10-31'], ['2026-12','2026-12-31']]) { for (const [month, expected] of [['2024-02','2024-02-29'], ['2026-02','2026-02-28'], ['2026-04','2026-04-30'], ['2026-10','2026-10-31'], ['2026-12','2026-12-31']]) {
assert.equal(shanghaiTimestamp(monthCutoff(month)), `${expected}T17:00:00.000+08:00`); assert.equal(shanghaiTimestamp(monthCutoff(month)), `${expected}T23:55:00.000+08:00`);
assert.equal(monthCutoff(month).toISOString(), `${expected}T09:00:00.000Z`); assert.equal(monthCutoff(month).toISOString(), `${expected}T15:55:00.000Z`);
} }
assert.equal(shanghaiMonth(new Date('2026-12-31T16:00:00Z')), '2027-01'); assert.equal(shanghaiMonth(new Date('2026-12-31T16:00:00Z')), '2027-01');
assert.equal(nextCapture(new Date('2026-12-31T09:00:00Z')), '2027-01-31T17:00:00.000+08:00'); assert.equal(nextCapture(new Date('2026-12-31T15:55:00Z')), '2027-01-01T23:55:00.000+08:00');
assert.throws(() => monthCutoff('2026-13')); assert.throws(() => monthCutoff('2026-13'));
}); });
test('host TZ cannot change cutoff or Shanghai calendar', () => { test('host TZ cannot change cutoff or Shanghai calendar', () => {
@@ -32,11 +32,25 @@ test('source rows preserve precise IDs and raw states; pending and abnormal do n
}); });
test('cutoff switches live to capturing/missing without fallback; a zero snapshot is archived', () => { test('cutoff switches live to capturing/missing without fallback; a zero snapshot is archived', () => {
const live = freezeLive([testVehicle('1')]); const live = freezeLive([testVehicle('1')]);
assert.equal(makePoint('2026-10', new Date('2026-10-31T08:59:59Z'), undefined, [], live).state, 'LIVE'); assert.equal(makePoint('2026-10', new Date('2026-10-31T15:54:59Z'), undefined, [], live).state, 'LIVE');
assert.equal(makePoint('2026-10', new Date('2026-10-31T09:00:00Z'), undefined, [], live).state, 'CAPTURING'); assert.equal(makePoint('2026-10', new Date('2026-10-31T15:55:00Z'), undefined, [], live).state, 'CAPTURING');
const missing = makePoint('2026-10', new Date('2026-10-31T09:01:00Z'), undefined, [], live); const missing = makePoint('2026-10', new Date('2026-10-31T15:56:00Z'), undefined, [], live);
assert.equal(missing.state, 'MISSING'); assert.equal(missing.counts, null); assert.equal(missing.state, 'MISSING'); assert.equal(missing.counts, null);
const archived = makePoint('2026-10', new Date('2026-10-31T09:00:20Z'), testBatch('2026-10'), [], live); const archived = makePoint('2026-10', new Date('2026-10-31T15:55:20Z'), testBatch('2026-10'), [], live);
assert.equal(archived.state, 'ARCHIVED'); assert.deepEqual(archived.counts, { total:0,operating:0,inventory:0 }); assert.equal(archived.state, 'ARCHIVED'); assert.deepEqual(archived.counts, { total:0,operating:0,inventory:0 });
assert.equal(makePoint('2026-11', new Date('2026-10-19T00:00Z')).state, 'FUTURE'); assert.equal(makePoint('2026-11', new Date('2026-10-19T00:00Z')).state, 'FUTURE');
}); });
test('daily calendar handles leap days, year changes and next capture within a month', () => {
assert.equal(shiftDay('2024-02-28',1),'2024-02-29');
assert.equal(shiftDay('2026-02-28',1),'2026-03-01');
assert.equal(shiftDay('2026-12-31',1),'2027-01-01');
assert.equal(nextCapture(new Date('2026-10-30T23:54:59+08:00')),'2026-10-30T23:55:00.000+08:00');
assert.equal(nextCapture(new Date('2026-10-30T23:55:00+08:00')),'2026-10-31T23:55:00.000+08:00');
assert.throws(()=>dayCutoff('2026-02-29'));
});
test('legacy monthly archive keeps its original scheduled and captured times', () => {
const batch={...testBatch('2026-09'),scheduledFor:'2026-09-30T17:00:00+08:00',capturedAt:'2026-09-30T17:00:02+08:00'};
const point=makePoint('2026-09',new Date('2026-10-30T00:00:00+08:00'),batch,[]);
assert.equal(point.state,'ARCHIVED');assert.equal(point.scheduledFor,batch.scheduledFor);assert.equal(point.asOf,batch.capturedAt);
});
+2 -2
View File
@@ -47,7 +47,7 @@ export function counts(vehicles: Vehicle[]): HistoryCounts {
return { total: vehicles.length, operating: vehicles.filter(isOperatingVehicle).length, inventory: vehicles.filter(isInventoryVehicle).length }; return { total: vehicles.length, operating: vehicles.filter(isOperatingVehicle).length, inventory: vehicles.filter(isInventoryVehicle).length };
} }
export function makePoint(month: string, now: Date, batch?: HistoryBatch, data?: HistoryVehicle[], live?: HistoryVehicle[], annual = false): HistoryPoint { export function makePoint(month: string, now: Date, batch?: HistoryBatch, data?: HistoryVehicle[], live?: HistoryVehicle[], annual = false): HistoryPoint {
const cutoff = monthCutoff(month); const cutoff = batch ? new Date(batch.scheduledFor) : monthCutoff(month);
let state: HistoryPoint['state'] = 'MISSING'; let state: HistoryPoint['state'] = 'MISSING';
if (cutoff > now) state = month.slice(0, 4) === shanghaiTimestamp(now).slice(0, 4) && (annual || month === shanghaiTimestamp(now).slice(0, 7)) ? 'LIVE' : 'FUTURE'; if (cutoff > now) state = month.slice(0, 4) === shanghaiTimestamp(now).slice(0, 4) && (annual || month === shanghaiTimestamp(now).slice(0, 7)) ? 'LIVE' : 'FUTURE';
else if (batch?.status === 'COMPLETE') state = 'ARCHIVED'; else if (batch?.status === 'COMPLETE') state = 'ARCHIVED';
@@ -58,7 +58,7 @@ export function makePoint(month: string, now: Date, batch?: HistoryBatch, data?:
return { return {
period: annual ? month.slice(0, 4) : month, period: annual ? month.slice(0, 4) : month,
label: annual ? `${month.slice(0, 4)}年` : `${Number(month.slice(5))}月`, month, label: annual ? `${month.slice(0, 4)}年` : `${Number(month.slice(5))}月`, month,
scheduledFor: shanghaiTimestamp(cutoff), scheduledFor: batch?.scheduledFor || shanghaiTimestamp(cutoff),
asOf: state === 'LIVE' ? shanghaiTimestamp(now) : state === 'ARCHIVED' ? batch!.capturedAt : null, asOf: state === 'LIVE' ? shanghaiTimestamp(now) : state === 'ARCHIVED' ? batch!.capturedAt : null,
state, source: state === 'LIVE' ? 'LIVE' : state === 'ARCHIVED' ? batch!.source : null, state, source: state === 'LIVE' ? 'LIVE' : state === 'ARCHIVED' ? batch!.source : null,
batchId: state === 'ARCHIVED' ? batch!.id : null, counts: pointCounts, batchId: state === 'ARCHIVED' ? batch!.id : null, counts: pointCounts,
@@ -1,42 +1,51 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { HistoryScheduler, type HistoryCaptureStore } from './scheduler.js'; import { HistoryScheduler, type HistoryCaptureStore } from './scheduler.js';
import { testBatch } from './fixtures.js';
function fixture(enabledAt='2026-10-19T13:00:00+08:00') { function fixture(enabledAt='2026-10-30T13:00:00+08:00') {
const captures:string[]=[],missed:string[]=[],failed:string[]=[]; const captures:string[]=[],missed:string[]=[],failed:string[]=[];
let activates=0; let activates=0;
const store:HistoryCaptureStore={ const store:HistoryCaptureStore={
activate:async()=>{activates++;return{enabledAt};},batches:async()=>[], activate:async()=>{activates++;return{enabledAt};},dailyBatches:async()=>[],
capture:async(month)=>{captures.push(month);return'captured';}, capture:async(month)=>{captures.push(month);return'captured';},
recordOutcome:async(month,status)=>{(status==='FAILED'?failed:missed).push(month);}, recordOutcome:async(month,status)=>{(status==='FAILED'?failed:missed).push(month);},
}; };
return {store,captures,missed,failed,activates:()=>activates}; return {store,captures,missed,failed,activates:()=>activates};
} }
test('scheduler captures only in month-end 17:00 minute and activation is persisted once',async()=>{ test('scheduler captures only in daily 23:55 minute and activation is persisted once',async()=>{
const f=fixture();let date=new Date('2026-10-31T16:59:59+08:00'); const f=fixture();let date=new Date('2026-10-31T23:54:59+08:00');
const scheduler=new HistoryScheduler(f.store,()=>date,()=>{}); const scheduler=new HistoryScheduler(f.store,()=>date,()=>{});
await scheduler.tick();assert.deepEqual(f.captures,[]); await scheduler.tick();assert.deepEqual(f.captures,[]);
date=new Date('2026-10-31T17:00:00+08:00');await scheduler.tick();assert.deepEqual(f.captures,['2026-10']);assert.equal(f.activates(),1); date=new Date('2026-10-31T23:55:00+08:00');await scheduler.tick();assert.deepEqual(f.captures,['2026-10-31']);assert.equal(f.activates(),1);
}); });
test('restart records missed eligible months without backfilling old live data or pre-activation periods',async()=>{ test('restart records missed eligible days without backfilling old live data or pre-activation periods',async()=>{
const f=fixture();const scheduler=new HistoryScheduler(f.store,()=>new Date('2027-01-15T08:00:00+08:00'),()=>{}); const f=fixture();const scheduler=new HistoryScheduler(f.store,()=>new Date('2026-11-02T08:00:00+08:00'),()=>{});
await scheduler.tick();assert.deepEqual(f.captures,[]);assert.deepEqual(f.missed,['2026-10','2026-11','2026-12']); await scheduler.tick();assert.deepEqual(f.captures,[]);assert.deepEqual(f.missed,['2026-10-30','2026-10-31','2026-11-01']);
const late=fixture('2026-10-31T17:00:10+08:00');await new HistoryScheduler(late.store,()=>new Date('2026-10-31T17:00:20+08:00'),()=>{}).tick(); const late=fixture('2026-10-31T23:55:10+08:00');await new HistoryScheduler(late.store,()=>new Date('2026-10-31T23:55:20+08:00'),()=>{}).tick();
assert.deepEqual(late.captures,[]);assert.deepEqual(late.missed,[]); assert.deepEqual(late.captures,[]);assert.deepEqual(late.missed,[]);
}); });
test('completed December skips duplicate annual capture; failures stay marked without late retries',async()=>{ test('completed days skip capture and existing failures are preserved after the window',async()=>{
const f=fixture();f.store.batches=async()=>[testBatch('2026-10')]; const f=fixture();f.store.dailyBatches=async()=>[{day:'2026-10-30',status:'COMPLETE'}];
f.store.capture=async()=>{throw new Error('source unavailable');}; f.store.capture=async()=>{throw new Error('source unavailable');};
await new HistoryScheduler(f.store,()=>new Date('2026-12-31T17:00:20+08:00'),()=>{}).tick(); await new HistoryScheduler(f.store,()=>new Date('2026-10-31T23:55:20+08:00'),()=>{}).tick();
assert.deepEqual(f.missed,['2026-11']);assert.deepEqual(f.failed,['2026-12']); assert.deepEqual(f.missed,[]);assert.deepEqual(f.failed,['2026-10-31']);
const done=fixture('2026-12-01T00:00+08:00');done.store.batches=async()=>[testBatch('2026-12')]; f.store.dailyBatches=async()=>[{day:'2026-10-30',status:'COMPLETE'},{day:'2026-10-31',status:'FAILED'}];
await new HistoryScheduler(done.store,()=>new Date('2026-12-31T17:00:20+08:00'),()=>{}).tick();assert.deepEqual(done.captures,[]); await new HistoryScheduler(f.store,()=>new Date('2026-10-31T23:56:00+08:00'),()=>{}).tick();
assert.deepEqual(f.failed,['2026-10-31']);assert.deepEqual(f.missed,[]);
}); });
test('concurrent ticks and repeated start do not register duplicate jobs',async()=>{ test('concurrent ticks and repeated start do not register duplicate jobs',async()=>{
const f=fixture();let release!:()=>void; const f=fixture();let release!:()=>void;
f.store.capture=async()=>{f.captures.push('2026-10');await new Promise<void>(r=>{release=r;});return'captured';}; f.store.capture=async()=>{f.captures.push('2026-10-31');await new Promise<void>(r=>{release=r;});return'captured';};
const scheduler=new HistoryScheduler(f.store,()=>new Date('2026-10-31T17:00:20+08:00'),()=>{}); const scheduler=new HistoryScheduler(f.store,()=>new Date('2026-10-31T23:55:20+08:00'),()=>{});
const stop=scheduler.start();scheduler.start(); const stop=scheduler.start();scheduler.start();
await new Promise<void>(r=>setImmediate(r));await scheduler.tick();assert.equal(f.captures.length,1); await new Promise<void>(r=>setImmediate(r));await scheduler.tick();assert.equal(f.captures.length,1);
stop();release();await new Promise<void>(r=>setImmediate(r)); stop();release();await new Promise<void>(r=>setImmediate(r));
}); });
test('consecutive dates in one month and across month boundary capture independently',async()=>{
const f=fixture();
for(const day of ['2026-10-30','2026-10-31','2026-11-01']) {
await new HistoryScheduler(f.store,()=>new Date(`${day}T23:55:00+08:00`),()=>{}).tick();
}
assert.deepEqual(f.captures,['2026-10-30','2026-10-31','2026-11-01']);
});
+13 -13
View File
@@ -1,12 +1,12 @@
import { CAPTURE_WINDOW_MS, monthCutoff, shanghaiMonth, shiftMonth } from '../../../shared/assets/history.js'; import { CAPTURE_WINDOW_MS, dayCutoff, shanghaiDay, shiftDay } from '../../../shared/assets/history.js';
import type { CaptureResult } from './store.js'; import type { CaptureResult } from './store.js';
import { HistoryDataError, type HistoryBatch, type HistorySettings } from './model.js'; import { HistoryDataError, type HistoryBatch, type HistorySettings } from './model.js';
export interface HistoryCaptureStore { export interface HistoryCaptureStore {
activate(now: Date): Promise<HistorySettings>; activate(now: Date): Promise<HistorySettings>;
batches(): Promise<HistoryBatch[]>; dailyBatches(): Promise<Array<{ day: string; status: HistoryBatch['status'] }>>;
capture(month: string, clock: () => Date): Promise<CaptureResult>; capture(day: string, clock: () => Date): Promise<CaptureResult>;
recordOutcome(month: string, status: 'MISSED' | 'FAILED', code: string): Promise<void>; recordOutcome(day: string, status: 'MISSED' | 'FAILED', code: string): Promise<void>;
} }
export class HistoryScheduler { export class HistoryScheduler {
private timer: ReturnType<typeof setTimeout> | undefined; private timer: ReturnType<typeof setTimeout> | undefined;
@@ -24,20 +24,20 @@ export class HistoryScheduler {
const now = this.clock(); const now = this.clock();
this.settings ||= await this.store.activate(now); this.settings ||= await this.store.activate(now);
const enabled = new Date(this.settings.enabledAt); const enabled = new Date(this.settings.enabledAt);
const existing = new Map((await this.store.batches()).map(b => [b.month, b])); const existing = new Map((await this.store.dailyBatches()).map(b => [b.day, b]));
for (let month = shanghaiMonth(enabled); month <= shanghaiMonth(now); month = shiftMonth(month, 1)) { for (let day = shanghaiDay(enabled); day <= shanghaiDay(now); day = shiftDay(day, 1)) {
const cutoff = monthCutoff(month); const cutoff = dayCutoff(day);
if (cutoff < enabled || cutoff > now || existing.get(month)?.status === 'COMPLETE') continue; if (cutoff < enabled || cutoff > now || existing.get(day)?.status === 'COMPLETE') continue;
if (now.getTime() >= cutoff.getTime() + CAPTURE_WINDOW_MS) { if (now.getTime() >= cutoff.getTime() + CAPTURE_WINDOW_MS) {
if (!existing.has(month)) { await this.store.recordOutcome(month, 'MISSED', 'CAPTURE_WINDOW_MISSED'); this.log(`[asset-history] missed ${month}`); } if (!existing.has(day)) { await this.store.recordOutcome(day, 'MISSED', 'CAPTURE_WINDOW_MISSED'); this.log(`[asset-history] missed ${day}`); }
continue; continue;
} }
try { const result = await this.store.capture(month, this.clock); this.log(`[asset-history] ${month}: ${result}`); } try { const result = await this.store.capture(day, this.clock); this.log(`[asset-history] ${day}: ${result}`); }
catch (error) { catch (error) {
const databaseCode = (error as { code?: unknown })?.code; const databaseCode = (error as { code?: unknown })?.code;
const code = error instanceof HistoryDataError ? 'INVALID_VEHICLE_DATA' : typeof databaseCode === 'string' && /^[A-Z0-9_]{1,48}$/.test(databaseCode) ? databaseCode : 'CAPTURE_FAILED'; const code = error instanceof HistoryDataError ? 'INVALID_VEHICLE_DATA' : typeof databaseCode === 'string' && /^[A-Z0-9_]{1,48}$/.test(databaseCode) ? databaseCode : 'CAPTURE_FAILED';
await this.store.recordOutcome(month, 'FAILED', code); await this.store.recordOutcome(day, 'FAILED', code);
this.log(`[asset-history] failed ${month} (${code}); no later live data will be substituted`); this.log(`[asset-history] failed ${day} (${code}); no later live data will be substituted`);
} }
} }
} finally { this.ticking = false; } } finally { this.ticking = false; }
@@ -50,7 +50,7 @@ export class HistoryScheduler {
try { await this.tick(); } catch { this.log('[asset-history] scheduler unavailable; check migration and database access'); } try { await this.tick(); } catch { this.log('[asset-history] scheduler unavailable; check migration and database access'); }
if (!this.running) return; if (!this.running) return;
const now = this.clock(); const now = this.clock();
const untilCutoff = monthCutoff(shanghaiMonth(now)).getTime() - now.getTime(); const untilCutoff = dayCutoff(shanghaiDay(now)).getTime() - now.getTime();
this.timer = setTimeout(run, untilCutoff > 0 ? Math.min(untilCutoff, 60_000) : 15_000); this.timer = setTimeout(run, untilCutoff > 0 ? Math.min(untilCutoff, 60_000) : 15_000);
this.timer.unref?.(); this.timer.unref?.();
}; };
@@ -84,7 +84,7 @@ test('missing storage degrades to live without any DDL; genuine database errors
}); });
test('once cutoff passes live values cannot replace missing history, even when capture was not enabled', async () => { test('once cutoff passes live values cannot replace missing history, even when capture was not enabled', async () => {
const {store}=setup();store.settingsValue=null;let liveCalls=0; const {store}=setup();store.settingsValue=null;let liveCalls=0;
const service=new HistoryService(store,async()=>{liveCalls++;return[leased];},()=>new Date('2026-10-31T17:00:30+08:00')); const service=new HistoryService(store,async()=>{liveCalls++;return[leased];},()=>new Date('2026-10-31T23:55:30+08:00'));
const result=await service.trend(user(),{});assert.equal(result.points[9].state,'MISSING');assert.equal(result.points[9].counts,null); const result=await service.trend(user(),{});assert.equal(result.points[9].state,'MISSING');assert.equal(result.points[9].counts,null);
assert.equal(liveCalls,0);await assert.rejects(()=>service.details(user(),{month:'2026-10'}),HistoryPeriodUnavailable); assert.equal(liveCalls,0);await assert.rejects(()=>service.details(user(),{month:'2026-10'}),HistoryPeriodUnavailable);
}); });
+26 -17
View File
@@ -14,7 +14,7 @@ class Database {
locked=1; existing: Array<{id:string;status:string}>=[]; locked=1; existing: Array<{id:string;status:string}>=[];
committed:string[]=[]; staged:string[]=[]; committed:string[]=[]; staged:string[]=[];
failDetails=0; failReads=false; failCommitAck=false; beforeLock?:()=>void; afterSource?:()=>void; onReadTransaction?:()=>void; failDetails=0; failReads=false; failCommitAck=false; beforeLock?:()=>void; afterSource?:()=>void; onReadTransaction?:()=>void;
released=false; clock=()=>new Date('2026-10-31T17:00:00.500+08:00'); released=false; clock=()=>new Date('2026-10-31T23:55:00.500+08:00');
async query(sql:string,_args?:unknown[]) { async query(sql:string,_args?:unknown[]) {
this.commands.push(sql); this.commands.push(sql);
if(sql.includes('GET_LOCK')){this.beforeLock?.();return[[{acquired:this.locked}]];} if(sql.includes('GET_LOCK')){this.beforeLock?.();return[[{acquired:this.locked}]];}
@@ -38,7 +38,7 @@ class Database {
store(){return new MysqlHistoryStore(this as unknown as Pool);} store(){return new MysqlHistoryStore(this as unknown as Pool);}
} }
test('capture uses uncached shared SQL and one consistent read; detail/header publish atomically',async()=>{ test('capture uses uncached shared SQL and one consistent read; detail/header publish atomically',async()=>{
const db=new Database();assert.equal(await db.store().capture('2026-10',db.clock),'captured'); const db=new Database();assert.equal(await db.store().capture('2026-10-31',db.clock),'captured');
assert.deepEqual(db.committed,['batch','details','complete']); assert.deepEqual(db.committed,['batch','details','complete']);
assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1); assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1);
assert.ok(db.commands.indexOf('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY')<db.commands.indexOf(MAIN_SQL)); assert.ok(db.commands.indexOf('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY')<db.commands.indexOf(MAIN_SQL));
@@ -46,52 +46,52 @@ test('capture uses uncached shared SQL and one consistent read; detail/header pu
assert.equal(db.released,true);assert.ok(db.commands.at(-1)!.includes('RELEASE_LOCK')); assert.equal(db.released,true);assert.ok(db.commands.at(-1)!.includes('RELEASE_LOCK'));
}); });
test('write retry preserves the exact captured rows and time, even after the minute expires',async()=>{ test('write retry preserves the exact captured rows and time, even after the minute expires',async()=>{
const db=new Database();db.failDetails=1;let now=new Date('2026-10-31T17:00:30+08:00'); const db=new Database();db.failDetails=1;let now=new Date('2026-10-31T23:55:30+08:00');
db.afterSource=()=>{now=new Date('2026-10-31T18:00:00+08:00');};const clock=()=>now; db.afterSource=()=>{now=new Date('2026-10-31T23:59:00+08:00');};const clock=()=>now;
assert.equal(await db.store().capture('2026-10',clock),'captured'); assert.equal(await db.store().capture('2026-10-31',clock),'captured');
assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1); assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1);
assert.deepEqual(db.committed,['batch','details','complete']); assert.deepEqual(db.committed,['batch','details','complete']);
assert.equal(db.commands.filter(s=>s==='BEGIN').length,2); assert.equal(db.commands.filter(s=>s==='BEGIN').length,2);
}); });
test('lost commit acknowledgement does not overwrite or duplicate an already-published batch',async()=>{ test('lost commit acknowledgement does not overwrite or duplicate an already-published batch',async()=>{
const db=new Database();db.failCommitAck=true; const db=new Database();db.failCommitAck=true;
assert.equal(await db.store().capture('2026-10',db.clock),'already-complete'); assert.equal(await db.store().capture('2026-10-31',db.clock),'already-complete');
assert.deepEqual(db.committed,['batch','details','complete']);assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1); assert.deepEqual(db.committed,['batch','details','complete']);assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1);
}); });
test('source is not read if transaction establishment crosses the capture deadline',async()=>{ test('source is not read if transaction establishment crosses the capture deadline',async()=>{
const db=new Database();let now=new Date('2026-10-31T17:00:59.900+08:00'); const db=new Database();let now=new Date('2026-10-31T23:55:59.900+08:00');
db.onReadTransaction=()=>{now=new Date('2026-10-31T17:01:00+08:00');}; db.onReadTransaction=()=>{now=new Date('2026-10-31T23:56:00+08:00');};
assert.equal(await db.store().capture('2026-10',()=>now),'outside-window'); assert.equal(await db.store().capture('2026-10-31',()=>now),'outside-window');
assert.equal(db.commands.includes(MAIN_SQL),false);assert.deepEqual(db.committed,[]); assert.equal(db.commands.includes(MAIN_SQL),false);assert.deepEqual(db.committed,[]);
}); });
test('persistent detail-write failure publishes neither partial header nor rows',async()=>{ test('persistent detail-write failure publishes neither partial header nor rows',async()=>{
const db=new Database();db.failDetails=5; const db=new Database();db.failDetails=5;
await assert.rejects(()=>db.store().capture('2026-10',db.clock),/write failed/); await assert.rejects(()=>db.store().capture('2026-10-31',db.clock),/write failed/);
assert.deepEqual(db.committed,[]);assert.deepEqual(db.staged,[]);assert.equal(db.released,true); assert.deepEqual(db.committed,[]);assert.deepEqual(db.staged,[]);assert.equal(db.released,true);
assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1); assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1);
}); });
test('source failure and invalid rows cannot publish snapshots',async()=>{ test('source failure and invalid rows cannot publish snapshots',async()=>{
const db=new Database();db.failReads=true; const db=new Database();db.failReads=true;
await assert.rejects(()=>db.store().capture('2026-10',db.clock),/read failed/);assert.deepEqual(db.committed,[]); await assert.rejects(()=>db.store().capture('2026-10-31',db.clock),/read failed/);assert.deepEqual(db.committed,[]);
db.failReads=false;db.rows=[testRow('duplicate'),testRow('duplicate')]; db.failReads=false;db.rows=[testRow('duplicate'),testRow('duplicate')];
await assert.rejects(()=>db.store().capture('2026-10',db.clock),/重复/);assert.deepEqual(db.committed,[]); await assert.rejects(()=>db.store().capture('2026-10-31',db.clock),/重复/);assert.deepEqual(db.committed,[]);
}); });
test('completed period, concurrent lock and expired window cannot issue a source read',async()=>{ test('completed period, concurrent lock and expired window cannot issue a source read',async()=>{
for(const scenario of ['complete','lock','expired','delayed-lock','late-activation']){ for(const scenario of ['complete','lock','expired','delayed-lock','late-activation']){
const db=new Database();let clock=db.clock; const db=new Database();let clock=db.clock;
if(scenario==='complete')db.existing=[{id:'existing',status:'COMPLETE'}]; if(scenario==='complete')db.existing=[{id:'existing',status:'COMPLETE'}];
if(scenario==='lock')db.locked=0; if(scenario==='lock')db.locked=0;
if(scenario==='expired')clock=()=>new Date('2026-10-31T17:01:00+08:00'); if(scenario==='expired')clock=()=>new Date('2026-10-31T23:56:00+08:00');
if(scenario==='delayed-lock')db.beforeLock=()=>{clock=()=>new Date('2026-10-31T17:01:00+08:00');}; if(scenario==='delayed-lock')db.beforeLock=()=>{clock=()=>new Date('2026-10-31T23:56:00+08:00');};
if(scenario==='late-activation')db.settings='2026-10-31T17:00:01+08:00'; if(scenario==='late-activation')db.settings='2026-10-31T23:55:01+08:00';
const result=await db.store().capture('2026-10',()=>clock()); const result=await db.store().capture('2026-10-31',()=>clock());
assert.ok(['already-complete','locked','outside-window'].includes(result)); assert.ok(['already-complete','locked','outside-window'].includes(result));
assert.equal(db.commands.includes(MAIN_SQL),false,scenario);assert.deepEqual(db.committed,[]); assert.equal(db.commands.includes(MAIN_SQL),false,scenario);assert.deepEqual(db.committed,[]);
} }
}); });
test('a legitimate empty source commits a zero-count complete batch without detail inserts',async()=>{ test('a legitimate empty source commits a zero-count complete batch without detail inserts',async()=>{
const db=new Database();db.rows=[]; const db=new Database();db.rows=[];
assert.equal(await db.store().capture('2026-10',db.clock),'captured');assert.deepEqual(db.committed,['batch','complete']); assert.equal(await db.store().capture('2026-10-31',db.clock),'captured');assert.deepEqual(db.committed,['batch','complete']);
}); });
test('missing storage reads never issue schema writes and report storage-unavailable',async()=>{ test('missing storage reads never issue schema writes and report storage-unavailable',async()=>{
const calls:string[]=[]; const calls:string[]=[];
@@ -101,3 +101,12 @@ test('missing storage reads never issue schema writes and report storage-unavail
await assert.rejects(()=>store.batches(),HistoryStorageUnavailable); await assert.rejects(()=>store.batches(),HistoryStorageUnavailable);
assert.ok(calls.every(sql=>sql.startsWith('SELECT'))); assert.ok(calls.every(sql=>sql.startsWith('SELECT')));
}); });
test('monthly reader selects month-end keys and retains completed legacy archives when a daily capture failed',async()=>{
let query='';
const row=(id:string,month:string,status:string)=>({id,month,status,source:'AUTO',total_count:0,operating_count:0,inventory_count:0});
const db={query:async(sql:string)=>{query=sql;return[[row('old-sep','2026-09','COMPLETE'),row('new-sep','2026-09','FAILED'),row('old-oct','2026-10','COMPLETE'),row('new-oct','2026-10','COMPLETE')]];}};
const batches=await new MysqlHistoryStore(db as unknown as Pool).batches();
assert.deepEqual(batches.map(b=>b.id),['old-sep','new-oct']);
assert.match(query,/LAST_DAY/);assert.match(query,/CHAR_LENGTH\(period_key\)=7/);
});
+29 -19
View File
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Pool, PoolConnection, RowDataPacket } from 'mysql2/promise'; import type { Pool, PoolConnection, RowDataPacket } from 'mysql2/promise';
import { CAPTURE_WINDOW_MS, HISTORY_VERSION, monthCutoff, shanghaiTimestamp } from '../../../shared/assets/history.js'; import { CAPTURE_WINDOW_MS, HISTORY_VERSION, dayCutoff, shanghaiTimestamp } from '../../../shared/assets/history.js';
import type { VehicleRow } from '../../types.js'; import type { VehicleRow } from '../../types.js';
import { MAIN_SQL } from '../vehicles/repository.js'; import { MAIN_SQL } from '../vehicles/repository.js';
import { counts, freezeRows, HistoryStorageUnavailable, type HistoryBatch, type HistorySettings, type HistoryVehicle } from './model.js'; import { counts, freezeRows, HistoryStorageUnavailable, type HistoryBatch, type HistorySettings, type HistoryVehicle } from './model.js';
@@ -26,16 +26,21 @@ export class MysqlHistoryStore implements HistoryReader {
} }
} }
async settings(): Promise<HistorySettings | null> { async settings(): Promise<HistorySettings | null> {
const rows = await this.read(`SELECT ${dateColumn('enabled_at')} AS enabledAt FROM bi_asset_history_settings WHERE id=1`); const rows = await this.read(`SELECT ${dateColumn('daily_enabled_at')} AS enabledAt FROM bi_asset_history_settings WHERE id=1`);
return rows[0] ? { enabledAt: rows[0].enabledAt } : null; return rows[0]?.enabledAt ? { enabledAt: rows[0].enabledAt } : null;
} }
async batches(): Promise<HistoryBatch[]> { async batches(): Promise<HistoryBatch[]> {
const rows = await this.read(`SELECT id, period_month AS month, status, source, const rows = await this.read(`SELECT id, LEFT(period_key,7) AS month, status, source,
total_count,operating_count,inventory_count, total_count,operating_count,inventory_count,
${dateColumn('scheduled_for')} AS scheduledFor, ${dateColumn('captured_at')} AS capturedAt ${dateColumn('scheduled_for')} AS scheduledFor, ${dateColumn('captured_at')} AS capturedAt
FROM bi_asset_history_batches WHERE definition_version=? ORDER BY period_month`, [HISTORY_VERSION]); FROM bi_asset_history_batches WHERE definition_version=? AND (CHAR_LENGTH(period_key)=7 OR period_key=DATE_FORMAT(LAST_DAY(CONCAT(LEFT(period_key,7),'-01')), '%Y-%m-%d')) ORDER BY period_key`, [HISTORY_VERSION]);
return rows.map(row => ({ id: row.id, month: row.month, status: row.status, source: row.source, scheduledFor: row.scheduledFor, capturedAt: row.capturedAt, const batches = rows.map(row => ({ id: row.id, month: row.month, status: row.status, source: row.source, scheduledFor: row.scheduledFor, capturedAt: row.capturedAt,
counts: { total: row.total_count, operating: row.operating_count, inventory: row.inventory_count } })); counts: { total: row.total_count, operating: row.operating_count, inventory: row.inventory_count } }));
const byMonth = new Map<string, HistoryBatch>();
for (const batch of batches) {
if (batch.status === 'COMPLETE' || byMonth.get(batch.month)?.status !== 'COMPLETE') byMonth.set(batch.month, batch);
}
return [...byMonth.values()];
} }
async vehicles(batchIds: string[]): Promise<Map<string, HistoryVehicle[]>> { async vehicles(batchIds: string[]): Promise<Map<string, HistoryVehicle[]>> {
const result = new Map<string, HistoryVehicle[]>(batchIds.map(id => [id, []])); const result = new Map<string, HistoryVehicle[]>(batchIds.map(id => [id, []]));
@@ -49,24 +54,29 @@ export class MysqlHistoryStore implements HistoryReader {
return result; return result;
} }
async activate(now: Date): Promise<HistorySettings> { async activate(now: Date): Promise<HistorySettings> {
await this.db.query('INSERT IGNORE INTO bi_asset_history_settings (id,enabled_at) VALUES (1,?)', [sqlTime(now)]); await this.db.query(`INSERT INTO bi_asset_history_settings (id,enabled_at,daily_enabled_at) VALUES (1,?,?)
ON DUPLICATE KEY UPDATE daily_enabled_at=COALESCE(daily_enabled_at,VALUES(daily_enabled_at))`, [sqlTime(now), sqlTime(now)]);
return (await this.settings())!; return (await this.settings())!;
} }
async recordOutcome(month: string, status: 'FAILED' | 'MISSED', code: string): Promise<void> { async dailyBatches(): Promise<Array<{ day: string; status: HistoryBatch['status'] }>> {
const rows = await this.read('SELECT period_key AS day,status FROM bi_asset_history_batches WHERE definition_version=? AND CHAR_LENGTH(period_key)=10', [HISTORY_VERSION]);
return rows.map(row => ({ day: row.day, status: row.status }));
}
async recordOutcome(day: string, status: 'FAILED' | 'MISSED', code: string): Promise<void> {
// Never downgrade a completed snapshot, or replace a useful failure with MISSED. // Never downgrade a completed snapshot, or replace a useful failure with MISSED.
await this.db.query(`INSERT INTO bi_asset_history_batches await this.db.query(`INSERT INTO bi_asset_history_batches
(id,period_month,definition_version,scheduled_for,source,status,failure_code) (id,period_key,definition_version,scheduled_for,source,status,failure_code)
VALUES (?,?,?,?,'AUTO',?,?) ON DUPLICATE KEY UPDATE VALUES (?,?,?,?,'AUTO',?,?) ON DUPLICATE KEY UPDATE
failure_code=IF(status='COMPLETE' OR (status='FAILED' AND VALUES(status)='MISSED'),failure_code,VALUES(failure_code)), failure_code=IF(status='COMPLETE' OR (status='FAILED' AND VALUES(status)='MISSED'),failure_code,VALUES(failure_code)),
status=IF(status='COMPLETE' OR (status='FAILED' AND VALUES(status)='MISSED'),status,VALUES(status))`, status=IF(status='COMPLETE' OR (status='FAILED' AND VALUES(status)='MISSED'),status,VALUES(status))`,
[randomUUID(), month, HISTORY_VERSION, sqlTime(monthCutoff(month)), status, code]); [randomUUID(), day, HISTORY_VERSION, sqlTime(dayCutoff(day)), status, code]);
} }
async capture(month: string, clock: () => Date = () => new Date()): Promise<CaptureResult> { async capture(day: string, clock: () => Date = () => new Date()): Promise<CaptureResult> {
const cutoff = monthCutoff(month).getTime(); const cutoff = dayCutoff(day).getTime();
const inWindow = () => clock().getTime() >= cutoff && clock().getTime() < cutoff + CAPTURE_WINDOW_MS; const inWindow = () => clock().getTime() >= cutoff && clock().getTime() < cutoff + CAPTURE_WINDOW_MS;
if (!inWindow()) return 'outside-window'; if (!inWindow()) return 'outside-window';
const connection = await this.db.getConnection(); const connection = await this.db.getConnection();
const lock = `ln-bi:asset:${HISTORY_VERSION}:${month}`; const lock = `ln-bi:asset:${HISTORY_VERSION}:${day}`;
let locked = false; let locked = false;
try { try {
const [locks] = await connection.query<RowDataPacket[]>("SELECT GET_LOCK(CONCAT(DATABASE(),':',?),0) AS acquired", [lock]); const [locks] = await connection.query<RowDataPacket[]>("SELECT GET_LOCK(CONCAT(DATABASE(),':',?),0) AS acquired", [lock]);
@@ -74,7 +84,7 @@ export class MysqlHistoryStore implements HistoryReader {
if (!locked) return 'locked'; if (!locked) return 'locked';
const settings = await this.settings(); const settings = await this.settings();
if (!settings || new Date(settings.enabledAt).getTime() > cutoff || !inWindow()) return 'outside-window'; if (!settings || new Date(settings.enabledAt).getTime() > cutoff || !inWindow()) return 'outside-window';
const [existing] = await connection.query<RowDataPacket[]>(`SELECT id,status FROM bi_asset_history_batches WHERE period_month=? AND definition_version=?`, [month, HISTORY_VERSION]); const [existing] = await connection.query<RowDataPacket[]>(`SELECT id,status FROM bi_asset_history_batches WHERE period_key=? AND definition_version=?`, [day, HISTORY_VERSION]);
if (existing[0]?.status === 'COMPLETE') return 'already-complete'; if (existing[0]?.status === 'COMPLETE') return 'already-complete';
await connection.query("SET time_zone='+08:00'"); await connection.query("SET time_zone='+08:00'");
await connection.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ'); await connection.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');
@@ -90,7 +100,7 @@ export class MysqlHistoryStore implements HistoryReader {
// Retries below keep this exact frozen dataset. Never reread a later live ledger. // Retries below keep this exact frozen dataset. Never reread a later live ledger.
const id = existing[0]?.id || randomUUID(); const id = existing[0]?.id || randomUUID();
for (let attempt = 0; ; attempt++) { for (let attempt = 0; ; attempt++) {
try { return await this.publish(connection, id, month, capturedAt, frozen, clock); } try { return await this.publish(connection, id, day, capturedAt, frozen, clock); }
catch (error) { await connection.rollback(); if (attempt >= 2) throw error; } catch (error) { await connection.rollback(); if (attempt >= 2) throw error; }
} }
} finally { } finally {
@@ -98,19 +108,19 @@ export class MysqlHistoryStore implements HistoryReader {
finally { connection.release(); } finally { connection.release(); }
} }
} }
private async publish(connection: PoolConnection, id: string, month: string, capturedAt: Date, vehicles: HistoryVehicle[], clock: () => Date) { private async publish(connection: PoolConnection, id: string, day: string, capturedAt: Date, vehicles: HistoryVehicle[], clock: () => Date) {
const totals = counts(vehicles); const totals = counts(vehicles);
await connection.beginTransaction(); await connection.beginTransaction();
const [existing] = await connection.query<RowDataPacket[]>('SELECT id,status FROM bi_asset_history_batches WHERE period_month=? AND definition_version=? FOR UPDATE', [month, HISTORY_VERSION]); const [existing] = await connection.query<RowDataPacket[]>('SELECT id,status FROM bi_asset_history_batches WHERE period_key=? AND definition_version=? FOR UPDATE', [day, HISTORY_VERSION]);
// Also handles a committed transaction whose acknowledgement was lost. // Also handles a committed transaction whose acknowledgement was lost.
if (existing[0]?.status === 'COMPLETE') { await connection.commit(); return 'already-complete' as const; } if (existing[0]?.status === 'COMPLETE') { await connection.commit(); return 'already-complete' as const; }
if (existing[0]) id = existing[0].id; if (existing[0]) id = existing[0].id;
// Under the advisory lock, only FAILED/MISSED placeholders may be promoted. // Under the advisory lock, only FAILED/MISSED placeholders may be promoted.
await connection.query(`INSERT INTO bi_asset_history_batches await connection.query(`INSERT INTO bi_asset_history_batches
(id,period_month,definition_version,scheduled_for,captured_at,source,status,total_count,operating_count,inventory_count) (id,period_key,definition_version,scheduled_for,captured_at,source,status,total_count,operating_count,inventory_count)
VALUES (?,?,?,?,?,'AUTO','FAILED',?,?,?) ON DUPLICATE KEY UPDATE captured_at=VALUES(captured_at), VALUES (?,?,?,?,?,'AUTO','FAILED',?,?,?) ON DUPLICATE KEY UPDATE captured_at=VALUES(captured_at),
total_count=VALUES(total_count),operating_count=VALUES(operating_count),inventory_count=VALUES(inventory_count)`, total_count=VALUES(total_count),operating_count=VALUES(operating_count),inventory_count=VALUES(inventory_count)`,
[id, month, HISTORY_VERSION, sqlTime(monthCutoff(month)), sqlTime(capturedAt), totals.total, totals.operating, totals.inventory]); [id, day, HISTORY_VERSION, sqlTime(dayCutoff(day)), sqlTime(capturedAt), totals.total, totals.operating, totals.inventory]);
for (let offset = 0; offset < vehicles.length; offset += 100) { for (let offset = 0; offset < vehicles.length; offset += 100) {
const chunk = vehicles.slice(offset, offset + 100); const chunk = vehicles.slice(offset, offset + 100);
await connection.query(`INSERT INTO bi_asset_history_vehicles await connection.query(`INSERT INTO bi_asset_history_vehicles
+1 -1
View File
@@ -39,7 +39,7 @@ export function registerHydrogenDailyRoute(
HYDROGEN_BASE_WHERE_B, HYDROGEN_BASE_WHERE_B,
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`, `b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
dateRangeClause(`b.${HYDROGEN_LOCAL}`), dateRangeClause(`b.${HYDROGEN_LOCAL}`),
customerClause(customer), customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
].join(' AND '); ].join(' AND ');
// 站点级聚合(每日 × 每站)。前端组装成 day → stations // 站点级聚合(每日 × 每站)。前端组装成 day → stations
+4 -26
View File
@@ -1,6 +1,5 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { DatabaseSync } from 'node:sqlite';
import { import {
customerClause, customerClause,
dateRangeClause, dateRangeClause,
@@ -8,31 +7,10 @@ import {
resolveDateRange, resolveDateRange,
} from './query-model.js'; } from './query-model.js';
test('外部车辆只取佛山羚牛站未关联车辆的订单,车牌文字和收费不影响分类', () => { test('客户范围继续让外部车辆返回空数据', () => {
const db = new DatabaseSync(':memory:'); assert.equal(customerClause('external'), '1=0');
try { assert.equal(customerClause('lingniu'), '1=1');
db.exec(`CREATE TABLE orders ( assert.equal(customerClause('all'), '1=1');
id INTEGER, station_id INTEGER, vehicle_id INTEGER,
license_plate TEXT, customer_price REAL, del_flag TEXT
);
INSERT INTO orders VALUES
(1, 341, NULL, '粤A外部', 38, '0'),
(2, 341, NULL, '', 0, '0'),
(3, 341, 123, '粤A羚牛', 38, '0'),
(4, 52, NULL, '粤A其他站', 38, '0'),
(5, NULL, NULL, '', 0, '0'),
(6, NULL, 456, '粤A未关联站点', 0, '0'),
(7, 341, NULL, '粤A已删除', 38, '1');`);
const ids = (kind: 'external' | 'lingniu' | 'all') => db
.prepare(`SELECT b.id FROM orders b WHERE b.del_flag = '0' AND ${customerClause(kind)} ORDER BY b.id`)
.all().map(row => row.id);
assert.deepEqual(ids('external'), [1, 2]);
assert.deepEqual(ids('lingniu'), [3, 4, 5, 6]);
assert.deepEqual(ids('all'), [1, 2, 3, 4, 5, 6]);
assert.deepEqual([...ids('external'), ...ids('lingniu')].sort(), ids('all'));
} finally {
db.close();
}
}); });
test('能源日期快捷区间继续按本地自然日计算', () => { test('能源日期快捷区间继续按本地自然日计算', () => {
+3 -7
View File
@@ -8,14 +8,10 @@ export interface EnergyDateRange {
const YMD_RE = /^\d{4}-\d{2}-\d{2}$/; const YMD_RE = /^\d{4}-\d{2}-\d{2}$/;
// 主数据 ID 341:佛山南海羚牛加氢站。车牌文字存在不代表已关联车辆。 // 新账本目前只承载羚牛车辆订单;外部车辆仍需返回空集合而不是混用羚牛数据。
const FOSHAN_LINGNIU_STATION_ID = 341;
// 日明细账本使用别名 b;两类互斥,其他订单继续保留在原羚牛范围内。
export function customerClause(customer: CustomerKind): string { export function customerClause(customer: CustomerKind): string {
const external = `(COALESCE(b.station_id, 0) = ${FOSHAN_LINGNIU_STATION_ID} AND b.vehicle_id IS NULL)`; if (customer === 'external') return '1=0';
if (customer === 'external') return external; if (customer === 'lingniu') return '1=1';
if (customer === 'lingniu') return `NOT ${external}`;
return '1=1'; return '1=1';
} }
+2 -7
View File
@@ -105,11 +105,6 @@ test('四个处理器保持缓存和参数契约,加氢站点使用新主数
'/hydrogen/daily?startDate=2025-01-01&endDate=2025-01-02&customer=external&force=1', '/hydrogen/daily?startDate=2025-01-01&endDate=2025-01-02&customer=external&force=1',
); );
assert.equal(dailyResponse.status, 200); assert.equal(dailyResponse.status, 200);
assert.deepEqual(await dailyResponse.json(), [
{ date: '2025-01-02', totalKg: 10, chainPct: 0, customerType: 'external',
stations: [{ name: '测试站', pricePerKg: 35, kg: 10, chainPct: 0 }] },
{ date: '2025-01-01', totalKg: 0, chainPct: 0, customerType: 'external', stations: [] },
]);
const electricApp = new Hono(); const electricApp = new Hono();
const electricDependencies = { const electricDependencies = {
@@ -132,7 +127,7 @@ test('四个处理器保持缓存和参数契约,加氢站点使用新主数
{ key: 'electric/monthly?customer=external&start=2025-01-01&end=2025-01-02', force: true }, { key: 'electric/monthly?customer=external&start=2025-01-01&end=2025-01-02', force: true },
]); ]);
// 保留查询契约指纹;日明细已更新为佛山羚牛站未关联车辆的外部订单筛选。 // 指纹来自拆分前的 13 条 SQL,连空白符变化也会使测试失败。
assert.deepEqual( assert.deepEqual(
[...hydrogenCalls, ...electricCalls].map(call => sqlHash(call.sql)), [...hydrogenCalls, ...electricCalls].map(call => sqlHash(call.sql)),
[ [
@@ -143,7 +138,7 @@ test('四个处理器保持缓存和参数契约,加氢站点使用新主数
'256ac8309d5a', '256ac8309d5a',
'dac9e86fc527', 'dac9e86fc527',
'a71da8f05765', 'a71da8f05765',
'd1620280f520', '24b23780a259',
'226fd8f950a1', '226fd8f950a1',
'6e4031b10926', '6e4031b10926',
'4fc77463294c', '4fc77463294c',
+17 -12
View File
@@ -3,16 +3,16 @@ import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import pool from '../../db.js'; import pool from '../../db.js';
import { fetchVehicleInfoMap } from './vehicle-info.js'; import { fetchVehicleInfoMap } from './vehicle-info.js';
import { fetchOneOsDailyMileage, fetchOneOsMileageDates } from './oneos-api.js'; import { fetchOneOsDailyMileage } from './oneos-api.js';
import type { OneOsProtocol } from './source-policy.js'; import { queryMonitoringRange } from './query-cache.js';
import { buildRangeMileage, type RangeMileageResult } from './range-model.js'; import { DEFAULT_ONEOS_PROTOCOL_PRIORITY, type OneOsProtocol } from './source-policy.js';
import { buildSummaryMileage } from './summary-model.js';
import { import {
buildMonitoringFilters, buildMonitoringFilters,
buildPlateTargetNamesMap, buildPlateTargetNamesMap,
buildTargetPlatesMap, buildTargetPlatesMap,
dailyMileageMap, dailyMileageMap,
mergeMonitoringVehicles, mergeMonitoringVehicles,
mileageDatesBetween,
previousMileageDate, previousMileageDate,
toMileageRows, toMileageRows,
type TargetRow, type TargetRow,
@@ -117,17 +117,22 @@ export async function queryDateMileage(
export async function queryRangeMileage( export async function queryRangeMileage(
startDate: string, startDate: string,
endDate: string, endDate: string,
protocolPriority?: OneOsProtocol[], protocolPriority: OneOsProtocol[] = DEFAULT_ONEOS_PROTOCOL_PRIORITY,
): Promise<RangeMileageResult> { refresh = false,
const days = mileageDatesBetween(startDate, endDate); ) {
const [apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows] = await Promise.all([ const [cached, infoMap, targetRows] = await Promise.all([
fetchOneOsMileageDates(days, undefined, protocolPriority), queryMonitoringRange(startDate, endDate, protocolPriority, refresh),
fetchOneOsDailyMileage(endDate, undefined, protocolPriority),
fetchOneOsDailyMileage(previousMileageDate(startDate), undefined, protocolPriority),
fetchVehicleInfoMap(), fetchVehicleInfoMap(),
fetchTargetRows(), fetchTargetRows(),
]); ]);
return buildRangeMileage(startDate, endDate, apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows, regionMap); const { summary, yesterdayRows } = cached.result;
return {
vehicles: buildSummaryMileage(summary, yesterdayRows, infoMap, targetRows, regionMap),
summary,
start: startDate, end: endDate,
queriedAt: cached.queriedAt,
cacheStatus: cached.cacheStatus,
};
} }
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters { export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Hono } from 'hono';
import pool from '../../db.js';
import type { AuthUser } from '../../auth/types.js';
import type { MonitoringResponse } from './types.js';
import monitoring from './monitoring.js';
import recent from './vehicle-recent.js';
const originalConfig = { url: process.env.ONEOS_MILEAGE_API_BASE_URL, key: process.env.ONEOS_MILEAGE_API_KEY };
test.before(() => { process.env.ONEOS_MILEAGE_API_BASE_URL = 'https://oneos.test'; process.env.ONEOS_MILEAGE_API_KEY = 'test-only'; });
test.after(() => {
if (originalConfig.url === undefined) delete process.env.ONEOS_MILEAGE_API_BASE_URL; else process.env.ONEOS_MILEAGE_API_BASE_URL = originalConfig.url;
if (originalConfig.key === undefined) delete process.env.ONEOS_MILEAGE_API_KEY; else process.env.ONEOS_MILEAGE_API_KEY = originalConfig.key;
});
test('summary integration preserves scope, valid partial mileage, trend nulls, reuse, details and refresh', async t => {
const requests: {url:string;body:any}[]=[];
let reassigned=false;
let failure=0;
t.mock.method(globalThis,'fetch',async(url:string,options:RequestInit)=>{
const body=JSON.parse(options.body as string); requests.push({url,body});
if(url.endsWith('/summary/query')){
if(failure)return Response.json({code:'UPSTREAM_ERROR',traceId:'trace'}, {status:failure,headers:{'Retry-After':'12'}});
const selected=body.plateNumbers || ['TEST0','TEST1','TEST2'];
const values=[0,30,20];
return Response.json({code:'SUCCESS',startDate:body.startDate,endDate:body.endDate,timezone:'Asia/Shanghai',vehicleCount:selected.length,
data:{vehicles:selected.map((plate:string)=>({plateNumber:plate,vin:'VIN',periodMileageKm:values[Number(plate.slice(-1))],
endDateTotalMileageKm:plate==='TEST0'?null:100,endDateDataTime:null,endDateSourceProtocol:null,
validDays:1,missingDays:plate==='TEST2'?0:1,anomalyDays:plate==='TEST2'?1:0,
status:plate==='TEST2'?'DATA_ANOMALY':'PARTIAL_DATA',qualityReasons:[plate==='TEST2'?'BASELINE_MISSING':'CARRIED_FORWARD'],sourceProtocols:['MQTT'],updatedAt:null})),
dailyTrend:[{date:body.startDate,mileageKm:selected.reduce((sum:number,p:string)=>sum+values[Number(p.slice(-1))],0),validVehicleCount:selected.length,missingVehicleCount:0,anomalyVehicleCount:0},
{date:body.endDate,mileageKm:null,validVehicleCount:0,missingVehicleCount:selected.filter((p:string)=>p!=='TEST2').length,anomalyVehicleCount:selected.includes('TEST2')?1:0}]},
snapshotId:'snapshot',generatedAt:'2026-10-27T00:00:00Z',expiresAt:'2099-01-01T00:00:00Z',nextCursor:null});
}
if(url.endsWith('/range/query'))return Response.json({code:'SUCCESS',data:(body.plateNumbers||[]).flatMap((plate:string)=>[
{plateNumber:plate,date:body.startDate,dailyMileageKm:20,totalMileageKm:100,status:'NORMAL',sourceProtocol:'MQTT'},
{plateNumber:plate,date:body.endDate,dailyMileageKm:0,totalMileageKm:100,status:'NORMAL',dataQuality:'CARRIED_FORWARD',sourceProtocol:'MQTT'},
]),snapshotId:'detail',nextCursor:null});
return Response.json({code:'SUCCESS',data:[]});
});
t.mock.method(pool,'execute',async(sql:string)=>{
if(!sql.includes('FROM vehicle_info'))return [[],[]];
return [[0,1,2,3].map(i=>({plate:`TEST${i}`,vin:'VIN',customer:'客户',department:i===1?'B':'A',manager:null,
manager_id:i===0&&reassigned?'new-owner':`${i}`,rent_status:'租赁',entity:null,project:null,inventory_location:null,brand_label:null})),[]];
});
let user:AuthUser={userId:'0',userName:'测试',loginName:'test',depCode:'',depName:'A',roles:[],permissionLevel:'full'};
const app=new Hono<{Variables:{user:AuthUser}}>();
app.use('*',async(c,next)=>{c.set('user',user);await next();});
app.route('/monitoring',monitoring);app.route('/vehicle',recent);
const base='/monitoring?startDate=2026-10-10&endDate=2026-10-11&sourcePriority=instrument';
const read=async(extra='')=>{const response=await app.request(base+extra);assert.equal(response.status,200);return await response.json() as MonitoringResponse;};
const first=await read('&limit=1');
assert.equal(first.vehicles[0].plate,'TEST1');assert.equal(first.stats.totalToday,50);
assert.equal(first.rangeDailyTotals?.[1].totalKm,null);
assert.equal(requests.filter(r=>r.url.endsWith('/summary/query')).length,1);
assert.equal(requests.filter(r=>r.url.endsWith('/range/query')).length,0);
const second=await read('&page=2&limit=1&includeDailyTrend=0');assert.equal(second.vehicles[0].periodMileageKm,20);
const sorted=await read('&sortOrder=asc&includeDailyTrend=0');assert.equal(sorted.vehicles[0].periodMileageKm,0);
const exported=await read('&limit=9999&includeDailyTrend=0');
assert.equal(exported.vehicles.length,4);assert.equal(exported.vehicles[3].periodMileageKm,null);
assert.equal(exported.queriedAt,first.queriedAt);assert.ok(!exported.vehicles[0].dailyMileage);
assert.equal(requests.filter(r=>r.url.endsWith('/summary/query')).length,1);
user={...user,permissionLevel:'department'};
const department=await read();assert.equal(department.stats.totalToday,20);
assert.equal(department.rangeDailyTotals?.[0].totalKm,20);
assert.deepEqual(requests.filter(r=>r.url.endsWith('/summary/query')).at(-1)!.body.plateNumbers,['TEST0','TEST2']);
user={...user,permissionLevel:'personal'};
const personal=await read();assert.equal(personal.rangeDailyTotals?.[0].totalKm,0);
assert.equal(personal.rangeDailyTotals?.[1].totalKm,null);
const count=requests.length;reassigned=true;
const empty=await read();assert.equal(empty.total,0);assert.ok(empty.rangeDailyTotals?.every(d=>d.totalKm===null));assert.equal(requests.length,count);
const denied=await app.request('/vehicle/TEST0/recent?start=2026-10-10&end=2026-10-11');assert.equal(denied.status,403);
reassigned=false;user={...user,permissionLevel:'full'};
const detail=await read('&plate=TEST2&dailyDetails=1&includeDailyTrend=0');
assert.deepEqual(detail.vehicles[0].dailyMileage,{'2026-10-10':20,'2026-10-11':null});
assert.deepEqual(requests.at(-1)!.body.plateNumbers,['TEST2']);
const before=requests.filter(r=>r.url.endsWith('/summary/query')).length;
await read('&refresh=1');assert.equal(requests.filter(r=>r.url.endsWith('/summary/query')).length,before+1);
failure=403;const fail=await app.request(base+'&refresh=1');assert.equal(fail.status,403);assert.equal((await fail.json()).code,'UPSTREAM_ERROR');
failure=429;const limited=await app.request(base);assert.equal(limited.status,429);assert.equal(limited.headers.get('Retry-After'),'12');
});
+54 -10
View File
@@ -1,4 +1,9 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { queryMonitoringRange } from './query-cache.js';
import { MileageSummaryError, type MileageSummary } from './summary-api.js';
import { mileageDatesBetween } from './cache-model.js';
import { fetchOneOsMileageDates } from './oneos-api.js';
import { validDailyMileage, type OneOsDailyMileage } from './oneos-model.js';
import { getCache, queryDateMileage, queryRangeMileage, buildDateFilters } from './cache.js'; import { getCache, queryDateMileage, queryRangeMileage, buildDateFilters } from './cache.js';
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js'; import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
import type { AuthUser } from '../../auth/types.js'; import type { AuthUser } from '../../auth/types.js';
@@ -132,21 +137,29 @@ app.get('/', async (c) => {
brands: c.req.queries('brand') || [], brands: c.req.queries('brand') || [],
}; };
let rangeSummary: MileageSummary | undefined;
let queriedAt: string | undefined;
let allVehicles: CachedVehicle[]; let allVehicles: CachedVehicle[];
let filters: MonitoringFilters; let filters: MonitoringFilters;
let rangeDailyTotals: { date: string; totalKm: number }[] | undefined; let rangeDailyTotals: { date: string; totalKm: number | null }[] | undefined;
let dateRange: { start: string; end: string } | undefined; let dateRange: { start: string; end: string } | undefined;
if (range) { if (range) {
try { try {
const result = await queryRangeMileage(range.start, range.end, protocolPriority); const result = await queryRangeMileage(range.start, range.end, protocolPriority, c.req.query('refresh') === '1');
queriedAt = result.queriedAt;
c.header('X-Mileage-Cache', result.cacheStatus);
allVehicles = result.vehicles; allVehicles = result.vehicles;
rangeDailyTotals = result.dailyTotals; rangeSummary = result.summary;
dateRange = { start: result.start, end: result.end }; dateRange = { start: result.start, end: result.end };
filters = buildDateFilters(allVehicles); filters = buildDateFilters(allVehicles);
} catch (e: unknown) { } catch (e: unknown) {
if (e instanceof MileageSummaryError) {
if (e.retryAfter) c.header('Retry-After', e.retryAfter);
return c.json({ error: e.message, code: e.code, traceId: e.traceId }, e.status === 401 ? 502 : e.status as 400 | 403 | 429 | 500 | 502 | 504);
}
console.error('monitoring range query error:', e); console.error('monitoring range query error:', e);
return c.json(EMPTY_RESPONSE, 500); return c.json({ error: e instanceof Error ? e.message : '里程汇总查询失败' }, 502);
} }
} else if (date) { } else if (date) {
try { try {
@@ -177,17 +190,35 @@ app.get('/', async (c) => {
} }
const filtered = applyFilters(allVehicles, filterParams); const filtered = applyFilters(allVehicles, filterParams);
if (rangeDailyTotals && filtered.length !== allVehicles.length) { // The provider trend covers its entire selected fleet, not the BI user's scope.
rangeDailyTotals = rangeDailyTotals.map(item => ({ if (range && rangeSummary && c.req.query('includeDailyTrend') !== '0') {
...item, const authorized = new Set(rangeSummary.vehicles.map(v => v.plateNumber));
totalKm: Math.round(filtered.reduce((sum, vehicle) => sum + (vehicle.dailyMileage?.[item.date] || 0), 0)), const plates = [...new Set(filtered.map(v => v.plate.trim().toUpperCase()).filter(p => authorized.has(p)))].sort();
})); if (plates.length === 0) {
rangeDailyTotals = mileageDatesBetween(range.start, range.end).map(date => ({ date, totalKm: null }));
} else if (range.start === range.end) {
const valid = filtered.filter(v => v.periodMileageKm != null);
rangeDailyTotals = [{ date: range.start, totalKm: valid.length ? valid.reduce((sum,v) => sum + v.periodMileageKm!, 0) : null }];
} else {
try {
const summary = plates.length === authorized.size ? rangeSummary
: (await queryMonitoringRange(range.start, range.end, protocolPriority, c.req.query('refresh') === '1', plates)).result.summary;
rangeDailyTotals = summary.dailyTrend.map(d => ({ date: d.date, totalKm: d.mileageKm }));
} catch (e) {
if (e instanceof MileageSummaryError) {
if (e.retryAfter) c.header('Retry-After', e.retryAfter);
return c.json({ error: e.message, code: e.code, traceId: e.traceId }, e.status === 401 ? 502 : e.status as 400 | 403 | 429 | 500 | 502 | 504);
}
return c.json({ error: '所选车辆趋势查询失败,请重试' }, 502);
}
}
} }
const stats = { const stats = {
totalToday: Math.round(filtered.reduce((sum, v) => sum + v.dailyKm, 0)), totalToday: Math.round(filtered.reduce((sum, v) => sum + v.dailyKm, 0)),
totalAll: filtered.reduce((sum, v) => sum + (v.totalKm || 0), 0), totalAll: filtered.reduce((sum, v) => sum + (v.totalKm || 0), 0),
vehicleCount: filtered.length, vehicleCount: filtered.length,
...(rangeSummary ? { validVehicleCount: filtered.filter(v => v.periodMileageKm != null).length, totalMileageVehicleCount: filtered.filter(v => v.totalKm != null).length } : {}),
yesterdayTotal: filtered.reduce((sum, v) => sum + v.yesterdayKm, 0), yesterdayTotal: filtered.reduce((sum, v) => sum + v.yesterdayKm, 0),
}; };
@@ -215,7 +246,19 @@ app.get('/', async (c) => {
}); });
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const paged = sorted.slice(offset, offset + limit); let paged = sorted.slice(offset, offset + limit);
if (range && rangeSummary && c.req.query('dailyDetails') === '1') {
const authorized = new Set(rangeSummary.vehicles.map(v => v.plateNumber));
const plates = paged.map(v => v.plate.trim().toUpperCase()).filter(p => authorized.has(p));
try {
const dates = mileageDatesBetween(range.start, range.end);
const rows = plates.length ? await fetchOneOsMileageDates(dates, plates, protocolPriority) : new Map<string, OneOsDailyMileage[]>();
const byDate = new Map(dates.map(date => [date, new Map<string, number | null>((rows.get(date) || []).map(row => [row.plateNumber, validDailyMileage(row)]))]));
paged = paged.map(v => ({ ...v, dailyMileage: Object.fromEntries(dates.map(date => [date, byDate.get(date)?.get(v.plate.trim().toUpperCase()) ?? null])) }));
} catch {
return c.json({ error: '逐日明细导出失败,请缩小范围或重试' }, 502);
}
}
const total = filtered.length; const total = filtered.length;
return c.json({ return c.json({
@@ -229,6 +272,7 @@ app.get('/', async (c) => {
totalPages: Math.ceil(total / limit), totalPages: Math.ceil(total / limit),
updatedAt: dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(), updatedAt: dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(),
sourcePriority, sourcePriority,
queriedAt,
}); });
}); });
+2 -1
View File
@@ -224,6 +224,7 @@ export async function fetchOneOsDailyMileage(
date: string, date: string,
plateNumbers?: string[], plateNumbers?: string[],
protocolPriority: OneOsProtocol[] = DEFAULT_ONEOS_PROTOCOL_PRIORITY, protocolPriority: OneOsProtocol[] = DEFAULT_ONEOS_PROTOCOL_PRIORITY,
refresh = false,
): Promise<OneOsDailyMileage[]> { ): Promise<OneOsDailyMileage[]> {
assertMileageDate(date); assertMileageDate(date);
@@ -231,7 +232,7 @@ export async function fetchOneOsDailyMileage(
const cacheKey = `${date}:${policyKey}`; const cacheKey = `${date}:${policyKey}`;
const hit = cache.get(cacheKey); const hit = cache.get(cacheKey);
let rows: OneOsDailyMileage[]; let rows: OneOsDailyMileage[];
if (hit && hit.expiresAt > Date.now()) { if (!refresh && hit && hit.expiresAt > Date.now()) {
rows = hit.rows; rows = hit.rows;
} else { } else {
let pending = inflight.get(cacheKey); let pending = inflight.get(cacheKey);
@@ -98,3 +98,13 @@ test('异常日里程不伪造为有效值,保留仪表累计和异常原因',
assert.equal(row.totalMileageKm, 66852.4); assert.equal(row.totalMileageKm, 66852.4);
assert.equal(row.dataQuality, 'ODOMETER_SOURCE_CHANGED'); assert.equal(row.dataQuality, 'ODOMETER_SOURCE_CHANGED');
}); });
test('NORMAL carried-forward detail is missing while actual zero remains valid', async () => {
const { validDailyMileage } = await import('./oneos-model.js');
const rows=normalizeOneOsRows([
{plateNumber:'MISSING',status:'NORMAL',dailyMileageKm:0,dataQuality:'CARRIED_FORWARD'},
{plateNumber:'ZERO',status:'NORMAL',dailyMileageKm:0},
],'2026-10-01');
assert.equal(validDailyMileage(rows[0]),null);
assert.equal(validDailyMileage(rows[1]),0);
});
+2 -2
View File
@@ -17,7 +17,7 @@ export interface OneOsDailyMileage {
/** Missing or unusable daily mileage must not contribute to interval statistics. */ /** Missing or unusable daily mileage must not contribute to interval statistics. */
export function validDailyMileage(row: OneOsDailyMileage | undefined): number | null { export function validDailyMileage(row: OneOsDailyMileage | undefined): number | null {
const km = row?.dailyMileageKm; const km = row?.dailyMileageKm;
return row?.status === 'NORMAL' && km != null && Number.isFinite(km) && km >= 0 ? km : null; return row?.status === 'NORMAL' && row.dataQuality !== 'CARRIED_FORWARD' && km != null && Number.isFinite(km) && km >= 0 ? km : null;
} }
const MILEAGE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; const MILEAGE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
@@ -69,7 +69,7 @@ export function normalizeOneOsRows(value: unknown, requestedDate: string): OneOs
dailyMileageKm: nonNegativeMileage(row.dailyMileageKm, status), dailyMileageKm: nonNegativeMileage(row.dailyMileageKm, status),
totalMileageKm: nonNegativeMileage(row.totalMileageKm, status === 'DATA_ANOMALY' ? 'NORMAL' : status), totalMileageKm: nonNegativeMileage(row.totalMileageKm, status === 'DATA_ANOMALY' ? 'NORMAL' : status),
status, status,
...(status === 'DATA_ANOMALY' ? { dataQuality: stringField(row, 'dataQuality') || 'DATA_ANOMALY' } : {}), ...((row.dataQuality || status === 'DATA_ANOMALY') ? { dataQuality: stringField(row, 'dataQuality') || 'DATA_ANOMALY' } : {}),
dataTime, dataTime,
calculatedAt, calculatedAt,
updatedAt, updatedAt,
@@ -0,0 +1,89 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMonitoringQueryCache, type RangeMileageSource } from './query-cache.js';
import type { OneOsProtocol } from './source-policy.js';
const protocols: OneOsProtocol[] = ['GB32960', 'MQTT'];
const result = (): RangeMileageSource => ({ summary: { vehicles: [], dailyTrend: [], snapshotId: 'test', generatedAt: '2026-01-01T00:00:00Z', expiresAt: '2099-01-01T00:00:00Z' }, yesterdayRows: [] });
test('completed range results are reused for five minutes; refresh bypasses daily caches too', async () => {
let time = 1_000;
const calls: boolean[] = [];
const query = createMonitoringQueryCache(async (start, end, _protocols, refresh) => {
calls.push(!!refresh);
return result();
}, () => time);
const first = await query('2026-10-01', '2026-10-26', protocols);
assert.equal(first.cacheStatus, 'miss');
time += 299_999;
const hit = await query('2026-10-01', '2026-10-26', protocols);
assert.equal(hit.cacheStatus, 'hit');
assert.equal(hit.result, first.result);
assert.equal(hit.queriedAt, first.queriedAt);
time += 1;
assert.equal((await query('2026-10-01', '2026-10-26', protocols)).cacheStatus, 'miss');
assert.equal((await query('2026-10-01', '2026-10-26', protocols, true)).cacheStatus, 'refresh');
assert.deepEqual(calls, [false, false, true]);
});
test('date range and ordered protocol priority isolate cached results; entries are bounded', async () => {
let calls = 0;
const query = createMonitoringQueryCache(async (start, end) => { calls++; return result(); });
await query('2026-10-01', '2026-10-26', protocols);
await query('2026-10-01', '2026-10-26', ['MQTT', 'GB32960']);
await query('2026-10-01', '2026-10-26', ['JT808']);
await query('2026-10-02', '2026-10-26', protocols);
assert.equal(calls, 4);
assert.equal((await query('2026-10-01', '2026-10-26', protocols)).cacheStatus, 'hit');
await query('2026-10-03', '2026-10-26', protocols);
assert.equal((await query('2026-10-01', '2026-10-26', protocols)).cacheStatus, 'miss');
assert.equal(calls, 6);
});
test('simultaneous requests share upstream mileage retrieval, including explicit refresh', async () => {
let calls = 0;
let release!: (value: RangeMileageSource) => void;
const query = createMonitoringQueryCache(async () => {
calls++;
return new Promise<RangeMileageSource>(resolve => { release = resolve; });
});
const first = query('2026-10-01', '2026-10-26', protocols);
const second = query('2026-10-01', '2026-10-26', protocols);
const refresh = query('2026-10-01', '2026-10-26', protocols, true);
assert.equal(calls, 1);
release(result());
const entries = await Promise.all([first, second, refresh]);
assert.deepEqual(entries.map(entry => entry.cacheStatus), ['miss', 'shared', 'shared']);
assert.ok(entries.every(entry => entry.result === entries[0].result));
});
test('failed refresh removes stale data and failed requests can be retried', async () => {
let fail = false;
let calls = 0;
const query = createMonitoringQueryCache(async (start, end) => {
calls++;
if (fail) throw new Error('upstream unavailable');
return result();
});
await query('2026-10-01', '2026-10-26', protocols);
fail = true;
await assert.rejects(query('2026-10-01', '2026-10-26', protocols, true), /upstream unavailable/);
await assert.rejects(query('2026-10-01', '2026-10-26', protocols), /upstream unavailable/);
fail = false;
assert.equal((await query('2026-10-01', '2026-10-26', protocols)).cacheStatus, 'miss');
assert.equal(calls, 4);
});
test('summary cache isolates normalized vehicle scopes and respects provider snapshot expiry', async () => {
let time=1_000,calls=0;
const query=createMonitoringQueryCache(async()=>{
calls++;
const value=result();value.summary.expiresAt=new Date(2_000).toISOString();return value;
},()=>time);
await query('2026-10-01','2026-10-26',protocols,false,[' b ','A']);
assert.equal((await query('2026-10-01','2026-10-26',protocols,false,['A','B'])).cacheStatus,'hit');
await query('2026-10-01','2026-10-26',protocols,false,['A']);
assert.equal(calls,2);
time=2_000;
assert.equal((await query('2026-10-01','2026-10-26',protocols,false,['A','B'])).cacheStatus,'miss');
});
+58
View File
@@ -0,0 +1,58 @@
import { fetchOneOsDailyMileage, type OneOsDailyMileage } from './oneos-api.js';
import { previousMileageDate } from './cache-model.js';
import { fetchMileageSummary, type MileageSummary } from './summary-api.js';
import type { OneOsProtocol } from './source-policy.js';
// Cache upstream mileage only; ownership, permissions and business filters stay live.
const TTL_MS = 5 * 60 * 1000;
const MAX_ENTRIES = 4;
export interface RangeMileageSource {
summary: MileageSummary;
yesterdayRows: OneOsDailyMileage[];
}
type Entry = { result: RangeMileageSource; queriedAt: string; expiresAt: number };
async function loadSource(start: string, end: string, protocols: OneOsProtocol[], refresh = false, plates?: string[]): Promise<RangeMileageSource> {
const [summary, yesterdayRows] = await Promise.all([
fetchMileageSummary(start, end, protocols, plates),
plates ? Promise.resolve([]) : fetchOneOsDailyMileage(previousMileageDate(start), undefined, protocols, refresh),
]);
return { summary, yesterdayRows };
}
export function createMonitoringQueryCache(
query = loadSource,
now: () => number = Date.now,
) {
const entries = new Map<string, Entry>();
const inflight = new Map<string, Promise<Entry>>();
return async (start: string, end: string, protocols: OneOsProtocol[], refresh = false, plates?: string[]) => {
const selected = plates ? [...new Set(plates.map(p => p.trim().toUpperCase()))].sort() : undefined;
const key = JSON.stringify([start, end, protocols, selected]);
const timestamp = now();
for (const [entryKey, entry] of entries) {
if (entry.expiresAt <= timestamp) entries.delete(entryKey);
}
const pending = inflight.get(key);
if (pending) return { ...await pending, cacheStatus: 'shared' as const };
const hit = entries.get(key);
if (hit && !refresh) return { ...hit, cacheStatus: 'hit' as const };
// A failed explicit refresh must not silently fall back to the old result.
entries.delete(key);
const request = query(start, end, protocols, refresh, selected).then(result => {
const completedAt = now();
const entry = { result, queriedAt: new Date(completedAt).toISOString(), expiresAt: Math.min(completedAt + TTL_MS, Date.parse(result.summary.expiresAt)) };
entries.set(key, entry);
if (entries.size > MAX_ENTRIES) entries.delete(entries.keys().next().value!);
return entry;
}).finally(() => inflight.delete(key));
inflight.set(key, request);
return { ...await request, cacheStatus: refresh ? 'refresh' as const : 'miss' as const };
};
}
export const queryMonitoringRange = createMonitoringQueryCache();
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { fetchMileageSummary, parseMileageSummary, MileageSummaryError } from './summary-api.js';
const originalConfig = { url: process.env.ONEOS_MILEAGE_API_BASE_URL, key: process.env.ONEOS_MILEAGE_API_KEY };
test.before(() => { process.env.ONEOS_MILEAGE_API_BASE_URL = 'https://oneos.test'; process.env.ONEOS_MILEAGE_API_KEY = 'test-only'; });
test.after(() => {
if (originalConfig.url === undefined) delete process.env.ONEOS_MILEAGE_API_BASE_URL; else process.env.ONEOS_MILEAGE_API_BASE_URL = originalConfig.url;
if (originalConfig.key === undefined) delete process.env.ONEOS_MILEAGE_API_KEY; else process.env.ONEOS_MILEAGE_API_KEY = originalConfig.key;
});
const start = '2026-10-01', end = '2026-10-02';
const vehicle = (plate: string) => ({ plateNumber: plate, vin: 'VIN', periodMileageKm: 0, endDateTotalMileageKm: null,
endDateDataTime: null, endDateSourceProtocol: null, validDays: 1, missingDays: 1, anomalyDays: 0,
status: 'PARTIAL_DATA', qualityReasons: ['CARRIED_FORWARD'], sourceProtocols: ['MQTT'], updatedAt: null });
const payload = () => ({ code: 'SUCCESS', startDate: start, endDate: end, timezone: 'Asia/Shanghai', vehicleCount: 1,
data: { vehicles: [vehicle('TEST0')], dailyTrend: [
{ date: start, mileageKm: 0, validVehicleCount: 1, missingVehicleCount: 0, anomalyVehicleCount: 0 },
{ date: end, mileageKm: null, validVehicleCount: 0, missingVehicleCount: 1, anomalyVehicleCount: 0 },
] }, snapshotId: 's1', generatedAt: '2026-10-27T00:00:00Z', expiresAt: '2099-01-01T00:00:00Z', nextCursor: null as string | null });
test('summary boundary preserves real zero and missing trend; rejects malformed counts/status/values', () => {
const p = payload();
assert.equal(parseMileageSummary(p,start,end).vehicles[0].periodMileageKm,0);
assert.equal(parseMileageSummary(p,start,end).dailyTrend[1].mileageKm,null);
for (const mutate of [
(p: ReturnType<typeof payload>) => { p.data.vehicles[0].validDays = 2; },
(p: ReturnType<typeof payload>) => { p.data.vehicles[0].status = 'NORMAL'; },
(p: ReturnType<typeof payload>) => { p.data.vehicles[0].periodMileageKm = NaN; },
(p: ReturnType<typeof payload>) => { p.data.dailyTrend[1].mileageKm = 0; },
]) { const bad = payload(); mutate(bad); assert.throws(() => parseMileageSummary(bad,start,end)); }
});
test('summary pagination keeps the snapshot and reuses one fleet trend; empty scope never means all', async t => {
t.mock.method(globalThis, 'fetch', async (_url: string, options: RequestInit) => {
const body = JSON.parse(options.body as string);
assert.deepEqual(body.plateNumbers, ['TEST0','TEST1']);
assert.equal(body.includeDailyTrend,true);
assert.equal(body.pageSize,5000);
const p = payload(); p.vehicleCount = 2;
p.data.vehicles = [vehicle(body.cursor ? 'TEST1':'TEST0')];
p.data.dailyTrend[0].validVehicleCount = 2;
p.data.dailyTrend[1].missingVehicleCount = 2;
p.nextCursor = body.cursor ? null:'next';
return Response.json(p);
});
const result = await fetchMileageSummary(start,end,['MQTT'],['TEST0','TEST1']);
assert.equal(result.vehicles.length,2);
assert.equal(result.dailyTrend.length,2);
assert.equal(result.dailyTrend[1].mileageKm,null);
await assert.rejects(fetchMileageSummary(start,end,['MQTT'],[]), /空车牌/);
});
test('summary authorization, throttling and timeout errors are explicit and never return empty success', async t => {
for (const status of [401,403,429,504]) {
const mock = t.mock.method(globalThis, 'fetch', async () => Response.json({code:'TEST_ERROR',traceId:'test-trace'}, {status,headers:{'Retry-After':'30'}}));
await assert.rejects(fetchMileageSummary(start,end,['MQTT']), e => e instanceof MileageSummaryError && e.status === status && e.traceId === 'test-trace' && e.retryAfter === '30');
mock.mock.restore();
}
});
test('snapshot changes and duplicate vehicles reject partial pagination', async t => {
for (const changedSnapshot of [true,false]) {
const mock=t.mock.method(globalThis,'fetch',async (_url:string,options:RequestInit)=>{
const body=JSON.parse(options.body as string), p=payload(); p.vehicleCount=2;
p.data.dailyTrend[0].validVehicleCount=2; p.data.dailyTrend[1].missingVehicleCount=2;
p.nextCursor=body.cursor?null:'next';
if(body.cursor && changedSnapshot)p.snapshotId='s2';
return Response.json(p);
});
await assert.rejects(fetchMileageSummary(start,end,['MQTT']),changedSnapshot?/快照/:/重复车辆/);
mock.mock.restore();
}
});
+116
View File
@@ -0,0 +1,116 @@
import dotenv from 'dotenv';
import { mileageDatesBetween } from './cache-model.js';
import type { OneOsProtocol } from './source-policy.js';
dotenv.config();
export interface MileageSummaryVehicle {
vin: string;
plateNumber: string;
periodMileageKm: number | null;
endDateTotalMileageKm: number | null;
endDateDataTime: string | null;
endDateSourceProtocol: OneOsProtocol | null;
validDays: number;
missingDays: number;
anomalyDays: number;
status: 'NORMAL' | 'PARTIAL_DATA' | 'NO_DATA' | 'DATA_ANOMALY';
qualityReasons: string[];
sourceProtocols: OneOsProtocol[];
updatedAt: string | null;
}
export interface MileageSummaryTrend {
date: string;
mileageKm: number | null;
validVehicleCount: number;
missingVehicleCount: number;
anomalyVehicleCount: number;
}
export interface MileageSummary {
vehicles: MileageSummaryVehicle[];
dailyTrend: MileageSummaryTrend[];
snapshotId: string;
generatedAt: string;
expiresAt: string;
}
export class MileageSummaryError extends Error {
constructor(readonly status: number, readonly code: string, readonly traceId?: string, readonly retryAfter?: string) {
const message = status === 401 ? '里程服务凭据无效或未覆盖所选日期'
: status === 403 ? '里程服务未授权所选车辆或完整日期区间'
: status === 429 ? `里程查询频率受限,请${retryAfter ? ` ${retryAfter} 秒` : '稍'}后重试`
: status === 504 ? '里程查询超时,请缩小车辆或日期范围后重试'
: status === 400 ? '里程查询参数或快照无效,请重新查询'
: '里程服务暂不可用,请重试';
super(message);
}
}
// Validate once at the provider boundary; invalid responses must not become zero mileage.
export function parseMileageSummary(value: unknown, start: string, end: string): MileageSummary & { nextCursor: string | null; vehicleCount: number } {
const p = value as any;
const dates = mileageDatesBetween(start, end);
const protocols = new Set(['GB32960', 'MQTT', 'JT808']);
const count = (n: unknown) => Number.isInteger(n) && Number(n) >= 0;
const km = (n: unknown) => n === null || typeof n === 'number' && Number.isFinite(n) && n >= 0;
const time = (s: unknown) => s === null || typeof s === 'string' && Number.isFinite(Date.parse(s));
if (!p || p.code !== 'SUCCESS' || p.startDate !== start || p.endDate !== end || p.timezone !== 'Asia/Shanghai'
|| !count(p.vehicleCount) || p.vehicleCount > 5000 || !p.snapshotId || typeof p.snapshotId !== 'string'
|| typeof p.generatedAt !== 'string' || !time(p.generatedAt) || typeof p.expiresAt !== 'string' || !time(p.expiresAt)
|| !(p.nextCursor === null || typeof p.nextCursor === 'string' && p.nextCursor.length > 0)
|| !Array.isArray(p.data?.vehicles) || !Array.isArray(p.data?.dailyTrend)) throw new Error('里程汇总响应格式不符合契约');
for (const v of p.data.vehicles) {
if (!v || typeof v.plateNumber !== 'string' || !v.plateNumber.trim() || typeof v.vin !== 'string'
|| !km(v.periodMileageKm) || !km(v.endDateTotalMileageKm) || !time(v.endDateDataTime) || !time(v.updatedAt)
|| !(v.endDateSourceProtocol === null || protocols.has(v.endDateSourceProtocol))
|| ![v.validDays,v.missingDays,v.anomalyDays].every(count) || v.validDays + v.missingDays + v.anomalyDays !== dates.length
|| (v.validDays === 0) !== (v.periodMileageKm === null)
|| !Array.isArray(v.qualityReasons) || !v.qualityReasons.every((x: unknown) => typeof x === 'string')
|| !Array.isArray(v.sourceProtocols) || !v.sourceProtocols.every((x: string) => protocols.has(x))) throw new Error('里程汇总车辆字段不符合契约');
const status = v.anomalyDays > 0 ? 'DATA_ANOMALY' : v.validDays === 0 ? 'NO_DATA' : v.missingDays > 0 ? 'PARTIAL_DATA' : 'NORMAL';
if (v.status !== status) throw new Error('里程汇总质量状态不符合契约');
}
if (p.data.dailyTrend.length !== dates.length || p.data.dailyTrend.some((d: any, i: number) => !d || d.date !== dates[i]
|| !km(d.mileageKm) || ![d.validVehicleCount,d.missingVehicleCount,d.anomalyVehicleCount].every(count)
|| d.validVehicleCount + d.missingVehicleCount + d.anomalyVehicleCount !== p.vehicleCount
|| (d.validVehicleCount === 0) !== (d.mileageKm === null))) throw new Error('里程每日趋势不符合契约');
return { vehicles: p.data.vehicles, dailyTrend: p.data.dailyTrend, snapshotId: p.snapshotId,
generatedAt: p.generatedAt, expiresAt: p.expiresAt, nextCursor: p.nextCursor, vehicleCount: p.vehicleCount };
}
export async function fetchMileageSummary(start: string, end: string, protocols: OneOsProtocol[], plates?: string[]): Promise<MileageSummary> {
// An empty BI scope must never become the provider's "all authorized vehicles".
if (plates?.length === 0) throw new Error('不能使用空车牌集合查询里程汇总');
const base = (process.env.ONEOS_MILEAGE_API_BASE_URL || '').replace(/\/+$/, '');
const key = process.env.ONEOS_MILEAGE_API_KEY;
if (!base || !key) throw new Error('OneOS mileage API is not configured');
const vehicles: MileageSummaryVehicle[] = [];
const seen = new Set<string>();
let first: ReturnType<typeof parseMileageSummary> | undefined;
let cursor: string | null = null;
do {
const response = await fetch(`${base}/api/v1/vehicles/mileage/summary/query`, {
method: 'POST', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ startDate: start, endDate: end, protocolPriority: protocols,
includeDailyTrend: true, pageSize: 5000, ...(plates ? { plateNumbers: plates } : {}), ...(cursor ? { cursor } : {}) }),
signal: AbortSignal.timeout(Math.max(15000, Number(process.env.ONEOS_MILEAGE_API_TIMEOUT_MS) || 20000)),
});
const payload = await response.json();
if (!response.ok || payload.code !== 'SUCCESS') throw new MileageSummaryError(response.status === 200 ? 502 : response.status,
payload.code || 'UNKNOWN', payload.traceId, response.headers.get('Retry-After') || undefined);
const page = parseMileageSummary(payload, start, end);
if (first && (page.snapshotId !== first.snapshotId || page.vehicleCount !== first.vehicleCount
|| page.generatedAt !== first.generatedAt || page.expiresAt !== first.expiresAt)) throw new Error('里程汇总分页快照发生变化,请重新查询');
first ??= page;
for (const v of page.vehicles) {
const plate = v.plateNumber.trim().toUpperCase();
if (seen.has(plate)) throw new Error('里程汇总分页出现重复车辆');
seen.add(plate);
vehicles.push({ ...v, plateNumber: plate });
}
if (vehicles.length > first.vehicleCount || (page.nextCursor && page.vehicles.length === 0)) throw new Error('里程汇总分页数量不一致');
cursor = page.nextCursor;
} while (cursor);
if (vehicles.length !== first!.vehicleCount) throw new Error('里程汇总未返回完整车辆集合');
return { ...first!, vehicles }; // Reuse one complete trend; never sum trends from pages.
}
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildSummaryMileage } from './summary-model.js';
import type { MileageSummary } from './summary-api.js';
import type { VehicleInfoRow } from './types.js';
test('summary mapping preserves valid partial amounts, independent odometers and observation timestamps',()=>{
const info:VehicleInfoRow={plate:'TEST',vin:'VIN',customer:null,department:'A',manager:null,manager_id:'1',rent_status:null,entity:null,project:null,inventory_location:null,brand_label:null};
const summary:MileageSummary={snapshotId:'s',generatedAt:'2026-10-27T00:00:00Z',expiresAt:'2099-01-01T00:00:00Z',dailyTrend:[],vehicles:[{
plateNumber:'TEST',vin:'VIN',periodMileageKm:25,endDateTotalMileageKm:100,endDateDataTime:'2026-10-26T00:00:00Z',endDateSourceProtocol:'JT808',
validDays:1,missingDays:0,anomalyDays:1,status:'DATA_ANOMALY',qualityReasons:['BASELINE_MISSING'],sourceProtocols:['MQTT','JT808'],updatedAt:'2026-10-26T01:00:00Z',
}]};
const map=new Map([['TEST',info]]);
const row=buildSummaryMileage(summary,[],map,[],{})[0];
assert.equal(row.periodMileageKm,25);assert.equal(row.isDataSynced,true);assert.equal(row.sourceCategory,'MIXED');assert.equal(row.totalKm,100);
assert.equal(row.dataTime,summary.vehicles[0].endDateDataTime);assert.equal(row.updatedAt,summary.vehicles[0].updatedAt);assert.equal(row.calculatedAt,null,'query generation time is not a vehicle observation time');
summary.vehicles[0]={...summary.vehicles[0],periodMileageKm:null,validDays:0,anomalyDays:2};
const missing=buildSummaryMileage(summary,[],map,[],{})[0];assert.equal(missing.periodMileageKm,null);assert.equal(missing.totalKm,100);assert.equal(missing.isDataSynced,false);
});
@@ -0,0 +1,26 @@
import type { MileageSummary } from './summary-api.js';
import type { OneOsDailyMileage } from './oneos-model.js';
import { buildPlateTargetNamesMap, dailyMileageMap, mergeMonitoringVehicles, type TargetRow } from './cache-model.js';
import type { CachedVehicle, VehicleInfoRow } from './types.js';
import { sourceCategoryFromProtocol } from './source-policy.js';
export function buildSummaryMileage(summary: MileageSummary, yesterdayRows: OneOsDailyMileage[], infoMap: Map<string, VehicleInfoRow>, targets: TargetRow[], regions: Record<string, string>): CachedVehicle[] {
const byPlate = new Map(summary.vehicles.map(v => [v.plateNumber, v]));
return mergeMonitoringVehicles([], infoMap, dailyMileageMap(yesterdayRows), buildPlateTargetNamesMap(targets), regions).map(vehicle => {
const row = byPlate.get(vehicle.plate.trim().toUpperCase());
if (!row) return { ...vehicle, periodMileageKm: null };
const categories = new Set(row.sourceProtocols.map(sourceCategoryFromProtocol));
return { ...vehicle, vin: row.vin || vehicle.vin,
dailyKm: row.periodMileageKm ?? 0, periodMileageKm: row.periodMileageKm,
totalKm: row.endDateTotalMileageKm,
validDays: row.validDays, missingDays: row.missingDays, anomalyDays: row.anomalyDays,
qualityStatus: row.status, qualityReasons: row.qualityReasons, sourceProtocols: row.sourceProtocols,
mileageAnomaly: row.anomalyDays ? row.qualityReasons.join(', ') || 'DATA_ANOMALY' : null,
source: row.validDays > 0 ? 'ONEOS_API' : 'NONE', isDataSynced: row.validDays > 0,
isOnline: row.periodMileageKm != null && row.periodMileageKm > 0,
sourceProtocol: row.endDateSourceProtocol,
sourceCategory: categories.size > 1 ? 'MIXED' : categories.values().next().value || null,
dataTime: row.endDateDataTime, updatedAt: row.updatedAt, calculatedAt: null,
};
});
}
+11 -1
View File
@@ -3,6 +3,13 @@ export interface CachedVehicle {
plate: string; plate: string;
vin: string; vin: string;
dailyKm: number; dailyKm: number;
periodMileageKm?: number | null;
validDays?: number;
missingDays?: number;
anomalyDays?: number;
qualityStatus?: 'NORMAL' | 'PARTIAL_DATA' | 'NO_DATA' | 'DATA_ANOMALY';
qualityReasons?: string[];
sourceProtocols?: ('GB32960' | 'MQTT' | 'JT808')[];
mileageAnomaly?: string | null; mileageAnomaly?: string | null;
dailyMileage?: Record<string, number | null>; dailyMileage?: Record<string, number | null>;
totalKm: number | null; totalKm: number | null;
@@ -64,6 +71,8 @@ export interface MonitoringStats {
totalAll: number; totalAll: number;
vehicleCount: number; vehicleCount: number;
yesterdayTotal: number; yesterdayTotal: number;
validVehicleCount?: number;
totalMileageVehicleCount?: number;
} }
/** /monitoring 完整响应 */ /** /monitoring 完整响应 */
@@ -71,11 +80,12 @@ export interface MonitoringResponse {
vehicles: CachedVehicle[]; vehicles: CachedVehicle[];
stats: MonitoringStats; stats: MonitoringStats;
filters: MonitoringFilters; filters: MonitoringFilters;
rangeDailyTotals?: { date: string; totalKm: number }[]; rangeDailyTotals?: { date: string; totalKm: number | null }[];
dateRange?: { start: string; end: string }; dateRange?: { start: string; end: string };
total: number; total: number;
page: number; page: number;
totalPages: number; totalPages: number;
queriedAt?: string;
updatedAt: string; updatedAt: string;
sourcePriority: ('instrument' | 'gps')[]; sourcePriority: ('instrument' | 'gps')[];
} }
@@ -1,4 +1,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { fetchVehicleInfoByPlates } from './vehicle-info.js';
import { filterByPermission } from '../../auth/permissions.js';
import type { AuthUser } from '../../auth/types.js';
import { validDailyMileage } from './oneos-model.js'; import { validDailyMileage } from './oneos-model.js';
import { fetchOneOsMileageDates } from './oneos-api.js'; import { fetchOneOsMileageDates } from './oneos-api.js';
import { import {
@@ -60,6 +63,12 @@ app.get('/:plate/recent', async (c) => {
} }
try { try {
const user = c.get('user' as never) as AuthUser | undefined;
if (user) {
const info = await fetchVehicleInfoByPlates([plate]);
const visible = filterByPermission([...info.values()].map(v => ({ ...v, managerId: v.manager_id })), user);
if (!visible.length) return c.json({ error: '无权查看该车辆里程' }, 403);
}
const dates: string[] = []; const dates: string[] = [];
const dateCursor = new Date(start); const dateCursor = new Date(start);
while (dateCursor <= end) { while (dateCursor <= end) {
+12 -3
View File
@@ -57,15 +57,24 @@ export function shanghaiTimestamp(now: Date): string { return new Date(now.getTi
export function monthCutoff(month: string): Date { export function monthCutoff(month: string): Date {
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month)) throw new Error('月份格式无效'); if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month)) throw new Error('月份格式无效');
const [year, m] = month.split('-').map(Number); const [year, m] = month.split('-').map(Number);
return new Date(Date.UTC(year, m, 0, 9)); // 17:00 Asia/Shanghai, on the last day. return new Date(Date.UTC(year, m, 0, 15, 55)); // 23:55 Asia/Shanghai, on the last day.
} }
export function shiftMonth(month: string, offset: number): string { export function shiftMonth(month: string, offset: number): string {
const [year, m] = month.split('-').map(Number); const [year, m] = month.split('-').map(Number);
return new Date(Date.UTC(year, m - 1 + offset, 1)).toISOString().slice(0, 7); return new Date(Date.UTC(year, m - 1 + offset, 1)).toISOString().slice(0, 7);
} }
export function shanghaiDay(now: Date): string { return shanghaiTimestamp(now).slice(0, 10); }
export function dayCutoff(day: string): Date {
const date = new Date(`${day}T23:55:00+08:00`);
if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !Number.isFinite(date.getTime()) || shanghaiDay(date) !== day) throw new Error('日期格式无效');
return date;
}
export function shiftDay(day: string, offset: number): string {
return shanghaiDay(new Date(dayCutoff(day).getTime() + offset * 86_400_000));
}
export function nextCapture(now: Date): string { export function nextCapture(now: Date): string {
const month = shanghaiMonth(now); const day = shanghaiDay(now);
return shanghaiTimestamp(monthCutoff(monthCutoff(month).getTime() > now.getTime() ? month : shiftMonth(month, 1))); return shanghaiTimestamp(dayCutoff(dayCutoff(day) > now ? day : shiftDay(day, 1)));
} }
export function historyTime(value: string | null): string { export function historyTime(value: string | null): string {
if (!value) return '暂无时间'; if (!value) return '暂无时间';