Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ef30215e0 | ||
|
|
e2f932232f | ||
|
|
8afe13c9a3 | ||
|
|
dbe0421bfe | ||
|
|
1674f052d0 | ||
|
|
f107e96d3c | ||
|
|
4dec824285 | ||
|
|
39ecde55d4 | ||
|
|
a065e9fa1d | ||
|
|
80f8d2d92e | ||
|
|
9f89e9f61c |
@@ -0,0 +1,79 @@
|
||||
# 故障管理 BI 设计验收
|
||||
|
||||
final result: passed
|
||||
|
||||
## 最新调整:总览视觉与多维分析对齐
|
||||
|
||||
管理总览复用多维分析的页面色板与标题布局,统一浅蓝背景、白色7px圆角面板、蓝色标题标记、细边框按钮、日期控件和表格。趋势图已闭环/未闭环使用与分布分析一致的蓝/橙色;周报新增/解决仍用蓝/绿语义色。统计和排列顺序不变。
|
||||
|
||||
- 桌面:`../outputs/fault-overview-theme-20261013/desktop.jpg`(1448×1086);手机:`../outputs/fault-overview-theme-20261013/mobile.jpg`(390×844),均已目视检查。
|
||||
- 两页背景均为 rgb(248,251,255),共用主蓝、文字及边框变量;手机四卡132px等高,scrollWidth=390,无横向溢出。
|
||||
- 页签往返、手机日期选择打开/关闭正常,趋势和周报导出入口保留。类型检查、生产构建和diff检查通过;本次验证控制台无新增error/warn。
|
||||
- 未改动数据接口或统计口径;未重复执行数据库测试与下载导出文件。
|
||||
|
||||
## 最新调整:管理总览与处理效率合并
|
||||
|
||||
按用户最新要求,管理总览恢复原来的页面及 `/overview` 统计,依次展示实时汇总、故障处理趋势、故障运维周报;移除独立处理效率页签,保留多维分析、故障明细。以下原参考设计验收为历史记录,管理总览以本段及最新截图为准。
|
||||
|
||||
- 桌面证据:`../outputs/fault-overview-merged-20261013/desktop.jpg`,1448×1086。
|
||||
- 手机证据:`../outputs/fault-overview-merged-20261013/mobile.jpg`,390×844;四卡均为132px,无页面横向溢出。
|
||||
- 验证原今日新增、本日解决、当前在办及累计台账,当前在办下钻27单;页签往返正常,三个统计区同页出现,周报恢复默认业务周。
|
||||
- TypeScript、生产构建、diff检查通过;本次未修改后端统计口径。页面非空、无框架覆盖层,最终验证期间控制台无新增error/warn。
|
||||
- 保留趋势与周报的独立日期、下钻和导出按钮;本次未重复下载导出文件。已清除临时viewport覆盖。
|
||||
|
||||
## 对照依据
|
||||
|
||||
- 来源:`/var/folders/3b/z3fk0dm9419dk7qffdvrwmfh0000gn/T/codex-clipboard-1efea2c0-2930-4600-b725-874c3c2a1f4f.png`
|
||||
- 实现:`http://127.0.0.1:4310/asset#faults`
|
||||
- 桌面截图:`../outputs/fault-dashboard-20261013/desktop.jpg`
|
||||
- 同图左右对照:`../outputs/fault-dashboard-20261013/comparison.jpg`(左参考,右实现)
|
||||
- 局部对照:`../outputs/fault-dashboard-20261013/comparison-filters-kpis.jpg`(筛选、指标、提示条)
|
||||
- 参考和实现各 1448×1086 像素;CSS viewport 1448×1086,DPR 1,无密度缩放。比较图片由两份截图并排组成,展示工具可能缩小,局部图另行检查。
|
||||
- 状态:浅色、管理总览、2026-09-14 至 2026-10-13、全部授权数据。参考使用示例数据,实现使用实际数据库;数值、分类数量和客户名称不要求与示例相同。
|
||||
- 手机:390×844 `mobile.jpg`;320×844 `mobile-320.jpg`。另有 `efficiency.jpg`、`drilldown.jpg`。
|
||||
|
||||
## Findings / 比较与修复历史
|
||||
|
||||
1. [P2,已修复] 现有应用顶部浮动面包屑与新标题重叠。通过仅作用于故障页面的 shell 选择器隐藏重复面包屑;最终桌面、手机截图无重叠。
|
||||
2. [P2,已修复] 第一版分析面板和列表过长,重点跟进过度后移。车型默认前三、客户默认前四、跟进默认前三,提供全部入口;减小图表行距、等级间距和桌面表格单元格内边距。最终 `comparison.jpg` 显示完整指标、分析面板及跟进入口。
|
||||
3. [P2,已修复] 分布图自动跳过部分区域标签。YAxis interval=0;补充柱尾数量,包括未闭环为零的组,保留精确数量表与下钻。
|
||||
4. [P2,已修复] 320px 下标题和导出挤占,原生筛选框太窄。标题操作区换行,筛选单列,指标标题字号适配。`mobile-320.jpg` 显示全部入口及所选值,没有横向页面溢出。
|
||||
5. 最终对照无未解决的 P0/P1/P2。以下为有意保留的业务差异,而非像素复刻:现有导航品牌与功能、页面水印、今日新增/本日解决/累计台账、未标注等级及部位、真实长车型名称和运维处置人第二列。实际内容略长,重点跟进明细需继续向下滚动;避免通过隐藏未知数据或缩小明细字体模拟示例高度。
|
||||
|
||||
## 必查表面
|
||||
|
||||
- 字体:沿用应用中文无衬线回退,桌面标题 24px、面板标题 14px、指标 29px,层级与参考一致;重点检查筛选、数字与窄屏换行。没有以图片模拟文字。
|
||||
- 间距:四指标等高、两行双列分析面板、统一边框与圆角;手机两列指标、分析面板单列,宽表内部横滚。
|
||||
- 颜色:浅蓝页面、白底面板、蓝色主操作;橙色未闭环、红色逾期,未知等级灰色。等级与实际停运含义分开说明。
|
||||
- 图像与图标:参考主要为数据图表。使用现有 Lucide 图标及真实数据图表;沿用产品已有品牌导航,未自行绘制替代参考 ONE OS 标识。无新增位图资产或虚构装饰图。
|
||||
- 文案与数据:使用真实口径和实际分类;“当前在办=待处理+处理中”,逾期为在办子集;“处置记录”避免把 solve_process 当作当前工作流阶段。发生频率缺分母,停运与修复时长缺数据,明确显示待补。
|
||||
|
||||
## 功能与数据验收
|
||||
|
||||
- 新增 3 项后端测试;故障模块与报表导出测试合计 32 项全部通过。
|
||||
- TypeScript 检查、生产构建、git diff --check 通过。
|
||||
- 实库只读核对 63 个分组、48 个车型×部位单元格,汇总与下钻一致;在办 26=17+9,逾期5,本期106,车辆去重89。
|
||||
- 浏览器验证:华东筛选后总览39单,趋势39单,周报新增39/解决125;按月切换及报表下载;等级20单下钻、交叉单元格3单下钻、单条故障明细、十二维分析默认运维处置人/故障工单状态。
|
||||
- 手机更多筛选 L1-特急得到本期1单;其他区域空数据正确显示0和空态。320/390px 页面 scrollWidth 等于 viewport width。
|
||||
- 总览 Excel 下载成功,重新读取工作簿确认6张表、日期/筛选说明、冻结表头和数字106/26与页面一致。
|
||||
- 处理效率保留既有柱图、周报折线图、今日行提示、报表导出与下钻。
|
||||
- 最终验证时间段(08:40 UTC 后)无浏览器 error/warn;开发编辑过程曾有一次已修复的 HMR 导入错误,未计为运行期问题。
|
||||
- 浏览器测试后恢复管理总览、全部筛选,并清除临时 viewport override。
|
||||
|
||||
## Open Questions / 残余限制
|
||||
|
||||
- 故障发生频率需要同期服务车辆规模;实际停运、修复时长需要业务定义和数据来源,未捏造这些指标。
|
||||
- 未进行不同浏览器引擎实机测试;已验证当前 in-app 浏览器桌面和移动断点。
|
||||
- 只读接入,未修改业务数据库;本次改动未提交或推送。
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [x] 参考布局落地并绑定真实数据。
|
||||
- [x] 汇总、业务筛选、分布、矩阵、客户和重点跟进下钻。
|
||||
- [x] 趋势/周报继承筛选并保留导出。
|
||||
- [x] 数据权限、空数据、移动端与导出验证。
|
||||
- [x] 同图对照、局部检查、问题修复和最终证据保存。
|
||||
|
||||
## Follow-up Polish
|
||||
|
||||
- P3:后续如提供统一品牌资产,可单独统一应用级导航标识;当前保持现有产品导航。
|
||||
@@ -0,0 +1,23 @@
|
||||
# AI 问数全量记录
|
||||
|
||||
在部署包含本次代码的服务之前,执行增量建表(只创建审计表,不修改业务数据):
|
||||
|
||||
```sh
|
||||
node --import tsx scripts/migrate-ai-questions.ts # 只查看 SQL,不连接数据库
|
||||
node --import tsx scripts/migrate-ai-questions.ts --apply # 使用部署环境 DB 配置执行
|
||||
```
|
||||
|
||||
表:`bi_asset_ai_questions`。所有已登录、通过参数校验的 `/ask` 请求都先 INSERT,再执行查询;不按问题去重。
|
||||
未登录、空问题、非法请求体不作为用户提问保存。输入中的凭据会脱敏。没有自动清理策略;旧的需求缺口表仍按原有 90 天规则维护。
|
||||
|
||||
- `event_type`:question 首次提问、followup 连续追问、action 下钻操作、feedback 未解决反馈。
|
||||
- `business_types`:JSON 数组,使用既有计划分类(inventory 库存、operating 运营、pending 待交车、abnormal 异动、flow 交还替换等);综合问题可以有多个分类,无法完成理解时为空数组。
|
||||
- `outcome`:processing 处理中、success 成功、empty 无匹配、partial 部分回答、clarification 待澄清、help 使用说明、unsupported 暂不支持、failed 失败、cancelled 取消、rejected 拒绝执行、feedback 用户反馈。
|
||||
- `reason`:失败/拒绝/缺口原因代码,例如 busy、session_expired、generation、unsatisfied。
|
||||
- `actor_id` 是实际操作者;`user_id`、`login_name`、`user_name` 是当前数据权限用户;模拟视角另有 `is_preview` 与 `preview_session_id`。
|
||||
- `question` 保存接受的完整问题(最多 2000 字符,敏感凭据脱敏),`action_json` 保存下钻参数。会话、上级结果、当前结果、公司和权限级别分别关联。
|
||||
- `answer` 保存回答摘要、提示和子分析摘要,不复制车辆明细数据;另存引擎、开始/结束时间、毫秒耗时。
|
||||
|
||||
初始写入失败返回 503,不继续执行无记录的查询。结束更新失败重试一次;原始问题仍在数据库,回答提示“处理结果暂未保存”,服务只输出记录 ID,不输出问题或数据库错误。进程突然终止或结束更新失败的记录保持 processing,可通过 started_at 识别长期未完成,不能将其算作成功。
|
||||
|
||||
模拟用户提问同样记录,但不新增需求缺口反馈,也不改变原有只读业务权限。没有新增公开查询接口;数据库查看权限沿用现有管理制度。新表不能补回此前仅在内存中保存的历史问题。
|
||||
@@ -0,0 +1,101 @@
|
||||
# 故障管理
|
||||
|
||||
2026-10-13:故障管理为左侧独立业务入口 `/asset#faults`,与资产管理并列。首页展示四项总统计及故障趋势统计,不展示旧版维度排行或常驻故障明细。点击任一卡片进入多维分析,再进入故障明细。
|
||||
|
||||
## 四项指标
|
||||
|
||||
| 指标 | 口径 |
|
||||
| --- | --- |
|
||||
| 今日新增 | `fault_report_time` 在北京时间今日自然日内 |
|
||||
| 本日解决 | 状态已归档且 `archived_at` 在北京时间今日内,与提报日无关 |
|
||||
| 当前在办 | 全部待处理、处理中故障,不限制提报日期;按最新确认,在办 = 待处理 + 处理中 |
|
||||
| 累计台账 | 全部有效故障台账,包括归档、未闭环和其他状态 |
|
||||
|
||||
「当前在办」卡片以“待处理 + 处理中”展示构成,逾期以总量旁的“逾期”独立标记,均使用故障多维分析与故障明细下钻。按最新确认,在办仅为待处理与处理中之和,挂起保留在累计台账;旧兼容接口的全部未闭环口径保持不变。
|
||||
|
||||
- 待处理/处理中:按 OneOS 标准化任务状态分别统计,兼容待处置、未处理、处置中等历史值。
|
||||
- 逾期:待处理或处理中,且保存的 `deadline_at` 严格早于本次数据时间。按北京时间解释截止时间,挂起和已归档不计入;无截止时间不自行以提报时间或固定 30 天推算。与 OneOS `selectSupervisorRows.overdueCount` 汇总口径一致,存储的截止时间包含业务端 SLA 配置及挂起顺延结果。
|
||||
- 逾期是当前在办的重叠子集,不与待处理、处理中相加。下钻明细不展示处置截止时间,也不提供该字段的统计维度;该字段仅保留用于逾期计算。
|
||||
- 四张卡片使用统一紧凑高度:桌面 164px、移动端 132px。
|
||||
|
||||
所有数量以故障主键去重、单位为“单”;同车多故障分别统计。有效范围为 `vehicle_fault_manage.del_flag='0' AND is_submit IN (0,1)`。保留退出运营、缺失车辆关联的历史故障;缺失日期不以创建时间补齐。
|
||||
|
||||
## 下钻
|
||||
|
||||
在资产管理九个维度(车型、型号、区域、城市、部门、业务负责人、客户、资产用途、运营状态)基础上,增加独立的「运维处置人」「车牌」「故障工单状态」,共十二个维度,「运维处置人」固定为第二个维度选项,「车牌」为第三个。车牌取故障台账车牌,同车多故障按故障单计数,缺失车牌归入「未关联车辆」。支持组合分组、多选筛选、合并单元格、组内小计、排序、筛选此组、故障明细搜索和分批加载。运维处置人与业务负责人独立,按人员 ID 分组,同名不同 ID 不合并;多人共同处置分别计入各人,总数、上级小计和明细按故障去重,人员行不可直接相加。
|
||||
|
||||
车辆相关维度使用车辆当前资料,运维处置人取故障或关联工单的当前处置分配。城市使用 OneOS `vehicle_info.operation_city` 对应的城市,不使用车机 GPS;区域采用 Asset BI 的宏观区域映射。客户/部门/业务负责人取每车最新 `vehicle_lease_order_record`,按 `last_delivery_time DESC,id DESC`,型号与车型复用资产管理的别名和分类函数。无匹配资料保留未知组。客户键采用散列,展示名称脱敏,不因相同脱敏名称合并不同客户。
|
||||
|
||||
明细第一列主显示车牌、辅显示故障单号,第二列为运维处置人;其后依次展示故障描述、状态、提报时间、归档时间、运营城市、客户 / 部门。处置人来源优先级:有效来源任务工单多人→任务工单单人→故障多人→故障单人或历史姓名;用户 BIGINT ID 保持字符串精度,停用/删除人员保留历史姓名。
|
||||
|
||||
## 权限与接口
|
||||
|
||||
- `GET /api/asset-faults/overview`:北京时间日期、数据时间、四项总数。
|
||||
- `GET /api/asset-faults/analysis?metric=reported|closed|open|waiting|processing|suspended|overdue|ledger`:所选指标的授权记录及分析单元,供前端在同份数据上分组和下钻。
|
||||
- 旧 `GET /api/asset-faults` 查询接口保留兼容,首页不再使用。
|
||||
|
||||
接口以只读一致性事务读取数据,不修改 OneOS 业务表,不运行定时任务或迁移。汇总及下钻先应用既有全量/部门/本人权限;历史故障不继承公共库存的权限放宽规则;缺车辆资料仅全量权限可见。无共享响应缓存,HTTP `no-store`。401 未认证、400 无效参数、503 查询失败。首页和下钻分别读取最新数据,实时变化可能造成短暂数量差异。
|
||||
|
||||
源码依据(`oneos/ln-asset-management`):`FaultLedgerStatus`、`FaultLedgerQuerySupport`、`FaultManualCreateSupport`、`FaultHandlerAssignmentService.resolveForSupervisorBatch`、`VehicleFaultDispositionServiceImpl.archiveNow`、`VehicleFaultDispositionMapper.xml`、`VehicleLeaseOrderRecordMapper.xml`。
|
||||
|
||||
## 验证
|
||||
|
||||
故障模型与路由测试覆盖北京时间午夜边界、跨日归档、全部日期在办、累计范围、权限隔离、客户脱敏、同车多故障、资产维度映射及总数与明细一致。同步运行导航、资产分组/筛选/合并单元格回归、TypeScript检查、生产构建及桌面/手机 Playwright 交互验证。仅本地开发,未部署。
|
||||
|
||||
## 故障趋势统计
|
||||
|
||||
复用交还车的日期选择器,默认北京时间近30天,支持自选日期(最多370天)、按日/按月切换。以 `fault_report_time` 划分新增批次,统计同一批故障截至查询时刻的已闭环(已归档)和未闭环(其他状态,含挂起)。不将该日归档的其他批次故障混入,归档时间可在提报区间之后。
|
||||
|
||||
堆叠柱总高度为新增数,新增 = 已闭环 + 未闭环。零新增周期补零;无提报时间不进入趋势;月聚合严格保持起止边界,不扩展整月。每日/每月统计表按新日期在前展示新增、已闭环、未闭环和闭环率(零分母显示横线)。汇总与周期数字均可下钻到故障多维分析及故障明细。点击柱图内已闭环 / 未闭环色块,按对应日或月和状态下钻;趋势下钻默认按「运维处置人 → 故障工单状态」分组,重置也恢复这两个维度,按月仍保留所选日期边界。
|
||||
|
||||
接口 `GET /api/asset-faults/trend?start=YYYY-MM-DD&end=YYYY-MM-DD&granularity=day|month` 返回区间汇总与周期序列。`GET /api/asset-faults/trend-analysis` 使用同样区间及粒度,可附 `period=YYYY-MM-DD|YYYY-MM` 和 `status=all|closed|unclosed`。两个接口均先应用原有数据权限,范围/粒度/周期校验在查询数据库前执行。总览和下钻按请求分别读取最新数据。
|
||||
|
||||
## 故障运维周报
|
||||
|
||||
周报复用故障处理趋势的日期选择器,默认业务周(周六至周五),支持自选最多370天。按最新要求仅展示「新增故障」「解决故障」两项区间合计、日期倒序的每日数量及表尾合计,均可下钻。汇总下方增加每日折线图,蓝色实线表示新增故障,绿色实线表示解决故障,与日期选择器、每日统计表使用同一份数据;补齐零值日期、纵轴从零开始,保留表格提供精确数量与下钻。
|
||||
|
||||
- 新增故障:按 `fault_report_time` 当日计单。
|
||||
- 解决故障:已归档故障按 `archived_at` 当日计单,不受提报日期限制。
|
||||
|
||||
已移除接收处理汇总、每日列、说明及故障明细中的接收时间列。现有接口暂保留接收指标兼容能力,页面不使用;不更改业务数据。
|
||||
|
||||
接口:`GET /api/asset-faults/weekly-report?start=YYYY-MM-DD&end=YYYY-MM-DD`;下钻 `GET /api/asset-faults/weekly-report-analysis` 传 `metric=reported|closed`,可附 `day=YYYY-MM-DD`。所有计数和下钻均先应用既有业务数据权限,沿用只读事务及 HTTP no-store。
|
||||
|
||||
页面时间标签统一为「解决时间」,底层仍取 `archived_at`,统计口径不变。周报折线图仍按时间正序,采用平滑实线、顶部图例、浅网格与统一悬浮提示。
|
||||
|
||||
故障工单状态独立于车辆运营状态:已归档为「已闭环」,其他状态(含待处理、处理中、挂起)均为「未闭环」,与故障处理趋势口径一致。所有故障下钻入口均可按该维度分组、多选筛选、查看明细。
|
||||
|
||||
所有下钻明细按提报时间倒序展示,无提报时间排在末尾;同一提报时间按故障 ID 倒序稳定排序。先筛选、排序,再分页,搜索和加载更多保持该顺序。
|
||||
|
||||
客户展示:已关联车辆且当前运营状态为库存、客户为空时,客户分组、筛选和明细显示「库存车」(独立键 `inventory-unlinked`);非库存或缺失车辆资料的空客户仍为「未关联客户」。有客户名称时仍按原客户展示和脱敏。此调整不改业务数据及权限。
|
||||
|
||||
下钻维度和筛选按入口保存在当前页面内存中;关闭、误点遮罩或 Esc 后重新打开恢复已应用设置。各入口互不覆盖,桌面与手机共用。点击重置恢复该入口默认维度并清空筛选;刷新整个页面后全部清除,不写入 localStorage 或跨刷新存储。每次重开仍重新请求实时数据。
|
||||
|
||||
## 报表导出
|
||||
|
||||
故障处理趋势和故障运维周报标题右侧均提供「导出 Excel」。导出当前已加载且经过权限过滤的统计快照;日期范围、趋势按日/按月粒度与页面一致,不重新查询或扩大数据范围。加载中及请求失败时禁用导出,生成文件期间显示进度状态并防止重复点击,失败可重试。
|
||||
|
||||
- 趋势:提报日期/月份、新增故障、已闭环、未闭环、闭环率,包含全部统计日期,按日期倒序。
|
||||
- 周报:日期、星期、新增故障、解决故障,按日期倒序;不导出接收处理指标。
|
||||
- XLSX 包含带标题、范围、数据时间、汇总指标的统计表;当前有图表时附加「图表」工作表,以图片保存页面图表快照并保持比例。图表为静态快照,修改统计表不会联动图表。
|
||||
- 统一深色表头、指标配色、交替底色、今日浅色标记;真实 Excel 日期和数字格式、合计及闭环率公式、冻结表头、筛选与打印设置。ExcelJS 按导出操作动态加载。
|
||||
|
||||
## 风险与服务分析总览(2026-10-13)
|
||||
|
||||
故障管理按最新确认保留「管理总览 / 多维分析 / 故障明细」三个页签。管理总览与处理效率合并,恢复原有页面及统计:今日新增、本日解决、当前在办(待处理 + 处理中,逾期为子集)、累计台账,沿用 `/overview` 接口;下方直接展示原有故障处理趋势与故障运维周报。趋势默认近30天,周报默认周六至周五,分别选择日期、导出和下钻。管理总览不受其他页签的业务筛选影响。多维分析、故障明细保留新增分析能力及统一日期、区域、客户、品牌车型查询;更多筛选包含城市、业务部门、运维处置人、故障程度、故障部位、库存状态,点击查询生效。
|
||||
|
||||
- 期间新增故障:所选提报日期内的有效故障。卡片及导出仅展示期间新增总量,不再拆分提报来源。对比上期采用紧邻本期、天数相同的区间;上期为零时显示无可比基数。
|
||||
- 受影响车辆:同批故障按关联车辆 ID 去重,缺 ID 时使用故障车牌;两者都缺失的不计车辆数,单列数据完整性提示。车辆跨组可能重复,不直接累加。
|
||||
- 当前在办:已应用业务筛选下,所有日期的待处理 + 处理中。逾期为其子集;最长逾期按截止时间到数据时间的天数向上取整。
|
||||
- 区域、等级、客户、车型分布和车型×部位矩阵:按本期提报队列统计,闭环状态截至更新时间。一单多部位分别计入单元格,车型合计按单去重。
|
||||
- 客户影响表:本期影响车辆去重;同一客户的当前在办不限提报日期。库存空客户单列库存车。仅展示本期存在故障的客户组。
|
||||
- 重点跟进:只展示 L1-特急且未闭环(状态非已归档,含挂起等)的故障,沿用权限及业务筛选,不限制提报日期。逾期优先,同优先级按提报时间倒序,最多展示5单;统计标签使用同一 L1 未闭环范围,没有符合条件的故障时显示空态。“查看全部”始终可用,进入当前权限内的全部故障台账,清除业务筛选和搜索条件,不限等级、状态及提报日期。完整明细可切换本期、在办、逾期及累计台账,搜索车牌、单号、描述或运维处置人。
|
||||
|
||||
新增只读字段:`fault_level`、`fault_categories`、`solve_process`。等级映射经 OneOS 字典核对:1=L1-特急、2=L2-紧急、3=L3-一般、4=L4-提示;缺失等级/部位独立显示未标注。处置记录使用 `solve_process`,不将 AI 流转字段 `process_status` 当作业务处理进度。没有同期服务车辆规模、实际停运和修复时长依据,发生频率禁用并提示缺少分母,停运和修复时长提示待补数据。
|
||||
|
||||
新增接口:
|
||||
- `GET /api/asset-faults/dashboard?start=YYYY-MM-DD&end=YYYY-MM-DD`:授权范围内汇总、筛选项、分布、交叉矩阵、重点跟进和完整性信息。
|
||||
- `GET /api/asset-faults/dashboard-analysis`:同样日期和业务筛选;`scope=focus|cohort|open|waiting|processing|overdue|ledger|reported|closed`。focus 为全部提报日期的 L1 未闭环故障;cohort 为提报日期队列,reported/closed 为北京时间今日,其他为全部日期当前状态;可附 `id` 查看单条记录。
|
||||
- 趋势、周报及其下钻接口同时支持上述业务筛选。所有接口先执行权限筛选,筛选项也仅来自授权记录,保持 no-store,不写业务数据库。
|
||||
|
||||
总览导出新增 6 张 Excel 工作表:管理总览、区域分布、故障程度、客户影响、车型分布、车型与部位。导出已加载统计快照,包含日期、业务筛选、数据时间、冻结表头、交替底色和数字格式。趋势、周报的既有导出保留,并增加业务筛选说明。
|
||||
@@ -0,0 +1 @@
|
||||
period_month,scheduled_for,captured_at,definition_version,source_reference,vehicle_id,plate_number,vin,operation_status,vehicle_status,subject_org,department_name,manager_id,manager_name,vehicle_type,model,brand,color,ownership,rent_company,province,city,contract_no,customer_name,contract_org,project_name
|
||||
|
@@ -0,0 +1,179 @@
|
||||
## 当前展示口径(2026-10-24 更新)
|
||||
|
||||
按用户最新要求,停止展示交还记录推算的历史资产数据。正式路由只提供当前实时数据和真实归档快照,已取消 `ASSET_HISTORY_RECONSTRUCTION_FILE` 的接入;即使保留旧环境变量,也不能通过趋势或明细接口读取回溯文件。旧核对表和私有回溯文件保留用于离线核查,不删除、不写入正式快照。
|
||||
|
||||
2026年10月前的缺失期间返回空值,不补零、不提供估算明细。页面仅显示一句提示:“历史库存状态快照缺失,核对中,暂不展示。” 当前10月继续读取实时数据;今后只有采集成功的真实快照才能归档展示。服务端仍返回存储和采集状态供运维检查,页面不再重复展示部署及回溯说明。
|
||||
|
||||
本地验收服务同步停用回溯文件。生产迁移、部署和自动采集仍未执行;该展示调整不等于采集已经启用。下文的回溯回填章节保留为历史实施记录,其环境变量接入和页面展示说明已被本节取代。
|
||||
|
||||
# 资产历史趋势
|
||||
|
||||
资产概览顶部指标之后新增历史趋势。月度选择年份,年度每年一个年末点;运营和库存为堆叠柱,总数为折线。下方期间按钮支持键盘和触摸选点,月度明细可分类、搜索车牌/VIN及分页。
|
||||
|
||||
## 口径及时间
|
||||
|
||||
- 复用资产仓储 `MAIN_SQL`、`transformRow`、`isOperatingVehicle`、`isInventoryVehicle`。
|
||||
- 总数为未删除、未退出运营的车辆;运营为租赁/自营,库存为可运营/待运营;异动、待交车不另加。
|
||||
- 每天 `Asia/Shanghai` 23:55 为计划时点,归属当天;只允许在 23:55:00(含)到 23:56:00(不含)内开始读取。每天独立保存完整逐车状态;月度展示月末当天、年度展示12月31日快照,不累加每日存量。
|
||||
- `captured_at` 是程序记录的实际开始读取时间,`scheduled_for` 是计划时点;两者不宣称完全相同。
|
||||
- 一致性读取可在窗口内开始后继续执行;持久化失败最多重试三次,始终使用同一份已取得数据,不重新读晚到的台账。
|
||||
- 当月在截止前显示实时点;年末前当年年度点显示实时点。截止后只能显示归档、归档中、失败或缺失。12 月快照直接用作年度年末点。
|
||||
- 实时点每五秒走 `/live`,不重读全部历史;切换年份/公司/模式、手动刷新、跨月或归档边界时更新历史。
|
||||
- 实时数据沿用现有资产仓储的五秒缓存,实时 `asOf` 为本次服务端查询时间,不代表源台账所有记录的最后修改时间;固定快照绕过该缓存。
|
||||
- 缺失及未来数量为 `null`,合法零数量为 `0`,不插值、不跨缺失点连线。比较只取相邻期间;一月使用上年十二月作为基期。
|
||||
- 默认年度显示最近五年;更早有归档或导入时自动扩展年份。月度允许查询 2000 年至当前年份。
|
||||
|
||||
## 权限及历史字段
|
||||
|
||||
本功能是资产存量统计。实时和归档都复用 `filterAssetVehiclesByPermission`:库存对账号公共可见,其余车辆沿用全量、部门或个人权限。公共库存按所查询时点的运营状态判断;历史公司、部门和负责人取快照,角色及个人身份别名取当前认证系统。公司筛选在权限过滤范围内取 `subjectOrg`。
|
||||
|
||||
不连接今日车辆列表补字段,不因车辆退出、删除或转部门丢失历史。当前来源的部门权限使用部门名称,快照如实保存该名称,不虚构来源没有的部门 ID。未来源系统若增加稳定部门 ID,需版本化升级口径。
|
||||
|
||||
归档汇总从授权后的同一批逐车数据计算,不返回批次全量计数。明细客户名称复用既有脱敏。历史公司仅出现在有权限的相关历史数据中;顶部公司列表中的历史独有公司标注“历史主体”,不改变现有实时公司接口含义。权限视角切换会由现有认证容器重建页面并取消旧请求。
|
||||
|
||||
## 存储
|
||||
|
||||
`bi_asset_history_settings.enabled_at` 保留旧采集首次启用时间;`daily_enabled_at` 保存每日采集首次实际启用时间。重复启用保留首次时间,升级前的日期不会被标记为漏采。
|
||||
|
||||
`bi_asset_history_batches` 保存期间键、口径版本、计划及实际时间、来源、状态、失败码、全量校验计数及导入追溯信息。`period_key` 与口径版本唯一;新快照使用 YYYY-MM-DD,旧月度 YYYY-MM 键和明细原样保留。月度查询仅选月末日及旧月度记录;同月优先新日快照,但失败或缺失不会遮蔽旧 COMPLETE 归档。正式归档为 `COMPLETE`,失败为 `FAILED`,错过为 `MISSED`。
|
||||
|
||||
`bi_asset_history_vehicles` 保存车辆字符串 ID、原始运营/车辆状态、归档分类、公司、部门、负责人 ID 和冻结车辆 JSON。批次内车辆唯一;不以当前车辆表为外键。JSON 保留车牌、VIN、型号、品牌、业务归属等明细信息,排除无关的异动工作详情和待交车附件信息。
|
||||
|
||||
采集用 MySQL 同一连接的命名锁防止多实例并发,以只读一致性事务执行原始 SQL,再以独立事务原子发布头表、全部明细和 COMPLETE 状态。窗口外不读源数据。读接口、普通模块导入和只读预览不建表、不采集。读到不存在的表时显示部署提示并保留当前实时点;真实数据库故障返回错误,不伪装成空数据。
|
||||
|
||||
归档读取会校验车辆唯一性、分类、明细计数和批次计数,不把损坏的明细显示为合法零快照。已提交但提交确认丢失时,重试先检查 COMPLETE,不覆盖已发布数据。
|
||||
|
||||
## API
|
||||
|
||||
全部接口经现有认证和权限预览中间件,响应 `Cache-Control: private, no-store`。
|
||||
|
||||
| GET | 参数 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `/api/asset-history/trend` | `mode=monthly\|annual`、`year`、`subject` | 趋势点、相邻差值、可选年份及相关历史公司 |
|
||||
| `/api/asset-history/live` | `mode`、`subject` | 仅更新当前实时点,不查历史明细 |
|
||||
| `/api/asset-history/details` | `month=YYYY-MM`、`batchId`、`subject`、`category=all\|operating\|inventory`、`search`、`page`、`pageSize` | 月度明细,默认 50 台,最大 100 台;页面每页 30 台 |
|
||||
|
||||
归档明细入口附带批次 ID,服务端同时核对月份。没有可用快照返回 409,不回退到实时台账。400 为参数错误,401 为未认证,503 为读取或数据质量异常。没有 HTTP 采集、写入和导入接口。
|
||||
|
||||
## 迁移与启用
|
||||
|
||||
默认关闭:`ASSET_HISTORY_CAPTURE_ENABLED` 只有精确设为 `1` 才启动采集。迁移不等于启用,迁移本身不会写入首次启用时间。
|
||||
|
||||
在项目根目录审阅迁移(不连接数据库):
|
||||
|
||||
```sh
|
||||
node --import tsx scripts/migrate-asset-history.ts
|
||||
```
|
||||
|
||||
取得目标数据库迁移授权后,使用受控环境变量配置正确数据库,再显式执行:
|
||||
|
||||
```sh
|
||||
node --import tsx scripts/migrate-asset-history.ts --apply
|
||||
```
|
||||
|
||||
如部分建表失败,修正原因后可重复执行;脚本使用 `CREATE TABLE IF NOT EXISTS`。新建空表不会伪造以前历史。
|
||||
|
||||
每日采集升级(2026-10-30):先停止旧版采集服务,执行基础建表迁移(已有表不变),再审阅并执行一次增量迁移:
|
||||
|
||||
```sh
|
||||
node --import tsx scripts/migrate-asset-history.ts --daily
|
||||
node --import tsx scripts/migrate-asset-history.ts --daily --apply
|
||||
```
|
||||
|
||||
增量迁移把 `period_month CHAR(7)` 改为 `period_key CHAR(10)`,原唯一索引继续约束期间键和版本;新增可空 `daily_enabled_at`。这是一次性 ALTER,不能重复执行。应在目标库完成迁移后启动新版服务并设置采集开关;旧版程序不能直接使用新字段名。迁移不启动采集,也不修改旧归档的计划/实际时间。当前页面仍提供月度和年度浏览,普通日期快照保存在数据库中。
|
||||
|
||||
|
||||
授权启用后,将服务进程环境变量设为 `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
|
||||
SELECT enabled_at FROM bi_asset_history_settings WHERE id=1;
|
||||
SELECT period_key, scheduled_for, captured_at, completed_at,
|
||||
definition_version, source, status, failure_code,
|
||||
total_count, operating_count, inventory_count
|
||||
FROM bi_asset_history_batches ORDER BY period_key DESC LIMIT 31;
|
||||
SELECT b.period_key, b.total_count, COUNT(v.vehicle_id) AS detail_count
|
||||
FROM bi_asset_history_batches b
|
||||
LEFT JOIN bi_asset_history_vehicles v ON v.batch_id=b.id
|
||||
WHERE b.status='COMPLETE'
|
||||
GROUP BY b.id, b.period_key, b.total_count;
|
||||
```
|
||||
|
||||
## 回退
|
||||
|
||||
1. 设置 `ASSET_HISTORY_CAPTURE_ENABLED=0` 并重启服务,停止后续采集。
|
||||
2. 回退应用代码;已有归档表可原样保留,不影响旧代码。
|
||||
3. 若确需撤销表,先备份并取得数据库删除授权;依次删除明细表、批次表、设置表。默认不删除任何归档数据。
|
||||
|
||||
读接口不自动建表,因此回退后也不会被请求重新创建。再次上线可继续读取既有归档;首次启用时间不自动重置。
|
||||
|
||||
## 后续逐车导入契约(本次不提供导入写接口)
|
||||
|
||||
提供仅表头模板 `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` 必须来自可信历史来源,不能把导入执行时间当作历史读取时间。
|
||||
|
||||
其余字段见模板。部门、负责人、主体缺失须显式留空,不能用今日台账补齐;部门/个人权限因此可能看不到未归属的运营车辆,这是数据缺失而非自动扩大授权的理由。展示型号、品牌、原始车辆状态按历史来源保存;历史状态映射复用现有分类函数,不接受退出运营车辆或未知运营状态作为有效存量。归档分类由导入校验程序计算,不直接信任文件中的分类列。
|
||||
|
||||
状态映射:`1`/`租赁`、`2`/`自营`属于运营;`3`/`可运营`/`在库`、`4`/`待运营`属于库存;`5`/退出运营及未知值拒绝计入存量。`vehicle_status` 为历史原值,待交车或异动不改变上述归档分类。展示用 `status` 应按现有 `mapStatus` 生成。
|
||||
|
||||
后续导入器必须先提供 dry-run:校验日期和时区、来源、未来时间、车辆唯一性、状态、权限所需字段、计数对平及同年月冲突,再原子发布来源为 IMPORT 的批次和逐车数据。`import_provenance` 至少保存文件校验和、来源引用、导入人、导入时间、校验报告与修订原因;不要在日志保存原始敏感明细。
|
||||
|
||||
现有 COMPLETE 批次不自动覆盖;相同口径同月冲突应拒绝。修订需新增可追溯版本并明确有效版本切换及跨版本读取策略,不直接改已归档车辆。版本切换功能属于后续导入工作,本次只提供存储空间及契约。
|
||||
|
||||
仅三个汇总数字的文件不符合逐车导入规范,不能提供个人/部门历史明细。若将来支持汇总导入,需另设权限和“无明细”模型。
|
||||
|
||||
## 交还业务回溯的本地回填
|
||||
|
||||
2026-10-19 按用户确认的回溯核对表,将 **2022-06 至 2026-09 共52个月、22,319条车辆月度记录** 发布到本地私有 JSON 文件。原始核查资料截至北京时间 `2026-10-19 14:38:33`。2026-10 未结期不回填,继续走当前实时查询。未向真实数据库建表、插入或更新。
|
||||
|
||||
这批数据保留 `HANDOVER_REVIEW_V1` 方法、核对表文件名与 SHA-256、原始核查时间、逐车交还单号、业务时间、分类依据及未判状态数量。车辆 ID 使用字符串,候选车辆的车牌/VIN来自交还单原值;未用当前台账补公司、部门、负责人、客户或未知运营状态。
|
||||
|
||||
它是独立的 **RECONSTRUCTED(历史回溯·估算)** 来源,不发布为 `AUTO` / `IMPORT` 正式快照,也不填写月末实际采集时间。`counts` 为 `null`;运营/库存推算数、未分类数、当前已退出子集和来源信息在 `point.reconstruction` 中返回。总数折线保持空白,页面明确显示“总数待核实”。正式归档始终优先,不覆盖已有归档或归档失败诊断。
|
||||
|
||||
权限单独收紧:只有当前全量权限且未选择公司时可读取这批估算。它缺少可靠历史主体和个人/部门归属,不能按今天的车辆权限、公司或库存状态补足历史授权。部门、个人及公司筛选继续显示原有正式快照或空白;直接访问回溯明细、篡改批次或公司参数也不能放宽权限。正式归档和实时数据原有权限规则不变。
|
||||
|
||||
年度仅复用各年12月的回溯记录。相邻两个回溯点可比较推算运营/库存,不计算总数差值;回溯点与实时或正式快照不作差,防止把不同覆盖范围的差异显示为业务增长。回溯明细支持分类、搜索、分页,汇总与逐车记录在加载前对平;未知归属明确标为“历史未提供”。
|
||||
|
||||
显式本地发布命令(没有数据库依赖,默认只校验):
|
||||
|
||||
```sh
|
||||
node --import tsx scripts/backfill-asset-reconstruction.ts --input /private/reviewed-reconstruction.json
|
||||
node --import tsx scripts/backfill-asset-reconstruction.ts --input /private/reviewed-reconstruction.json --output /private/asset-history/reconstruction-v1.json --apply
|
||||
```
|
||||
|
||||
数据文件格式为 `ReconstructionData`(`src/server/routes/asset-history/reconstruction.ts`)。必须提供来源及文件校验和,每月车辆唯一、逐车分类与计数对齐、参考月份早于原始核查时间。合法的0与未知总数区分。发布使用私有文件权限和原子创建;相同内容幂等,不同内容拒绝覆盖。文件不能放入公开静态目录或源码目录。
|
||||
|
||||
启动时显式设置 `ASSET_HISTORY_RECONSTRUCTION_FILE` 为已发布文件的绝对路径;没有该变量时不读取估算数据。不会启用自动采集、初始化快照表或运行其他后台任务。读取结果在进程内缓存;修订时保留原文件和核对表,发布到新文件后更新变量并重启。回退时移除该变量并重启,正式归档及实时数据不受影响。本地验收启动脚本单独注入该变量,回退需同步移除该脚本的配置。
|
||||
|
||||
本地验收地址仍为 `http://127.0.0.1:3000/asset#assets`。当前仅本地回填,生产未部署、采集未启用;正式存储迁移及生产回填仍需明确指定目标和生产授权。
|
||||
|
||||
## 验证入口
|
||||
|
||||
```sh
|
||||
node --import tsx --test src/server/routes/asset-history/*.test.ts src/modules/assets/history/*.test.ts
|
||||
npm run lint
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
单元、接口和事务故障测试只用模拟仓储与事务数据库替身,不访问真实数据库。开发启动若加载现有 `src/server/index.ts`,会执行其他模块的后台初始化;验收应使用隔离服务、不导入 bootstrap,并阻断所有数据库连接,避免顺带运行现有后台任务。
|
||||
|
||||
## 本次交付验证(2026-10-19)
|
||||
|
||||
- 新增 32 项针对性测试,全量 452 项通过;`npm run lint`、`npm test`、`npm run build`、`git diff --check` 均通过。
|
||||
- 构建仍报告部分现有重依赖分包超过 500 kB 的体积提示;不影响构建,本次不扩展为全站拆包重构。
|
||||
- 使用已有 Playwright Chromium(当前会话没有 Browser 插件),在 `http://127.0.0.1:3146/asset#assets` 隔离服务验收;测试服务未导入 bootstrap,数据库入口被阻断。该地址为临时验收环境,非生产部署。
|
||||
- 1440×1200、390×844 验证交互,补充 390×1400 截图。13 组浏览器检查通过:页面身份/非空/无框架错误、月度归档及明细、分页搜索、年度、缺失/未来/零值、公司筛选、请求竞态、实时刷新不重读历史、部门及个人权限、存储未部署、503 重试、原有资产分析、手机触摸及无横向溢出。
|
||||
- 浏览器无未解释的控制台错误或警告;模拟 503 的错误属于已验证的故障注入。最终验收记录中数据库访问尝试为 0。
|
||||
- 参考图的卡片、堆叠柱/折线和选中期间指标保留;历史曲线按真实归档缺失留空,不复制示例数值。顶部现有资产样式保留;手机明细使用卡片而非横向表格。
|
||||
- 迁移审阅命令已运行,未执行 `--apply`。事务、锁、故障和提交确认丢失在隔离数据库替身中验证,未向真实 MySQL 实例执行 DDL 或采集。生产迁移和启用需按上述授权步骤执行。
|
||||
- 当前交付状态:本地实现完成,未发布,自动采集默认关闭;正式逐车快照导入仅提供契约和模板。用户确认的交还业务回溯另用上述显式本地文件发布入口,不混入正式快照。
|
||||
- 回溯回填补充验证:新增8项测试,全量460项通过;类型检查与构建通过。52个月运营/库存与核对表逐月对齐,API明细分类数及分页核对通过。1440×1200、390×844完成月度、年度、明细、搜索分页、触摸、焦点关闭、公司范围及缺失月份验收;无未解释控制台错误、无横向溢出。本地只读保护记录的写入尝试为0,采集及后台任务均关闭。
|
||||
@@ -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,31 @@
|
||||
-- Explicit additive migration. No business tables are changed and no historical records are purged.
|
||||
CREATE TABLE IF NOT EXISTS bi_asset_ai_questions (
|
||||
id CHAR(36) PRIMARY KEY,
|
||||
actor_id VARCHAR(64) NOT NULL,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
login_name VARCHAR(255) NOT NULL,
|
||||
user_name VARCHAR(255) NOT NULL,
|
||||
permission_level VARCHAR(24) NOT NULL,
|
||||
is_preview TINYINT(1) NOT NULL DEFAULT 0,
|
||||
preview_session_id VARCHAR(64) NULL,
|
||||
session_id VARCHAR(64) NULL,
|
||||
base_result_id VARCHAR(64) NULL,
|
||||
result_id VARCHAR(64) NULL,
|
||||
subject VARCHAR(150) NULL,
|
||||
event_type VARCHAR(24) NOT NULL,
|
||||
question TEXT NULL,
|
||||
action_json JSON NULL,
|
||||
outcome VARCHAR(24) NOT NULL,
|
||||
reason VARCHAR(64) NULL,
|
||||
business_types JSON NULL,
|
||||
engine VARCHAR(24) NULL,
|
||||
answer MEDIUMTEXT NULL,
|
||||
started_at DATETIME(3) NOT NULL,
|
||||
finished_at DATETIME(3) NULL,
|
||||
duration_ms BIGINT UNSIGNED NULL,
|
||||
KEY user_time (user_id, started_at),
|
||||
KEY actor_time (actor_id, started_at),
|
||||
KEY result_time (outcome, started_at),
|
||||
KEY session_time (session_id, started_at),
|
||||
KEY event_time (event_type, started_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Explicit migration only; API reads and module imports never create storage.
|
||||
CREATE TABLE IF NOT EXISTS bi_asset_history_settings (
|
||||
id TINYINT NOT NULL PRIMARY KEY,
|
||||
enabled_at DATETIME(3) NOT NULL,
|
||||
CONSTRAINT chk_asset_history_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bi_asset_history_batches (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
period_month CHAR(7) NOT NULL,
|
||||
definition_version VARCHAR(32) NOT NULL,
|
||||
scheduled_for DATETIME(3) NOT NULL,
|
||||
captured_at DATETIME(3) NULL,
|
||||
completed_at DATETIME(3) NULL,
|
||||
timezone VARCHAR(32) NOT NULL DEFAULT 'Asia/Shanghai',
|
||||
source ENUM('AUTO','IMPORT') NOT NULL,
|
||||
status ENUM('COMPLETE','FAILED','MISSED') NOT NULL,
|
||||
failure_code VARCHAR(64) NULL,
|
||||
total_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
operating_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
inventory_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
import_provenance JSON NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE KEY uk_asset_history_period (period_month, definition_version),
|
||||
KEY idx_asset_history_status (status, period_month),
|
||||
CONSTRAINT chk_asset_history_counts CHECK (total_count = operating_count + inventory_count)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bi_asset_history_vehicles (
|
||||
batch_id VARCHAR(36) NOT NULL,
|
||||
vehicle_id VARCHAR(64) NOT NULL,
|
||||
operation_status VARCHAR(50) NOT NULL,
|
||||
vehicle_status VARCHAR(50) NULL,
|
||||
disposition ENUM('operating','inventory') NOT NULL,
|
||||
subject_org VARCHAR(500) NULL,
|
||||
department_name VARCHAR(500) NULL,
|
||||
manager_id VARCHAR(64) NULL,
|
||||
frozen_vehicle JSON NOT NULL,
|
||||
PRIMARY KEY (batch_id, vehicle_id),
|
||||
KEY idx_asset_history_subject (batch_id, subject_org(128)),
|
||||
KEY idx_asset_history_manager (batch_id, manager_id),
|
||||
CONSTRAINT fk_asset_history_batch FOREIGN KEY (batch_id) REFERENCES bi_asset_history_batches (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -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;
|
||||
Generated
+2069
-3
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ln-bi",
|
||||
"private": true,
|
||||
"version": "1.1.18",
|
||||
"version": "1.1.19",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
||||
@@ -22,6 +22,7 @@
|
||||
"cn": "^0.4.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"echarts": "^6.1.0",
|
||||
"exceljs": "4.4.0",
|
||||
"hono": "^4.7.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-react": "^0.546.0",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Does not load dotenv, database pools, application bootstrap or background jobs.
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, relative, sep } from 'node:path';
|
||||
import { publishReconstruction, validateReconstruction } from '../src/server/routes/asset-history/reconstruction.js';
|
||||
const args = process.argv.slice(2);
|
||||
const inputIndex = args.indexOf('--input'), outputIndex = args.indexOf('--output');
|
||||
if (inputIndex < 0 || !args[inputIndex + 1]) throw new Error('需要 --input 指定已确认的回溯文件');
|
||||
const data = validateReconstruction(JSON.parse(await readFile(resolve(args[inputIndex + 1]), 'utf8')));
|
||||
console.log(JSON.stringify({ action: args.includes('--apply') ? 'local-file-publication' : 'dry-run', months: data.periods.length,
|
||||
firstMonth: data.periods[0]?.month, lastMonth: data.periods.at(-1)?.month, sourceAsOf: data.sourceAsOf,
|
||||
workbookSha256: data.workbookSha256, historicalTotal: null, databaseWrites: 0 }));
|
||||
if (args.includes('--apply')) {
|
||||
if (outputIndex < 0 || !args[outputIndex + 1]) throw new Error('需要 --output 指定本地私有存储位置');
|
||||
const output = resolve(args[outputIndex + 1]);
|
||||
for (const folder of ['public', 'dist', 'src']) {
|
||||
const within = relative(resolve(folder), output);
|
||||
if (!within || (!within.startsWith(`..${sep}`) && within !== '..' && !within.startsWith(sep))) throw new Error('回溯文件必须保存于源码及公开静态目录之外');
|
||||
}
|
||||
console.log(await publishReconstruction(output, data));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
// Import the pool only after an explicit apply flag, to avoid accidental connections in review mode.
|
||||
const sql = await readFile(new URL('../migrations/20260928_asset_ai_questions.sql', import.meta.url), 'utf8');
|
||||
if (!process.argv.includes('--apply')) {
|
||||
console.log(sql);
|
||||
console.log('Review only. To create this audit table in the configured DB, pass --apply.');
|
||||
} else {
|
||||
const { default: pool } = await import('../src/server/db.js');
|
||||
try {
|
||||
await pool.query(sql);
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS columns_count FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=?', ['bi_asset_ai_questions']);
|
||||
console.log('AI question audit table ready.', rows);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
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')) {
|
||||
console.log(sql);
|
||||
console.log('Review only. Pass --apply only after approving the configured database migration.');
|
||||
} else {
|
||||
const { default: pool } = await import('../src/server/db.js');
|
||||
try {
|
||||
for (const statement of sql.split(/;\s*(?:\n|$)/).filter(s => s.trim())) await pool.query(statement);
|
||||
console.log('Asset history schema ready. Capture is still disabled unless ASSET_HISTORY_CAPTURE_ENABLED=1.');
|
||||
} finally { await pool.end(); }
|
||||
}
|
||||
@@ -12,9 +12,10 @@ function heatmapChildIds(roles?: string[]): string[] {
|
||||
}
|
||||
|
||||
test('keeps the base asset navigation and role-gated scheduling module', () => {
|
||||
assert.deepEqual(moduleIds(), ['assets', 'mileage', 'heatmap']);
|
||||
assert.deepEqual(moduleIds(), ['assets', 'faults', 'mileage', 'heatmap']);
|
||||
assert.deepEqual(moduleIds(['BI-SCHEDULE-OPT']), [
|
||||
'assets',
|
||||
'faults',
|
||||
'mileage',
|
||||
'heatmap',
|
||||
'scheduling',
|
||||
|
||||
@@ -7,12 +7,14 @@ import {
|
||||
Receipt,
|
||||
Route,
|
||||
Truck,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import type { ModuleConfig } from '../components/Shell';
|
||||
import { canAccessEnergy, canAccessScheduling } from '../shared/auth/roles';
|
||||
import type { PathSet } from './routing';
|
||||
|
||||
const AssetsModule = lazy(() => import('../modules/assets/AssetsModule'));
|
||||
const FaultManagement = lazy(() => import('../modules/assets/faults/FaultManagement'));
|
||||
const MileageModule = lazy(() => import('../modules/mileage/MileageModule'));
|
||||
const SchedulingModule = lazy(() => import('../modules/scheduling/SchedulingModule'));
|
||||
const HydrogenModule = lazy(() => import('../modules/energy/HydrogenModule'));
|
||||
@@ -23,6 +25,7 @@ const HydrogenHeatmapModule = lazy(() => import('../modules/hydrogen-heatmap/Hyd
|
||||
|
||||
const ASSET_MODULES: ModuleConfig[] = [
|
||||
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
||||
{ id: 'faults', label: '故障管理', icon: Wrench, component: FaultManagement },
|
||||
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
||||
];
|
||||
|
||||
|
||||
@@ -102,6 +102,10 @@ export default function AssetsModule() {
|
||||
// 所属公司(归属主体)筛选 —— 影响全页聚合
|
||||
const [selectedSubject, setSelectedSubject] = useState<string | null>(null);
|
||||
const [subjects, setSubjects] = useState<SubjectOption[]>([]);
|
||||
const [historySubjects, setHistorySubjects] = useState<string[]>([]);
|
||||
const updateHistorySubjects = useCallback((names: string[]) => setHistorySubjects(names), []);
|
||||
const subjectOptions = useMemo(() => [...subjects, ...historySubjects.filter(name => !subjects.some(s => s.name === name))
|
||||
.map(name => ({ name, total: 0, operating: 0, inventory: 0, historicalOnly: true }))], [subjects, historySubjects]);
|
||||
const [subjectDropdownOpen, setSubjectDropdownOpen] = useState(false);
|
||||
const [subjectSearch, setSubjectSearch] = useState('');
|
||||
const subjectDropdownRef = useRef<HTMLDivElement>(null);
|
||||
@@ -485,7 +489,7 @@ export default function AssetsModule() {
|
||||
loading={loading}
|
||||
selectedSubject={selectedSubject}
|
||||
setSelectedSubject={setSelectedSubject}
|
||||
subjects={subjects}
|
||||
subjects={subjectOptions}
|
||||
subjectDropdownOpen={subjectDropdownOpen}
|
||||
setSubjectDropdownOpen={setSubjectDropdownOpen}
|
||||
subjectSearch={subjectSearch}
|
||||
@@ -504,6 +508,8 @@ export default function AssetsModule() {
|
||||
|
||||
{tabReady && activeTab === 'overview' && (
|
||||
<OverviewView
|
||||
selectedSubject={selectedSubject}
|
||||
onHistorySubjects={updateHistorySubjects}
|
||||
summary={SUMMARY}
|
||||
operatingRate={operatingRate}
|
||||
inventoryRate={inventoryRate}
|
||||
|
||||
@@ -41,3 +41,13 @@ test('one Excel workbook contains statistics and all details in two named sheets
|
||||
const empty = await createAnalysisWorkbook([], []);
|
||||
assert.deepEqual(empty.SheetNames, ['统计表', '详情表']);
|
||||
});
|
||||
|
||||
test('inventory export uses assigned operations managers and a dash when unset', () => {
|
||||
const vehicles = ['张三、李四', null, ' '].map(opsManagers => ({
|
||||
opsManagers, customerManager: '业务负责人', customerName: '客户', status: 'Inventory',
|
||||
} as VehicleListItem));
|
||||
const rows = vehicleExportRows(vehicles, true);
|
||||
assert.deepEqual(rows.map(row => row.运维负责人), ['张三、李四', '-', '-']);
|
||||
assert.ok(rows.every(row => !('业务负责人' in row) && !('客户' in row)));
|
||||
assert.equal(vehicleExportRows(vehicles)[0].业务负责人, '业务负责人');
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ export function statisticsExportRows(labels: string[], groups: { values: string[
|
||||
}));
|
||||
}
|
||||
|
||||
export function vehicleExportRows(vehicles: VehicleListItem[]): ExportRow[] {
|
||||
export function vehicleExportRows(vehicles: VehicleListItem[], inventory = false): ExportRow[] {
|
||||
const status: Record<string, string> = { Abnormal: '异动', Pending: '待交车', Inventory: '在库', Operating: '运营中' };
|
||||
const operation: Record<string, string> = { '1': '租赁', '2': '自营', '3': '可运营', '4': '待运营' };
|
||||
const hasPending = vehicles.some(v => v.pendingDelivery);
|
||||
@@ -21,7 +21,7 @@ export function vehicleExportRows(vehicles: VehicleListItem[]): ExportRow[] {
|
||||
车牌: v.plateNumber, VIN: v.vin, 品牌: v.brandLabel, 车型: v.type, 型号: v.model,
|
||||
省份: v.province, 城市: v.city || v.location,
|
||||
状态: status[v.status] || v.status, 运营状态: operation[v.operationStatus || ''] || v.operationStatus,
|
||||
客户: v.customerName, 签约公司: v.orgName, 部门: v.departmentName, 业务负责人: v.customerManager,
|
||||
...(inventory ? { 运维负责人: v.opsManagers?.trim() || '-' } : { 客户: v.customerName, 签约公司: v.orgName, 部门: v.departmentName, 业务负责人: v.customerManager }),
|
||||
所属公司: v.subjectOrg, 合同: v.contractNo,
|
||||
...(hasPending ? { 待交车合同: v.pendingDelivery?.contractNo, 待交车客户: v.pendingDelivery?.customerName } : {}),
|
||||
...(hasMovement ? {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { fetchJson } from '../../auth/api-client';
|
||||
const BASE = '/api/vehicles';
|
||||
|
||||
export interface SubjectOption {
|
||||
historicalOnly?: boolean;
|
||||
name: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
|
||||
@@ -34,7 +34,9 @@ import { useAuth } from "../../../auth/useAuth";
|
||||
import { ThinkingState } from "./ThinkingState";
|
||||
import { CompoundResultView } from "./CompoundResultView";
|
||||
import { BUSINESS_QUESTIONS, conversationTitle, findConversationResult, replaceAnalysisResult } from "./conversation-presentation";
|
||||
import { useMobileLayout } from "../useMobileLayout";
|
||||
import "./assistant.css";
|
||||
import "./mobile-assistant.css";
|
||||
|
||||
type Turn = {
|
||||
id: number;
|
||||
@@ -55,24 +57,28 @@ const PROMPTS = [
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: "现在多少车在运营、多少车在库存?",
|
||||
mobileTitle: "运营与库存",
|
||||
text: BUSINESS_QUESTIONS[0],
|
||||
detail: "总资产 · 运营 · 库存 · 运营率",
|
||||
},
|
||||
{
|
||||
icon: Truck,
|
||||
title: "库存集中在哪些车型和区域?",
|
||||
mobileTitle: "库存分布",
|
||||
text: BUSINESS_QUESTIONS[1],
|
||||
detail: "库存分布 · 继续查看车辆",
|
||||
},
|
||||
{
|
||||
icon: History,
|
||||
title: "最近7天交车、还车分别多少?",
|
||||
mobileTitle: "近 7 天交还车",
|
||||
text: BUSINESS_QUESTIONS[2],
|
||||
detail: "按日趋势 · 业务明细",
|
||||
},
|
||||
{
|
||||
icon: ListIcon,
|
||||
title: "有哪些车辆需要跟进?",
|
||||
mobileTitle: "待跟进车辆",
|
||||
text: BUSINESS_QUESTIONS[3],
|
||||
detail: "异动详情 · 预计完成时间",
|
||||
},
|
||||
@@ -94,6 +100,8 @@ export default function AssetAssistant({
|
||||
const dataScopeLabel = !user ? "正在确认数据范围"
|
||||
: user.permissionLevel === "full" ? "全量数据"
|
||||
: user.permissionLevel === "department" ? "部门数据" : "本人数据";
|
||||
const mobile = useMobileLayout();
|
||||
const [contextExpanded, setContextExpanded] = useState(false);
|
||||
const page = useRef<HTMLElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
const viewport = window.visualViewport;
|
||||
@@ -557,7 +565,7 @@ export default function AssetAssistant({
|
||||
<p.icon size={18} aria-hidden="true" />
|
||||
<ArrowUpRight size={14} aria-hidden="true" />
|
||||
</div>
|
||||
<strong>{p.title}</strong>
|
||||
<strong>{mobile ? p.mobileTitle : p.title}</strong>
|
||||
<span>{p.detail}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -745,7 +753,7 @@ export default function AssetAssistant({
|
||||
)}
|
||||
</div>
|
||||
<div className="aq-composer-area">
|
||||
{turns.length > 0 && (
|
||||
{turns.length > 0 && (!mobile || (viewedTurn !== undefined && viewedTurn !== turns.at(-1)?.id)) && (
|
||||
<button
|
||||
className="aq-latest-answer"
|
||||
onClick={() => revealTurn(turns[turns.length - 1].id)}
|
||||
@@ -754,8 +762,8 @@ export default function AssetAssistant({
|
||||
</button>
|
||||
)}
|
||||
{lastPlan && (
|
||||
<div className="aq-context">
|
||||
<span>正在分析</span>
|
||||
<details className="aq-context-disclosure" open={!mobile || contextExpanded} onToggle={event => { if (mobile) setContextExpanded(event.currentTarget.open); }}><summary><span>追问范围:{lastPlan.kind === 'flow' ? '交还车' : CATEGORY_LABELS[lastPlan.category]}{Object.keys(lastPlan.filters).length ? ` · ${Object.entries(lastPlan.filters).map(([dimension,values]) => `${GROUP_LABELS[dimension as Dimension]} ${values.length === 1 ? values[0] : `${values.length}项`}`).join(' · ')}` : ''}</span><ChevronDown size={15} /></summary><div className="aq-context">
|
||||
<span className="aq-context-heading">当前条件</span>
|
||||
<span>
|
||||
{lastPlan.kind === "flow"
|
||||
? "交退换车"
|
||||
@@ -847,7 +855,7 @@ export default function AssetAssistant({
|
||||
<Plus size={12} />
|
||||
换个话题
|
||||
</button>
|
||||
</div>
|
||||
</div></details>
|
||||
)}
|
||||
{independent && (
|
||||
<p className="aq-topic-notice">
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
} from "../../../shared/assets/assistant";
|
||||
import { ResultView, type ResultViewProps } from "./ResultView";
|
||||
import { GenerativeResultView } from "./GenerativeResultView";
|
||||
import { useMobileLayout } from '../useMobileLayout';
|
||||
import { mobileResultSummary } from './mobile-presentation';
|
||||
import { copyResultText } from "./result-presentation";
|
||||
|
||||
export type CompoundResultViewProps = Omit<
|
||||
@@ -34,6 +36,7 @@ export function CompoundResultView({
|
||||
activate,
|
||||
...props
|
||||
}: CompoundResultViewProps) {
|
||||
const mobile = useMobileLayout();
|
||||
const [selection, setSelection] = useState<{
|
||||
sectionId: string;
|
||||
observedActiveId?: string;
|
||||
@@ -72,7 +75,7 @@ export function CompoundResultView({
|
||||
externallyActive ||
|
||||
sections[0];
|
||||
const selectedIndex = sections.indexOf(selected);
|
||||
const stacked = result.layout === "stack";
|
||||
const stacked = result.layout === "stack" && !mobile;
|
||||
const renderSection = (section: typeof selected) => {
|
||||
const child = section.result;
|
||||
const correction = child?.resultId
|
||||
@@ -120,11 +123,11 @@ export function CompoundResultView({
|
||||
className="aq-compound-heading"
|
||||
id={result.resultId ? `aq-query-${result.resultId}` : undefined}
|
||||
>
|
||||
<div>
|
||||
<div className="aq-compound-title">
|
||||
<strong>{result.title || "综合分析"}</strong>
|
||||
<span>{sections.length}项分析</span>
|
||||
</div>
|
||||
<p id={result.resultId ? `aq-answer-${result.resultId}` : undefined}>
|
||||
<p className={mobile ? "aq-mobile-compound-summary" : undefined} id={result.resultId ? `aq-answer-${result.resultId}` : undefined}>
|
||||
{result.summary}
|
||||
</p>
|
||||
</header>
|
||||
@@ -219,7 +222,7 @@ export function CompoundResultView({
|
||||
<span className="aq-section-preview">
|
||||
<strong>{section.title}</strong>
|
||||
<span>
|
||||
{section.result?.summary || section.error || "本项暂无结果"}
|
||||
{section.result ? (mobile ? mobileResultSummary(section.result) : section.result.summary) : section.error || "本项暂无结果"}
|
||||
</span>
|
||||
</span>
|
||||
{chosen ? <Check size={14} /> : <ArrowUpRight size={14} />}
|
||||
|
||||
@@ -45,6 +45,10 @@ import {
|
||||
type PresentationDatum,
|
||||
} from "./generative-presentation";
|
||||
|
||||
import { useMobileLayout } from '../useMobileLayout';
|
||||
import { mobileBlockGroups, mobileResultSummary } from './mobile-presentation';
|
||||
import { ResultActions } from './ResultActions';
|
||||
|
||||
const COLORS = [
|
||||
"#4e806a",
|
||||
"#527da5",
|
||||
@@ -69,6 +73,7 @@ function DataTable({
|
||||
operate,
|
||||
disabled,
|
||||
}: DataBlockProps) {
|
||||
const mobile = useMobileLayout();
|
||||
const [page, setPage] = useState(0);
|
||||
const data = presentationData(result, block.dataset);
|
||||
const columns = presentationFields(block);
|
||||
@@ -78,7 +83,16 @@ function DataTable({
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="aq-table-scroll">
|
||||
{mobile ? <div className="aq-mobile-data-list" aria-label={block.title || '分析数据'}>{data.slice(currentPage * 8, currentPage * 8 + 8).map((row, index) => {
|
||||
const action = presentationAction(interaction, row);
|
||||
const vehicle = block.dataset === 'rows' && columns.some(column => column.field === 'plate');
|
||||
const primary = vehicle ? columns.filter(column => ['plate', 'status', 'event', 'vehicleClass', 'type', 'city'].includes(column.field)) : columns.slice(0, 3);
|
||||
const extra = columns.filter(column => !primary.includes(column));
|
||||
return <article className="aq-mobile-data-row" key={String(row.id ?? row.name ?? row.date ?? row.label ?? index)}>
|
||||
{vehicle ? <><div className="aq-mobile-record-heading"><strong>{presentationValue(row.plate, 'plate')}</strong><span>{primary.filter(column => ['status', 'event'].includes(column.field)).map(column => presentationValue(row[column.field], column.field)).join(' · ')}</span></div><p>{primary.filter(column => !['plate', 'status', 'event'].includes(column.field)).map(column => presentationValue(row[column.field], column.field)).join(' · ')}</p></> : <div className="aq-mobile-data-values">{primary.map(column => <div key={column.field}><span>{column.label}</span><strong>{presentationValue(row[column.field], column.field)}</strong></div>)}</div>}
|
||||
{(extra.length > 0 || action) && <div className="aq-mobile-data-footer">{extra.length > 0 && <details><summary>更多字段</summary><dl>{extra.map(column => <div key={column.field}><dt>{column.label}</dt><dd>{presentationValue(row[column.field], column.field)}</dd></div>)}</dl></details>}{action && <button disabled={disabled} aria-label={`${datumName(row)} · ${interaction!.label}`} onClick={() => operate(action, `${datumName(row)} · ${interaction!.label}`)}>继续分析<ArrowUpRight size={15} /></button>}</div>}
|
||||
</article>;
|
||||
})}</div> : <div className="aq-table-scroll">
|
||||
<table>
|
||||
<caption className="aq-sr-only">{block.title || "分析数据"}</caption>
|
||||
<thead>
|
||||
@@ -132,7 +146,7 @@ function DataTable({
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>}
|
||||
{data.length > 8 && (
|
||||
<div className="aq-generated-pagination">
|
||||
<span>
|
||||
@@ -159,6 +173,7 @@ function DataTable({
|
||||
);
|
||||
}
|
||||
function DataChart(props: DataBlockProps) {
|
||||
const mobile = useMobileLayout();
|
||||
const { block, result, interaction, operate, disabled } = props;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const allData = presentationData(result, block.dataset);
|
||||
@@ -186,6 +201,15 @@ function DataChart(props: DataBlockProps) {
|
||||
};
|
||||
if (!series.length) return <DataTable {...props} />;
|
||||
const common = { data, margin: { top: 8, right: 16, bottom: 0, left: 0 } };
|
||||
if (mobile && (block.chart === 'bar' || (block.chart === 'donut' && block.dataset === 'groups')) && series.length === 1 && allData.every(row => typeof row[series[0].field] === 'number' && Number(row[series[0].field]) >= 0)) {
|
||||
const field = series[0].field;
|
||||
const max = Math.max(1, ...allData.map(row => typeof row[field] === 'number' ? Math.abs(row[field] as number) : 0));
|
||||
return <div className="aq-mobile-ranking" aria-label={block.title || '分组图表'}>{(expanded ? allData : allData.slice(0, 5)).map((row, index) => {
|
||||
const action = presentationAction(interaction, row);
|
||||
const content = <><span className="aq-mobile-ranking-line"><span>{datumName(row)}</span><strong>{presentationValue(row[field], field)}</strong>{action && <ArrowUpRight size={15} />}</span><span className="aq-mobile-ranking-track"><span style={{ width: `${Math.abs(Number(row[field]) || 0) / max * 100}%` }} /></span></>;
|
||||
return action ? <button key={index} disabled={disabled} aria-label={`${datumName(row)} · ${interaction!.label}`} onClick={() => explore(row)}>{content}</button> : <div className="aq-mobile-ranking-item" key={index}>{content}</div>;
|
||||
})}{allData.length > 5 && <button className="aq-generated-expand" onClick={() => setExpanded(!expanded)}>{expanded ? '收起分组' : `查看全部 ${allData.length} 项`}</button>}</div>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -294,7 +318,7 @@ function DataChart(props: DataBlockProps) {
|
||||
: `当前展示前5项 · 查看全部${allData.length}项`}
|
||||
</button>
|
||||
)}
|
||||
<details className="aq-generated-data">
|
||||
{!mobile && <details className="aq-generated-data">
|
||||
<summary>查看图表数据</summary>
|
||||
<DataTable
|
||||
{...props}
|
||||
@@ -309,7 +333,7 @@ function DataChart(props: DataBlockProps) {
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</details>
|
||||
</details>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -319,6 +343,9 @@ function Block({
|
||||
operate,
|
||||
disabled,
|
||||
}: Omit<DataBlockProps, "interaction">) {
|
||||
const mobile = useMobileLayout();
|
||||
const [expandedMetrics, setExpandedMetrics] = useState(false);
|
||||
const metrics = presentationMetrics(result, block.metrics);
|
||||
const interaction = result.presentation?.interactions.find(
|
||||
(item) => item.id === block.interactionId,
|
||||
);
|
||||
@@ -345,8 +372,8 @@ function Block({
|
||||
) : block.kind === "narrative" ? (
|
||||
<p className="aq-generated-narrative">{block.text}</p>
|
||||
) : block.kind === "metrics" ? (
|
||||
<div className="aq-generated-metrics">
|
||||
{presentationMetrics(result, block.metrics).map((metric) => (
|
||||
<><div className="aq-generated-metrics">
|
||||
{(mobile && !expandedMetrics ? metrics.slice(0, 4) : metrics).map((metric) => (
|
||||
<div key={metric.label}>
|
||||
<span>{metric.label}</span>
|
||||
<strong>
|
||||
@@ -355,10 +382,10 @@ function Block({
|
||||
</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>{mobile && metrics.length > 4 && <button className="aq-generated-expand" onClick={() => setExpandedMetrics(!expandedMetrics)}>{expandedMetrics ? '收起指标' : `其余 ${metrics.length - 4} 项指标`}</button>}</>
|
||||
) : (
|
||||
<>
|
||||
{interaction && (
|
||||
{interaction && !mobile && (
|
||||
<p className="aq-generated-interaction">
|
||||
点击{block.dataset === "daily" ? "日期" : "数据项"} →{" "}
|
||||
{interaction.label}
|
||||
@@ -391,6 +418,12 @@ function Block({
|
||||
);
|
||||
}
|
||||
|
||||
function MobileDataBlock({ block, table, ...props }: Omit<DataBlockProps, 'interaction'> & { table?: PresentationBlock }) {
|
||||
const [view, setView] = useState<'chart' | 'table'>('chart');
|
||||
const tableBlock: PresentationBlock = table || { ...block, kind: 'table', columns: block.columns || [{ field: block.dataset === 'daily' ? 'date' : block.dataset === 'comparison' ? 'label' : 'name', label: block.dataset === 'daily' ? '日期' : '分组' }, ...presentationSeries(block)] };
|
||||
return <section className="aq-mobile-visual"><div className="aq-mobile-visual-header"><h4>{block.title || '数据分析'}</h4><div aria-label="图表与明细"><button aria-pressed={view === 'chart'} onClick={() => setView('chart')}>图表</button><button aria-pressed={view === 'table'} onClick={() => setView('table')}>明细</button></div></div><Block {...props} block={{ ...(view === 'chart' ? block : tableBlock), title: undefined }} /></section>;
|
||||
}
|
||||
|
||||
function GenerativeResultViewContent({
|
||||
result,
|
||||
operate,
|
||||
@@ -403,6 +436,7 @@ function GenerativeResultViewContent({
|
||||
correctionResultId,
|
||||
showCorrection,
|
||||
}: ResultViewProps) {
|
||||
const mobile = useMobileLayout();
|
||||
const [noticeByResult, setNoticeByResult] = useState<{
|
||||
key: string;
|
||||
text: string;
|
||||
@@ -536,7 +570,7 @@ function GenerativeResultViewContent({
|
||||
已纠正上一条回答:{result.correction.message}
|
||||
</p>
|
||||
)}
|
||||
{presentation.title && (
|
||||
{!mobile && presentation.title && (
|
||||
<h3 className="aq-generated-title">{presentation.title}</h3>
|
||||
)}
|
||||
{result.outcome === "empty" && (
|
||||
@@ -552,15 +586,11 @@ function GenerativeResultViewContent({
|
||||
{result.outcome === "unsupported" && (
|
||||
<p className="aq-generated-notice">本次需求暂不能完整生成。</p>
|
||||
)}
|
||||
{presentation.blocks.map((block) => (
|
||||
<Block
|
||||
key={block.id}
|
||||
block={block}
|
||||
result={result}
|
||||
operate={operate}
|
||||
disabled={disabled || !!correctionMessage}
|
||||
/>
|
||||
))}
|
||||
{mobile ? <>
|
||||
<p className="aq-answer aq-mobile-lead" id={result.resultId ? `aq-answer-${result.resultId}` : undefined}>{mobileResultSummary(result)}</p>
|
||||
{mobileBlockGroups(presentation.blocks).filter(({ block }) => block.kind !== 'summary' && block.kind !== 'narrative').map(({ block, table }) => block.kind === 'chart' ? <MobileDataBlock key={block.id} block={block} table={table} result={result} operate={operate} disabled={disabled || !!correctionMessage} /> : <Block key={block.id} block={block} result={result} operate={operate} disabled={disabled || !!correctionMessage} />)}
|
||||
<details className="aq-secondary-details"><summary>分析说明</summary><p>{result.summary}</p>{presentation.blocks.filter(block => (block.kind === 'summary' || block.kind === 'narrative') && block.text && block.text !== result.summary).map(block => <p key={block.id}>{block.text}</p>)}</details>
|
||||
</> : presentation.blocks.map((block) => <Block key={block.id} block={block} result={result} operate={operate} disabled={disabled || !!correctionMessage} />)}
|
||||
{!!result.unanswered?.length && (
|
||||
<div className="aq-compound-unanswered">
|
||||
<strong>尚未回答</strong>
|
||||
@@ -611,7 +641,12 @@ function GenerativeResultViewContent({
|
||||
{result.contextNote && <p>{result.contextNote}</p>}
|
||||
<p>来源:{result.source}</p>
|
||||
</details>
|
||||
<div className="aq-answer-actions">
|
||||
<ResultActions primary={<button
|
||||
disabled={disabled || active || !!correctionMessage}
|
||||
onClick={activate}
|
||||
>
|
||||
{mobile ? (active ? "当前追问范围" : "继续追问") : (active ? "正在基于此结果追问" : "基于此结果追问")}
|
||||
</button>}>
|
||||
<button
|
||||
disabled={disabled || saving}
|
||||
onClick={() => void save("export")}
|
||||
@@ -619,12 +654,7 @@ function GenerativeResultViewContent({
|
||||
<ArrowDownToLine size={14} />
|
||||
导出明细
|
||||
</button>
|
||||
<button
|
||||
disabled={disabled || active || !!correctionMessage}
|
||||
onClick={activate}
|
||||
>
|
||||
{active ? "正在基于此结果追问" : "基于此结果追问"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled={saving || flagged}
|
||||
onClick={() => void save("feedback")}
|
||||
@@ -642,7 +672,7 @@ function GenerativeResultViewContent({
|
||||
<Copy size={14} />
|
||||
复制完整结果
|
||||
</button>
|
||||
</div>
|
||||
</ResultActions>
|
||||
{notice && (
|
||||
<p className="aq-notice" role="status">
|
||||
{notice}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import { useMobileLayout } from '../useMobileLayout';
|
||||
|
||||
export function ResultActions({ children, primary }: { children: ReactNode; primary?: ReactNode }) {
|
||||
const mobile = useMobileLayout();
|
||||
return mobile ? <div className="aq-mobile-actions">{primary}<details className="aq-more-actions"><summary><MoreHorizontal size={18} />更多</summary><div className="aq-answer-actions">{children}</div></details></div> : <div className="aq-answer-actions">{primary}{children}</div>;
|
||||
}
|
||||
@@ -26,7 +26,12 @@ import {
|
||||
compactAnalysisPath,
|
||||
} from "./result-presentation";
|
||||
|
||||
import { useMobileLayout } from '../useMobileLayout';
|
||||
import { mobileResultSummary } from './mobile-presentation';
|
||||
import { ResultActions } from './ResultActions';
|
||||
|
||||
function Records({ rows, flow }: { rows: AssistantRow[]; flow: boolean }) {
|
||||
const mobile = useMobileLayout();
|
||||
const [page, setPage] = useState(0),
|
||||
[search, setSearch] = useState("");
|
||||
const filtered = rows.filter((r) =>
|
||||
@@ -52,7 +57,7 @@ function Records({ rows, flow }: { rows: AssistantRow[]; flow: boolean }) {
|
||||
</label>
|
||||
<span>{filtered.length}条</span>
|
||||
</div>
|
||||
<div className="aq-table-scroll">
|
||||
{mobile ? <div className="aq-mobile-data-list" aria-label="车辆明细">{filtered.slice(current * 8, current * 8 + 8).map(r => <article className="aq-mobile-data-row" key={r.id}><div className="aq-mobile-record-heading"><strong>{r.plate || '未登记车牌'}</strong><span>{flow ? r.event : r.status}</span></div><p>{r.type} · {r.city || '未标注城市'}</p><details><summary>更多资料</summary><dl>{[['型号', r.model], ['部门', r.department], ...(flow ? [['日期', r.date]] : [['库存子集', r.abnormal ? '异动' : r.pending ? '待交车' : '—'], ['异动类型', r.abnormalType], ['预计完成时间', r.targetCompletionTime]])].map(([label,value]) => <div key={label}><dt>{label}</dt><dd>{value || '—'}</dd></div>)}</dl></details></article>)}</div> : <div className="aq-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -90,7 +95,7 @@ function Records({ rows, flow }: { rows: AssistantRow[]; flow: boolean }) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>}
|
||||
{!filtered.length && <p className="aq-no-data">没有符合条件的明细</p>}
|
||||
<div className="aq-pagination">
|
||||
<span>
|
||||
@@ -148,6 +153,7 @@ function ResultViewContent({
|
||||
showCorrection,
|
||||
display: preferredDisplay,
|
||||
}: ResultViewProps) {
|
||||
const mobile = useMobileLayout();
|
||||
const [expandedGroupResult, setExpandedGroupResult] = useState<string | null>(
|
||||
null,
|
||||
),
|
||||
@@ -360,7 +366,7 @@ function ResultViewContent({
|
||||
className="aq-answer"
|
||||
id={result.resultId ? `aq-answer-${result.resultId}` : undefined}
|
||||
>
|
||||
{result.summary}
|
||||
{mobile ? mobileResultSummary(result) : result.summary}
|
||||
</p>
|
||||
{!result.conciseCount && !!visibleMetrics.length && (
|
||||
<div className="aq-metrics aq-primary-metrics">
|
||||
@@ -790,6 +796,8 @@ function ResultViewContent({
|
||||
点击分组继续下钻 · 筛选条件自动用于下一次追问
|
||||
</p>
|
||||
</div>
|
||||
) : mobile ? (
|
||||
<div className="aq-mobile-data-list" aria-label="分组明细">{result.groups.map(g => <article className="aq-mobile-data-row" key={JSON.stringify(g.keys)}><div className="aq-mobile-data-values"><div><span>分组</span><strong>{g.name}</strong></div><div><span>车辆数</span><strong>{g.count}</strong></div><div><span>占比</span><strong>{g.share == null ? '—' : `${g.share}%`}</strong></div></div><div className="aq-mobile-data-footer">{plan.category !== 'operating' && <details><summary>更多指标</summary><p>运营率 {g.rate}% · 异动 {g.abnormal ?? '—'} · 待交车 {g.pending ?? '—'}</p></details>}<button disabled={disabled || !Object.keys(g.keys || {}).length} aria-label={`下钻${g.name}`} onClick={() => run({ type: 'drill', keys: g.keys || {} }, `下钻${g.name}`)}>继续分析<ArrowUpRight size={15} /></button></div></article>)}</div>
|
||||
) : (
|
||||
<div className="aq-table-scroll">
|
||||
<table>
|
||||
@@ -859,6 +867,7 @@ function ResultViewContent({
|
||||
<summary>
|
||||
统计口径、权限与数据时间{result.warning ? " · 有补充说明" : ""}
|
||||
</summary>
|
||||
{mobile && <p>{result.summary}</p>}
|
||||
<p>
|
||||
公司:{scope.company} · 权限:{scope.permission}
|
||||
</p>
|
||||
@@ -877,7 +886,14 @@ function ResultViewContent({
|
||||
))}
|
||||
<p>来源:{result.source}</p>
|
||||
</details>
|
||||
<div className="aq-answer-actions">
|
||||
<ResultActions primary={plan.kind !== "clarify" && (
|
||||
<button
|
||||
disabled={disabled || active || !!correctionMessage}
|
||||
onClick={activate}
|
||||
>
|
||||
{mobile ? (active ? "当前追问范围" : "继续追问") : (active ? "正在基于此结果追问" : "基于此结果追问")}
|
||||
</button>
|
||||
)}>
|
||||
{plan.kind !== "clarify" && (!result.conciseCount || result.metrics[0]?.value > 0) && (
|
||||
<button
|
||||
disabled={disabled || saving}
|
||||
@@ -888,14 +904,7 @@ function ResultViewContent({
|
||||
导出明细
|
||||
</button>
|
||||
)}
|
||||
{plan.kind !== "clarify" && (
|
||||
<button
|
||||
disabled={disabled || active || !!correctionMessage}
|
||||
onClick={activate}
|
||||
>
|
||||
{active ? "正在基于此结果追问" : "基于此结果追问"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button disabled={saving || flagged} onClick={() => void flag()}>
|
||||
{flagged ? "已记录" : "未解决我的问题"}
|
||||
</button>
|
||||
@@ -910,7 +919,7 @@ function ResultViewContent({
|
||||
<Copy size={14} />
|
||||
复制完整结果
|
||||
</button>
|
||||
</div>
|
||||
</ResultActions>
|
||||
{notice && (
|
||||
<p className="aq-notice" role="status">
|
||||
{notice}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { Check, ChevronDown, CircleAlert, LoaderCircle, Pause, Sparkles } from "lucide-react";
|
||||
|
||||
import { useMobileLayout } from '../useMobileLayout';
|
||||
|
||||
export function ThinkingState({
|
||||
steps,
|
||||
working,
|
||||
@@ -18,6 +20,7 @@ export function ThinkingState({
|
||||
stopped?: boolean;
|
||||
outcome?: string;
|
||||
}) {
|
||||
const mobile = useMobileLayout();
|
||||
const traceId = useId();
|
||||
const [manualExpanded, setExpanded] = useState<boolean | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
@@ -28,7 +31,7 @@ export function ThinkingState({
|
||||
const timer = window.setInterval(update, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [working, started]);
|
||||
const expanded = manualExpanded ?? working;
|
||||
const expanded = manualExpanded ?? (working && !mobile);
|
||||
const StatusIcon = working ? LoaderCircle : stopped ? Pause : failed ? CircleAlert : Sparkles;
|
||||
return (
|
||||
<div className={`aq-thinking${working ? " aq-thinking-working" : ""}`} data-state={working ? "working" : stopped ? "stopped" : failed ? "failed" : "complete"}>
|
||||
@@ -41,7 +44,7 @@ export function ThinkingState({
|
||||
<StatusIcon size={16} className={working ? "aq-spin" : ""} aria-hidden="true" />
|
||||
<span className={`aq-thinking-title${working ? " aq-shimmer" : ""}`}>
|
||||
{working
|
||||
? "正在分析"
|
||||
? (mobile ? steps.at(-1) || "正在分析" : "正在分析")
|
||||
: stopped
|
||||
? "已由用户停止"
|
||||
: failed
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/* Mobile presentation only. Keep desktop analysis density unchanged. */
|
||||
.aq-context-disclosure > summary { display: none; }
|
||||
@media (max-width: 767px) {
|
||||
.aq-page { font-size: 14px; }
|
||||
.aq-page .aq-turns { padding: 16px 16px 24px; }
|
||||
.aq-page .aq-result, .aq-page .aq-compound-result { margin-left: 0; }
|
||||
.aq-page .aq-question { margin-bottom: 16px; }
|
||||
.aq-page .aq-question p { font-size: 15px; line-height: 1.65; }
|
||||
.aq-page .aq-thinking { border: 0; border-radius: 8px; padding: 0 8px; margin-bottom: 8px; }
|
||||
.aq-page .aq-thinking > button { font-size: 13px; }
|
||||
.aq-page .aq-thinking-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.aq-page .aq-thinking-meta { font-size: 12px; }
|
||||
.aq-page .aq-query-heading { position: static; padding: 4px 0 8px; margin-bottom: 10px; }
|
||||
.aq-page .aq-current-path { flex-wrap: wrap; overflow: visible; font-size: 13px; gap: 4px 6px; }
|
||||
.aq-page .aq-current-path > span { flex-shrink: 1; white-space: normal; }
|
||||
.aq-page .aq-current-path > .aq-path-current { position: static; }
|
||||
.aq-page .aq-query-navigation { flex-wrap: wrap; font-size: 12px; }
|
||||
.aq-page .aq-query-navigation > span { font-size: 12px; }
|
||||
.aq-page .aq-query-navigation > button, .aq-page .aq-path-history > summary { min-height: 44px; font-size: 13px; }
|
||||
.aq-page .aq-path-history nav button { min-height: 44px; font-size: 13px; }
|
||||
.aq-page .aq-result .aq-answer { font-size: 15px; line-height: 1.75; margin: 0 0 12px; }
|
||||
.aq-page .aq-generated-block { margin: 12px 0; }
|
||||
.aq-page .aq-generated-block h4 { font-size: 14px; }
|
||||
.aq-page .aq-generated-metrics, .aq-page .aq-primary-metrics,
|
||||
.aq-page .aq-primary-metrics:has(> div:nth-child(4):last-child) { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.aq-page .aq-generated-metrics > div { padding: 10px 12px; border: 0; border-radius: 8px; }
|
||||
.aq-page .aq-generated-metrics span, .aq-page .aq-metrics span { font-size: 13px; line-height: 1.5; }
|
||||
.aq-page .aq-generated-metrics strong, .aq-page .aq-metrics strong { font-size: 26px; margin-top: 3px; }
|
||||
.aq-page .aq-generated-metrics small, .aq-page .aq-metrics small { font-size: 12px; }
|
||||
.aq-page .aq-primary-metrics > div { border: 0; padding: 8px 10px !important; background: var(--aq-inset); border-radius: 8px; }
|
||||
.aq-mobile-visual { margin: 12px 0; border: 1px solid var(--aq-line); border-radius: 10px; padding: 4px 12px 8px; }
|
||||
.aq-mobile-visual-header { display: flex; align-items: center; gap: 8px; justify-content: space-between; }
|
||||
.aq-mobile-visual-header > h4 { font-size: 14px; font-weight: 600; margin: 0; min-width: 0; }
|
||||
.aq-mobile-visual-header > div { display: flex; flex-shrink: 0; gap: 2px; }
|
||||
.aq-mobile-visual-header button { min-height: 44px; min-width: 44px; padding: 5px 6px; font-size: 13px; color: var(--aq-muted); }
|
||||
.aq-mobile-visual-header button[aria-pressed="true"] { color: var(--aq-accent); font-weight: 600; text-decoration: underline; text-underline-offset: 7px; }
|
||||
.aq-mobile-ranking { display: grid; gap: 4px; }
|
||||
.aq-mobile-ranking > button:not(.aq-generated-expand), .aq-mobile-ranking-item { width: 100%; min-height: 48px; display: block; padding: 6px 0; text-align: left; }
|
||||
.aq-mobile-ranking-line { display: flex; gap: 8px; align-items: center; font-size: 14px; line-height: 1.5; }
|
||||
.aq-mobile-ranking-line > span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.aq-mobile-ranking-line > strong { font-variant-numeric: tabular-nums; font-weight: 600; white-space: nowrap; }
|
||||
.aq-mobile-ranking-line > svg { color: var(--aq-muted); }
|
||||
.aq-mobile-ranking-track { display: block; height: 5px; border-radius: 3px; background: var(--aq-inset); margin-top: 6px; overflow: hidden; }
|
||||
.aq-mobile-ranking-track > span { height: 100%; display: block; background: var(--aq-accent); opacity: .65; border-radius: inherit; }
|
||||
.aq-page .aq-generated-expand { min-height: 44px; font-size: 13px; }
|
||||
.aq-mobile-data-list { display: grid; gap: 8px; padding: 4px 0; }
|
||||
.aq-mobile-data-row { min-width: 0; padding: 10px 12px; background: var(--aq-inset); border-radius: 8px; font-size: 14px; line-height: 1.6; }
|
||||
.aq-mobile-data-values { display: grid; grid-template-columns: minmax(0, 1.35fr) repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.aq-mobile-data-values span { display: block; font-size: 12px; color: var(--aq-muted); margin-bottom: 2px; }
|
||||
.aq-mobile-data-values strong { display: block; font-size: 14px; font-weight: 550; overflow-wrap: anywhere; }
|
||||
.aq-mobile-data-values > div:only-child { grid-column: 1 / -1; }
|
||||
.aq-mobile-data-row dl { display: grid; gap: 7px; margin: 8px 0; }
|
||||
.aq-mobile-data-row dl > div { display: grid; grid-template-columns: 80px minmax(0, 1fr); gap: 8px; }
|
||||
.aq-mobile-data-row dt { color: var(--aq-muted); font-size: 13px; }
|
||||
.aq-mobile-data-row dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.aq-mobile-data-footer { display: flex; align-items: flex-start; flex-wrap: wrap; gap: 0 8px; justify-content: space-between; margin-top: 4px; }
|
||||
.aq-mobile-data-footer > details[open] { width: 100%; }
|
||||
.aq-mobile-data-row summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; color: var(--aq-muted); font-size: 13px; }
|
||||
.aq-mobile-data-row summary::after { content: '+'; margin-left: 8px; }
|
||||
.aq-mobile-data-row details[open] > summary::after { content: '−'; }
|
||||
.aq-mobile-data-footer > button { min-height: 44px; color: var(--aq-accent); font-size: 13px; }
|
||||
.aq-mobile-record-heading { display: flex; gap: 8px; justify-content: space-between; align-items: center; }
|
||||
.aq-mobile-record-heading > strong { white-space: nowrap; font-size: 16px; }
|
||||
.aq-mobile-record-heading > span { font-size: 12px; color: var(--aq-muted); }
|
||||
.aq-mobile-data-row > p { margin: 5px 0 0; color: var(--aq-muted); font-size: 13px; }
|
||||
.aq-page .aq-generated-pagination, .aq-page .aq-pagination { font-size: 12px; padding: 6px 0; }
|
||||
.aq-page .aq-generated-pagination > button, .aq-page .aq-pagination > button { min-width: 44px; min-height: 44px; }
|
||||
.aq-mobile-actions { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 4px 12px; margin: 4px 0 10px; }
|
||||
.aq-mobile-actions > button { min-height: 44px; padding: 4px 0; color: var(--aq-accent); font-size: 13px; }
|
||||
.aq-mobile-actions > button:disabled { opacity: 1; color: var(--aq-muted); cursor: default; }
|
||||
.aq-more-actions > summary { display: flex; align-items: center; gap: 5px; min-height: 44px; cursor: pointer; color: var(--aq-muted); font-size: 13px; }
|
||||
.aq-more-actions[open] { width: 100%; }
|
||||
.aq-more-actions[open] > summary { justify-content: flex-end; }
|
||||
.aq-page .aq-more-actions .aq-answer-actions { margin: 0; display: flex; flex-wrap: wrap; gap: 4px 16px; }
|
||||
.aq-page .aq-more-actions .aq-answer-actions > button { font-size: 13px; min-height: 44px; }
|
||||
.aq-page .aq-more-actions .aq-answer-actions > span { font-size: 12px; }
|
||||
.aq-page .aq-result .aq-result-definitions > summary, .aq-page .aq-result .aq-secondary-details > summary,
|
||||
.aq-page .aq-result .aq-advanced-controls > summary, .aq-page .aq-result .aq-extra-metrics > summary { min-height: 44px; font-size: 13px; line-height: 1.6; display: flex; align-items: center; }
|
||||
.aq-page .aq-result-definitions, .aq-page .aq-generated-narrative { font-size: 14px; line-height: 1.75; }
|
||||
.aq-page .aq-followups { gap: 8px; }
|
||||
.aq-page .aq-followups > span { width: 100%; font-size: 12px; }
|
||||
.aq-page .aq-followups > button { min-height: 44px; font-size: 13px; text-align: left; }
|
||||
.aq-page .aq-generated-drill-targets { max-height: none; overflow: visible; gap: 8px; }
|
||||
.aq-page .aq-generated-drill-targets button { min-height: 44px; font-size: 13px; }
|
||||
.aq-page .aq-result .aq-table-scroll { max-height: none; overflow-y: visible; }
|
||||
.aq-page .aq-table-scroll th, .aq-page .aq-table-scroll td { font-size: 13px; white-space: normal; overflow-wrap: anywhere; padding: 10px 8px; }
|
||||
.aq-page .aq-table-scroll th { position: static; }
|
||||
.aq-page .aq-result .aq-bar-row { min-height: 44px; grid-template-columns: 75px minmax(15px, 1fr) 36px 40px 13px; font-size: 13px !important; gap: 6px !important; }
|
||||
.aq-page .aq-bar-name { white-space: normal; overflow-wrap: anywhere; }
|
||||
.aq-page .aq-result .aq-view-tabs button, .aq-page .aq-result .aq-more { min-height: 44px; font-size: 13px; }
|
||||
.aq-page .aq-record-toolbar input { font-size: 16px; min-height: 44px; }
|
||||
.aq-page .aq-section-preview > strong { font-size: 14px; }
|
||||
.aq-page .aq-section-preview > span { font-size: 13px; }
|
||||
.aq-page .aq-section-overview > button { padding: 10px; }
|
||||
.aq-page .aq-compound-toolbar { font-size: 12px; }
|
||||
.aq-page .aq-compound-toolbar button { min-height: 44px; font-size: 13px; }
|
||||
.aq-page .aq-context-disclosure { margin-bottom: 4px; min-width: 0; }
|
||||
.aq-page .aq-context-disclosure > summary { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 44px; color: var(--aq-muted); font-size: 13px; cursor: pointer; }
|
||||
.aq-context-disclosure > summary::-webkit-details-marker { display: none; }
|
||||
.aq-context-disclosure > summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.aq-context-disclosure[open] > summary > span { white-space: normal; }
|
||||
.aq-page .aq-context { flex-wrap: wrap; overflow: visible; padding: 4px 0; gap: 6px; max-height: 160px; overflow-y: auto; }
|
||||
.aq-page .aq-context > * { flex-shrink: 1; }
|
||||
.aq-page .aq-context .aq-filter-chip { max-width: 100%; font-size: 13px; overflow-wrap: anywhere; white-space: normal; }
|
||||
.aq-page .aq-context button { min-height: 44px; min-width: 44px; }
|
||||
.aq-page .aq-context .aq-topic-button { padding: 4px 10px; font-size: 13px; }
|
||||
.aq-page .aq-context-heading { font-size: 12px; }
|
||||
.aq-page .aq-composer-area { border-top: 1px solid var(--aq-line); }
|
||||
.aq-page .aq-composer-foot { font-size: 11px; }
|
||||
.aq-page .aq-latest-answer { min-height: 44px; font-size: 13px; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.aq-page .aq-header h1 { font-size: 16px; }
|
||||
.aq-page .aq-scope, .aq-page .aq-compound-heading > div > span,
|
||||
.aq-page .aq-section-number, .aq-page .aq-generated-notice,
|
||||
.aq-page .aq-generated-empty, .aq-page .aq-result-unit,
|
||||
.aq-page .aq-chart-caption, .aq-page .aq-bar-share,
|
||||
.aq-page .aq-model, .aq-page .aq-record-toolbar,
|
||||
.aq-page .aq-compound-notice, .aq-page .aq-inline-progress { font-size: 12px; }
|
||||
.aq-page .aq-secondary-details[open] > p { font-size: 14px; line-height: 1.75; margin-bottom: 8px; }
|
||||
.aq-page .aq-mobile-record-heading { flex-wrap: wrap; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { AssistantResult, PresentationBlock } from '../../../shared/assets/assistant';
|
||||
import { mobileBlockGroups, mobileResultSummary } from './mobile-presentation';
|
||||
|
||||
const result: AssistantResult = {
|
||||
plan: { kind: 'assets', category: 'inventory', groupBy: 'region', view: 'chart', filters: { type: ['4.5T'] } },
|
||||
title: '库存', summary: '完整说明和统计限制。', source: '资产台账', fetchedAt: '', rows: [],
|
||||
metrics: [{ label: '库存车辆', value: 200, unit: '台' }, { label: '其中异动', value: 8, unit: '台' }],
|
||||
groups: [{ name: '华东', count: 125, share: 62.5, operating: 0, rate: 0 }, { name: '华南', count: 75, share: 37.5, operating: 0, rate: 0 }],
|
||||
};
|
||||
test('mobile summary preserves computed total and share without adding subset metrics', () => {
|
||||
assert.equal(mobileResultSummary(result), '库存车辆 200 台;华东最多,125 台(62.5%)。');
|
||||
assert.equal(mobileResultSummary({ ...result, groups: [...result.groups].reverse() }), mobileResultSummary(result));
|
||||
});
|
||||
test('partial, exception, comparison, briefing and simple count answers retain their qualification', () => {
|
||||
for (const outcome of ['partial', 'empty', 'unsupported'] as const) assert.equal(mobileResultSummary({ ...result, outcome }), result.summary);
|
||||
for (const analysis of ['compare', 'exceptions', 'briefing'] as const) assert.equal(mobileResultSummary({ ...result, plan: { ...result.plan, analysis } }), result.summary);
|
||||
assert.equal(mobileResultSummary({ ...result, conciseCount: true }), result.summary);
|
||||
assert.equal(mobileResultSummary({ ...result, metrics: [] }), result.summary);
|
||||
});
|
||||
test('chart/table toggles only combine identical data and drill semantics; unrelated fields remain reachable', () => {
|
||||
const blocks: PresentationBlock[] = [
|
||||
{ id: 'chart', kind: 'chart', dataset: 'groups', interactionId: 'region' },
|
||||
{ id: 'table-other', kind: 'table', dataset: 'groups', interactionId: 'type' },
|
||||
{ id: 'table', kind: 'table', dataset: 'groups', interactionId: 'region', columns: [{ field: 'pending', label: '待交车' }] },
|
||||
{ id: 'rows', kind: 'table', dataset: 'rows' },
|
||||
{ id: 'second-chart', kind: 'chart', dataset: 'groups', interactionId: 'region' },
|
||||
{ id: 'explanation', kind: 'narrative', text: '统计限制' },
|
||||
];
|
||||
const grouped = mobileBlockGroups(blocks);
|
||||
assert.equal(grouped[0].table, blocks[2]);
|
||||
assert.equal(grouped.find(entry => entry.block.id === 'second-chart')?.table, undefined);
|
||||
assert.deepEqual(grouped.map(entry => entry.block.id), ['chart', 'table-other', 'rows', 'second-chart', 'explanation']);
|
||||
assert.equal(grouped.flatMap(entry => [entry.block, ...(entry.table ? [entry.table] : [])]).length, blocks.length);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { AssistantResult, PresentationBlock } from '../../../shared/assets/assistant';
|
||||
|
||||
/** Summarise computed values only; never add totals across independent scopes. */
|
||||
export function mobileResultSummary(result: AssistantResult): string {
|
||||
if (result.plan.kind !== 'assets' || (result.outcome && result.outcome !== 'complete') || (result.plan.analysis && result.plan.analysis !== 'distribution') || result.conciseCount) return result.summary;
|
||||
const total = result.metrics.find(metric => metric.unit === '台' && Number.isFinite(metric.value));
|
||||
if (!total) return result.summary;
|
||||
const largest = result.groups.filter(group => Number.isFinite(group.count)).reduce<(typeof result.groups)[number] | undefined>((lead, group) => !lead || group.count > lead.count ? group : lead, undefined);
|
||||
const headline = `${total.label} ${total.value.toLocaleString('zh-CN')} 台`;
|
||||
if (result.groups.length < 2 || !largest || !largest.count) return `${headline}。`;
|
||||
const share = largest.share;
|
||||
return `${headline};${largest.name}最多,${largest.count.toLocaleString('zh-CN')} 台${typeof share === 'number' && Number.isFinite(share) ? `(${share}%)` : ''}。`;
|
||||
}
|
||||
|
||||
export type MobileBlockGroup = { block: PresentationBlock; table?: PresentationBlock };
|
||||
/** Pair one chart and one table only when they share both data and drill semantics. */
|
||||
export function mobileBlockGroups(blocks: PresentationBlock[]): MobileBlockGroup[] {
|
||||
const paired = new Set<string>();
|
||||
const pairs = new Map<string, PresentationBlock>();
|
||||
for (const block of blocks) {
|
||||
if (block.kind !== 'chart' || !block.dataset) continue;
|
||||
const table = blocks.find(candidate => candidate.kind === 'table' && !paired.has(candidate.id) && candidate.dataset === block.dataset && candidate.interactionId === block.interactionId);
|
||||
if (table) { paired.add(table.id); pairs.set(block.id, table); }
|
||||
}
|
||||
return blocks.filter(block => !paired.has(block.id)).map(block => ({ block, table: pairs.get(block.id) }));
|
||||
}
|
||||
@@ -28,7 +28,7 @@ function InventoryVehicleDetails({ vehicles, kind }: DetailsProps & { kind: 'abn
|
||||
<p className="mt-1 text-xs text-slate-500">{vehicle.brandLabel || '—'} · {vehicle.type} · {vehicle.location === '其他' ? '对接中' : vehicle.location}</p>
|
||||
<dl className="mt-4 space-y-3 text-sm">
|
||||
{details(vehicle).map((value, index) => <div key={labels[index]} className="grid grid-cols-[6rem_minmax(0,1fr)] gap-2">
|
||||
<dt className="text-slate-500">{labels[index]}</dt>
|
||||
<dt className="text-slate-500">{pending && index === 1 ? "客户(已脱敏)" : labels[index]}</dt>
|
||||
<dd className="whitespace-pre-wrap break-words font-medium text-slate-800 [overflow-wrap:anywhere]"><Blur>{value}</Blur></dd>
|
||||
</div>)}
|
||||
</dl>
|
||||
|
||||
@@ -170,9 +170,9 @@ export function AssetsHeader({
|
||||
<span className="truncate">{s.name}</span>
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 flex-shrink-0 tabular-nums">
|
||||
{s.total} 台
|
||||
{s.historicalOnly ? '历史主体' : <>{s.total} 台
|
||||
<span className="mx-1 text-gray-200">·</span>
|
||||
<span className="text-green-500">运营 {s.operating}</span>
|
||||
<span className="text-green-500">运营 {s.operating}</span></>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import { ArrowLeft, X } from 'lucide-react';
|
||||
import type { FlowDetailItem, FlowType } from '../api';
|
||||
import type { FlowSelection } from '../model';
|
||||
@@ -12,6 +12,10 @@ import { AnalysisExportButton } from './analysis/AnalysisExportButton';
|
||||
import { downloadAnalysisWorkbook, statisticsExportRows } from '../analysis-export';
|
||||
import './analysis/asset-analysis.css';
|
||||
import './flow-analysis-modal.css';
|
||||
import './analysis/mobile-analysis.css';
|
||||
import { MobileAnalysisControls } from './analysis/MobileAnalysisControls';
|
||||
import { MobileGroupedTable } from './analysis/MobileGroupedTable';
|
||||
import { useMobileLayout } from '../useMobileLayout';
|
||||
|
||||
interface FlowAnalysisModalProps {
|
||||
selectedFlow: FlowSelection | null;
|
||||
@@ -48,7 +52,8 @@ function groupRows(rows: FlowDetailItem[], dimensions: Dimension[]): FlowGroup[]
|
||||
group.rows.push(row);
|
||||
groups.set(key, group);
|
||||
}
|
||||
return Array.from(groups.values()).sort((a, b) => b.rows.length - a.rows.length || a.key.localeCompare(b.key, 'zh-CN'));
|
||||
const dateIndex = dimensions.indexOf('date');
|
||||
return Array.from(groups.values()).sort((a, b) => (dateIndex >= 0 ? b.values[dateIndex].localeCompare(a.values[dateIndex]) : 0) || b.rows.length - a.rows.length || a.key.localeCompare(b.key, 'zh-CN'));
|
||||
}
|
||||
|
||||
export function FlowAnalysisModal(props: FlowAnalysisModalProps) {
|
||||
@@ -57,6 +62,7 @@ export function FlowAnalysisModal(props: FlowAnalysisModalProps) {
|
||||
}
|
||||
|
||||
function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlow }: Omit<FlowAnalysisModalProps, 'selectedFlow'> & { selectedFlow: FlowSelection }) {
|
||||
const mobile = useMobileLayout();
|
||||
const [dimensions, setDimensions] = useState<Dimension[]>(['date']);
|
||||
const [mergeParents, setMergeParents] = useState(true);
|
||||
const mergeActive = mergeParents && dimensions.length > 1;
|
||||
@@ -68,7 +74,8 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo
|
||||
useDialogFocus(true, dialog, close);
|
||||
|
||||
const rows = useMemo(() => selectedFlowDetails.filter(row => matchesType(row, selectedFlow.type)
|
||||
&& ('date' in selectedFlow ? row.date === selectedFlow.date : row.date >= selectedFlow.start && row.date <= selectedFlow.end)), [selectedFlowDetails, selectedFlow]);
|
||||
&& ('date' in selectedFlow ? row.date === selectedFlow.date : row.date >= selectedFlow.start && row.date <= selectedFlow.end))
|
||||
.sort((a, b) => b.date.localeCompare(a.date) || (b.eventTime || '').localeCompare(a.eventTime || '')), [selectedFlowDetails, selectedFlow]);
|
||||
const filterOptions = useMemo(() => Object.fromEntries(DIMENSIONS.map(({ key }) => {
|
||||
const eligible = rows.filter(row => DIMENSIONS.every(({ key: other }) => other === key || !filters[other]?.length || filters[other]!.includes(dimensionValue(row, other))));
|
||||
const counts = new Map<string, number>();
|
||||
@@ -79,11 +86,15 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo
|
||||
const filteredRows = useMemo(() => rows.filter(row => DIMENSIONS.every(({ key }) => !filters[key]?.length || filters[key]!.includes(dimensionValue(row, key)))), [rows, filters]);
|
||||
const groups = useMemo(() => {
|
||||
const flat = groupRows(filteredRows, dimensions);
|
||||
return mergeActive ? orderDimensionGroups(flat, dimensions.length, group => group.values, group => group.rows.length) : flat;
|
||||
return mergeActive ? orderDimensionGroups(flat, dimensions.length, group => group.values, group => group.rows.length, dimensions.includes('date') ? 'label' : 'count-desc',
|
||||
(a, b, index) => dimensions[index] === 'date' ? b.values[index].localeCompare(a.values[index]) : a.values[index].localeCompare(b.values[index], 'zh-CN')) : flat;
|
||||
}, [filteredRows, dimensions, mergeActive]);
|
||||
const detailGroup = useMemo(() => detailKey === null ? null : groups.find(group => group.key === detailKey), [groups, detailKey]);
|
||||
const detailRows = detailGroup?.rows ?? EMPTY_FLOW_ROWS;
|
||||
const showDetails = detailKey !== null;
|
||||
const overviewScroll = useRef(0);
|
||||
const openDetails = (key: string) => { overviewScroll.current = scrollRoot.current?.scrollTop || 0; setDetailKey(key); };
|
||||
useLayoutEffect(() => { if (scrollRoot.current) scrollRoot.current.scrollTop = showDetails ? 0 : overviewScroll.current; }, [showDetails]);
|
||||
const detailLabel = detailGroup && dimensions.length ? detailGroup.values.map((value, i) => `${DIMENSION_LABELS[dimensions[i]]}:${dimensionLabel(dimensions[i], value)}`).join(' · ') : '当前筛选范围';
|
||||
const selectedRows = showDetails ? detailRows : filteredRows;
|
||||
const { replacedCount, departmentCount } = useMemo(() => ({
|
||||
@@ -93,7 +104,7 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo
|
||||
const rangeLabel = 'date' in selectedFlow ? selectedFlow.date : `${selectedFlow.start} 至 ${selectedFlow.end}`;
|
||||
const parentTotals = useMemo(() => mergeActive ? dimensionParentTotals(groups, dimensions.length, group => group.values, group => group.rows.length) : [], [groups, dimensions.length, mergeActive]);
|
||||
const childCounts = useMemo(() => mergeActive ? dimensionChildCounts(groups, dimensions.length, group => group.values) : [], [groups, dimensions.length, mergeActive]);
|
||||
const groupProgress = useProgressiveRows({ total: groups.length, rootRef: scrollRoot, resetKey: groups, enabled: !showDetails });
|
||||
const groupProgress = useProgressiveRows({ total: groups.length, rootRef: scrollRoot, resetKey: groups, enabled: !showDetails, preserveOnDisable: true });
|
||||
const detailProgress = useProgressiveRows({ total: detailRows.length, rootRef: scrollRoot, resetKey: detailRows, enabled: showDetails });
|
||||
const shownGroups = useMemo(() => groups.slice(0, groupProgress.visibleCount), [groups, groupProgress.visibleCount]);
|
||||
const completeSpans = useMemo(() => mergeActive ? dimensionRowSpans(groups, dimensions.length, group => group.values) : [], [groups, dimensions.length, mergeActive]);
|
||||
@@ -121,19 +132,19 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo
|
||||
return <div className="bi-flow-modal-backdrop" onClick={event => { if (event.target === event.currentTarget) close(); }}>
|
||||
<div ref={dialog} role="dialog" aria-modal="true" aria-labelledby="bi-flow-modal-title" tabIndex={-1} className="bi-flow-modal" data-testid="asset-flow-analysis-modal">
|
||||
<header className="bi-flow-modal-header">
|
||||
<div className="bi-flow-modal-header-row"><div><h3 id="bi-flow-modal-title">{FLOW_META[selectedFlow.type].label}分析</h3><p>{rangeLabel} · 运维签章时间 · 车次</p></div><button type="button" aria-label="关闭交还车分析" onClick={close} className="bi-flow-modal-close"><X size={16} /></button></div>
|
||||
<p className="bi-flow-modal-definition">交车包含替换交车;业务类型按实际记录拆分,同车多笔业务分别计次。</p>
|
||||
<div className="bi-flow-modal-header-row">{mobile && <button className="bi-flow-mobile-back" aria-label={showDetails ? "返回交还车统计" : "返回资产管理"} onClick={() => showDetails ? setDetailKey(null) : close()}><ArrowLeft size={20} /></button>}<div><h3 id="bi-flow-modal-title">{FLOW_META[selectedFlow.type].label}分析</h3><p>{rangeLabel} · 运维签章时间 · 车次</p></div><button type="button" aria-label="关闭交还车分析" onClick={close} className="bi-flow-modal-close"><X size={16} /></button></div>
|
||||
{!mobile && <p className="bi-flow-modal-definition">交车包含替换交车;业务类型按实际记录拆分,同车多笔业务分别计次。</p>}
|
||||
</header>
|
||||
|
||||
<div ref={scrollRoot} className="bi-flow-modal-body">
|
||||
{!showDetails ? <div className="bi-flow-modal-controls">
|
||||
{!mobile && (!showDetails ? <div className="bi-flow-modal-controls">
|
||||
<div className="bi-flow-modal-control-row"><span className="bi-flow-modal-control-label">分析维度</span><div className="adp-row" role="group" aria-label="选择交还车统计维度">
|
||||
{DIMENSIONS.map(({ key, label }) => <DimensionPicker key={key} label={label} grouped={dimensions.includes(key)} order={dimensions.indexOf(key) + 1} options={filterOptions[key]} selectedValues={filters[key]} onToggleGroup={() => toggleDimension(key)} onApplyFilter={values => changeFilter(key, values)} />)}
|
||||
<button type="button" className={`bi-flow-modal-chip${dimensions.length === 0 ? ' bi-flow-modal-chip-active' : ''}`} aria-pressed={dimensions.length === 0} onClick={() => { setDimensions([]); resetResult(); }}>仅筛选</button>
|
||||
{activeFilters.length ? <button type="button" className="bi-flow-modal-clear" onClick={() => { setFilters({}); resetResult(); }}>清空筛选</button> : null}
|
||||
</div></div>
|
||||
<p className="bi-flow-modal-control-hint">点击维度名切换分组,点击右侧箭头筛选;同维度多选为“或”,不同维度为“且”。</p>
|
||||
</div> : <div className="bi-flow-modal-detail-nav"><button type="button" className="bi-flow-modal-back" onClick={() => { setDetailKey(null); }}><ArrowLeft size={13} />返回统计</button><span>{detailLabel}</span></div>}
|
||||
</div> : <div className="bi-flow-modal-detail-nav"><button type="button" className="bi-flow-modal-back" onClick={() => { setDetailKey(null); }}><ArrowLeft size={13} />返回统计</button><span>{detailLabel}</span></div>)}
|
||||
|
||||
<div className="bi-flow-modal-summary">
|
||||
<div><span>{showDetails ? '当前分组' : '当前筛选'}{FLOW_META[selectedFlow.type].label}</span><strong>{countLabel(selectedRows.length)}<small>车次</small></strong></div>
|
||||
@@ -141,14 +152,19 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo
|
||||
{selectedFlow.type === 'delivered' ? <div><span>其中:替换交车</span><strong className="bi-flow-modal-purple">{countLabel(replacedCount)}<small>车次</small></strong></div> : <div><span>{showDetails ? '占当前筛选' : '统计分组'}</span><strong>{showDetails ? `${filteredRows.length ? (selectedRows.length / filteredRows.length * 100).toFixed(1) : '0.0'}%` : groups.length}{!showDetails ? <small>组</small> : null}</strong></div>}
|
||||
</div>
|
||||
|
||||
<div className="bi-flow-modal-section-heading"><div><h4>{showDetails ? '业务明细' : dimensions.length ? '组合统计' : '筛选汇总'}</h4><p>{showDetails ? `共 ${countLabel(detailRows.length)} 条业务记录` : `共 ${countLabel(groups.length)} 组 · 占比以当前筛选的 ${countLabel(filteredRows.length)} 车次为基数`}</p></div>
|
||||
{!showDetails && dimensions.length > 1 ? <label className="bi-flow-modal-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label> : null}
|
||||
{mobile && !showDetails && (filteredRows.length > 0 || activeFilters.length > 0) && <MobileAnalysisControls available={DIMENSIONS.map(d => ({ ...d, options: filterOptions[d.key] }))} dimensions={dimensions} filters={filters} defaults={['date']} apply={(nextDimensions, nextFilters) => { setDimensions(nextDimensions as Dimension[]); setFilters(nextFilters); resetResult(); }} />}
|
||||
{mobile && showDetails && <p className="analysis-mobile-detail-scope">{detailLabel}</p>}
|
||||
<div className="bi-flow-modal-section-heading"><div>{(!mobile || showDetails) && <h4>{showDetails ? '业务明细' : dimensions.length ? '组合统计' : '筛选汇总'}</h4>}<p>{showDetails ? `共 ${countLabel(detailRows.length)} 条业务记录` : mobile ? `共 ${countLabel(groups.length)} 组 · ${countLabel(filteredRows.length)} 车次` : `共 ${countLabel(groups.length)} 组 · 占比以当前筛选的 ${countLabel(filteredRows.length)} 车次为基数`}</p></div>
|
||||
{!mobile && !showDetails && dimensions.length > 1 ? <label className="bi-flow-modal-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label> : null}
|
||||
<div className="analysis-export-actions">
|
||||
<AnalysisExportButton disabled={!selectedRows.length} onExport={exportWorkbook} />
|
||||
{(!mobile || selectedRows.length > 0) && <AnalysisExportButton disabled={!selectedRows.length} onExport={exportWorkbook} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!showDetails ? groups.length ? <>
|
||||
{mobile ? <MobileGroupedTable dimensions={dimensions.map(d => DIMENSION_LABELS[d])} unit="车次" merged={mergeActive} setMerged={setMergeParents} spans={groupSpans} parentTotals={parentTotals}
|
||||
rows={shownGroups.map(group => ({ key: group.key, label: group.values.map((value, i) => dimensionLabel(dimensions[i], value)).join('、') || '当前筛选范围', values: group.values.map((value, i) => dimensionLabel(dimensions[i], value)), count: group.rows.length, share: group.rows.length / filteredRows.length * 100 }))}
|
||||
onDetails={index => openDetails(shownGroups[index].key)} /> :
|
||||
<div className={`bi-flow-modal-table-wrap${mergeActive ? ' is-merged' : ''}`}><table className="bi-flow-modal-table" data-testid="asset-flow-group-table"><thead><tr>{dimensions.length ? dimensions.map(dimension => <th key={dimension}>{DIMENSION_LABELS[dimension]}</th>) : <th>范围</th>}<th className="bi-flow-modal-number">车次</th><th className="bi-flow-modal-share">占比</th><th className="bi-flow-modal-action">操作</th></tr></thead><tbody>
|
||||
{shownGroups.map((group, rowIndex) => <tr key={group.key} className={mergeActive && groupSpans[rowIndex][0] > 0 ? 'bi-flow-modal-group-start' : undefined}>
|
||||
{dimensions.length ? group.values.map((value, index) => {
|
||||
@@ -156,16 +172,18 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo
|
||||
return span === 0 ? null : <td key={dimensions[index]} rowSpan={span > 1 ? span : undefined} className={span > 1 ? 'bi-flow-modal-parent-cell' : undefined}>{dimensionLabel(dimensions[index], value)}{mergeActive && index < dimensions.length - 1 && childCounts[rowIndex][index] > 1 && <><small className="bi-flow-modal-parent-total" title="当前筛选范围内该分组的完整合计(全部筛选结果)">合计 <strong>{countLabel(parentTotals[rowIndex][index])}</strong> 车次</small><small className="bi-flow-modal-parent-children" title="当前筛选范围内下一级维度的去重分组数(全部筛选结果)">{DIMENSION_LABELS[dimensions[index + 1]]}:<strong>{countLabel(childCounts[rowIndex][index])}</strong> {dimensionCountUnit(dimensions[index + 1])}</small></>}</td>;
|
||||
}) : <td>当前筛选范围</td>}
|
||||
<td className="bi-flow-modal-number"><strong>{countLabel(group.rows.length)}</strong></td><td className="bi-flow-modal-share"><div><span className="bi-flow-modal-track"><span className={`bi-flow-modal-fill bi-flow-modal-fill-${selectedFlow.type}`} style={{ width: `${group.rows.length / filteredRows.length * 100}%` }} /></span><span>{(group.rows.length / filteredRows.length * 100).toFixed(1)}%</span></div></td>
|
||||
<td className="bi-flow-modal-action"><button type="button" aria-label={`查看${group.values.map((value, i) => dimensionLabel(dimensions[i], value)).join('、') || '当前筛选范围'}明细`} onClick={() => { setDetailKey(group.key); }}>查看明细</button></td>
|
||||
<td className="bi-flow-modal-action"><button type="button" aria-label={`查看${group.values.map((value, i) => dimensionLabel(dimensions[i], value)).join('、') || '当前筛选范围'}明细`} onClick={() => openDetails(group.key)}>查看明细</button></td>
|
||||
</tr>)}
|
||||
</tbody></table></div>
|
||||
</tbody></table></div>}
|
||||
<ProgressiveRowsStatus {...groupProgress} unit="组" />
|
||||
</> : <div className="bi-flow-modal-empty">当前筛选范围暂无交还车数据,请调整筛选。</div>
|
||||
</> : <div className="bi-flow-modal-empty">{activeFilters.length ? '当前筛选范围暂无交还车数据,可调整筛选。' : '当前时段暂无交还车记录。'}</div>
|
||||
: detailRows.length ? <>
|
||||
<div className="bi-flow-modal-table-wrap" data-testid="asset-flow-final-details"><table className="bi-flow-modal-table bi-flow-modal-detail-table"><thead><tr><th>车牌</th><th>业务类型</th><th>运维签章时间</th><th>建单时间</th><th>部门</th><th>业务负责人</th><th>客户</th><th>合同</th><th>项目名称</th></tr></thead><tbody>{shownDetails.map(item => <tr key={item.id}><td>{item.plateNumber || '未登记车牌'}</td><td>{FLOW_META[item.type].label}</td><td>{item.eventTime || '—'}</td><td>{item.submitTime || '—'}</td><td>{dimensionLabel('department', dimensionValue(item, 'department'))}</td><td>{item.manager || '—'}</td><td>{item.customerName || '—'}</td><td>{item.contractNo || '—'}</td><td>{item.projectName || '—'}</td></tr>)}</tbody></table></div>
|
||||
{mobile ? <div className="analysis-vehicle-cards" data-testid="asset-flow-final-details">{shownDetails.map(item => <article className="analysis-vehicle-card" key={item.id}><header><h4>{item.plateNumber || '未登记车牌'}</h4><span>{FLOW_META[item.type].label}</span></header><p>{item.eventTime || item.date} · {item.department || '未填写部门'}</p><p>{item.customerName || '未填写客户'}</p><details><summary>业务资料</summary><dl>{[['运维签章时间', item.eventTime], ['建单时间', item.submitTime], ['业务负责人', item.manager], ['合同', item.contractNo], ['项目名称', item.projectName]].map(([label,value]) => <div key={label}><dt>{label}</dt><dd>{value || '未提供'}</dd></div>)}</dl></details></article>)}</div> :
|
||||
<div className="bi-flow-modal-table-wrap" data-testid="asset-flow-final-details"><table className="bi-flow-modal-table bi-flow-modal-detail-table"><thead><tr><th>车牌</th><th>业务类型</th><th>运维签章时间</th><th>建单时间</th><th>部门</th><th>业务负责人</th><th>客户</th><th>合同</th><th>项目名称</th></tr></thead><tbody>{shownDetails.map(item => <tr key={item.id}><td>{item.plateNumber || '未登记车牌'}</td><td>{FLOW_META[item.type].label}</td><td>{item.eventTime || '—'}</td><td>{item.submitTime || '—'}</td><td>{dimensionLabel('department', dimensionValue(item, 'department'))}</td><td>{item.manager || '—'}</td><td>{item.customerName || '—'}</td><td>{item.contractNo || '—'}</td><td>{item.projectName || '—'}</td></tr>)}</tbody></table></div>}
|
||||
<ProgressiveRowsStatus {...detailProgress} unit="条" />
|
||||
</> : <div className="bi-flow-modal-empty">当前分组暂无业务明细,请返回统计调整条件。</div>}
|
||||
</div>
|
||||
{mobile && <footer className="analysis-footer"><details><summary>统计口径</summary><p>按运维签章时间统计。交车包含替换交车;同车多笔业务分别计次。仅展示当前可查看范围与筛选条件内的记录。</p></details></footer>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AssetSummarySection } from './overview/AssetSummarySection';
|
||||
import { FlowStatisticsCard } from './overview/FlowStatisticsCard';
|
||||
import { InventoryStatisticsSection } from './overview/InventoryStatisticsSection';
|
||||
import { SummaryMetrics } from './overview/SummaryMetrics';
|
||||
import { AssetHistorySection } from '../history/AssetHistorySection';
|
||||
import type { OverviewViewProps } from './overview/types';
|
||||
|
||||
export function OverviewView(props: OverviewViewProps) {
|
||||
@@ -15,6 +16,8 @@ export function OverviewView(props: OverviewViewProps) {
|
||||
setVehicleSelection={props.setVehicleSelection}
|
||||
/>
|
||||
|
||||
<AssetHistorySection subject={props.selectedSubject} onSubjects={props.onHistorySubjects} />
|
||||
|
||||
<FlowStatisticsCard
|
||||
flowRange={props.flowRange}
|
||||
setFlowRange={props.setFlowRange}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Check, ChevronDown, Filter, Plus, RotateCcw, Search, X } from 'lucide-react';
|
||||
import type { AssetAnalysisCell, AssetAnalysisDimension as Dimension } from '../../../../shared/assets/analytics';
|
||||
import { ANALYSIS_META, DIMENSION_LABELS, filterCustomCells, getDimensionValueLabel, type AnalysisFilterValues, type AnalysisView } from '../../analytics-model';
|
||||
import { ANALYSIS_META, DIMENSION_LABELS, filterCustomCells, getDefaultDimensions, getDimensionValueLabel, type AnalysisFilterValues, type AnalysisView } from '../../analytics-model';
|
||||
import { useMobileLayout } from '../../useMobileLayout';
|
||||
import { MobileAnalysisControls } from './MobileAnalysisControls';
|
||||
|
||||
type Props = {
|
||||
view: AnalysisView;
|
||||
@@ -15,6 +17,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export function AnalysisControls({ view, cells, available, dimensions, setDimensions, filters, setFilters, reset }: Props) {
|
||||
const mobile = useMobileLayout();
|
||||
const root = useRef<HTMLElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
@@ -56,6 +59,14 @@ export function AnalysisControls({ view, cells, available, dimensions, setDimens
|
||||
if (draft.length) next[editor] = draft; else delete next[editor];
|
||||
setFilters(next); closeEditor();
|
||||
};
|
||||
const mobileOptions = useMemo(() => mobile ? available.map(key => {
|
||||
const other = { ...filters }; delete other[key];
|
||||
const counts = new Map<string, number>();
|
||||
filterCustomCells(cells, other, metric).forEach(cell => counts.set(cell[key], (counts.get(cell[key]) || 0) + cell.quantity));
|
||||
for (const value of filters[key] || []) if (!counts.has(value)) counts.set(value, 0);
|
||||
return { key, label: DIMENSION_LABELS[key], options: [...counts].map(([value, count]) => ({ value, count, label: getDimensionValueLabel(key, value) })).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'zh-CN')) };
|
||||
}) : [], [mobile, available, cells, filters, metric]);
|
||||
if (mobile) return <MobileAnalysisControls available={mobileOptions} dimensions={dimensions} filters={filters} defaults={getDefaultDimensions(view)} apply={(nextDimensions, nextFilters) => { setDimensions(nextDimensions as Dimension[]); setFilters(nextFilters); }} />;
|
||||
return <section ref={root} className="analysis-builder" aria-label="自定义分析设置">
|
||||
<div className="analysis-builder-heading"><h3>分析维度</h3><span>点名称组合统计 · 点箭头搜索筛选</span><button className="analysis-reset" onClick={() => { reset(); setEditor(null); }}><RotateCcw size={12} />重置分析</button></div>
|
||||
<div className="analysis-dimension-options" role="group" aria-label="选择分析维度">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ArrowLeft, BarChart3, Filter, Loader2, Search, X } from 'lucide-react';
|
||||
import { fetchJson } from '../../../../auth/api-client';
|
||||
import Blur from '../../../../components/Blur';
|
||||
@@ -15,8 +15,12 @@ import type { VehicleListItem } from '../../types';
|
||||
import { AbnormalVehicleDetails, PendingVehicleDetails } from '../AbnormalVehicleDetails';
|
||||
import { useDialogFocus } from '../useDialogFocus';
|
||||
import { AnalysisExportButton } from './AnalysisExportButton';
|
||||
import { MobileAnalysisControls } from './MobileAnalysisControls';
|
||||
import { MobileGroupedTable } from './MobileGroupedTable';
|
||||
import { downloadAnalysisWorkbook, statisticsExportRows, vehicleExportRows } from '../../analysis-export';
|
||||
import { useMobileLayout } from '../../useMobileLayout';
|
||||
import './asset-analysis.css';
|
||||
import './mobile-analysis.css';
|
||||
|
||||
type Props = { selection: VehicleModalSelection | null; subject: string | null; close: () => void };
|
||||
const number = (value: number) => value.toLocaleString('zh-CN');
|
||||
@@ -36,6 +40,7 @@ export function AssetAnalysisModal({ selection, subject, close }: Props) {
|
||||
}
|
||||
|
||||
function AnalysisDialog({ selection, subject, close }: Props & { selection: VehicleModalSelection }) {
|
||||
const mobile = useMobileLayout();
|
||||
const dialog = useRef<HTMLDivElement>(null);
|
||||
const scrollRoot = useRef<HTMLDivElement>(null);
|
||||
useDialogFocus(true, dialog, close);
|
||||
@@ -51,6 +56,9 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
|
||||
const [mergeParents, setMergeParents] = useState(true);
|
||||
const [detailGroup, setDetailGroup] = useState<CustomAnalysisRow | null>(null);
|
||||
const showDetails = detailGroup !== null;
|
||||
const overviewScroll = useRef(0);
|
||||
const openDetails = (row: CustomAnalysisRow) => { overviewScroll.current = scrollRoot.current?.scrollTop || 0; setDetailGroup(row); };
|
||||
useLayoutEffect(() => { if (scrollRoot.current) scrollRoot.current.scrollTop = showDetails ? 0 : overviewScroll.current; }, [showDetails]);
|
||||
const [vehicles, setVehicles] = useState<VehicleListItem[]>([]);
|
||||
const [detailBusy, setDetailBusy] = useState(false);
|
||||
const [detailError, setDetailError] = useState('');
|
||||
@@ -107,7 +115,7 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
|
||||
: analysis.rows, [analysis, merged, sort]);
|
||||
const parentTotals = useMemo(() => merged ? dimensionParentTotals(orderedGroups, analysis.dimensions.length, row => analysis.dimensions.map(dimension => row.values[dimension] || ''), row => row.count) : [], [orderedGroups, analysis.dimensions, merged]);
|
||||
const childCounts = useMemo(() => merged ? dimensionChildCounts(orderedGroups, analysis.dimensions.length, row => analysis.dimensions.map(dimension => row.values[dimension] || '')) : [], [orderedGroups, analysis.dimensions, merged]);
|
||||
const groupProgress = useProgressiveRows({ total: orderedGroups.length, rootRef: scrollRoot, resetKey: orderedGroups, enabled: !showDetails && !busy, batchSize: 40 });
|
||||
const groupProgress = useProgressiveRows({ total: orderedGroups.length, rootRef: scrollRoot, resetKey: orderedGroups, enabled: !showDetails && !busy, batchSize: 40, preserveOnDisable: true });
|
||||
const shownGroups = useMemo(() => orderedGroups.slice(0, groupProgress.visibleCount), [orderedGroups, groupProgress.visibleCount]);
|
||||
const completeSpans = useMemo(() => merged ? dimensionRowSpans(orderedGroups, analysis.dimensions.length, row => analysis.dimensions.map(dimension => row.values[dimension] || '')) : [], [merged, orderedGroups, analysis.dimensions]);
|
||||
const spans = useMemo(() => visibleDimensionRowSpans(completeSpans, groupProgress.visibleCount), [completeSpans, groupProgress.visibleCount]);
|
||||
@@ -128,21 +136,25 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
|
||||
extra: view === 'inventory' ? { 待交车: row.totals.pending, 异动: row.totals.abnormal } : undefined })),
|
||||
showDetails ? rows.length : analysis.total,
|
||||
);
|
||||
await downloadAnalysisWorkbook(statistics, vehicleExportRows(rows), `${title}-${scopeLabel}${detailGroup ? `-${detailGroup.label}` : ''}`);
|
||||
await downloadAnalysisWorkbook(statistics, vehicleExportRows(rows, view === 'inventory'), `${title}-${scopeLabel}${detailGroup ? `-${detailGroup.label}` : ''}`);
|
||||
};
|
||||
return <div className="asset-analysis-overlay">
|
||||
<div ref={dialog} role="dialog" aria-modal="true" aria-label={title} tabIndex={-1} className={`asset-analysis-dialog ${view === 'abnormal' ? 'analysis-amber' : ''}`}>
|
||||
<header className="analysis-header"><div><h2><BarChart3 size={16} />{title}</h2><p>{scopeLabel}</p></div><button type="button" aria-label="关闭统计分析" onClick={close}><X size={18} /></button></header>
|
||||
{busy ? <div className="analysis-message" role="status"><Loader2 className="animate-spin" />正在汇总统计数据…</div> : error ? <div className="analysis-message" role="alert"><p>统计数据加载失败:{error}</p><button className="analysis-button" onClick={() => setRetry(retry + 1)}>重新加载</button></div> : request.kind === 'weekly' ? <WeeklyAnalysis rows={weekly} /> : <>
|
||||
{showDetails && <div className="analysis-navigation"><button className="analysis-back" onClick={() => setDetailGroup(null)}><ArrowLeft size={14} />返回统计分析</button><span className="analysis-detail-scope">{detailGroup.label}</span></div>}
|
||||
{(!mobile || request.kind !== 'weekly' || busy || error) && <header className="analysis-header">{mobile && <button type="button" aria-label={showDetails ? "返回统计分析" : "返回资产管理"} onClick={() => showDetails ? setDetailGroup(null) : close()}><ArrowLeft size={20} /></button>}<div><h2><BarChart3 size={16} />{title}</h2><p>{scopeLabel}</p></div><button type="button" aria-label="关闭统计分析" onClick={close}><X size={18} /></button></header>}
|
||||
{busy ? <div className="analysis-message" role="status"><Loader2 className="animate-spin" />正在汇总统计数据…</div> : error ? <div className="analysis-message" role="alert"><p>统计数据加载失败:{error}</p><button className="analysis-button" onClick={() => setRetry(retry + 1)}>重新加载</button></div> : request.kind === 'weekly' ? <WeeklyAnalysis rows={weekly} title={title} scopeLabel={scopeLabel} close={close} /> : <>
|
||||
{showDetails && !mobile && <div className="analysis-navigation"><button className="analysis-back" onClick={() => setDetailGroup(null)}><ArrowLeft size={14} />返回统计分析</button><span className="analysis-detail-scope">{detailGroup.label}</span></div>}
|
||||
<div ref={scrollRoot} className="analysis-scroll">
|
||||
{!showDetails && <>
|
||||
<AnalysisControls view={view} cells={data?.cells ?? []} available={available} dimensions={dimensions} setDimensions={updateDimensions} filters={filterValues} setFilters={updateFilters} reset={reset} />
|
||||
{!mobile && <AnalysisControls view={view} cells={data?.cells ?? []} available={available} dimensions={dimensions} setDimensions={updateDimensions} filters={filterValues} setFilters={updateFilters} reset={reset} />}
|
||||
<div className="analysis-result-bar" role="status" aria-live="polite"><span>当前结果 <strong>{number(analysis.total)}</strong> 台</span><span>{analysis.rows.length} 个分组</span><span className="analysis-result-dimensions">{analysis.dimensions.length ? analysis.dimensions.map(dimension => DIMENSION_LABELS[dimension]).join(' × ') : '仅汇总,不分组'}</span></div>
|
||||
{view === 'inventory' && <div className="analysis-inventory-subsets" aria-label="库存子集统计"><span className="analysis-pending-count">待交车 <strong>{number(analysis.summary.pending)}</strong> 台</span><span className="analysis-abnormal-count">异动 <strong>{number(analysis.summary.abnormal)}</strong> 台</span><small>已包含在当前库存数量中</small></div>}
|
||||
{view === 'abnormal' && <p className="analysis-definition">已异动时长=统计时间-审批通过时间;区间左含右不含,缺失时间单列。{data && !data.approvalTimeAvailable && ' 当前未取得可用审批时间。'}</p>}
|
||||
<div className="analysis-table-tools"><p>{merged ? '按层级汇总数量排序,相同上级合并展示。' : '筛选分组可继续分析,查看明细可核对车辆。'}</p><div className="analysis-table-options"><AnalysisExportButton disabled={!analysis.total} onExport={exportWorkbook} />{analysis.dimensions.length > 1 && <label className="analysis-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label>}<label>排序<select aria-label="统计分组排序" value={sort} onChange={event => { setSort(event.target.value as AnalysisSort); }}><option value="count-desc">数量从多到少</option><option value="count-asc">数量从少到多</option><option value="label">维度顺序</option></select></label></div></div>
|
||||
{analysis.total === 0 ? <div className="analysis-message"><BarChart3 size={22} /><h3>没有符合条件的数据</h3><p>移除部分筛选条件,或恢复当前入口的全部数据。</p>{Object.keys(filterValues).length > 0 && <button className="analysis-button" onClick={() => updateFilters({})}>清空筛选</button>}</div> : <>
|
||||
{mobile && (analysis.total > 0 || Object.keys(filterValues).length > 0) && <AnalysisControls view={view} cells={data?.cells ?? []} available={available} dimensions={dimensions} setDimensions={updateDimensions} filters={filterValues} setFilters={updateFilters} reset={reset} />}
|
||||
{!mobile && view === 'abnormal' && <p className="analysis-definition">已异动时长=统计时间-审批通过时间;区间左含右不含,缺失时间单列。{data && !data.approvalTimeAvailable && ' 当前未取得可用审批时间。'}</p>}
|
||||
<div className={`analysis-table-tools analysis-overview-tools${!analysis.total ? ' is-empty' : ''}`}><p>{merged ? '按层级汇总数量排序,相同上级合并展示。' : '筛选分组可继续分析,查看明细可核对车辆。'}</p><div className="analysis-table-options"><AnalysisExportButton disabled={!analysis.total} onExport={exportWorkbook} />{analysis.dimensions.length > 1 && <label className="analysis-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label>}<label>排序<select aria-label="统计分组排序" value={sort} onChange={event => { setSort(event.target.value as AnalysisSort); }}><option value="count-desc">数量从多到少</option><option value="count-asc">数量从少到多</option><option value="label">维度顺序</option></select></label></div></div>
|
||||
{analysis.total === 0 ? <div className="analysis-message"><BarChart3 size={22} /><h3>{Object.keys(filterValues).length ? '没有符合条件的数据' : view === 'abnormal' ? '当前无异动车辆' : view === 'pending' ? '当前无待交车辆' : '当前范围暂无车辆'}</h3><p>{Object.keys(filterValues).length ? '可移除筛选条件后重新查看。' : '当前可查看范围内没有相关记录。'}</p>{Object.keys(filterValues).length > 0 && <button className="analysis-button" onClick={() => updateFilters({})}>清空筛选</button>}</div> : <>
|
||||
{mobile ? <MobileGroupedTable dimensions={analysis.dimensions.map(d => DIMENSION_LABELS[d])} unit="台" merged={merged} setMerged={setMergeParents} spans={spans} parentTotals={parentTotals}
|
||||
rows={shownGroups.map(row => ({ key: row.key, label: row.label, values: analysis.dimensions.map(d => <Blur key={d}>{getDimensionValueLabel(d, row.values[d] || '')}</Blur>), count: row.count, share: row.share, note: view === 'inventory' && (row.totals.pending || row.totals.abnormal) ? [row.totals.pending ? `待交 ${row.totals.pending}` : '', row.totals.abnormal ? `异动 ${row.totals.abnormal}` : ''].filter(Boolean).join(' · ') : undefined }))}
|
||||
onDetails={index => openDetails(shownGroups[index])} onFilter={index => narrowToGroup(shownGroups[index])} /> : <>
|
||||
<div className={`analysis-group-table${merged ? ' is-merged' : ''}`} role="region" aria-label="组合统计表" tabIndex={0}><table>
|
||||
<thead><tr>{analysis.dimensions.length ? analysis.dimensions.map(dimension => <th key={dimension}>{DIMENSION_LABELS[dimension]}</th>) : <th>统计范围</th>}<th className="analysis-numeric">数量(台)</th>{view === 'inventory' && <><th className="analysis-numeric">待交车</th><th className="analysis-numeric">异动</th></>}<th className="analysis-numeric">占比</th><th>分析操作</th></tr></thead>
|
||||
<tbody>{shownGroups.map((row, rowIndex) => <tr key={row.key} className={merged && spans[rowIndex][0] > 0 ? 'analysis-group-start' : undefined}>
|
||||
@@ -153,36 +165,44 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
|
||||
<td className="analysis-numeric" data-label="数量(台)"><strong>{number(row.count)}</strong></td>
|
||||
{view === 'inventory' && <><td className={`analysis-numeric ${row.totals.pending ? 'analysis-pending-count' : 'analysis-zero-count'}`} data-label="待交车(台)">{number(row.totals.pending)}</td><td className={`analysis-numeric ${row.totals.abnormal ? 'analysis-abnormal-count' : 'analysis-zero-count'}`} data-label="异动(台)">{number(row.totals.abnormal)}</td></>}
|
||||
<td className="analysis-share-cell" data-label="占比"><span>{row.share.toFixed(1)}%</span><span className="analysis-bar-track" aria-hidden="true"><span style={{ width: `${row.share}%` }} /></span></td>
|
||||
<td className="analysis-group-actions">{analysis.dimensions.length > 0 && <button aria-label={`筛选分组 ${row.label}`} onClick={() => narrowToGroup(row)}><Filter size={12} />筛选此组</button>}<button aria-label={`查看明细 ${row.label}`} onClick={() => setDetailGroup(row)}>查看明细</button></td>
|
||||
<td className="analysis-group-actions">{analysis.dimensions.length > 0 && <button aria-label={`筛选分组 ${row.label}`} onClick={() => narrowToGroup(row)}><Filter size={12} />筛选此组</button>}<button aria-label={`查看明细 ${row.label}`} onClick={() => openDetails(row)}>查看明细</button></td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>
|
||||
</>}
|
||||
<div className="analysis-table-total"><span>合计 {number(analysis.total)} 台{view === 'inventory' && ` · 待交车 ${number(analysis.summary.pending)} 台 · 异动 ${number(analysis.summary.abnormal)} 台`} · 占比基于当前筛选结果</span><span>100%</span></div>
|
||||
<ProgressiveRowsStatus {...groupProgress} unit="组" />
|
||||
</>}
|
||||
</>}
|
||||
{showDetails && <div className="analysis-details">
|
||||
{showDetails && <div className="analysis-details">{mobile && <p className="analysis-mobile-detail-scope">{detailGroup.label} · 当前分组</p>}
|
||||
<div className="analysis-table-tools"><h3>车辆明细 <small>{number(visibleVehicles.length)} 台</small></h3><AnalysisExportButton disabled={detailBusy || !!detailError || !visibleVehicles.length} onExport={exportWorkbook} /><label className="analysis-search"><Search size={15} /><input aria-label="搜索车辆明细" placeholder="搜索车牌、VIN、型号、城市" value={search} onChange={event => { setSearch(event.target.value); }} /></label></div>
|
||||
{detailBusy ? <div className="analysis-message" role="status"><Loader2 className="animate-spin" />正在加载车辆明细…</div> : detailError ? <div className="analysis-message" role="alert"><p>{detailError}</p><button className="analysis-button" onClick={() => setDetailRetry(detailRetry + 1)}>重新加载明细</button></div> : <>
|
||||
{vehicles.length !== detailGroup.count && <p className="analysis-definition">车辆状态可能已更新:统计快照 {detailGroup.count} 台,当前明细 {vehicles.length} 台。</p>}
|
||||
{(view === 'abnormal' || detailGroup.values.inventoryState === 'abnormal' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'abnormal')) ? <AbnormalVehicleDetails vehicles={shownVehicles} /> : (view === 'pending' || detailGroup.values.inventoryState === 'pending' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'pending')) ? <PendingVehicleDetails vehicles={shownVehicles} /> : <VehicleRows vehicles={shownVehicles} />}
|
||||
{(view === 'abnormal' || detailGroup.values.inventoryState === 'abnormal' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'abnormal')) ? <AbnormalVehicleDetails vehicles={shownVehicles} /> : (view === 'pending' || detailGroup.values.inventoryState === 'pending' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'pending')) ? <PendingVehicleDetails vehicles={shownVehicles} /> : <VehicleRows vehicles={shownVehicles} inventory={view === 'inventory'} />}
|
||||
<ProgressiveRowsStatus {...detailProgress} unit="台" />
|
||||
</>}
|
||||
</div>}
|
||||
</div>
|
||||
<footer className="analysis-footer"><span>统计时间 {data ? formatLocalDateTime(new Date(data.generatedAt)) : '—'} · 当前可查看范围</span><span>{showDetails ? '仅展示当前分组与筛选条件内的车辆' : '同维度筛选为“或”,不同维度筛选为“且”'}</span></footer>
|
||||
<footer className="analysis-footer">{mobile ? <details><summary>统计口径与数据时间</summary><p>统计时间 {data ? formatLocalDateTime(new Date(data.generatedAt)) : '—'} · 当前可查看范围</p><p>同维度筛选为“或”,不同维度为“且”。仅展示当前入口与筛选条件内的车辆。</p>{view === 'abnormal' && <p>已异动时长=统计时间-审批通过时间;区间左含右不含,缺失时间单列。{data && !data.approvalTimeAvailable && ' 当前未取得可用审批时间。'}</p>}</details> : <><span>统计时间 {data ? formatLocalDateTime(new Date(data.generatedAt)) : '—'} · 当前可查看范围</span><span>{showDetails ? '仅展示当前分组与筛选条件内的车辆' : '同维度筛选为“或”,不同维度筛选为“且”'}</span></>}</footer>
|
||||
</>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function VehicleRows({ vehicles }: { vehicles: VehicleListItem[] }) {
|
||||
function VehicleRows({ vehicles, inventory = false }: { vehicles: VehicleListItem[]; inventory?: boolean }) {
|
||||
const mobile = useMobileLayout();
|
||||
if (!vehicles.length) return <div className="analysis-message">暂无匹配车辆</div>;
|
||||
const stateLabel = (vehicle: VehicleListItem) => {
|
||||
const operation = ({ '1': '租赁', '2': '自营', '3': '可运营', '4': '待运营' } as Record<string, string>)[vehicle.operationStatus || ''] || vehicle.operationStatus;
|
||||
const business = ({ Abnormal: '异动', Pending: '待交车', Inventory: '在库', Operating: '运营中' } as Record<string, string>)[vehicle.status] || vehicle.status;
|
||||
return [business, operation].filter(Boolean).join(' · ');
|
||||
};
|
||||
return <div className="analysis-detail-table" tabIndex={0} role="region" aria-label="车辆明细表"><table><thead><tr>{['车牌 / VIN', '品牌 / 车型 / 型号', '省份 / 城市', '状态', '客户 / 签约公司', '部门 / 业务负责人', '所属公司'].map(label => <th key={label}>{label}</th>)}</tr></thead><tbody>{vehicles.map(vehicle => <tr key={vehicle.id}><td><Blur>{vehicle.plateNumber || vehicle.vin || '未提供'}</Blur></td><td>{vehicle.brandLabel || '—'} · {vehicle.type}<small>{vehicle.model}</small></td><td>{vehicle.province || '未标注'} / {vehicle.city || vehicle.location || '未标注'}</td><td>{stateLabel(vehicle)}</td><td><Blur>{vehicle.customerName || '—'}</Blur><small><Blur>{vehicle.orgName || '—'}</Blur></small></td><td><Blur>{vehicle.departmentName || '—'}</Blur><small><Blur>{vehicle.customerManager || '—'}</Blur></small></td><td>{vehicle.subjectOrg || '—'}</td></tr>)}</tbody></table></div>;
|
||||
if (mobile) return <div className="analysis-vehicle-cards" aria-label="车辆明细列表">{vehicles.map(vehicle => <article className="analysis-vehicle-card" key={vehicle.id}>
|
||||
<header><h4><Blur>{vehicle.plateNumber || vehicle.vin || '未提供'}</Blur></h4><span>{stateLabel(vehicle)}</span></header>
|
||||
<p>{vehicle.model || `${vehicle.brandLabel || '—'} · ${vehicle.type}`} · {vehicle.city || vehicle.location || '未标注城市'}</p>
|
||||
{!inventory && vehicle.customerName && <p><Blur>{vehicle.customerName}</Blur></p>}
|
||||
<details><summary>车辆资料</summary><dl>{[['VIN', vehicle.vin], ['品牌 / 车型', `${vehicle.brandLabel || '—'} · ${vehicle.type}`], ['省份 / 城市', `${vehicle.province || '未标注'} / ${vehicle.city || vehicle.location || '未标注'}`], ...(inventory ? [['运维负责人', vehicle.opsManagers?.trim() || '-']] : [['签约公司', vehicle.orgName], ['部门', vehicle.departmentName], ['业务负责人', vehicle.customerManager]]), ['所属公司', vehicle.subjectOrg]].map(([label, value]) => <div key={label}><dt>{label}</dt><dd><Blur>{value || '未提供'}</Blur></dd></div>)}</dl></details>
|
||||
</article>)}</div>;
|
||||
return <div className="analysis-detail-table" tabIndex={0} role="region" aria-label="车辆明细表"><table><thead><tr>{['车牌 / VIN', '品牌 / 车型 / 型号', '省份 / 城市', '状态', ...(inventory ? ['运维负责人'] : ['客户 / 签约公司', '部门 / 业务负责人']), '所属公司'].map(label => <th key={label}>{label}</th>)}</tr></thead><tbody>{vehicles.map(vehicle => <tr key={vehicle.id}><td><Blur>{vehicle.plateNumber || vehicle.vin || '未提供'}</Blur></td><td>{vehicle.brandLabel || '—'} · {vehicle.type}<small>{vehicle.model}</small></td><td>{vehicle.province || '未标注'} / {vehicle.city || vehicle.location || '未标注'}</td><td>{stateLabel(vehicle)}</td>{inventory ? <td><Blur>{vehicle.opsManagers?.trim() || '-'}</Blur></td> : <><td><Blur>{vehicle.customerName || '—'}</Blur><small><Blur>{vehicle.orgName || '—'}</Blur></small></td><td><Blur>{vehicle.departmentName || '—'}</Blur><small><Blur>{vehicle.customerManager || '—'}</Blur></small></td></>}<td>{vehicle.subjectOrg || '—'}</td></tr>)}</tbody></table></div>;
|
||||
}
|
||||
|
||||
type WeeklyDimension = 'date' | 'contract_type';
|
||||
@@ -191,7 +211,8 @@ const EMPTY_WEEKLY_ROWS: WeeklyDetailItem[] = [];
|
||||
const weeklyDimensionValue = (row: WeeklyDetailItem, dimension: WeeklyDimension) => dimension === 'date'
|
||||
? row.handover_date?.slice(0, 10) || '' : row.contract_type?.trim() || '';
|
||||
|
||||
function WeeklyAnalysis({ rows }: { rows: WeeklyDetailItem[] }) {
|
||||
function WeeklyAnalysis({ rows, title, scopeLabel, close }: { rows: WeeklyDetailItem[]; title: string; scopeLabel: string; close: () => void }) {
|
||||
const mobile = useMobileLayout();
|
||||
type Dimension = WeeklyDimension;
|
||||
const dimensionsAvailable = WEEKLY_DIMENSIONS;
|
||||
const scrollRoot = useRef<HTMLDivElement>(null);
|
||||
@@ -222,12 +243,15 @@ function WeeklyAnalysis({ rows }: { rows: WeeklyDetailItem[] }) {
|
||||
const detailGroup = detailKey === null ? null : grouped.get(detailKey);
|
||||
const detailRows = detailGroup?.rows ?? EMPTY_WEEKLY_ROWS;
|
||||
const showDetails = detailKey !== null;
|
||||
const overviewScroll = useRef(0);
|
||||
const openDetails = (key: string) => { overviewScroll.current = scrollRoot.current?.scrollTop || 0; setDetailKey(key); };
|
||||
useLayoutEffect(() => { if (scrollRoot.current) scrollRoot.current.scrollTop = showDetails ? 0 : overviewScroll.current; }, [showDetails]);
|
||||
const groupLabel = (group: { values: string[] }) => dimensions.length
|
||||
? dimensions.map((dimension, index) => `${labels[dimension]}:${label(dimension, group.values[index])}`).join(' · ')
|
||||
: '当前筛选结果';
|
||||
const parentTotals = useMemo(() => mergeActive ? dimensionParentTotals(groups, dimensions.length, group => group.values, group => group.rows.length) : [], [groups, dimensions.length, mergeActive]);
|
||||
const childCounts = useMemo(() => mergeActive ? dimensionChildCounts(groups, dimensions.length, group => group.values) : [], [groups, dimensions.length, mergeActive]);
|
||||
const groupProgress = useProgressiveRows({ total: groups.length, rootRef: scrollRoot, resetKey: groups, enabled: !showDetails });
|
||||
const groupProgress = useProgressiveRows({ total: groups.length, rootRef: scrollRoot, resetKey: groups, enabled: !showDetails, preserveOnDisable: true });
|
||||
const detailProgress = useProgressiveRows({ total: detailRows.length, rootRef: scrollRoot, resetKey: detailRows, enabled: showDetails });
|
||||
const shownGroups = useMemo(() => groups.slice(0, groupProgress.visibleCount), [groups, groupProgress.visibleCount]);
|
||||
const completeSpans = useMemo(() => mergeActive ? dimensionRowSpans(groups, dimensions.length, group => group.values) : [], [groups, dimensions.length, mergeActive]);
|
||||
@@ -256,6 +280,25 @@ function WeeklyAnalysis({ rows }: { rows: WeeklyDetailItem[] }) {
|
||||
const details = selectedRows.map(row => ({ 车牌: row.plate_number, 日期: row.handover_date, 合同类型: row.contract_type, 客户: row.customer_name }));
|
||||
return downloadAnalysisWorkbook(statistics, details, `交还车分析-${detailGroup ? groupLabel(detailGroup) : '当前筛选结果'}`);
|
||||
};
|
||||
if (mobile) return <>
|
||||
<header className="analysis-header"><button aria-label={showDetails ? '返回统计分析' : '返回资产管理'} onClick={() => showDetails ? setDetailKey(null) : close()}><ArrowLeft size={20} /></button><div><h2>{title}</h2><p>{scopeLabel}</p></div><button aria-label="关闭统计分析" onClick={close}><X size={18} /></button></header>
|
||||
<div ref={scrollRoot} className="analysis-scroll">
|
||||
<div className="analysis-result-bar" role="status"><span>当前结果 <strong>{number(showDetails ? detailRows.length : filtered.length)}</strong> 车次</span>{!showDetails && <span>{groups.length} 个分组</span>}</div>
|
||||
{!showDetails ? <>
|
||||
{(filtered.length > 0 || activeFilters.length > 0) && <MobileAnalysisControls available={dimensionsAvailable.map(key => ({ key, label: labels[key], options: filterOptions[key] }))} dimensions={dimensions} filters={filters} defaults={['date']} apply={(nextDimensions, nextFilters) => { updateDimensions(nextDimensions as Dimension[]); updateFilters(nextFilters); }} />}
|
||||
{filtered.length > 0 && <div className="analysis-table-tools"><AnalysisExportButton onExport={exportWorkbook} /></div>}
|
||||
{groups.length ? <><MobileGroupedTable dimensions={dimensions.map(d => labels[d])} unit="车次" merged={mergeActive} setMerged={setMergeParents} spans={groupSpans} parentTotals={parentTotals}
|
||||
rows={shownGroups.map(group => ({ key: group.key, label: groupLabel(group), values: group.values.map((entry, i) => label(dimensions[i], entry)), count: group.rows.length, share: group.rows.length / filtered.length * 100 }))}
|
||||
onDetails={index => openDetails(shownGroups[index].key)} onFilter={index => updateFilters({ ...filters, ...Object.fromEntries(dimensions.map((dimension, i) => [dimension, [shownGroups[index].values[i]]])) })} /><ProgressiveRowsStatus {...groupProgress} unit="组" /></> : <div className="analysis-message"><p>{activeFilters.length ? '没有符合条件的数据,可调整筛选。' : '当前范围暂无交还车记录。'}</p>{activeFilters.length > 0 && <button className="analysis-button" onClick={() => updateFilters({})}>清空筛选</button>}</div>}
|
||||
</> : <>
|
||||
<p className="analysis-mobile-detail-scope">{detailGroup ? groupLabel(detailGroup) : '当前分组'}</p>
|
||||
<div className="analysis-table-tools"><h3>交还车业务明细</h3><AnalysisExportButton disabled={!detailRows.length} onExport={exportWorkbook} /></div>
|
||||
<div className="analysis-vehicle-cards">{shownDetails.map((row, index) => <article className="analysis-vehicle-card" key={`${row.truck_id}-${index}`}><header><h4><Blur>{row.plate_number || '未登记车牌'}</Blur></h4><span>{row.contract_type || '未标注合同类型'}</span></header><p>{row.handover_date || '日期缺失'}</p><p><Blur>{row.customer_name || '未提供客户'}</Blur></p></article>)}</div>
|
||||
<ProgressiveRowsStatus {...detailProgress} unit="条" />
|
||||
</>}
|
||||
</div>
|
||||
<footer className="analysis-footer"><details><summary>统计口径</summary><p>按当前入口的周业务记录统计车次,同一车辆多笔记录分别计次。仅展示当前可查看范围和筛选条件内的数据。</p></details></footer>
|
||||
</>;
|
||||
return <div ref={scrollRoot} className="analysis-scroll">
|
||||
{!showDetails ? <>
|
||||
<div className="analysis-builder">
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useId, useRef, useState } from 'react';
|
||||
import { ArrowLeft, Check, ChevronDown, Filter, Search, X } from 'lucide-react';
|
||||
|
||||
export type MobileDimension = { key: string; label: string; options: { value: string; label: string; count?: number }[] };
|
||||
type Filters = Partial<Record<string, string[]>>;
|
||||
type Props = {
|
||||
available: MobileDimension[];
|
||||
dimensions: string[];
|
||||
filters: Filters;
|
||||
defaults: string[];
|
||||
apply: (dimensions: string[], filters: Filters) => void;
|
||||
};
|
||||
|
||||
/** Draft all mobile settings together; cancelling never changes the current result. */
|
||||
export function MobileAnalysisControls({ available, dimensions, filters, defaults, apply }: Props) {
|
||||
const [mode, setMode] = useState<'group' | 'filter' | null>(null);
|
||||
const [draftDimensions, setDraftDimensions] = useState(dimensions);
|
||||
const [draftFilters, setDraftFilters] = useState(filters);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const trigger = useRef<HTMLButtonElement | null>(null);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const id = useId();
|
||||
const selected = available.filter(d => filters[d.key]?.length);
|
||||
const editor = available.find(d => d.key === editing);
|
||||
const options = editor?.options.filter(o => o.label.toLocaleLowerCase().includes(query.toLocaleLowerCase())) || [];
|
||||
const close = () => { setMode(null); trigger.current?.focus(); };
|
||||
const open = (next: 'group' | 'filter', button: HTMLButtonElement) => {
|
||||
trigger.current = button; setDraftDimensions([...dimensions]); setDraftFilters({ ...filters });
|
||||
setEditing(null); setQuery(''); setMode(next);
|
||||
requestAnimationFrame(() => panel.current?.focus());
|
||||
};
|
||||
const toggle = (key: string, value: string) => {
|
||||
const values = draftFilters[key] || [];
|
||||
setDraftFilters({ ...draftFilters, [key]: values.includes(value) ? values.filter(v => v !== value) : [...values, value] });
|
||||
};
|
||||
return <div className="analysis-mobile-controls">
|
||||
<div className="analysis-mobile-dimension-heading"><strong>分析维度</strong><span>可多选 · 左右滑动</span><button onClick={e => open('group', e.currentTarget)} aria-expanded={mode === 'group'} aria-controls={id}>分组设置</button></div>
|
||||
<div className="analysis-mobile-toolbar">
|
||||
<div className="analysis-dimension-strip" role="group" aria-label="分析维度">
|
||||
{available.map(d => <button key={d.key} type="button" aria-pressed={dimensions.includes(d.key)} onClick={() => apply(dimensions.includes(d.key) ? dimensions.filter(key => key !== d.key) : [...dimensions, d.key], filters)}>{d.label}{dimensions.includes(d.key) && <small>{dimensions.indexOf(d.key) + 1}</small>}</button>)}
|
||||
<button type="button" aria-pressed={!dimensions.length} onClick={() => apply([], filters)}>仅汇总</button>
|
||||
</div>
|
||||
<button type="button" onClick={e => open('filter', e.currentTarget)} aria-expanded={mode === 'filter'} aria-controls={id}><Filter size={16} />筛选{selected.length ? ` ${selected.length}` : ''}</button>
|
||||
</div>
|
||||
{!!selected.length && <div className="analysis-mobile-chips" aria-label="已选筛选条件">{selected.map(d => <button key={d.key} title={(filters[d.key] || []).map(v => d.options.find(o => o.value === v)?.label || v).join('、')} aria-label={`移除${d.label}筛选`} onClick={() => { const next = { ...filters }; delete next[d.key]; apply(dimensions, next); }}><span>{d.label}:{filters[d.key]?.length === 1 ? d.options.find(o => o.value === filters[d.key]?.[0])?.label || filters[d.key]?.[0] : `${filters[d.key]?.length}项`}</span><X size={14} /></button>)}</div>}
|
||||
{mode && <div className="analysis-settings-backdrop" onClick={e => { if (e.target === e.currentTarget) close(); }}>
|
||||
<div ref={panel} id={id} role="dialog" aria-modal="true" aria-label={mode === 'group' ? '分组设置' : '筛选条件'} tabIndex={-1} className="analysis-settings-panel" onKeyDown={e => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); if (editing) { setEditing(null); setQuery(''); } else close(); }
|
||||
if (e.key === 'Tab') {
|
||||
e.stopPropagation();
|
||||
const items = Array.from(panel.current?.querySelectorAll<HTMLElement>('button:not([disabled]),input') || []).filter(el => el.getClientRects().length);
|
||||
const first = items[0], last = items.at(-1);
|
||||
if (e.shiftKey && (document.activeElement === first || document.activeElement === panel.current)) { e.preventDefault(); last?.focus(); }
|
||||
else if (!e.shiftKey && (document.activeElement === last || document.activeElement === panel.current)) { e.preventDefault(); first?.focus(); }
|
||||
}
|
||||
}}>
|
||||
<header><button aria-label={editing ? '返回筛选条件' : '取消设置'} onClick={() => editing ? (setEditing(null), setQuery('')) : close()}><ArrowLeft size={20} /></button><h3>{editor?.label || (mode === 'group' ? '分组设置' : '筛选条件')}</h3><button aria-label="关闭设置" onClick={close}><X size={20} /></button></header>
|
||||
{editor && <label className="analysis-settings-search"><Search size={17} /><input aria-label={`搜索${editor.label}`} placeholder={`搜索${editor.label}`} value={query} onChange={e => setQuery(e.target.value)} /></label>}
|
||||
<div className="analysis-settings-content">
|
||||
{mode === 'group' ? <><p>可选择多个维度,按选择顺序组合统计。</p><div className="analysis-settings-groups">{available.map(d => <button key={d.key} aria-pressed={draftDimensions.includes(d.key)} onClick={() => setDraftDimensions(draftDimensions.includes(d.key) ? draftDimensions.filter(v => v !== d.key) : [...draftDimensions, d.key])}><span>{d.label}</span>{draftDimensions.includes(d.key) && <span className="analysis-dimension-order">{draftDimensions.indexOf(d.key) + 1}</span>}</button>)}</div><button className="analysis-settings-row" aria-pressed={!draftDimensions.length} onClick={() => setDraftDimensions([])}>仅汇总,不分组{!draftDimensions.length && <Check size={18} />}</button></> : editor ? <>
|
||||
<div className="analysis-settings-select"><button onClick={() => setDraftFilters({ ...draftFilters, [editor.key]: [...new Set([...(draftFilters[editor.key] || []), ...options.map(o => o.value)])] })}>全选{query ? '搜索结果' : ''}</button><button onClick={() => setDraftFilters({ ...draftFilters, [editor.key]: [] })}>清除选择</button><span>{draftFilters[editor.key]?.length || 0} 项已选</span></div>
|
||||
{options.map(o => <label className="analysis-settings-option" key={o.value}><input type="checkbox" checked={draftFilters[editor.key]?.includes(o.value) || false} onChange={() => toggle(editor.key, o.value)} /><span>{o.label}</span>{o.count !== undefined && <small>{o.count.toLocaleString()}</small>}</label>)}
|
||||
{!options.length && <p>没有匹配选项,可调整搜索。</p>}
|
||||
</> : <><p>同一维度多选取并集,不同维度同时满足。未选择表示不限。</p>{available.map(d => <button className="analysis-settings-row" key={d.key} onClick={() => { setEditing(d.key); setQuery(''); }}><span>{d.label}</span><small>{draftFilters[d.key]?.length ? `已选 ${draftFilters[d.key]?.length} 项` : '不限'}</small><ChevronDown size={16} /></button>)}</>}
|
||||
</div>
|
||||
<footer><button onClick={() => mode === 'group' ? setDraftDimensions([...defaults]) : setDraftFilters({})}>重置{mode === 'group' ? '分组' : '筛选'}</button><button className="analysis-settings-apply" onClick={() => { const clean = Object.fromEntries(Object.entries(draftFilters).filter(([, values]) => values?.length)); apply(draftDimensions, clean); close(); }}>查看结果</button></footer>
|
||||
</div>
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Filter } from 'lucide-react';
|
||||
|
||||
type Row = { key: string; label: string; values: ReactNode[]; count: number; share: number; note?: ReactNode };
|
||||
type Props = {
|
||||
dimensions: string[];
|
||||
rows: Row[];
|
||||
unit: string;
|
||||
merged: boolean;
|
||||
setMerged: (value: boolean) => void;
|
||||
spans: number[][];
|
||||
parentTotals: number[][];
|
||||
onDetails: (index: number) => void;
|
||||
onFilter?: (index: number) => void;
|
||||
};
|
||||
|
||||
/** Retain table semantics and actual parent row spans at narrow viewport widths. */
|
||||
export function MobileGroupedTable({ dimensions, rows, unit, merged, setMerged, spans, parentTotals, onDetails, onFilter }: Props) {
|
||||
const multiple = dimensions.length > 1;
|
||||
return <section className={`analysis-mobile-grid${onFilter ? '' : ' without-filter'}`}>
|
||||
<div className="analysis-mobile-grid-heading"><strong>分组统计</strong>{multiple ? <label><input type="checkbox" role="switch" checked={merged} onChange={e => setMerged(e.target.checked)} /><span>合并单元格</span></label> : <span>数量 · 占比</span>}</div>
|
||||
{multiple && <p className="analysis-mobile-grid-hint">左右滑动查看完整表格{merged ? ' · 相同上级已合并' : ' · 每行独立显示'}</p>}
|
||||
<div className="analysis-mobile-grid-scroll" role="region" aria-label="分组统计表,可左右滑动" tabIndex={0}>
|
||||
<table style={{ minWidth: multiple ? `${dimensions.length * 108 + 192}px` : '100%' }}>
|
||||
<thead><tr>{dimensions.length ? dimensions.map((label, i) => <th scope="col" key={i}>{label}</th>) : <th scope="col">范围</th>}<th scope="col" className="mobile-grid-number">数量<small>({unit})</small></th><th scope="col" className="mobile-grid-share">占比</th><th scope="col" className="mobile-grid-actions">操作</th></tr></thead>
|
||||
<tbody>{rows.map((row, rowIndex) => <tr key={row.key} className={merged && spans[rowIndex]?.[0] > 0 ? 'mobile-grid-group-start' : undefined}>
|
||||
{dimensions.length ? row.values.map((value, colIndex) => {
|
||||
const span = merged ? spans[rowIndex]?.[colIndex] ?? 1 : 1;
|
||||
if (!span) return null;
|
||||
const parent = merged && colIndex < dimensions.length - 1;
|
||||
return <td key={colIndex} rowSpan={span} className={parent ? 'mobile-grid-parent' : 'mobile-grid-label'}>{value}{parent && <small>合计 <b>{parentTotals[rowIndex]?.[colIndex]?.toLocaleString('zh-CN')}</b> {unit}</small>}</td>;
|
||||
}) : <td className="mobile-grid-label">当前结果</td>}
|
||||
<td className="mobile-grid-number"><strong>{row.count.toLocaleString('zh-CN')}</strong>{row.note && <small>{row.note}</small>}</td>
|
||||
<td className="mobile-grid-share"><span>{row.share.toFixed(1)}%</span><i aria-hidden="true"><i style={{ width: `${row.share}%` }} /></i></td>
|
||||
<td className="mobile-grid-actions"><div>{onFilter && dimensions.length > 0 && <button className="mobile-grid-filter" aria-label={`筛选分组 ${row.label}`} title="筛选此组" onClick={() => onFilter(rowIndex)}><Filter size={15} /></button>}<button aria-label={`查看明细 ${row.label}`} onClick={() => onDetails(rowIndex)}>明细</button></div></td>
|
||||
</tr>)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/* Shared mobile presentation for asset statistics, filters and vehicle details. */
|
||||
@media (max-width: 767px) {
|
||||
.asset-analysis-overlay, .bi-flow-modal-backdrop { padding: 0; align-items: stretch; }
|
||||
.asset-analysis-dialog, .bi-flow-modal { width: 100%; max-width: none; height: 100dvh; max-height: 100dvh; min-height: 0; border-radius: 0; font-size: 13px; }
|
||||
.analysis-header, .bi-flow-modal-header { padding: max(6px, env(safe-area-inset-top)) 10px 6px; background: #fff; color: var(--color-slate-800, #1e293b); border-bottom: 1px solid var(--color-slate-200, #e2e8f0); }
|
||||
.analysis-header { gap: 8px; }
|
||||
.analysis-header > div { flex: 1; min-width: 0; }
|
||||
.analysis-header h2, .bi-flow-modal-header h3 { font-size: 15px; }
|
||||
.analysis-header h2 > svg { display: none; }
|
||||
.analysis-header h2, .bi-flow-modal-header h3 { border-left: 3px solid var(--color-blue-600, #2563eb); padding-left: 8px; }
|
||||
.analysis-header p, .bi-flow-modal-header p { font-size: 11px; line-height: 1.5; color: var(--color-slate-500, #64748b); }
|
||||
.analysis-header button, .bi-flow-modal-close, .bi-flow-mobile-back { display: grid; place-items: center; flex-shrink: 0; width: 44px; height: 44px; padding: 0; color: var(--color-slate-500, #64748b); background: var(--color-slate-50, #f8fafc); border-radius: 8px; }
|
||||
.analysis-header button:hover, .bi-flow-modal-close:hover { background: var(--color-blue-50, #eff6ff); }
|
||||
.analysis-scroll, .bi-flow-modal-body { padding: 12px 16px max(16px, env(safe-area-inset-bottom)); background: var(--app-bg, #f4f7fb); }
|
||||
.analysis-result-bar { padding: 10px 14px; gap: 8px; font-size: 12px; background: var(--color-blue-50, #eff6ff); border: 1px solid var(--color-blue-100, #dbeafe); border-radius: 10px; color: var(--color-slate-600, #475569); margin-bottom: 10px; }
|
||||
.analysis-result-bar strong { font-size: 28px; margin: 0 4px; color: var(--color-blue-600, #2563eb); }
|
||||
.analysis-result-dimensions { display: none; }
|
||||
.analysis-inventory-subsets { gap: 5px 12px; font-size: 11px; margin: -11px 0 8px; padding: 8px 14px; background: var(--color-blue-50, #eff6ff); border: 1px solid var(--color-blue-100, #dbeafe); border-top: 0; border-radius: 0 0 10px 10px; }
|
||||
.analysis-inventory-subsets small { width: 100%; font-size: 11px; }
|
||||
.analysis-mobile-toolbar { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 8px; }
|
||||
.analysis-mobile-toolbar > button { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; border: 1px solid #d8e0eb; border-radius: 9px; padding: 8px 12px; background: #f8fafc; color: #334155; font-size: 13px; }
|
||||
.analysis-mobile-toolbar > button:first-child { justify-content: space-between; }
|
||||
.analysis-mobile-toolbar > button > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.analysis-mobile-toolbar svg { flex-shrink: 0; }
|
||||
.analysis-mobile-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.analysis-mobile-chips button { display: flex; align-items: center; gap: 6px; max-width: 100%; min-height: 44px; padding: 6px 10px; border-radius: 8px; color: var(--color-blue-700, #1d4ed8); background: #eff6ff; font-size: 11px; }
|
||||
.analysis-mobile-chips span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.analysis-mobile-chips svg { flex-shrink: 0; }
|
||||
.analysis-overview-tools > p, .analysis-overview-tools .analysis-merge-toggle { display: none; }
|
||||
.analysis-overview-tools.is-empty { display: none; }
|
||||
.analysis-overview-tools .analysis-table-options { width: 100%; justify-content: space-between; gap: 6px; }
|
||||
.analysis-table-tools { margin: 8px 0; }
|
||||
.analysis-table-tools label, .analysis-table-tools select, .analysis-export-action button { font-size: 11px; }
|
||||
.analysis-table-tools select, .analysis-export-action button { min-height: 44px; }
|
||||
.analysis-table-tools h3 { font-size: 14px; }
|
||||
.analysis-table-tools h3 small { font-size: 12px; }
|
||||
.analysis-table-total { border: 0; border-radius: 0; background: none; padding: 8px 0; font-size: 11px; line-height: 1.6; }
|
||||
.analysis-table-total > span:last-child { display: none; }
|
||||
.analysis-mobile-detail-scope { font-size: 12px; color: #64748b; margin-bottom: 8px; }
|
||||
.analysis-search { width: 100%; min-height: 44px; padding: 9px 12px; border-radius: 8px; }
|
||||
.analysis-search input { font-size: 16px; width: 100%; }
|
||||
.analysis-vehicle-cards { display: grid; gap: 8px; margin-top: 10px; }
|
||||
.analysis-vehicle-card { min-width: 0; padding: 12px; border: 1px solid #e2e8f0; border-radius: 10px; background: #fff; }
|
||||
.analysis-vehicle-card header { display: flex; align-items: flex-start; flex-wrap: wrap; gap: 6px 12px; justify-content: space-between; }
|
||||
.analysis-vehicle-card h4 { color: var(--color-blue-700, #1d4ed8); font-size: 14px; font-weight: 600; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.analysis-vehicle-card header > span { font-size: 11px; padding: 3px 6px; border-radius: 4px; color: #475569; background: #f1f5f9; }
|
||||
.analysis-vehicle-card > p { font-size: 12px; color: #64748b; line-height: 1.6; margin-top: 6px; overflow-wrap: anywhere; }
|
||||
.analysis-vehicle-card summary { display: flex; align-items: center; min-height: 44px; font-size: 12px; color: var(--color-blue-600, #2563eb); cursor: pointer; }
|
||||
.analysis-vehicle-card summary::after { content: '+'; margin-left: auto; }
|
||||
.analysis-vehicle-card details[open] summary::after { content: '−'; }
|
||||
.analysis-vehicle-card dl > div { display: grid; grid-template-columns: 90px minmax(0,1fr); gap: 10px; padding: 4px 0; font-size: 12px; line-height: 1.6; }
|
||||
.analysis-vehicle-card dt { color: #64748b; }
|
||||
.analysis-vehicle-card dd { color: #334155; overflow-wrap: anywhere; }
|
||||
.analysis-footer { padding: 0 16px max(0px, env(safe-area-inset-bottom)); background: #fff; font-size: 11px; }
|
||||
.analysis-footer details { width: 100%; max-height: 28dvh; overflow-y: auto; }
|
||||
.analysis-footer summary { display: flex; align-items: center; min-height: 44px; cursor: pointer; }
|
||||
.analysis-footer details p { margin-bottom: 10px; line-height: 1.6; }
|
||||
.analysis-message { font-size: 13px; min-height: 240px; }
|
||||
.analysis-message h3 { font-size: 15px; }
|
||||
.analysis-definition { font-size: 11px; }
|
||||
.analysis-details .text-base { font-size: 14px; }
|
||||
.analysis-details .text-sm { font-size: 13px; }
|
||||
.analysis-details .text-xs { font-size: 11px; }
|
||||
.analysis-details .space-y-3.px-3 { padding: 0; }
|
||||
.analysis-details .space-y-3 article { box-shadow: none; }
|
||||
.analysis-settings-backdrop { position: fixed; inset: 0; z-index: 30; background: rgb(15 23 42 / 38%); display: flex; align-items: flex-end; }
|
||||
.analysis-settings-panel { display: flex; flex-direction: column; width: 100%; height: min(680px, 90dvh); min-height: 0; background: #fff; border-radius: 12px 12px 0 0; color: #334155; box-shadow: 0 -12px 36px #0f172a20; outline: none; }
|
||||
.analysis-settings-panel header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid #e2e8f0; }
|
||||
.analysis-settings-panel header h3 { flex: 1; font-size: 15px; font-weight: 600; }
|
||||
.analysis-settings-panel header button { width: 44px; height: 44px; display: grid; place-items: center; }
|
||||
.analysis-settings-search { display: flex; align-items: center; gap: 8px; margin: 12px 16px 0; padding: 10px 12px; border: 1px solid #cbd5e1; border-radius: 8px; }
|
||||
.analysis-settings-search input { min-width: 0; width: 100%; font-size: 16px; outline: none; }
|
||||
.analysis-settings-content { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 12px 16px; }
|
||||
.analysis-settings-content > p { color: #64748b; font-size: 12px; line-height: 1.6; margin: 0 0 12px; }
|
||||
.analysis-settings-groups { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; margin-bottom: 18px; }
|
||||
.analysis-settings-groups button { min-height: 48px; display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border: 1px solid #d8e0eb; border-radius: 8px; font-size: 13px; }
|
||||
.analysis-settings-groups button[aria-pressed=true] { color: var(--color-blue-700, #1d4ed8); background: #eff6ff; border-color: #93c5fd; }
|
||||
.analysis-settings-groups .analysis-dimension-order { width: 20px; height: 20px; font-size: 11px; }
|
||||
.analysis-settings-row { display: flex; gap: 10px; width: 100%; align-items: center; min-height: 48px; padding: 8px 0; border-bottom: 1px solid #e2e8f0; text-align: left; font-size: 13px; }
|
||||
.analysis-settings-row > span { flex: 1; }
|
||||
.analysis-settings-row > small { font-size: 12px; color: #64748b; }
|
||||
.analysis-settings-select { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; font-size: 12px; }
|
||||
.analysis-settings-select button { min-height: 44px; color: var(--color-blue-600, #2563eb); }
|
||||
.analysis-settings-select > span { margin-left: auto; color: #64748b; }
|
||||
.analysis-settings-option { display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 8px 0; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
|
||||
.analysis-settings-option input { width: 18px; height: 18px; accent-color: var(--color-blue-600, #2563eb); flex-shrink: 0; }
|
||||
.analysis-settings-option span { flex: 1; overflow-wrap: anywhere; }
|
||||
.analysis-settings-option small { color: #64748b; font-size: 12px; }
|
||||
.analysis-settings-panel footer { display: grid; grid-template-columns: 1fr 2fr; gap: 12px; padding: 12px 16px max(12px, env(safe-area-inset-bottom)); border-top: 1px solid #e2e8f0; }
|
||||
.analysis-settings-panel footer button { min-height: 46px; border-radius: 9px; background: #f1f5f9; font-size: 13px; }
|
||||
.analysis-settings-panel footer .analysis-settings-apply { background: var(--color-blue-600, #2563eb); color: #fff; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.bi-flow-modal-header-row { align-items: center; gap: 8px; }
|
||||
.bi-flow-modal-header-row > div { flex: 1; min-width: 0; }
|
||||
.bi-flow-modal-summary { margin: 0 0 12px; background: var(--color-blue-50, #eff6ff); border-color: var(--color-blue-100, #dbeafe); }
|
||||
.bi-flow-modal-summary > div { padding: 10px 8px; gap: 6px; }
|
||||
.bi-flow-modal-summary span, .bi-flow-modal strong small { font-size: 11px; }
|
||||
.bi-flow-modal-summary strong { font-size: 23px; }
|
||||
.bi-flow-modal-section-heading { margin: 12px 0 4px; }
|
||||
.bi-flow-modal-section-heading h4 { font-size: 14px; }
|
||||
.bi-flow-modal-section-heading p { font-size: 11px; }
|
||||
.bi-flow-modal-empty { font-size: 13px; line-height: 1.7; color: #64748b; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.analysis-details .space-y-3 dt { font-size: 11px; line-height: 1.8; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.analysis-mobile-controls { position: sticky; top: -12px; z-index: 5; background: var(--app-bg, #f4f7fb); padding: 0 0 6px; }
|
||||
.analysis-mobile-dimension-heading { display: flex; align-items: center; gap: 8px; color: var(--color-slate-700, #334155); font-size: 12px; }
|
||||
.analysis-mobile-dimension-heading > span { color: var(--color-slate-500, #64748b); font-size: 11px; }
|
||||
.analysis-mobile-dimension-heading > button { margin-left: auto; min-height: 44px; color: var(--color-blue-600, #2563eb); font-size: 11px; }
|
||||
.analysis-mobile-toolbar { grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
|
||||
.analysis-mobile-toolbar > button { align-self: start; height: 44px; padding: 0 8px; gap: 4px; white-space: nowrap; color: var(--color-blue-600, #2563eb); border-color: var(--color-blue-100, #dbeafe); background: var(--color-blue-50, #eff6ff); }
|
||||
.analysis-dimension-strip { display: flex; gap: 6px; min-width: 0; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; scrollbar-color: var(--color-slate-300, #cbd5e1) transparent; padding-bottom: 4px; }
|
||||
.analysis-dimension-strip button { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 44px; padding: 0 11px; border: 1px solid var(--color-slate-200, #e2e8f0); border-radius: 8px; background: #fff; color: var(--color-slate-600, #475569); font-size: 12px; }
|
||||
.analysis-dimension-strip button[aria-pressed=true] { background: var(--color-blue-600, #2563eb); border-color: var(--color-blue-600, #2563eb); color: #fff; box-shadow: 0 2px 4px rgb(37 99 235 / 12%); }
|
||||
.analysis-dimension-strip small { display: grid; place-items: center; width: 17px; height: 17px; border-radius: 50%; background: #ffffff2b; font-size: 10px; }
|
||||
.analysis-mobile-grid { border: 1px solid var(--color-slate-200, #e2e8f0); border-radius: 10px; overflow: hidden; background: #fff; box-shadow: 0 2px 6px rgb(15 23 42 / 3%); }
|
||||
.analysis-mobile-grid-heading { min-height: 44px; display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 0 12px; background: var(--color-blue-50, #eff6ff); color: var(--color-slate-700, #334155); font-size: 12px; }
|
||||
.analysis-mobile-grid-heading > span { font-size: 11px; color: var(--color-slate-500, #64748b); }
|
||||
.analysis-mobile-grid-heading label { display: inline-flex; align-items: center; gap: 7px; min-height: 44px; font-size: 11px; cursor: pointer; }
|
||||
.analysis-mobile-grid-heading input { appearance: none; width: 30px; height: 18px; border-radius: 10px; background: var(--color-slate-400, #94a3b8); position: relative; cursor: pointer; }
|
||||
.analysis-mobile-grid-heading input::after { content: ''; position: absolute; top: 3px; left: 3px; width: 12px; height: 12px; border-radius: 50%; background: #fff; }
|
||||
.analysis-mobile-grid-heading input:checked { background: var(--color-blue-600, #2563eb); }
|
||||
.analysis-mobile-grid-heading input:checked::after { left: 15px; }
|
||||
.analysis-mobile-grid-hint { padding: 6px 12px; font-size: 11px; color: var(--color-slate-500, #64748b); border-bottom: 1px solid var(--color-slate-200, #e2e8f0); background: var(--color-slate-50, #f8fafc); }
|
||||
.analysis-mobile-grid-scroll { overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; }
|
||||
.analysis-mobile-grid table { width: 100%; border-collapse: separate; border-spacing: 0; table-layout: fixed; font-size: 12px; }
|
||||
.analysis-mobile-grid th { background: var(--color-slate-50, #f8fafc); color: var(--color-slate-500, #64748b); font-weight: 500; padding: 7px 6px; text-align: left; border-bottom: 1px solid var(--color-slate-200, #e2e8f0); }
|
||||
.analysis-mobile-grid th:first-child { padding-left: 12px; }
|
||||
.analysis-mobile-grid td { padding: 4px 6px; border-bottom: 1px solid var(--color-slate-100, #f1f5f9); vertical-align: middle; overflow-wrap: anywhere; color: var(--color-slate-700, #334155); line-height: 1.5; }
|
||||
.analysis-mobile-grid tr:nth-child(even) td { background: var(--color-slate-50, #f8fafc); }
|
||||
.analysis-mobile-grid tr:last-child td { border-bottom: 0; }
|
||||
.analysis-mobile-grid td:first-child { padding-left: 12px; }
|
||||
.analysis-mobile-grid .mobile-grid-parent { background: var(--color-blue-50, #eff6ff); border-right: 1px solid var(--color-blue-100, #dbeafe); font-weight: 600; vertical-align: top; padding-top: 14px; }
|
||||
.analysis-mobile-grid .mobile-grid-parent small { display: block; margin-top: 6px; font-size: 11px; font-weight: 400; color: var(--color-slate-500, #64748b); }
|
||||
.analysis-mobile-grid .mobile-grid-group-start:not(:first-child) td { border-top: 1px solid var(--color-slate-300, #cbd5e1); }
|
||||
.analysis-mobile-grid .mobile-grid-number { width: 60px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.analysis-mobile-grid th small { display: block; font-size: 10px; }
|
||||
.analysis-mobile-grid td.mobile-grid-number strong { font-size: 17px; color: var(--color-blue-600, #2563eb); font-weight: 600; }
|
||||
.analysis-mobile-grid td.mobile-grid-number small { display: block; font-size: 10px; color: var(--color-amber-600, #d97706); }
|
||||
.analysis-mobile-grid .mobile-grid-share { width: 58px; text-align: right; font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.mobile-grid-share > i { display: block; margin-top: 5px; height: 3px; border-radius: 3px; overflow: hidden; background: var(--color-blue-100, #dbeafe); }
|
||||
.mobile-grid-share > i > i { display: block; height: 100%; background: var(--color-blue-500, #3b82f6); }
|
||||
.analysis-mobile-grid .mobile-grid-actions { width: 90px; text-align: center; padding-left: 0; padding-right: 4px; }
|
||||
.analysis-mobile-grid.without-filter .mobile-grid-actions { width: 56px; }
|
||||
.mobile-grid-actions > div { display: flex; justify-content: flex-end; }
|
||||
.mobile-grid-actions button { display: grid; place-items: center; min-width: 44px; height: 44px; color: var(--color-blue-600, #2563eb); font-size: 11px; border-radius: 7px; }
|
||||
.mobile-grid-actions button:active { background: #dbeafe; }
|
||||
.mobile-grid-actions .mobile-grid-filter { color: var(--color-slate-400, #94a3b8); }
|
||||
.analysis-table-tools { margin-top: 0; }
|
||||
.analysis-mobile-detail-scope { padding: 10px 12px; border-left: 3px solid var(--color-blue-600, #2563eb); border-radius: 0 6px 6px 0; background: var(--color-blue-50, #eff6ff); color: var(--color-slate-600, #475569); }
|
||||
.analysis-vehicle-card { border-color: var(--color-slate-200, #e2e8f0); border-top: 3px solid var(--color-blue-100, #dbeafe); }
|
||||
.bi-flow-modal-summary strong { color: var(--color-blue-600, #2563eb); }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type RefObject } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
|
||||
|
||||
interface ProgressiveRowsOptions {
|
||||
total: number;
|
||||
@@ -6,11 +6,14 @@ interface ProgressiveRowsOptions {
|
||||
resetKey: unknown;
|
||||
enabled?: boolean;
|
||||
batchSize?: number;
|
||||
preserveOnDisable?: boolean;
|
||||
}
|
||||
|
||||
/** Render a cumulative prefix, loading another batch near the actual scroll body's bottom. */
|
||||
export function useProgressiveRows({ total, rootRef, resetKey, enabled = true, batchSize = 40 }: ProgressiveRowsOptions) {
|
||||
const context = useMemo(() => ({ resetKey, enabled, batchSize }), [resetKey, enabled, batchSize]);
|
||||
export function useProgressiveRows({ total, rootRef, resetKey, enabled = true, batchSize = 40, preserveOnDisable = false }: ProgressiveRowsOptions) {
|
||||
const resetEnabled = preserveOnDisable || enabled;
|
||||
const context = useMemo(() => ({ resetKey, enabled: resetEnabled, batchSize }), [resetKey, resetEnabled, batchSize]);
|
||||
const scrollContext = useRef(context);
|
||||
const [progress, setProgress] = useState(() => ({ context, limit: batchSize }));
|
||||
const [sentinel, setSentinel] = useState<HTMLDivElement | null>(null);
|
||||
const visibleCount = Math.min(total, progress.context === context ? progress.limit : batchSize);
|
||||
@@ -21,8 +24,9 @@ export function useProgressiveRows({ total, rootRef, resetKey, enabled = true, b
|
||||
}, [enabled, context, total, batchSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled && rootRef.current) rootRef.current.scrollTop = 0;
|
||||
}, [context, enabled, rootRef]);
|
||||
if (enabled && rootRef.current && (!preserveOnDisable || scrollContext.current !== context)) rootRef.current.scrollTop = 0;
|
||||
scrollContext.current = context;
|
||||
}, [context, enabled, rootRef, preserveOnDisable]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 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 }: { value: DateRange; onChange: (range: DateRange) => void }) {
|
||||
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');
|
||||
@@ -69,7 +69,7 @@ export function FlowDateRangePicker({ value, onChange }: { value: DateRange; onC
|
||||
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">统计日期 · 运维签章时间</span>
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CalendarDays, ChevronDown, Download, Loader2 } from 'lucide-react';
|
||||
import { CalendarDays, ChevronDown, Download, ListFilter, Loader2 } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { FlowType } from '../../api';
|
||||
import { FLOW_META } from '../FlowDetailModal';
|
||||
@@ -65,18 +65,24 @@ export function FlowStatisticsCard({
|
||||
<button
|
||||
type="button"
|
||||
data-testid="asset-flow-daily-toggle"
|
||||
aria-expanded={flowDailyExpanded}
|
||||
aria-controls="asset-flow-daily-details"
|
||||
onClick={() => setFlowDailyExpanded((prev) => !prev)}
|
||||
className="mt-2 flex w-full items-center justify-between rounded-xl border border-slate-100 bg-white px-3 py-1.5 text-[12px] font-black text-slate-500 transition hover:border-blue-100 hover:bg-blue-50/50 hover:text-blue-600"
|
||||
className="mt-3 flex min-h-16 w-full items-center justify-between gap-3 rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-left text-blue-700 shadow-sm transition hover:border-blue-400 hover:bg-blue-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
<span>{flowDailyExpanded ? '收起每日明细' : '展开每日明细'}</span>
|
||||
<span className="flex items-center gap-2 text-[11px] text-slate-400">
|
||||
{flowStats?.daily.length ?? 0} 天
|
||||
<ChevronDown size={15} className={`transition-transform ${flowDailyExpanded ? 'rotate-180' : ''}`} />
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white text-blue-600"><ListFilter size={21} /></span>
|
||||
<span><span className="block text-sm font-black">{flowDailyExpanded ? '收起每日明细' : '展开每日明细'}</span><span className="mt-0.5 block text-[11px] font-medium text-blue-600">查看每日交还车数量 · 最新日期在前</span></span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2 text-xs font-bold">
|
||||
<span className="rounded-lg bg-white px-2 py-1">{flowStats?.daily.length ?? 0} 天</span>
|
||||
<ChevronDown size={20} className={`transition-transform ${flowDailyExpanded ? 'rotate-180' : ''}`} />
|
||||
</span>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{flowDailyExpanded && (
|
||||
<motion.div
|
||||
id="asset-flow-daily-details"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
|
||||
@@ -66,17 +66,17 @@ export function SummaryMetrics({
|
||||
|
||||
</div>
|
||||
|
||||
<div data-testid="asset-operation-ratio-strip" className="mb-3 rounded-2xl border border-slate-100 bg-white/85 px-4 py-3 shadow-sm">
|
||||
<div 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="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="px-2">
|
||||
<div className="flex flex-wrap items-baseline justify-center gap-x-2 px-2">
|
||||
<div className="text-[10px] font-black text-slate-400">运营率</div>
|
||||
<div className="mt-0.5 text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div>
|
||||
<div className="text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<div className="flex flex-wrap items-baseline justify-center gap-x-2 px-2">
|
||||
<div className="text-[10px] font-black text-slate-400">库存率</div>
|
||||
<div className="mt-0.5 text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
|
||||
<div className="text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -67,6 +67,8 @@ export type OverviewViewProps = SummaryMetricsProps
|
||||
& AssetSummarySectionProps
|
||||
& InventoryStatisticsSectionProps
|
||||
& {
|
||||
selectedSubject: string | null;
|
||||
onHistorySubjects: (names: string[]) => void;
|
||||
allTypesExpanded: boolean;
|
||||
toggleAllAssetTypes: () => void;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState } from 'react';
|
||||
import { Bar, BarChart, CartesianGrid, LabelList, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import { ArrowUpRight, Info } from 'lucide-react';
|
||||
import type { FaultAnalysisRecord, FaultDashboardDimension, FaultDashboardResponse, FaultDashboardScope, FaultDistribution } from '../../../shared/assets/faults';
|
||||
export type DashboardDrill = (title: string, scope?: FaultDashboardScope, filters?: Record<string, string>) => void;
|
||||
const colors = ['#ef4444','#f59e0b','#2563eb','#93c5fd','#94a3b8'];
|
||||
export function DashboardPanels({data, drill}:{data:FaultDashboardResponse;drill:DashboardDrill}) {
|
||||
const [dimension,setDimension]=useState<'region'|'level'|'customer'|'model'>('region');
|
||||
const [metric,setMetric]=useState<'reported'|'vehicles'>('reported');
|
||||
const [expanded,setExpanded]=useState(false),[matrixExpanded,setMatrixExpanded]=useState(false),[customersExpanded,setCustomersExpanded]=useState(false);
|
||||
const groups=data.distributions[dimension],shown=expanded?groups:groups.slice(0,8);
|
||||
const levels=[...data.distributions.level].sort((a,b)=>(a.value==='未标注'?1:0)-(b.value==='未标注'?1:0)||a.value.localeCompare(b.value,'zh-CN'));
|
||||
const models=matrixExpanded?data.matrix.models:data.matrix.models.slice(0,3);
|
||||
const max=Math.max(1,...data.matrix.cells.map(c=>c.count));
|
||||
const customers=[...data.distributions.customer].sort((a,b)=>b.vehicles-a.vehicles||b.reported-a.reported);
|
||||
const chartDrill=(datum:unknown,key:FaultDashboardDimension,title:string)=>{const row=(datum as {payload?:FaultDistribution})?.payload;if(row)drill(`${title} · ${row.label}`,'cohort',{[key]:row.value});};
|
||||
return <>
|
||||
<div className="fd-grid fd-grid-primary">
|
||||
<section className="fd-panel" aria-label="故障分布与对比"><div className="fd-panel-title"><h2>故障分布与对比</h2><span>按本期提报队列统计,截至当前的闭环状态。</span></div>
|
||||
<div className="fd-chart-controls"><div className="fd-segment" aria-label="故障分布维度">{([['region','区域'],['level','程度'],['customer','客户'],['model','车型']] as const).map(([key,label])=><button key={key} aria-pressed={dimension===key} onClick={()=>{setDimension(key);setExpanded(false);}}>{label}</button>)}</div><div className="fd-segment" aria-label="故障分布指标"><button aria-pressed={metric==='reported'} onClick={()=>setMetric('reported')}>故障量</button><button aria-pressed={metric==='vehicles'} onClick={()=>setMetric('vehicles')}>受影响车辆</button><button disabled title="缺少同期服务车辆规模,暂不计算发生频率">发生频率 · 待补分母</button></div></div>
|
||||
<div className="fd-legend">{metric==='reported'?<><span className="fd-blue">已闭环 {groups.reduce((n,r)=>n+r.closed,0)}</span><span className="fd-orange">未闭环 {groups.reduce((n,r)=>n+r.unclosed,0)}</span></>:<span className="fd-blue">按车辆去重 · 辆</span>}</div>
|
||||
{shown.length?<div className="fd-distribution-chart"><ResponsiveContainer width="100%" height={Math.max(150,shown.length*26)} minWidth={0}><BarChart data={shown} layout="vertical" margin={{top:4,right:30,left:0,bottom:0}}>
|
||||
<CartesianGrid horizontal={false} stroke="#edf2fa"/><XAxis type="number" allowDecimals={false} axisLine={false} tickLine={false} tick={{fontSize:10,fill:'#7c8ba7'}}/><YAxis type="category" dataKey="label" interval={0} width={116} axisLine={false} tickLine={false} tick={{fontSize:11,fill:'#334155'}} tickFormatter={v=>v.length>12?`${v.slice(0,11)}…`:v}/><Tooltip cursor={{fill:'#f4f7fc'}} contentStyle={{borderRadius:8,borderColor:'#e2e8f0',fontSize:12}}/>
|
||||
{metric==='reported'?<><Bar dataKey="closed" name="已闭环" stackId="faults" fill="#2475ff" maxBarSize={18} isAnimationActive={false} cursor="pointer" onClick={d=>chartDrill(d,dimension,'故障分布')}><LabelList dataKey={(row:FaultDistribution)=>row.unclosed===0?row.reported:''} position="right" fontSize={10} fill="#334155"/></Bar><Bar dataKey="unclosed" name="未闭环" stackId="faults" fill="#ff981a" maxBarSize={18} isAnimationActive={false} cursor="pointer" onClick={d=>chartDrill(d,dimension,'故障分布')}><LabelList dataKey="reported" position="right" fontSize={10} fill="#334155"/></Bar></>:<Bar dataKey="vehicles" name="受影响车辆" fill="#2475ff" maxBarSize={18} isAnimationActive={false} cursor="pointer" onClick={d=>chartDrill(d,dimension,'受影响车辆对应故障')}><LabelList dataKey="vehicles" position="right" fontSize={10} fill="#334155"/></Bar>}</BarChart></ResponsiveContainer></div>:<p className="fd-empty">所选条件下暂无故障。</p>}
|
||||
<div className="fd-panel-foot"><span>点击图表查看对应故障;车辆跨组可能重复,不直接累加。</span>{groups.length>8&&<button onClick={()=>setExpanded(!expanded)}>{expanded?'收起':`全部 ${groups.length} 组`}</button>}</div>
|
||||
<details className="fd-chart-data"><summary>查看精确数量与下钻</summary><table><thead><tr><th>分组</th><th>故障量</th><th>已闭环</th><th>未闭环</th><th>车辆</th></tr></thead><tbody>{groups.map(r=><tr key={r.value}><td><button onClick={()=>drill(r.label,'cohort',{[dimension]:r.value})}>{r.label}</button></td><td>{r.reported}</td><td>{r.closed}</td><td>{r.unclosed}</td><td>{r.vehicles}</td></tr>)}</tbody></table></details>
|
||||
</section>
|
||||
<section className="fd-panel" aria-label="故障程度"><div className="fd-panel-title"><h2>故障程度</h2><span className="fd-chip">现有等级口径</span></div><p className="fd-caption">按本期故障数量统计,点击等级查看明细。</p>
|
||||
<div className="fd-level-list">{levels.map(row=><button key={row.value} onClick={()=>drill(`故障程度 · ${row.label}`,'cohort',{level:row.value})}><span>{row.label}</span><span className="fd-level-track"><span style={{width:`${row.reported/Math.max(1,...levels.map(r=>r.reported))*100}%`,background:colors[/^L[1-4]/.test(row.value)?Number(row.value[1])-1:4]}}/></span><b>{row.reported}</b><ArrowUpRight size={12}/></button>)}</div>
|
||||
{!levels.length&&<p className="fd-empty">暂无等级统计。</p>}
|
||||
<p className="fd-info"><Info size={15}/>等级不等于实际停运影响,请结合车辆状态与服务记录判断。</p><p className="fd-caption">未标注等级 {data.quality.missingLevel} 单;停运车辆与修复时长待补数据。</p>
|
||||
</section>
|
||||
</div>
|
||||
<div className="fd-grid fd-grid-secondary">
|
||||
<section className="fd-panel" aria-label="品牌车型与故障部位"><div className="fd-panel-title"><h2>品牌车型 × 故障部位</h2><span>点击交叉单元格查看对应故障。</span></div><div className="fd-table-scroll"><table className="fd-matrix"><thead><tr><th>品牌车型</th>{data.matrix.categories.map(c=><th key={c.value}>{c.label}</th>)}<th>合计</th></tr></thead><tbody>{models.map(m=><tr key={m.value}><th title={m.label}>{m.label}</th>{data.matrix.categories.map(c=>{const count=data.matrix.cells.find(cell=>cell.model===m.value&&cell.category===c.value)?.count||0;return <td key={c.value} style={{background:count?`rgba(36,117,255,${.08+.52*count/max})`:undefined}}><button disabled={!count} aria-label={`${m.label},${c.label},${count} 单`} onClick={()=>drill(`${m.label} · ${c.label}`,'cohort',{model:m.value,category:c.value})}>{count||'—'}</button></td>})}<td><button onClick={()=>drill(m.label,'cohort',{model:m.value})}>{data.matrix.totals[m.value]}</button></td></tr>)}</tbody></table></div>{!models.length&&<p className="fd-empty">暂无车型与部位数据。</p>}<div className="fd-panel-foot"><span>一单多部位分别计入,合计按故障单去重。</span>{data.matrix.models.length>3&&<button onClick={()=>setMatrixExpanded(!matrixExpanded)}>{matrixExpanded?'收起':`全部 ${data.matrix.models.length} 个车型`}</button>}</div></section>
|
||||
<section className="fd-panel" aria-label="客户受影响情况"><div className="fd-panel-title"><h2>客户受影响情况</h2><span>同一车辆在同一客户下去重。</span></div><div className="fd-table-scroll"><table className="fd-customers"><thead><tr><th>#</th><th>客户名称</th><th>本期影响车辆</th><th>当前在办</th></tr></thead><tbody>{(customersExpanded?customers:customers.slice(0,4)).map((r,i)=><tr key={r.value}><td>{i+1}</td><td><button onClick={()=>drill(r.label,'cohort',{customer:r.value})}>{r.label}</button></td><td><button className="fd-customer-meter" onClick={()=>drill(`${r.label} · 本期故障`,'cohort',{customer:r.value})}><progress max={Math.max(1,customers[0]?.vehicles||0)} value={r.vehicles}/><b>{r.vehicles}</b></button></td><td><button className="fd-danger" disabled={!r.open} onClick={()=>drill(`${r.label} · 当前在办`,'open',{customer:r.value})}>{r.open}</button></td></tr>)}</tbody></table></div>{!customers.length&&<p className="fd-empty">暂无客户影响数据。</p>}<div className="fd-panel-foot"><span>当前在办不限制提报日期;库存车单独展示。</span>{customers.length>4&&<button onClick={()=>setCustomersExpanded(!customersExpanded)}>{customersExpanded?'收起':`全部 ${customers.length} 组`}</button>}</div></section>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
export function DashboardTicketTable({rows,updatedAt,drill,emptyMessage='当前条件下暂无故障。'}:{rows:FaultAnalysisRecord[];updatedAt:string;drill:DashboardDrill;emptyMessage?:string}) {
|
||||
return <div className="fd-table-scroll"><table className="fd-tickets"><thead><tr><th>车辆 / 故障单号</th><th>运维处置人</th><th>区域 / 客户</th><th>等级</th><th>当前状态</th><th>处置记录 / 故障描述</th><th>提报时间</th><th>操作</th></tr></thead><tbody>{rows.map(row=>{const overdue=['待处理','处理中'].includes(row.status)&&row.deadlineAt&&Date.parse(`${row.deadlineAt.replace(' ','T')}+08:00`)<Date.parse(updatedAt);return <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.cell.region}<small>{row.customerLabel}</small></td><td><span className={`fd-grade fd-grade-${row.level?.slice(0,2)}`}>{row.level||'未标注'}</span></td><td><span className={row.status==='已归档'?'fd-green':'fd-orange'}>{row.status==='已归档'?'已闭环':row.status}</span>{overdue&&<small className="fd-overdue-tag">已逾期</small>}</td><td className="fd-description" title={row.progress||row.description}>{row.progress||row.description||'暂无处置记录'}</td><td>{row.reportedAt||'未记录'}</td><td><button onClick={()=>drill(`${row.plate} · ${row.code}`,'ledger',{id:row.id})}>查看</button></td></tr>})}</tbody></table>{!rows.length&&<p className="fd-empty">{emptyMessage}</p>}</div>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Neutral placeholders communicate structure without suggesting partial statistics. */
|
||||
export function FaultAnalysisLoading() {
|
||||
return <div className="fault-analysis-loading" role="status" aria-live="polite">
|
||||
<div className="fault-loading-status">
|
||||
<svg className="fault-loading-spinner" viewBox="0 0 32 32" aria-hidden="true">
|
||||
{Array.from({ length: 12 }, (_, i) => <rect key={i} x="14.75" y="2" width="2.5" height="7" rx="1.25" fill="currentColor" transform={`rotate(${i * 30} 16 16)`} style={{ animationDelay: `${i / 12 - 1}s` }} />)}
|
||||
</svg>
|
||||
<span>正在加载故障数据…</span>
|
||||
</div>
|
||||
<div className="fault-loading-skeleton" aria-hidden="true">
|
||||
<div className="fault-loading-controls"><span className="fault-loading-block fault-loading-title" /><div>{Array.from({ length: 6 }, (_, i) => <span className="fault-loading-block" key={i} />)}</div></div>
|
||||
<div className="fault-loading-summary"><span className="fault-loading-block" /><span className="fault-loading-block" /></div>
|
||||
<div className="fault-loading-table"><div className="fault-loading-table-head">{Array.from({ length: 3 }, (_, i) => <span className="fault-loading-block" key={i} />)}</div>{Array.from({ length: 5 }, (_, i) => <div className="fault-loading-row" key={i}><span className="fault-loading-block" /><span className="fault-loading-block" /><span className="fault-loading-block" /></div>)}</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ArrowLeft, Search, Wrench, X } from 'lucide-react';
|
||||
import { fetchJson } from '../../../auth/api-client';
|
||||
import { FAULT_OVERVIEW_METRICS, type FaultAnalysisResponse, type FaultOverviewMetric } from '../../../shared/assets/faults';
|
||||
import { getDimensionValueLabel } from '../analytics-model';
|
||||
import { FAULT_CLOSURE_LABELS, FAULT_ANALYSIS_DIMENSIONS, FAULT_ANALYSIS_LABELS as DIMENSION_LABELS, analyzeFaultRecords, filterFaultRecords, faultDimensionValues, faultParentTotals, type FaultAnalysisDimension, type FaultAnalysisFilters, type FaultAnalysisGroup } from './analysis-model';
|
||||
import { dimensionRowSpans, orderDimensionGroups } from '../dimension-layout';
|
||||
import { FaultAnalysisLoading } from './FaultAnalysisLoading';
|
||||
import { DimensionPicker } from '../components/analysis/DimensionPicker';
|
||||
import { MobileAnalysisControls } from '../components/analysis/MobileAnalysisControls';
|
||||
import { MobileGroupedTable } from '../components/analysis/MobileGroupedTable';
|
||||
import { useDialogFocus } from '../components/useDialogFocus';
|
||||
import { useMobileLayout } from '../useMobileLayout';
|
||||
import '../components/analysis/asset-analysis.css';
|
||||
import '../components/analysis/mobile-analysis.css';
|
||||
|
||||
const available = FAULT_ANALYSIS_DIMENSIONS;
|
||||
// Page-lifetime memory only: closing a modal retains settings; a full reload clears them.
|
||||
const analysisSettings = new Map<string, { dimensions: FaultAnalysisDimension[]; filters: FaultAnalysisFilters }>();
|
||||
export interface FaultAnalysisSource { url: string; title: string; periodLabel: string; definition?: string; defaultDimensions?: FaultAnalysisDimension[]; initialDetails?: boolean }
|
||||
export function FaultAnalysisModal({ metric, onClose, source }: { metric: FaultOverviewMetric; onClose: () => void; source?: FaultAnalysisSource }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useDialogFocus(true, ref, onClose);
|
||||
const mobile = useMobileLayout();
|
||||
const [data, setData] = useState<FaultAnalysisResponse | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [revision, setRevision] = useState(0);
|
||||
const defaultDimensions: FaultAnalysisDimension[] = source?.defaultDimensions || ['type'];
|
||||
const settingsKey = source?.url || `overview:${metric}`;
|
||||
const [dimensions, setDimensions] = useState<FaultAnalysisDimension[]>(() => analysisSettings.get(settingsKey)?.dimensions || defaultDimensions);
|
||||
const [filters, setFilters] = useState<FaultAnalysisFilters>(() => analysisSettings.get(settingsKey)?.filters || {});
|
||||
const [merged, setMerged] = useState(true);
|
||||
const [sort, setSort] = useState<'count-desc' | 'count-asc' | 'label'>('count-desc');
|
||||
const [group, setGroup] = useState<FaultAnalysisGroup | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [limit, setLimit] = useState(50);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setError(''); setData(null);
|
||||
fetchJson<FaultAnalysisResponse>(source?.url || `/api/asset-faults/analysis?metric=${metric}`, { signal: controller.signal })
|
||||
.then(result => { if (!controller.signal.aborted) { setData(result); if (source?.initialDetails) setGroup({key:"all", values:{}, count:result.rows.length, share:100, recordIds:result.rows.map(r=>r.id)}); } }).catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); });
|
||||
return () => controller.abort();
|
||||
}, [metric, revision, source?.url]);
|
||||
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 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 otherFilters = { ...filters }; delete otherFilters[key];
|
||||
const counts = new Map<string, number>();
|
||||
filterFaultRecords(records, otherFilters).forEach(row => faultDimensionValues(row, key).forEach(value => counts.set(value, (counts.get(value) || 0) + 1)));
|
||||
// Keep applied selections removable even if another filter excludes them.
|
||||
filters[key]?.forEach(value => { if (!counts.has(value)) counts.set(value, 0); });
|
||||
return { key, label: DIMENSION_LABELS[key], options: [...counts].map(([value, count]) => ({ value, count, label: label(key, value) })).sort((a, b) => a.label.localeCompare(b.label, 'zh-CN')) };
|
||||
});
|
||||
const analysis = useMemo(() => analyzeFaultRecords(records, dimensions, filters), [records, dimensions, filters]);
|
||||
const values = (row: FaultAnalysisGroup) => dimensions.map(d => row.values[d]!);
|
||||
const ordered = orderDimensionGroups(analysis.rows, dimensions.length, values, row => row.count, sort,
|
||||
(a, b, i) => label(dimensions[i], values(a)[i]).localeCompare(label(dimensions[i], values(b)[i]), 'zh-CN'));
|
||||
const rows = ordered.slice(0, limit);
|
||||
const spans = dimensionRowSpans(rows, dimensions.length, values);
|
||||
const totals = faultParentTotals(ordered, dimensions);
|
||||
const groupLabel = (row: FaultAnalysisGroup) => dimensions.map(d => label(d, row.values[d]!)).join(' · ') || '合计';
|
||||
const apply = (dims: FaultAnalysisDimension[], nextFilters: FaultAnalysisFilters) => {
|
||||
const saved = { dimensions: [...dims], filters: Object.fromEntries(Object.entries(nextFilters).filter(([, values]) => values?.length).map(([key, values]) => [key, [...values!]])) };
|
||||
analysisSettings.set(settingsKey, saved);
|
||||
setDimensions(saved.dimensions); setFilters(saved.filters); setLimit(50);
|
||||
};
|
||||
const reset = () => { analysisSettings.delete(settingsKey); setDimensions([...defaultDimensions]); setFilters({}); setLimit(50); };
|
||||
const showDetails = (row: FaultAnalysisGroup) => { setGroup(row); setSearch(''); setLimit(50); };
|
||||
const filterGroup = (row: FaultAnalysisGroup) => apply(dimensions, { ...filters, ...Object.fromEntries(dimensions.map(d => [d, [row.values[d]!]])) });
|
||||
const groupIds = useMemo(() => new Set(group?.recordIds || []), [group]);
|
||||
const details = group ? records.filter(row => groupIds.has(row.id) &&
|
||||
`${row.code} ${row.plate} ${row.description} ${row.status} ${row.handlers.map(h => h.label).join(' ')}`.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()))
|
||||
.sort((a, b) => (b.reportedAt || '').localeCompare(a.reportedAt || '') || b.id.localeCompare(a.id, undefined, { numeric: true })) : [];
|
||||
return <div className="asset-analysis-overlay fault-analysis-overlay" onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div className="asset-analysis-dialog fault-analysis-dialog" ref={ref} role="dialog" aria-modal="true" aria-labelledby="fault-analysis-title" tabIndex={-1}>
|
||||
<header className="analysis-header"><div><h2 id="fault-analysis-title"><Wrench size={18} />{source?.title || FAULT_OVERVIEW_METRICS[metric]} · {group ? '故障明细' : '多维分析'}</h2><p>{source?.periodLabel || `${data?.date || '正在读取数据'} · ${metric !== 'reported' && metric !== 'closed' ? '全部日期' : '北京时间自然日'}`} · 按故障单统计</p></div><button onClick={onClose} aria-label="关闭故障分析"><X size={20} /></button></header>
|
||||
<div className="analysis-scroll">
|
||||
{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-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 > limit && <button className="analysis-button fault-more" onClick={() => setLimit(v => v + 50)}>加载更多明细(已显示 {limit} / {details.length})</button>}
|
||||
</> : <>
|
||||
{mobile ? <MobileAnalysisControls available={options} dimensions={dimensions} filters={filters} defaults={defaultDimensions} apply={(dims, next) => apply(dims as FaultAnalysisDimension[], next)} /> : <div className="analysis-builder"><div className="analysis-builder-heading"><h3>分析维度</h3><span>点击维度分组,展开箭头可多选筛选</span><button className="analysis-reset" onClick={reset}>重置</button></div><div className="analysis-dimension-options">{options.map(d => <DimensionPicker key={d.key} label={d.label} grouped={dimensions.includes(d.key)} order={dimensions.indexOf(d.key) + 1} options={d.options} selectedValues={filters[d.key]} onToggleGroup={() => apply(dimensions.includes(d.key) ? dimensions.filter(v => v !== d.key) : [...dimensions, d.key], filters)} onApplyFilter={selected => apply(dimensions, { ...filters, [d.key]: selected })} />)}<button aria-pressed={!dimensions.length} onClick={() => apply([], filters)}>仅汇总</button></div></div>}
|
||||
<div className="analysis-result-bar"><span>当前结果<strong>{analysis.total.toLocaleString()}</strong>单</span><span>{analysis.rows.length.toLocaleString()} 个分组</span>{mobile && <button className="analysis-text-button" onClick={reset}>重置</button>}<button className="analysis-text-button" onClick={() => showDetails({ key: 'all', values: {}, count: analysis.total, share: 100, recordIds: filterFaultRecords(records, filters).map(row => row.id) })} disabled={dimensions.length > 0 || !analysis.total} hidden={dimensions.length > 0}>查看全部明细</button></div>
|
||||
{dimensions.includes('handler') && <p className="fault-analysis-note">运维处置人来自故障或关联工单的当前处置分配。多人共同处置分别计入各人,总数和上级小计按故障去重,人员行不可直接相加。</p>}
|
||||
<div className="analysis-table-tools">{!mobile && <h3>分组统计</h3>}<label>排序<select aria-label="分组排序" value={sort} onChange={e => { setSort(e.target.value as typeof sort); setLimit(50); }}><option value="count-desc">数量从多到少</option><option value="count-asc">数量从少到多</option><option value="label">名称顺序</option></select></label>{!mobile && dimensions.length > 1 && <label><input type="checkbox" checked={merged} onChange={e => setMerged(e.target.checked)} />合并单元格</label>}</div>
|
||||
{mobile ? <MobileGroupedTable dimensions={dimensions.map(d => DIMENSION_LABELS[d])} rows={rows.map(row => ({ ...row, label: groupLabel(row), values: dimensions.map(d => label(d, row.values[d]!)) }))} unit="单" merged={merged} setMerged={setMerged} spans={spans} parentTotals={totals} onDetails={i => showDetails(rows[i])} onFilter={i => filterGroup(rows[i])} /> : <div className="analysis-group-table"><table><thead><tr>{dimensions.length ? dimensions.map(d => <th key={d}>{DIMENSION_LABELS[d]}</th>) : <th>范围</th>}<th>数量(单)</th><th>占比</th><th>操作</th></tr></thead><tbody>{rows.map((row, ri) => <tr key={row.key}>{dimensions.length ? dimensions.map((d, ci) => {
|
||||
const span = merged ? spans[ri][ci] : 1;
|
||||
return span ? <td key={d} rowSpan={span}>{label(d, row.values[d]!)}{merged && ci < dimensions.length - 1 && <small className="fault-parent-total">合计 {totals[ri][ci].toLocaleString()} 单</small>}</td> : null;
|
||||
}) : <td>合计</td>}<td><strong>{row.count.toLocaleString()}</strong></td><td>{row.share.toFixed(1)}%</td><td>{dimensions.length > 0 && <button className="analysis-text-button" onClick={() => filterGroup(row)}>筛选此组</button>}<button className="analysis-text-button" onClick={() => showDetails(row)} aria-label={`查看明细 ${groupLabel(row)}`}>查看明细</button></td></tr>)}</tbody></table></div>}
|
||||
{!rows.length && <p className="fault-empty">暂无符合条件的故障,可调整筛选条件。</p>}
|
||||
{ordered.length > limit && <button className="analysis-button fault-more" onClick={() => setLimit(v => v + 50)}>加载更多分组(已显示 {limit} / {ordered.length})</button>}
|
||||
<p className="fault-analysis-note">{metric === 'overdue' && '逾期仅统计待处理、处理中且处置截止时间早于本次数据时间的故障;挂起停表,无截止时间不计入。'}{source ? source.definition || '按提报日期筛选同批故障,闭环状态截至本次数据时间;已归档为已闭环,其余为未闭环。' : '维度按车辆当前信息归属;本日解决按解决时间统计。'}下钻读取最新台账,实时变动可能与首页数量存在差异。</p>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ArrowUpRight, CarFront, ClipboardList, Clock3, Filter, Info, RefreshCw, Search, TriangleAlert } from 'lucide-react';
|
||||
import { fetchJson } from '../../../auth/api-client';
|
||||
import { FAULT_DASHBOARD_FILTERS, type FaultAnalysisResponse, type FaultDashboardDimension, type FaultDashboardFilters, type FaultDashboardResponse, type FaultDashboardScope } from '../../../shared/assets/faults';
|
||||
import { FlowDateRangePicker } from '../components/overview/FlowDateRangePicker';
|
||||
import { AnalysisExportButton } from '../components/analysis/AnalysisExportButton';
|
||||
import { FaultAnalysisModal, type FaultAnalysisSource } from './FaultAnalysisModal';
|
||||
import { FaultOverview } from './FaultOverview';
|
||||
import { DashboardPanels, DashboardTicketTable, type DashboardDrill } from './DashboardPanels';
|
||||
import { downloadDashboard } from './dashboard-export';
|
||||
import './fault-management.css';
|
||||
import './dashboard.css';
|
||||
const tabs = ['管理总览','多维分析','故障明细'] as const;
|
||||
function initialRange(){const end=new Date(Date.now()+8*3600000).toISOString().slice(0,10);return {start:new Date(Date.parse(end)-29*86400000).toISOString().slice(0,10),end};}
|
||||
export default function FaultManagement(){
|
||||
const [range,setRange]=useState(initialRange),[filters,setFilters]=useState<FaultDashboardFilters>({});
|
||||
const [applied,setApplied]=useState(()=>({...initialRange(),filters:{} as FaultDashboardFilters}));
|
||||
const [tab,setTab]=useState<typeof tabs[number]>('管理总览'),[more,setMore]=useState(false);
|
||||
const [data,setData]=useState<FaultDashboardResponse|null>(null),[loading,setLoading]=useState(true),[error,setError]=useState('');
|
||||
const [revision,setRevision]=useState(0),[source,setSource]=useState<FaultAnalysisSource|null>(null);
|
||||
const [detailScope,setDetailScope]=useState<FaultDashboardScope>('cohort'),[detailData,setDetailData]=useState<FaultAnalysisResponse|null>(null),[detailError,setDetailError]=useState(''),[detailLoading,setDetailLoading]=useState(false);
|
||||
const [search,setSearch]=useState(''),[limit,setLimit]=useState(50);
|
||||
const close=useCallback(()=>setSource(null),[]);
|
||||
const query=new URLSearchParams({start:applied.start,end:applied.end,...applied.filters}).toString();
|
||||
useEffect(()=>{if(tab==='管理总览')return;const c=new AbortController();setLoading(true);setError('');fetchJson<FaultDashboardResponse>(`/api/asset-faults/dashboard?${query}`,{signal:c.signal}).then(d=>{if(!c.signal.aborted)setData(d);}).catch(e=>{if(!c.signal.aborted)setError(e instanceof Error?e.message:'加载失败');}).finally(()=>{if(!c.signal.aborted)setLoading(false);});return()=>c.abort();},[query,revision,tab]);
|
||||
useEffect(()=>{if(tab!=='故障明细')return;const c=new AbortController();setDetailData(null);setDetailError('');setDetailLoading(true);setLimit(50);fetchJson<FaultAnalysisResponse>(`/api/asset-faults/dashboard-analysis?${query}&scope=${detailScope}`,{signal:c.signal}).then(d=>{if(!c.signal.aborted)setDetailData(d);}).catch(()=>{if(!c.signal.aborted)setDetailError('故障明细加载失败,请重试。');}).finally(()=>{if(!c.signal.aborted)setDetailLoading(false);});return()=>c.abort();},[tab,query,detailScope,revision]);
|
||||
const drill:DashboardDrill=(title,scope='cohort',extra={})=>{if(loading||error||!data)return;const params=new URLSearchParams({start:data.start,end:data.end,...applied.filters,...extra,scope});setSource({url:`/api/asset-faults/dashboard-analysis?${params}`,title,periodLabel:scope==='cohort'?`${data.start} 至 ${data.end} · 按提报日期`:scope==='reported'||scope==='closed'?'北京时间今日':'全部提报日期 · 当前状态',defaultDimensions:extra.id?[]:['handler','faultStatus'],initialDetails:!!extra.id,definition:scope==='focus'?'仅包含 L1-特急且未归档的故障(含待处理、处理中、挂起等状态),不限制提报日期。':scope==='cohort'?'按提报日期筛选同批故障,已归档为已闭环,其余状态为未闭环。':scope==='overdue'?'逾期仅包含待处理、处理中且处置截止时间早于数据时间的故障;挂起、未设置截止时间不计入。':scope==='reported'?'按北京时间今日提报时间统计。':scope==='closed'?'按北京时间今日解决时间统计,不限制提报日期。':'不限制提报日期,按当前状态及已应用条件统计;当前在办 = 待处理 + 处理中。'});};
|
||||
const t=data?.totals;
|
||||
const detailRows=(detailData?.rows||[]).filter(r=>`${r.code} ${r.plate} ${r.description} ${r.handlers.map(h=>h.label).join(' ')}`.toLowerCase().includes(search.trim().toLowerCase())).sort((a,b)=>(b.reportedAt||'').localeCompare(a.reportedAt||'')||b.id.localeCompare(a.id,'en',{numeric:true}));
|
||||
const dirty=range.start!==applied.start||range.end!==applied.end||JSON.stringify(filters)!==JSON.stringify(applied.filters);
|
||||
const count=(n?:number)=>loading?'—':n?.toLocaleString('zh-CN')??'—';
|
||||
const select=(key:FaultDashboardDimension)=><label className="fd-filter" key={key}><span>{FAULT_DASHBOARD_FILTERS[key]}</span><select aria-label={FAULT_DASHBOARD_FILTERS[key]} value={filters[key]||''} onChange={e=>setFilters(v=>({...v,[key]:e.target.value}))}><option value="">全部</option>{data?.options[key].map(o=><option key={o.value} value={o.value}>{o.label}</option>)}</select></label>;
|
||||
const showAllOpen=()=>{setDetailScope('open');setTab('故障明细');setSearch('');};
|
||||
const showAllFaults=()=>{setFilters({});setApplied({...range,filters:{}});setDetailScope('ledger');setTab('故障明细');setSearch('');};
|
||||
const navigation=<nav className="fd-tabs" aria-label="故障分析页面">{tabs.map(name=><button key={name} aria-current={tab===name?'page':undefined} onClick={()=>setTab(name)}>{name}</button>)}</nav>;
|
||||
if(tab==='管理总览')return <FaultOverview navigation={navigation}/>;
|
||||
return <div className="fault-page fd-page">
|
||||
<header className="fd-header"><div><p className="fd-breadcrumb">羚牛氢能 BI / 故障分析</p><div className="fd-heading-line"><h1>故障风险与服务分析</h1><p>从问题分布,追溯到车辆与处理进展。</p></div></div><div className="fd-header-actions"><span>更新时间:{data?new Date(data.updatedAt).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false}):'读取中'}</span><button disabled={loading} aria-label="刷新故障统计" onClick={()=>setRevision(v=>v+1)}><RefreshCw size={14} className={loading?'animate-spin':''}/>刷新</button><AnalysisExportButton disabled={loading||!data||!!error} onExport={()=>data?downloadDashboard(data,Object.entries(applied.filters).filter(([,v])=>v).map(([k,v])=>`${FAULT_DASHBOARD_FILTERS[k as FaultDashboardDimension]}:${data.options[k as FaultDashboardDimension].find(o=>o.value===v)?.label||v}`).join(' · ')):undefined}/></div></header>
|
||||
{navigation}
|
||||
<form className="fd-toolbar" onSubmit={e=>{e.preventDefault();setApplied({...range,filters:{...filters}});}}><div className="fd-date"><FlowDateRangePicker value={range} onChange={setRange} dateLabel="故障提报时间"/></div>{(['region','customer','model'] as const).map(select)}<button className="fd-filter-toggle" type="button" aria-expanded={more} onClick={()=>setMore(!more)}><Filter size={15}/>更多筛选{Object.entries(filters).filter(([k,v])=>!['region','customer','model'].includes(k)&&v).length?` (${Object.entries(filters).filter(([k,v])=>!['region','customer','model'].includes(k)&&v).length})`:''}</button><button className="fd-query" type="submit" disabled={loading}>查询</button>{more&&<div className="fd-more-filters">{(['city','department','handler','level','category','inventory'] as const).map(select)}<button type="button" onClick={()=>{setFilters({});setApplied({...range,filters:{}});}}>重置筛选</button></div>}</form>
|
||||
<p className="fd-scope-note">期间指标按提报日期统计;当前在办不限制提报日期,状态截至更新时间。{dirty&&<strong>条件已修改,点击查询后生效。</strong>}</p>
|
||||
{error&&<div className="fault-message" role="alert">{error}<button onClick={()=>setRevision(v=>v+1)}>重试</button></div>}
|
||||
{!data&&loading?<div className="fd-loading" role="status"><RefreshCw className="animate-spin" size={20}/>正在汇总故障分析…</div>:data&&<div className={loading||error?'fd-content is-loading':'fd-content'} aria-busy={loading}>
|
||||
{tab==='多维分析'&&<>
|
||||
<section className="fd-kpis" aria-label="故障总统计">
|
||||
<article className="fd-kpi"><button disabled={loading||!!error} onClick={()=>drill('期间新增故障')}><ClipboardList className="fd-kpi-icon"/><span>期间新增故障</span><ArrowUpRight size={14}/><strong>{count(t?.reported)}<small>单</small></strong></button><p>对比上期 <b className={t?.change!=null&&t.change>0?'fd-orange':'fd-blue'}>{t?.change==null?'无可比基数':`${t.change>0?'+':''}${t.change.toFixed(1)}%`}</b><small>上期 {t?.previous} 单</small></p></article>
|
||||
<article className="fd-kpi"><button disabled={loading||!!error} onClick={()=>drill('受影响车辆对应故障')}><CarFront className="fd-kpi-icon fd-purple"/><span>受影响车辆</span><ArrowUpRight size={14}/><strong>{count(t?.vehicles)}<small>辆</small></strong></button><p>涉及 <b>{t?.customers}</b> 家已关联客户<small>按车辆去重</small></p></article>
|
||||
<article className="fd-kpi"><button disabled={loading||!!error} onClick={()=>drill('当前在办','open')}><Clock3 className="fd-kpi-icon fd-orange"/><span>当前在办</span><ArrowUpRight size={14}/><strong>{count(t?.open)}<small>单</small></strong></button><div className="fd-kpi-parts"><button disabled={loading||!!error} onClick={()=>drill('待处理','waiting')}>待处理 <b>{count(t?.waiting)}</b></button><span>+</span><button disabled={loading||!!error} onClick={()=>drill('处理中','processing')}>处理中 <b>{count(t?.processing)}</b></button></div></article>
|
||||
<article className="fd-kpi"><button disabled={loading||!!error} onClick={()=>drill('逾期','overdue')}><TriangleAlert className="fd-kpi-icon fd-danger"/><span>逾期</span><ArrowUpRight size={14}/><strong className="fd-danger">{count(t?.overdue)}<small>单</small></strong></button><p>最长逾期 <b className="fd-danger">{t?.longestOverdueDays} 天</b><small>在办子集</small></p></article>
|
||||
</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-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>支持运维处置人、车牌、工单状态、车型、区域、客户等 12 个维度,多选筛选与明细下钻。</p></div><button className="fd-query" onClick={()=>drill('本期故障多维分析')}>打开多维分析</button></div>}
|
||||
<DashboardPanels data={data} drill={drill}/>
|
||||
<section className="fd-panel fd-focus" aria-label="重点故障跟进">
|
||||
<div className="fd-panel-title">
|
||||
<h2>重点故障跟进</h2>
|
||||
<span className="fd-chip fd-orange">L1 未闭环 {data.focusTotals.total}</span>
|
||||
<span className="fd-chip fd-danger">逾期 {data.focusTotals.overdue}</span>
|
||||
<button onClick={showAllFaults} title="查看当前权限内的全部故障记录">查看全部 <ArrowUpRight size={13}/></button>
|
||||
</div>
|
||||
<DashboardTicketTable rows={data.focus} updatedAt={data.updatedAt} drill={drill} emptyMessage="当前筛选范围内暂无 L1 未闭环故障。"/>
|
||||
<p className="fd-caption">仅展示 L1-特急且未闭环(未归档)的故障;沿用当前业务筛选,不限制提报日期。逾期优先,同优先级按提报时间倒序,最多展示 5 单。“查看全部”进入当前权限内的全部故障台账,并清除业务筛选和搜索条件,不限等级、状态及提报日期。</p>
|
||||
</section>
|
||||
</>}
|
||||
{tab==='故障明细'&&<section className="fd-panel fd-details"><div className="fd-panel-title"><h2>故障明细</h2><span>按提报时间倒序 · {detailRows.length} 单</span><button onClick={()=>drill('故障明细多维分析',detailScope)}>多维分析 <ArrowUpRight size={13}/></button></div><div className="fd-detail-controls"><div className="fd-segment">{([['cohort','本期提报'],['open','当前在办'],['overdue','逾期故障'],['ledger','累计台账']] as const).map(([key,title])=><button key={key} aria-pressed={detailScope===key} onClick={()=>{setDetailScope(key);setSearch('');}}>{title}</button>)}</div><label><Search size={15}/><input placeholder="搜索车牌、单号、处置人" aria-label="搜索故障明细列表" value={search} onChange={e=>{setSearch(e.target.value);setLimit(50);}}/></label></div>{detailLoading?<p className="fd-loading" role="status">正在加载故障明细…</p>:detailError?<p role="alert" className="fault-message">{detailError}<button onClick={()=>setRevision(v=>v+1)}>重试</button></p>:<><DashboardTicketTable rows={detailRows.slice(0,limit)} updatedAt={detailData?.updatedAt||data.updatedAt} drill={drill}/>{detailRows.length>limit&&<button className="fd-load-more" onClick={()=>setLimit(v=>v+50)}>加载更多({Math.min(limit,detailRows.length)} / {detailRows.length})</button>}</>}</section>}
|
||||
<footer className="fd-quality"><Info size={13}/>数据完整性:本期未标注等级 {data.quality.missingLevel} 单,未标注部位 {data.quality.missingCategory} 单,缺少车辆标识 {data.quality.missingVehicle} 单;在办中 {data.quality.missingDeadline} 单未设置截止时间,不计入逾期。</footer>
|
||||
</div>}
|
||||
{source&&<FaultAnalysisModal key={source.url} metric="ledger" source={source} onClose={close}/>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Fragment, useCallback, useEffect, useState } from 'react';
|
||||
import { ArrowUpRight, CheckCircle2, ClipboardList, Clock3, PlusCircle, RefreshCw } from 'lucide-react';
|
||||
import { fetchJson } from '../../../auth/api-client';
|
||||
import { FAULT_OVERVIEW_METRICS, type FaultOverview, type FaultOverviewMetric } from '../../../shared/assets/faults';
|
||||
import { FaultAnalysisModal } from './FaultAnalysisModal';
|
||||
import { FaultWeeklyReport } from './FaultWeeklyReport';
|
||||
import { FaultTrendCard } from './FaultTrendCard';
|
||||
import './fault-management.css';
|
||||
|
||||
export const FAULT_DEFINITIONS = {
|
||||
reported: '按今日提报时间统计', closed: '按今日解决时间统计',
|
||||
open: '当前在办 = 待处理 + 处理中', ledger: '全部有效故障台账',
|
||||
};
|
||||
const icons = { reported: PlusCircle, closed: CheckCircle2, open: Clock3, ledger: ClipboardList };
|
||||
const metrics = ['reported', 'closed', 'open', 'ledger'] as const;
|
||||
export function FaultOverview({ navigation }: { navigation: ReactNode }) {
|
||||
const [data, setData] = useState<FaultOverview | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [metric, setMetric] = useState<FaultOverviewMetric | null>(null);
|
||||
const close = useCallback(() => setMetric(null), []);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true); setError('');
|
||||
fetchJson<FaultOverview>('/api/asset-faults/overview', { signal: controller.signal })
|
||||
.then(result => { if (!controller.signal.aborted) setData(result); }).catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); })
|
||||
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
||||
return () => controller.abort();
|
||||
}, [revision]);
|
||||
return <div className="fault-page fault-overview-page">
|
||||
<header className="fd-header">
|
||||
<div><p className="fd-breadcrumb">羚牛氢能 BI / 故障分析</p><div className="fd-heading-line"><h1>羚牛氢能-故障管理</h1><p>实时掌握故障数量、闭环趋势与运维进展。</p></div></div>
|
||||
<div className="fd-header-actions"><span>{data ? `更新时间:${new Date(data.updatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}` : '正在读取故障台账'}</span><button aria-label="刷新故障统计" disabled={loading} onClick={() => setRevision(v => v + 1)}><RefreshCw size={14} className={loading ? 'animate-spin' : ''} />刷新</button></div>
|
||||
</header>
|
||||
{navigation}
|
||||
{error && <div className="fault-message" role="alert">{error}<button onClick={() => setRevision(v => v + 1)}>重试</button></div>}
|
||||
<section className="fault-summary" aria-label="故障总统计" aria-busy={loading}>
|
||||
<div className="fault-summary-heading"><h2>故障数据实时汇总</h2><span>{data?.date || '—'} · 北京时间</span></div>
|
||||
<div className="fault-metrics">{metrics.map(key => {
|
||||
const Icon = icons[key];
|
||||
return <article key={key} className={`fault-metric fault-metric-${key}`}><button className="fault-metric-main" disabled={!data || loading || !!error} onClick={() => setMetric(key)} aria-label={`${FAULT_OVERVIEW_METRICS[key]} ${data?.totals[key] ?? '加载中'} 单,查看分析`}>
|
||||
<div className="fault-metric-label"><span className="fault-metric-icon"><Icon size={19} /></span><span>{FAULT_OVERVIEW_METRICS[key]}</span><ArrowUpRight size={15} /></div>
|
||||
<div className="fault-metric-count"><strong>{loading ? '—' : data?.totals[key].toLocaleString('zh-CN') ?? '—'}</strong><span>单</span></div>
|
||||
<p>{FAULT_DEFINITIONS[key]}</p><span className="fault-metric-link">查看分析与明细 →</span>
|
||||
</button>
|
||||
{key === 'open' && <>
|
||||
<button className="fault-overdue-subset" disabled={!data || loading || !!error} onClick={() => setMetric('overdue')} aria-label={`逾期 ${data?.totals.overdue ?? '加载中'} 单,查看分析`}>
|
||||
<span>逾期</span><strong>{loading ? '—' : data?.totals.overdue?.toLocaleString('zh-CN') ?? '—'}</strong><small>单</small>
|
||||
</button>
|
||||
<div className="fault-open-breakdown" aria-label="当前在办等于待处理加处理中">
|
||||
{(['waiting', 'processing'] as const).map((part, index) => <Fragment key={part}>
|
||||
{index > 0 && <span className="fault-composition-plus" aria-hidden="true">+</span>}
|
||||
<button className={`fault-submetric fault-submetric-${part}`} disabled={!data || loading || !!error} onClick={() => setMetric(part)} aria-label={`${FAULT_OVERVIEW_METRICS[part]} ${data?.totals[part] ?? '加载中'} 单,查看分析`}>
|
||||
<span>{FAULT_OVERVIEW_METRICS[part]}</span><strong>{loading ? '—' : data?.totals[part]?.toLocaleString('zh-CN') ?? '—'}</strong>
|
||||
</button>
|
||||
</Fragment>)}
|
||||
</div>
|
||||
</>}
|
||||
</article>;
|
||||
})}</div>
|
||||
<p className="fault-summary-note">当前在办 = 待处理 + 处理中,逾期为在办子集,不重复累加;按处置截止时间统计,挂起停表,未设置截止时间不计入逾期。</p>
|
||||
</section>
|
||||
<FaultTrendCard refresh={revision} />
|
||||
<FaultWeeklyReport refresh={revision} />
|
||||
{metric && <FaultAnalysisModal key={metric} metric={metric} onClose={close} />}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { FaultDashboardFilters } from '../../../shared/assets/faults';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { CalendarDays, ChevronDown, RefreshCw } from 'lucide-react';
|
||||
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import { fetchJson } from '../../../auth/api-client';
|
||||
import type { FaultTrendResponse, FaultTrendStatus } from '../../../shared/assets/faults';
|
||||
import { AnalysisExportButton } from '../components/analysis/AnalysisExportButton';
|
||||
import { downloadFaultReport } from './report-export';
|
||||
import { FlowDateRangePicker } from '../components/overview/FlowDateRangePicker';
|
||||
import { FaultAnalysisModal, type FaultAnalysisSource } from './FaultAnalysisModal';
|
||||
|
||||
function initialRange() {
|
||||
const end = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10);
|
||||
return { start: new Date(Date.parse(end) - 29 * 86400000).toISOString().slice(0, 10), end };
|
||||
}
|
||||
const statusLabels = { all: '新增故障', closed: '其中已闭环', unclosed: '其中未闭环' };
|
||||
const countKeys = { all: 'reported', closed: 'closed', unclosed: 'unclosed' } as const;
|
||||
export function FaultTrendCard({ refresh, scope = {}, scopeLabel = '', period }: { refresh: number; scope?: FaultDashboardFilters; scopeLabel?: string; period?: { start: string; end: string } }) {
|
||||
const [range, setRange] = useState(() => period || initialRange());
|
||||
const [granularity, setGranularity] = useState<'day' | 'month'>('day');
|
||||
const scopeKey = new URLSearchParams(scope).toString();
|
||||
useEffect(() => { if (period) setRange(period); }, [period?.start, period?.end]);
|
||||
const [data, setData] = useState<FaultTrendResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [source, setSource] = useState<FaultAnalysisSource | null>(null);
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const close = useCallback(() => setSource(null), []);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true); setError(''); setData(null);
|
||||
const params = new URLSearchParams({ ...range, granularity, ...scope });
|
||||
fetchJson<FaultTrendResponse>(`/api/asset-faults/trend?${params}`, { signal: controller.signal })
|
||||
.then(result => { if (!controller.signal.aborted) setData(result); })
|
||||
.catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); })
|
||||
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
||||
return () => controller.abort();
|
||||
}, [range.start, range.end, granularity, revision, refresh, scopeKey]);
|
||||
const drill = (status: FaultTrendStatus, period?: string) => {
|
||||
if (!data || loading) return;
|
||||
const params = new URLSearchParams({ start: data.start, end: data.end, granularity: data.granularity, status, ...scope });
|
||||
if (period) params.set('period', period);
|
||||
setSource({ url: `/api/asset-faults/trend-analysis?${params}`, title: `故障处理趋势 · ${statusLabels[status]}`, defaultDimensions: ['handler', 'faultStatus'],
|
||||
periodLabel: `${data.start} 至 ${data.end}${period ? ` · ${period}` : ''} · 按提报日期` });
|
||||
};
|
||||
return <section className="fault-trend fault-summary" aria-label="故障处理趋势" aria-busy={loading}>
|
||||
<div className="fault-trend-heading"><h2><CalendarDays size={16} />故障处理趋势</h2><div className="fault-report-actions"><AnalysisExportButton disabled={loading || !data || !!error} onExport={() => data ? downloadFaultReport({ kind: 'trend', data, filterLabel: scopeLabel }, chartRef.current?.querySelector<SVGSVGElement>('svg.recharts-surface[role=application]') || undefined) : undefined} /><div role="group" aria-label="趋势时间粒度">{(['day', 'month'] as const).map(value => <button key={value} aria-pressed={granularity === value} onClick={() => setGranularity(value)}>{value === 'day' ? '按日' : '按月'}</button>)}</div></div></div>
|
||||
<p className="fault-trend-definition">按提报日期统计新增故障,展示这批故障截至当前的闭环情况。新增 = 已闭环 + 未闭环。点击柱内色块,可按运维处置人、故障工单状态查看对应日期的故障。</p>
|
||||
<FlowDateRangePicker dateLabel="故障提报时间" value={range} onChange={setRange} />
|
||||
{error ? <div className="fault-message" role="alert">故障趋势加载失败,请稍后重试。<button onClick={() => setRevision(v => v + 1)}>重试</button></div> : loading || !data ? <div className="fault-trend-loading" role="status"><RefreshCw size={16} className="animate-spin" />正在加载故障趋势…</div> : <>
|
||||
<div className="fault-trend-totals">{(['all', 'closed', 'unclosed'] as const).map(status => <button key={status} className={`fault-trend-${status}`} disabled={!data.totals[countKeys[status]]} onClick={() => drill(status)} aria-label={`${statusLabels[status]} ${data.totals[countKeys[status]]} 单,查看趋势分析`}><strong>{data.totals[countKeys[status]].toLocaleString()}</strong><span>{statusLabels[status]} ↗</span></button>)}</div>
|
||||
{data.totals.reported ? <div ref={chartRef} className="fault-trend-chart" role="region" aria-label={`故障${granularity === 'day' ? '日' : '月'}趋势堆叠柱图,新增${data.totals.reported}单,已闭环${data.totals.closed}单,未闭环${data.totals.unclosed}单;精确数量可展开统计表查看`}>
|
||||
<ResponsiveContainer width="100%" height={240} minWidth={0}>
|
||||
<BarChart data={data.periods} margin={{ top: 12, right: 8, left: -18, bottom: 0 }} accessibilityLayer>
|
||||
<CartesianGrid strokeDasharray="4 6" vertical={false} stroke="#e8edf3" />
|
||||
<XAxis dataKey="period" tick={{ fontSize: 10, fill: '#64748b' }} tickFormatter={v => data.granularity === 'day' ? v.slice(5) : v} minTickGap={24} axisLine={false} tickLine={false} />
|
||||
<YAxis allowDecimals={false} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
|
||||
<Tooltip cursor={{ fill: '#f1f5f9', opacity: 0.7 }} contentStyle={{ border: '1px solid #e2e8f0', borderRadius: 12, boxShadow: '0 8px 24px rgb(15 23 42 / .08)', fontSize: 12 }} labelFormatter={(_, payload) => { const point = payload?.[0]?.payload; return point ? `${point.period} · 新增 ${point.reported} 单` : ''; }} formatter={(value, name) => [`${value} 单`, name]} />
|
||||
<Legend iconType="circle" iconSize={8} wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar dataKey="closed" name="已闭环" cursor="pointer" onClick={bar => { const period = data.periods.find(row => row.period === bar.payload?.period); if (period?.closed) drill('closed', period.period); }} fill="#2475ff" stackId="faults" maxBarSize={34} isAnimationActive={false} />
|
||||
<Bar dataKey="unclosed" name="未闭环" cursor="pointer" onClick={bar => { const period = data.periods.find(row => row.period === bar.payload?.period); if (period?.unclosed) drill('unclosed', period.period); }} fill="#ff981a" stackId="faults" maxBarSize={34} isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div> : <p className="fault-empty">所选时间范围暂无新增故障</p>}
|
||||
<button className="fault-trend-expand" aria-expanded={expanded} aria-controls="fault-trend-periods" onClick={() => setExpanded(v => !v)}><span>{expanded ? '收起' : '展开'}{data.granularity === 'day' ? '每日' : '每月'}统计 · 点击数量查看故障闭环明细</span><ChevronDown size={16} style={{ transform: expanded ? 'rotate(180deg)' : undefined }} /></button>
|
||||
{expanded && <div id="fault-trend-periods" className="fault-trend-table"><table><thead><tr><th>{data.granularity === 'day' ? '提报日期' : '提报月份'}</th><th>新增</th><th>已闭环</th><th>未闭环</th><th>闭环率</th></tr></thead><tbody>{[...data.periods].reverse().map(row => <tr key={row.period}><td>{row.period}</td>{(['all', 'closed', 'unclosed'] as const).map(status => <td key={status}><button disabled={!row[countKeys[status]]} onClick={() => drill(status, row.period)} aria-label={`${row.period} ${statusLabels[status]} ${row[countKeys[status]]} 单,查看明细`}>{row[countKeys[status]].toLocaleString()}</button></td>)}<td>{row.reported ? `${(row.closed / row.reported * 100).toFixed(1)}%` : '—'}</td></tr>)}</tbody></table></div>}
|
||||
<p className="fault-summary-note">已归档视为已闭环,其余状态(含挂起)为未闭环;无提报时间不纳入趋势。按月仍仅统计所选日期范围,不扩展至整月。状态截至 {new Date(data.updatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}。</p>
|
||||
</>}
|
||||
{source && <FaultAnalysisModal key={source.url} metric="ledger" source={source} onClose={close} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { FaultDashboardFilters } from '../../../shared/assets/faults';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ClipboardList, RefreshCw } from 'lucide-react';
|
||||
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import { fetchJson } from '../../../auth/api-client';
|
||||
import { FAULT_WEEKLY_METRICS, type FaultWeeklyMetric, type FaultWeeklyResponse } from '../../../shared/assets/faults';
|
||||
import { AnalysisExportButton } from '../components/analysis/AnalysisExportButton';
|
||||
import { downloadFaultReport } from './report-export';
|
||||
import { FlowDateRangePicker } from '../components/overview/FlowDateRangePicker';
|
||||
import { getWeeklyFlowRange } from '../model';
|
||||
import { FaultAnalysisModal, type FaultAnalysisSource } from './FaultAnalysisModal';
|
||||
|
||||
const metrics: FaultWeeklyMetric[] = ['reported', 'closed'];
|
||||
const definition = '新增故障按提报时间统计,解决故障按解决时间统计;同一故障可在不同日期分别计入两个指标。';
|
||||
export function FaultWeeklyReport({ refresh, scope = {}, scopeLabel = '', period }: { refresh: number; scope?: FaultDashboardFilters; scopeLabel?: string; period?: { start: string; end: string } }) {
|
||||
const [range, setRange] = useState(() => period || getWeeklyFlowRange());
|
||||
const scopeKey = new URLSearchParams(scope).toString();
|
||||
useEffect(() => { if (period) setRange(period); }, [period?.start, period?.end]);
|
||||
const [data, setData] = useState<FaultWeeklyResponse | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [source, setSource] = useState<FaultAnalysisSource | null>(null);
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const close = useCallback(() => setSource(null), []);
|
||||
const today = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true); setData(null); setError('');
|
||||
fetchJson<FaultWeeklyResponse>(`/api/asset-faults/weekly-report?${new URLSearchParams({ start: range.start, end: range.end, ...scope })}`, { signal: controller.signal })
|
||||
.then(result => { if (!controller.signal.aborted) setData(result); })
|
||||
.catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); })
|
||||
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
||||
return () => controller.abort();
|
||||
}, [range.start, range.end, revision, refresh, scopeKey]);
|
||||
const drill = (metric: FaultWeeklyMetric, day?: string) => {
|
||||
if (!data || loading) return;
|
||||
const params = new URLSearchParams({ start: data.start, end: data.end, metric, ...scope });
|
||||
if (day) params.set('day', day);
|
||||
setSource({ url: `/api/asset-faults/weekly-report-analysis?${params}`, title: `故障运维周报 · ${FAULT_WEEKLY_METRICS[metric]}`,
|
||||
periodLabel: `${day || `${data.start} 至 ${data.end}`} · 按事件发生时间`, definition });
|
||||
};
|
||||
return <section className="fault-weekly fault-trend fault-summary" aria-label="故障运维周报" aria-busy={loading}>
|
||||
<div className="fault-trend-heading"><h2><ClipboardList size={16} />故障运维周报</h2><div className="fault-report-actions"><span className="fault-weekly-unit">单位:单</span><AnalysisExportButton disabled={loading || !data || !!error} onExport={() => data ? downloadFaultReport({ kind: 'weekly', data, filterLabel: scopeLabel }, chartRef.current?.querySelector<SVGSVGElement>('svg.recharts-surface[role=application]') || undefined) : undefined} /></div></div>
|
||||
<p className="fault-trend-definition">按日展示新增故障和解决故障。{period ? '统计周期沿用查询范围,可在下方单独调整。' : '默认业务周为周六至周五,可自选日期。'}</p>
|
||||
<FlowDateRangePicker dateLabel="提报 / 解决时间" value={range} onChange={setRange} />
|
||||
{error ? <div className="fault-message" role="alert">故障运维周报加载失败,请稍后重试。<button onClick={() => setRevision(v => v + 1)}>重试</button></div> : loading || !data ? <div className="fault-trend-loading" role="status"><RefreshCw size={16} className="animate-spin" />正在加载故障运维周报…</div> : <>
|
||||
<div className="fault-trend-totals">{metrics.map(metric => <button key={metric} className={`fault-weekly-${metric}`} disabled={!data.totals[metric]} onClick={() => drill(metric)} aria-label={`周报${FAULT_WEEKLY_METRICS[metric]} ${data.totals[metric]} 单,查看分析`}><strong>{data.totals[metric].toLocaleString()}</strong><span>{FAULT_WEEKLY_METRICS[metric]} ↗</span></button>)}</div>
|
||||
<div ref={chartRef} className="fault-trend-chart fault-weekly-chart" role="region" aria-label="每日新增故障与解决故障折线图,精确数量及下钻见下方每日统计表">
|
||||
<div className="fault-chart-heading"><span>每日变化<small>单位:单</small></span><div className="fault-chart-legend" aria-label="图例"><span className="fault-chart-key-reported"><i />新增故障</span><span className="fault-chart-key-closed"><i />解决故障</span></div></div>
|
||||
<ResponsiveContainer width="100%" height={240} minWidth={0}>
|
||||
<LineChart data={data.days} margin={{ top: 20, right: 14, left: 0, bottom: 8 }} accessibilityLayer>
|
||||
<CartesianGrid strokeDasharray="4 6" vertical={false} stroke="#e8edf3" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#64748b' }} tickFormatter={v => v.slice(5)} minTickGap={32} tickMargin={12} padding={{ left: 12, right: 12 }} axisLine={false} tickLine={false} />
|
||||
<YAxis width={30} tickMargin={8} tickCount={5} domain={[0, 'auto']} allowDecimals={false} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
|
||||
<Tooltip cursor={{ stroke: '#cbd5e1', strokeDasharray: '4 4' }} contentStyle={{ border: '1px solid #e2e8f0', borderRadius: 12, boxShadow: '0 8px 24px rgb(15 23 42 / .08)', padding: '10px 14px', fontSize: 12 }} labelStyle={{ color: '#334155', fontWeight: 600, marginBottom: 6 }} formatter={(value, name) => [`${value} 单`, name]} />
|
||||
<Line type="monotone" dataKey="reported" name="新增故障" stroke="#2563eb" strokeWidth={2.5} dot={data.days.length <= 14 ? { r: 3.5, fill: '#fff', strokeWidth: 2 } : false} activeDot={{ r: 6, stroke: '#fff', strokeWidth: 3 }} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="closed" name="解决故障" stroke="#059669" strokeWidth={2.5} dot={data.days.length <= 14 ? { r: 3.5, fill: '#059669', stroke: '#fff', strokeWidth: 1.5 } : false} activeDot={{ r: 6, stroke: '#fff', strokeWidth: 3 }} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="fault-trend-table fault-weekly-table" role="region" aria-label="故障运维每日统计表" tabIndex={0}><table><thead><tr><th>日期</th>{metrics.map(metric => <th key={metric}>{FAULT_WEEKLY_METRICS[metric]}</th>)}</tr></thead><tbody>{[...data.days].reverse().map(row => <tr key={row.date} className={row.date === today ? 'fault-weekly-today' : undefined} aria-current={row.date === today ? 'date' : undefined}><td><span className="fault-weekly-date">{row.date}{row.date === today && <span className="fault-today-label">今天</span>}</span><small>{new Date(`${row.date}T12:00:00+08:00`).toLocaleDateString('zh-CN', { weekday: 'short', timeZone: 'Asia/Shanghai' })}</small></td>{metrics.map(metric => <td key={metric}><button className={`fault-weekly-${metric}`} disabled={!row[metric]} onClick={() => drill(metric, row.date)} aria-label={`${row.date} ${FAULT_WEEKLY_METRICS[metric]} ${row[metric]} 单,查看明细`}>{row[metric].toLocaleString()}</button></td>)}</tr>)}</tbody><tfoot><tr><th>合计</th>{metrics.map(metric => <th key={metric}><button disabled={!data.totals[metric]} onClick={() => drill(metric)} aria-label={`合计${FAULT_WEEKLY_METRICS[metric]} ${data.totals[metric]} 单,查看明细`}>{data.totals[metric].toLocaleString()}</button></th>)}</tr></tfoot></table></div>
|
||||
{!metrics.some(metric => data.totals[metric]) && <p className="fault-weekly-empty">所选日期范围暂无新增或解决故障。</p>}
|
||||
<p className="fault-summary-note">新增故障按提报日期,解决故障按解决日期统计,不受提报日期限制。数据时间:{new Date(data.updatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}。</p>
|
||||
</>}
|
||||
{source && <FaultAnalysisModal key={source.url} metric="ledger" source={source} onClose={close} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { mapFault, faultHandlers } from '../../../server/routes/asset-faults/model.js';
|
||||
import type { FaultAnalysisRecord, FaultOption } from '../../../shared/assets/faults';
|
||||
import { analyzeFaultRecords, filterFaultRecords, faultDimensionValues, faultParentTotals, FAULT_ANALYSIS_DIMENSIONS, FAULT_ANALYSIS_LABELS } from './analysis-model.js';
|
||||
function fault(id: string, handlers: FaultOption[], manager = '客户业务经理', city = '上海市'): FaultAnalysisRecord {
|
||||
const row = mapFault({ id, business_manager: manager, city_name: city }, new Map());
|
||||
return { ...row, handlers, cell: row.assetCell };
|
||||
}
|
||||
const a = { value: 'id:1234567890123456789', label: '处置甲' };
|
||||
const b = { value: 'id:2234567890123456789', label: '处置乙' };
|
||||
const data = [fault('shared', [a, b, a]), fault('single', [a]), fault('none', [])];
|
||||
|
||||
test('运维处置人是独立维度,未分配不以业务负责人兜底', () => {
|
||||
assert.ok(FAULT_ANALYSIS_DIMENSIONS.includes('handler')); assert.ok(FAULT_ANALYSIS_DIMENSIONS.includes('manager'));
|
||||
assert.equal(FAULT_ANALYSIS_LABELS.handler, '运维处置人');
|
||||
assert.deepEqual(faultDimensionValues(data[0], 'handler'), [a.value, b.value]);
|
||||
assert.deepEqual(faultDimensionValues(data[0], 'manager'), ['客户业务经理']);
|
||||
assert.deepEqual(faultDimensionValues(data[2], 'handler'), ['unassigned']);
|
||||
assert.deepEqual(faultHandlers({ business_manager: '客户业务经理' }, new Map()), [{ value: 'unassigned', label: '未指派' }]);
|
||||
});
|
||||
test('多人各自成组,总数和组内明细按故障ID去重,父级小计取并集', () => {
|
||||
const result = analyzeFaultRecords([...data, data[0]], ['city', 'handler']);
|
||||
assert.equal(result.total, 3);
|
||||
assert.equal(result.rows.find(row => row.values.handler === a.value)?.count, 2);
|
||||
assert.equal(result.rows.find(row => row.values.handler === b.value)?.count, 1);
|
||||
assert.equal(result.rows.reduce((n, row) => n + row.count, 0), 4);
|
||||
assert.ok(faultParentTotals(result.rows, ['city', 'handler']).every(row => row[0] === 3));
|
||||
for (const group of result.rows) assert.equal(new Set(group.recordIds).size, group.count);
|
||||
});
|
||||
test('多选处置人取并集,不分组时不放大;筛选甲后不出现共享故障的乙分组', () => {
|
||||
assert.equal(filterFaultRecords(data, { handler: [a.value, b.value] }).length, 2);
|
||||
assert.equal(analyzeFaultRecords(data, ['city'], { handler: [a.value, b.value] }).rows[0].count, 2);
|
||||
const result = analyzeFaultRecords(data, ['handler'], { handler: [a.value] });
|
||||
assert.equal(result.total, 2); assert.equal(result.rows.length, 1); assert.equal(result.rows[0].values.handler, a.value);
|
||||
assert.equal(filterFaultRecords(data, { handler: [a.value], manager: ['其他业务经理'] }).length, 0);
|
||||
assert.equal(filterFaultRecords(data, { handler: ['unassigned'] })[0].id, 'none');
|
||||
});
|
||||
test('同名处置人保持ID独立,组合筛选与组明细对应,空数据安全', () => {
|
||||
const rows = [fault('one', [{ ...a, label: '同名' }]), fault('two', [{ ...b, label: '同名' }])];
|
||||
const result = analyzeFaultRecords(rows, ['manager', 'handler']);
|
||||
assert.equal(result.rows.length, 2);
|
||||
for (const group of result.rows) {
|
||||
const matched = filterFaultRecords(rows, { manager: [group.values.manager!], handler: [group.values.handler!] });
|
||||
assert.deepEqual(matched.map(row => row.id), group.recordIds);
|
||||
}
|
||||
assert.deepEqual(analyzeFaultRecords([], ['handler']), { total: 0, rows: [] });
|
||||
});
|
||||
|
||||
|
||||
test('故障工单状态按归档闭环,与车辆运营状态独立;组合分组、筛选和去重口径一致', () => {
|
||||
const states = ['已归档', '待处理', '处理中', '挂起', '未知状态'];
|
||||
const rows = states.map((status, i) => ({ ...fault(String(i), i === 0 ? [a, b] : [a]), status }));
|
||||
assert.deepEqual(rows.map(row => faultDimensionValues(row, 'faultStatus')), [['closed'], ['unclosed'], ['unclosed'], ['unclosed'], ['unclosed']]);
|
||||
const result = analyzeFaultRecords(rows, ['handler', 'faultStatus']);
|
||||
assert.equal(result.total, 5);
|
||||
assert.equal(result.rows.find(row => row.values.handler === a.value && row.values.faultStatus === 'closed')?.count, 1);
|
||||
assert.equal(result.rows.find(row => row.values.handler === a.value && row.values.faultStatus === 'unclosed')?.count, 4);
|
||||
const parentTotals = faultParentTotals(result.rows, ['handler', 'faultStatus']);
|
||||
result.rows.forEach((row, i) => assert.equal(parentTotals[i][0], row.values.handler === a.value ? 5 : 1));
|
||||
assert.deepEqual(filterFaultRecords(rows, { faultStatus: ['closed'], handler: [a.value, b.value] }).map(row => row.id), ['0']);
|
||||
assert.equal(filterFaultRecords(rows, { faultStatus: ['closed', 'unclosed'] }).length, 5);
|
||||
assert.deepEqual(filterFaultRecords(rows, { faultStatus: ['unclosed'], handler: [b.value] }), []);
|
||||
for (const group of result.rows) assert.deepEqual(filterFaultRecords(rows, { handler: [group.values.handler!], faultStatus: [group.values.faultStatus!] }).map(row => row.id), group.recordIds);
|
||||
});
|
||||
|
||||
|
||||
test('车牌位于第三个维度,同车多故障按单计数,支持组合筛选和缺失车牌', () => {
|
||||
assert.equal(FAULT_ANALYSIS_DIMENSIONS[2], 'plate');
|
||||
const rows = [{ ...fault('1', [a]), plate: '沪A12345' }, { ...fault('2', [b]), plate: '沪A12345' }, { ...fault('3', [a]), plate: '粤A12345' }, { ...fault('4', []), plate: '' }];
|
||||
const grouped = analyzeFaultRecords(rows, ['plate']);
|
||||
assert.equal(grouped.total, 4);
|
||||
assert.equal(grouped.rows.find(row => row.values.plate === '沪A12345')?.count, 2);
|
||||
assert.deepEqual(filterFaultRecords(rows, { plate: ['沪A12345'], handler: [b.value] }).map(row => row.id), ['2']);
|
||||
assert.equal(filterFaultRecords(rows, { plate: ['沪A12345', '粤A12345'] }).length, 3);
|
||||
assert.deepEqual(filterFaultRecords(rows, { plate: ['未关联车辆'] }).map(row => row.id), ['4']);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { AssetAnalysisDimension } from '../../../shared/assets/analytics';
|
||||
import type { FaultAnalysisRecord } from '../../../shared/assets/faults';
|
||||
import { DIMENSION_LABELS, getAvailableDimensions } from '../analytics-model';
|
||||
|
||||
export type FaultAnalysisDimension = AssetAnalysisDimension | 'handler' | 'plate' | 'faultStatus';
|
||||
export type FaultAnalysisFilters = Partial<Record<FaultAnalysisDimension, string[]>>;
|
||||
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: '故障工单状态' };
|
||||
export const FAULT_CLOSURE_LABELS: Record<string, string> = { closed: '已闭环', unclosed: '未闭环' };
|
||||
export interface FaultAnalysisGroup {
|
||||
key: string; values: Partial<Record<FaultAnalysisDimension, string>>;
|
||||
count: number; share: number; recordIds: string[];
|
||||
}
|
||||
export function faultDimensionValues(row: FaultAnalysisRecord, dimension: FaultAnalysisDimension): string[] {
|
||||
if (dimension === 'plate') return [row.plate?.trim() || '未关联车辆'];
|
||||
if (dimension === 'faultStatus') return [row.status === '已归档' ? 'closed' : 'unclosed'];
|
||||
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[] {
|
||||
return [...new Map(rows.filter(row => FAULT_ANALYSIS_DIMENSIONS.every(d => !filters[d]?.length || faultDimensionValues(row, d).some(value => filters[d]!.includes(value)))).map(row => [row.id, row])).values()];
|
||||
}
|
||||
/** Expand only the grouped handler dimension; totals and each group remain distinct fault IDs. */
|
||||
export function analyzeFaultRecords(rows: FaultAnalysisRecord[], dimensions: FaultAnalysisDimension[], filters: FaultAnalysisFilters = {}) {
|
||||
const selected = filterFaultRecords(rows, filters);
|
||||
const groups = new Map<string, { values: FaultAnalysisGroup['values']; ids: Set<string> }>();
|
||||
const validDimensions = [...new Set(dimensions.filter(d => FAULT_ANALYSIS_DIMENSIONS.includes(d)))];
|
||||
for (const row of selected) {
|
||||
let combinations: [FaultAnalysisDimension, string][][] = [[]];
|
||||
for (const d of validDimensions) {
|
||||
const values = faultDimensionValues(row, d).filter(value => !filters[d]?.length || filters[d]!.includes(value));
|
||||
combinations = combinations.flatMap(parts => values.map(value => [...parts, [d, value] as [FaultAnalysisDimension, string]]));
|
||||
}
|
||||
for (const entries of combinations) {
|
||||
const key = JSON.stringify(entries);
|
||||
const group = groups.get(key) || { values: Object.fromEntries(entries), ids: new Set<string>() };
|
||||
group.ids.add(row.id); groups.set(key, group);
|
||||
}
|
||||
}
|
||||
return { total: selected.length, rows: [...groups].map(([key, group]): FaultAnalysisGroup => ({ key, values: group.values,
|
||||
count: group.ids.size, share: selected.length ? group.ids.size / selected.length * 100 : 0, recordIds: [...group.ids] })) };
|
||||
}
|
||||
/** Multi-handler child rows overlap: parent subtotals must use the union, never sum children. */
|
||||
export function faultParentTotals(rows: FaultAnalysisGroup[], dimensions: FaultAnalysisDimension[]): number[][] {
|
||||
const totals = new Map<string, Set<string>>();
|
||||
const keys = rows.map(row => dimensions.slice(0, -1).map((_, depth) => JSON.stringify(dimensions.slice(0, depth + 1).map(d => row.values[d]))));
|
||||
rows.forEach((row, i) => keys[i].forEach(key => {
|
||||
const ids = totals.get(key) || new Set<string>(); row.recordIds.forEach(id => ids.add(id)); totals.set(key, ids);
|
||||
}));
|
||||
return keys.map(row => row.map(key => totals.get(key)!.size));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { FaultDashboardResponse } from '../../../shared/assets/faults';
|
||||
export async function downloadDashboard(data:FaultDashboardResponse,filters:string) {
|
||||
const {default:ExcelJS}=await import('exceljs');const book=new ExcelJS.Workbook();book.creator='羚牛氢能 BI';
|
||||
const tables: {name:string;headers:string[];rows:(string|number)[][]}[]=[
|
||||
{name:'管理总览',headers:['指标','数量','口径'],rows:[['期间新增故障',data.totals.reported,'按提报日期'],['受影响车辆',data.totals.vehicles,'本期故障车辆去重'],['当前在办',data.totals.open,'全部待处理 + 处理中'],['逾期',data.totals.overdue,'当前在办中超过处置截止时间'],['待处理',data.totals.waiting,'全部待处理'],['处理中',data.totals.processing,'全部处理中'],['今日新增',data.totals.todayReported,'北京时间今日提报'],['本日解决',data.totals.todayClosed,'北京时间今日解决'],['累计台账',data.totals.ledger,'当前筛选范围的全部有效故障']]},
|
||||
...Object.entries(data.distributions).map(([key,rows])=>({name:({region:'区域分布',level:'故障程度',customer:'客户影响',model:'车型分布'} as Record<string,string>)[key],headers:['分组','本期故障','已闭环','未闭环','影响车辆','当前在办'],rows:rows.map(r=>[r.label,r.reported,r.closed,r.unclosed,r.vehicles,r.open])})),
|
||||
{name:'车型与部位',headers:['品牌车型','故障部位','故障数量'],rows:data.matrix.cells.filter(c=>c.count).map(c=>[c.model,c.category,c.count])},
|
||||
];
|
||||
for(const table of tables){const sheet=book.addWorksheet(table.name);sheet.columns=table.headers.map((_,i)=>({width:i===0?32:24}));sheet.mergeCells(1,1,1,table.headers.length);sheet.getCell('A1').value=`故障风险与服务分析 · ${table.name}`;sheet.getRow(1).height=36;sheet.getCell('A1').font={size:18,bold:true,color:{argb:'FFFFFFFF'}};sheet.getCell('A1').fill={type:'pattern',pattern:'solid',fgColor:{argb:'FF1E293B'}};
|
||||
sheet.mergeCells(2,1,2,table.headers.length);sheet.getCell('A2').value=`${data.start} 至 ${data.end} · ${filters||'全部数据权限范围'}`;sheet.getCell('A2').alignment={wrapText:true};sheet.getRow(2).height=32;
|
||||
sheet.mergeCells(3,1,3,table.headers.length);sheet.getCell('A3').value=`数据时间:${new Date(data.updatedAt).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false})}(北京时间)`;
|
||||
sheet.getRow(5).values=table.headers;table.rows.forEach((r,i)=>sheet.getRow(i+6).values=r);
|
||||
for(let n=5;n<=table.rows.length+5;n++){sheet.getRow(n).height=27;sheet.getRow(n).eachCell((cell,i)=>{cell.font={name:'Microsoft YaHei',size:11,bold:n===5,color:{argb:n===5?'FFFFFFFF':'FF334155'}};cell.fill={type:'pattern',pattern:'solid',fgColor:{argb:n===5?'FF2563EB':n%2?'FFF5F8FC':'FFFFFFFF'}};cell.alignment={vertical:'middle',horizontal:i===1?'left':'center'};if(typeof cell.value==='number')cell.numFmt='#,##0';});}
|
||||
sheet.views=[{state:'frozen',ySplit:5,showGridLines:false}];sheet.autoFilter={from:'A5',to:{row:table.rows.length+5,column:table.headers.length}};
|
||||
}
|
||||
const bytes=await book.xlsx.writeBuffer(),url=URL.createObjectURL(new Blob([new Uint8Array(bytes)],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}));const a=document.createElement('a');a.href=url;a.download=`故障风险与服务分析_${data.start}_${data.end}.xlsx`;a.click();setTimeout(()=>URL.revokeObjectURL(url),10000);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/* Fault analysis workspace: compact panels and a shared, explicit query scope. */
|
||||
.fd-page,.fault-overview-page { --fd-blue:#0965ff; --fd-ink:#162449; --fd-muted:#7385a4; --fd-border:#e3ebf6; padding:18px 22px 22px; background:#f8fbff; color:var(--fd-ink); font-size:12px; min-height:100vh; }
|
||||
.fd-page button { cursor:pointer; }.fd-page button:disabled { cursor:default; opacity:.48; }.fd-page button:focus-visible,.fd-page select:focus-visible,.fd-page input:focus-visible,.fd-page summary:focus-visible { outline:2px solid #2563eb; outline-offset:3px; }
|
||||
.fd-header { display:flex; align-items:center; justify-content:space-between; gap:18px; }.fd-breadcrumb { color:var(--fd-muted); font-size:11px; margin-bottom:10px; }.fd-heading-line { display:flex; gap:16px; align-items:center; }.fd-heading-line h1 { font-size:24px; font-weight:750; letter-spacing:-.04em; color:#081e50; white-space:nowrap; }.fd-heading-line p { font-size:12px; color:#526584; }
|
||||
.fd-header-actions { display:flex; align-items:center; gap:16px; flex-shrink:0; font-size:11px; }.fd-header-actions>span { color:var(--fd-muted); }.fd-header-actions>button,.fd-header-actions .analysis-button { display:inline-flex; align-items:center; gap:6px; }.fd-header-actions .analysis-button { background:white; border:1px solid #d8e3f2; border-radius:6px; padding:9px 15px; }.fd-header-actions .analysis-export-action { display:flex; flex-direction:column; align-items:flex-end; }.fd-header-actions small { color:#dc2626; }
|
||||
.fd-tabs { display:flex; gap:28px; border-bottom:1px solid var(--fd-border); margin:14px 0 14px; }.fd-tabs button { padding:10px 20px 9px; border-bottom:3px solid transparent; font-size:13px; font-weight:600; }.fd-tabs button[aria-current=page] { color:var(--fd-blue); border-color:var(--fd-blue); }
|
||||
.fd-toolbar { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }.fd-date { flex:1.6 1 275px; min-width:240px; }.fd-date>button { margin:0; padding:6px 10px; border-radius:6px; box-shadow:none; gap:8px; height:42px; }.fd-date>button>span:first-child { width:24px; height:24px; background:white; }.fd-date>button>span:nth-child(2) { display:flex; align-items:center; gap:10px; }.fd-date>button>span:nth-child(2)>span:first-child { font-size:10px; }.fd-date>button>span:nth-child(2)>span:nth-child(2) { margin:0; font-size:11px; font-weight:500; }.fd-date>button>span:nth-child(3) { display:none; }
|
||||
.fd-filter { display:flex; align-items:center; gap:10px; flex:1 1 170px; min-width:0; }.fd-filter>span { color:#687e9f; font-size:11px; white-space:nowrap; }.fd-filter select { border:1px solid #d8e3f2; height:36px; border-radius:5px; min-width:0; flex:1; padding:0 10px; background:#fff; font-size:11px; }
|
||||
.fd-filter-toggle { display:flex; align-items:center; justify-content:center; gap:8px; border:1px solid #d8e3f2; height:36px; border-radius:5px; padding:0 13px; background:white; }.fd-filter-toggle svg { color:var(--fd-blue); }.fd-query { background:var(--fd-blue); color:white; padding:10px 27px; border-radius:5px; font-size:12px; font-weight:600; white-space:nowrap; }.fd-more-filters { flex-basis:100%; display:flex; align-items:center; flex-wrap:wrap; gap:14px; padding:12px; border:1px solid var(--fd-border); border-radius:6px; background:#fff; }.fd-more-filters>button { color:var(--fd-blue); padding:10px; }.fd-scope-note { color:#7e91ad; font-size:10px; margin:7px 0 10px; }.fd-scope-note strong { color:#c9750a; margin-left:8px; font-weight:500; }
|
||||
.fd-content.is-loading { opacity:.5; pointer-events:none; }.fd-kpis { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:16px; }.fd-kpi { background:white; border:1px solid var(--fd-border); border-radius:7px; padding:10px 14px; min-width:0; height:117px; }.fd-kpi>button { display:grid; grid-template-columns:32px 1fr 15px; align-items:center; width:100%; text-align:left; gap:7px 10px; }.fd-kpi>button>span { font-size:12px; font-weight:600; }.fd-kpi>button>svg:last-of-type { color:#8ca1c2; }.fd-kpi-icon { width:30px; height:30px; padding:6px; border-radius:8px; color:var(--fd-blue); background:#eff6ff; }.fd-kpi strong { grid-column:2/4; font-size:29px; font-weight:650; line-height:1.05; font-variant-numeric:tabular-nums; }.fd-kpi strong small { font-size:11px; font-weight:400; color:#8596b0; margin-left:8px; }.fd-kpi>p { display:flex; align-items:center; gap:8px; margin-top:12px; font-size:10px; color:#8091ac; }.fd-kpi>p b { font-size:12px; }.fd-kpi>p small { margin-left:auto; font-size:9px; }.fd-kpi-parts { display:flex; align-items:center; gap:5px; margin-top:9px; }.fd-kpi-parts>span { color:#8495af; }.fd-kpi-parts button { flex:1; display:flex; justify-content:space-between; align-items:center; background:#fff9e8; color:#ad7615; padding:6px 8px; border-radius:4px; font-size:10px; }.fd-kpi-parts button:last-child { background:#eff5ff; color:#1b65d8; }.fd-kpi-parts b { color:var(--fd-ink); font-size:13px; }
|
||||
.fd-blue { color:#1764f4; }.fd-purple { color:#5645ef; background:#f2f0ff; }.fd-orange { color:#d98308; }.fd-danger { color:#dc2626!important; }.fd-green { color:#059669; }.fd-kpi-icon.fd-orange { background:#fff7e6; }.fd-kpi-icon.fd-danger { background:#fff0f2; }
|
||||
.fd-today-stats { display:flex; align-items:center; gap:18px; padding:9px 2px; font-size:10px; color:#8192ad; }.fd-today-stats button { color:#647896; }.fd-today-stats b { color:#294779; margin-left:6px; font-size:11px; }.fd-insight { display:flex; align-items:center; gap:10px; background:#eef6ff; border:1px solid #c7ddff; color:#0965ff; padding:10px 13px; border-radius:6px; margin:0 0 12px; font-size:11px; }.fd-insight>svg { flex-shrink:0; }.fd-insight strong { font-weight:550; }.fd-insight>span { color:#7e94b8; font-size:10px; margin-left:auto; }.fd-insight button { display:flex; align-items:center; gap:4px; flex-shrink:0; }
|
||||
.fd-grid { display:grid; gap:12px; margin-bottom:12px; }.fd-grid-primary { grid-template-columns:1.5fr 1fr; }.fd-grid-secondary { grid-template-columns:1.2fr 1fr; }.fd-panel { background:#fff; border:1px solid var(--fd-border); border-radius:7px; padding:12px; min-width:0; }.fd-panel-title { display:flex; align-items:center; flex-wrap:wrap; gap:10px; margin-bottom:11px; }.fd-panel-title h2 { font-size:14px; font-weight:700; color:#172647; border-left:4px solid #0965ff; padding-left:9px; line-height:16px; }.fd-panel-title>span { color:#7a8eac; font-size:10px; }.fd-panel-title>button { display:flex; align-items:center; gap:4px; margin-left:auto; color:var(--fd-blue); font-size:11px; }.fd-panel-title>.fd-chip { margin-left:auto; }.fd-focus .fd-panel-title>.fd-chip { margin-left:0; }.fd-chip { padding:4px 8px; border:1px solid #e5ebf4; border-radius:4px; background:#f8fafc; }.fd-chip.fd-orange { color:#ad7615; background:#fff9e9; }.fd-chip.fd-danger { background:#fff1f2; border-color:#fecdd3; }
|
||||
.fd-chart-controls { display:flex; justify-content:space-between; gap:10px; flex-wrap:wrap; }.fd-segment { display:flex; border-radius:4px; background:#f3f6fa; padding:2px; gap:2px; }.fd-segment button { padding:6px 12px; font-size:11px; white-space:nowrap; border-radius:3px; color:#5b7296; }.fd-segment button[aria-pressed=true] { color:white; background:var(--fd-blue); }.fd-segment button:disabled { font-size:9px; padding:6px; }.fd-legend { display:flex; gap:18px; justify-content:flex-end; margin:12px 0 2px; font-size:10px; }.fd-distribution-chart { max-height:360px; overflow:auto; }.fd-panel-foot { display:flex; justify-content:space-between; align-items:center; gap:8px; margin-top:7px; font-size:9px; color:#8296b3; }.fd-panel-foot button { color:var(--fd-blue); font-size:10px; flex-shrink:0; padding:3px; }.fd-caption { font-size:10px; color:#8396b2; margin:8px 0; line-height:1.6; }.fd-level-list { display:grid; gap:11px; padding:14px 12px; }.fd-level-list button { display:grid; grid-template-columns:68px 1fr 28px 12px; align-items:center; gap:10px; font-size:11px; text-align:left; }.fd-level-track { height:18px; }.fd-level-track>span { display:block; height:100%; min-width:2px; }.fd-level-list button>svg { color:#90a3bf; }.fd-info { display:flex; align-items:center; gap:10px; font-size:10px; line-height:1.7; border-radius:5px; padding:8px 10px; color:#286dd7; background:#eff6ff; }.fd-info svg { flex-shrink:0; }
|
||||
.fd-table-scroll { overflow:auto; max-height:420px; border:1px solid #e7eef7; border-radius:5px; }.fd-page table { width:100%; border-collapse:collapse; font-size:11px; }.fd-page th { font-size:10px; font-weight:600; background:#f7f9fc; color:#637b9e; text-align:left; }.fd-page td,.fd-page th { padding:7px 9px; border-bottom:1px solid #edf2f9; }.fd-page tbody tr:last-child td,.fd-page tbody tr:last-child th { border-bottom:none; }.fd-page table button { color:#1764ef; }.fd-page table button:hover { text-decoration:underline; }.fd-matrix { min-width:620px; }.fd-matrix td,.fd-matrix thead th:not(:first-child) { text-align:center; border-left:1px solid #ecf2fa; }.fd-matrix tbody th { max-width:145px; min-width:135px; white-space:normal; background:white; color:#334a6d; }.fd-matrix td button { display:block; width:100%; min-width:38px; color:#12356e; font-weight:600; }.fd-customers { min-width:440px; }.fd-customer-meter { display:flex; align-items:center; gap:7px; width:100%; }.fd-customer-meter progress { width:110px; height:9px; appearance:none; border:none; background:transparent; border-radius:8px; }.fd-customer-meter progress::-webkit-progress-bar { background:#f0f5fc; border-radius:8px; }.fd-customer-meter progress::-webkit-progress-value { background:#2475ff; border-radius:8px; }.fd-customer-meter progress::-moz-progress-bar { background:#2475ff; border-radius:8px; }.fd-customers td:first-child { color:#8193b0; }.fd-customers td:last-child { text-align:center; }
|
||||
.fd-tickets { min-width:1000px; }.fd-tickets td { line-height:1.5; }.fd-tickets small { display:block; margin-top:2px; font-size:9px; color:#7d90ab; }.fd-tickets strong { font-weight:600; }.fd-tickets td:nth-child(2) { max-width:140px; }.fd-description { max-width:250px; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }.fd-grade { white-space:nowrap; font-size:10px; padding:3px 5px; border-radius:3px; color:#73859f; background:#f2f5f9; }.fd-grade-L1 { color:#dc2626; background:#fff0f1; }.fd-grade-L2 { color:#cf7b00; background:#fff7e8; }.fd-grade-L3 { color:#1264e8; background:#eff6ff; }.fd-grade-L4 { color:#5786c1; background:#eff6ff; }.fd-tickets .fd-overdue-tag { display:inline-block; color:#dc2626; background:#fff1f2; padding:1px 4px; margin-left:5px; }.fd-focus .fd-caption { margin-bottom:0; }.fd-quality { display:flex; gap:7px; align-items:center; font-size:10px; color:#8a9ab1; padding:12px 2px 0; line-height:1.7; }.fd-quality svg { flex-shrink:0; }.fd-empty { padding:28px 16px; text-align:center; color:#8798b1; }.fd-loading { display:flex; align-items:center; justify-content:center; gap:12px; padding:80px 20px; color:#8193ad; }.fd-chart-data { font-size:10px; color:#6b83a5; margin-top:6px; }.fd-chart-data summary { cursor:pointer; }.fd-chart-data[open] { max-height:260px; overflow:auto; }.fd-chart-data table { margin-top:6px; }
|
||||
.fd-analysis-banner { display:flex; justify-content:space-between; gap:16px; padding:15px; background:white; border:1px solid var(--fd-border); border-radius:6px; margin-bottom:12px; }.fd-analysis-banner h2 { font-weight:600; margin-bottom:6px; }.fd-analysis-banner p { color:#7c8da8; font-size:11px; }.fd-reports>.fault-summary { margin-top:12px; border-radius:8px; border-color:var(--fd-border); padding:16px; }.fd-detail-controls { display:flex; flex-wrap:wrap; justify-content:space-between; gap:12px; margin:14px 0; }.fd-detail-controls>label { display:flex; align-items:center; gap:7px; border:1px solid var(--fd-border); padding:7px 10px; border-radius:5px; color:#90a0b7; }.fd-detail-controls input { width:220px; outline:none; color:#334155; }.fd-details .fd-table-scroll { max-height:65vh; }.fd-load-more { display:block; margin:12px auto; color:var(--fd-blue); padding:10px; }
|
||||
@media(min-width:1350px) { .fd-toolbar { flex-wrap:nowrap; position:relative; }.fd-toolbar:has(.fd-more-filters) { flex-wrap:wrap; }.fd-date { flex:0 0 330px; }.fd-filter { flex:1 1 150px; }.fd-query { padding:10px 29px; } }
|
||||
@media(max-width:1150px) { .fd-header { align-items:flex-start; }.fd-heading-line { display:block; }.fd-heading-line p { margin-top:7px; }.fd-header-actions { gap:10px; flex-wrap:wrap; justify-content:flex-end; max-width:260px; }.fd-kpis { gap:10px; }.fd-insight>span { display:none; }.fd-grid-primary,.fd-grid-secondary { grid-template-columns:1.2fr 1fr; }.fd-kpi>p small { display:none; } }
|
||||
@media(max-width:900px) { .fd-grid-primary,.fd-grid-secondary { grid-template-columns:1fr; }.fd-kpis { grid-template-columns:repeat(2,minmax(0,1fr)); }.fd-header-actions>span { display:none; }.fd-heading-line h1 { font-size:21px; } }
|
||||
@media(max-width:767px) { .fd-page { padding:14px 12px 20px; }.fd-header { gap:10px; }.fd-breadcrumb { font-size:10px; margin-bottom:6px; }.fd-heading-line h1 { font-size:19px; }.fd-heading-line p { display:none; }.fd-header-actions { gap:7px; }.fd-header-actions .analysis-button { font-size:10px; padding:9px 7px; min-height:36px; }.fd-header-actions>button { font-size:0; padding:8px; }.fd-header-actions>button svg { width:16px; height:16px; }.fd-tabs { gap:0; margin:10px 0 12px; }.fd-tabs button { flex:1; padding:10px 0; font-size:12px; }.fd-toolbar { gap:8px; }.fd-date { flex-basis:100%; min-width:0; }.fd-date>button { height:40px; }.fd-date>button>span:nth-child(2) { justify-content:space-between; }.fd-filter { flex:1 1 calc(50% - 8px); gap:5px; }.fd-filter>span { font-size:10px; min-width:40px; }.fd-filter select { height:36px; padding:0 6px; font-size:11px; }.fd-toolbar>.fd-filter:nth-of-type(3) { flex-basis:100%; }.fd-filter-toggle { flex:1; }.fd-toolbar>.fd-query { flex:1; padding:10px; }.fd-scope-note { font-size:9px; line-height:1.7; }.fd-scope-note strong { display:block; margin:0; }.fd-kpis { gap:8px; }.fd-kpi { height:111px; padding:10px; }.fd-kpi>button { grid-template-columns:25px 1fr 12px; gap:7px; }.fd-kpi-icon { width:25px; height:25px; padding:5px; }.fd-kpi>button>span { font-size:11px; }.fd-kpi strong { font-size:26px; }.fd-kpi>p { font-size:9px; gap:6px; margin-top:10px; }.fd-kpi>p b { font-size:11px; }.fd-kpi-parts { margin-top:9px; gap:3px; }.fd-kpi-parts button { font-size:9px; padding:5px; }.fd-kpi-parts b { font-size:12px; }.fd-today-stats { gap:12px; justify-content:space-between; font-size:9px; }.fd-today-stats>span { display:none; }.fd-today-stats b { margin-left:4px; }.fd-insight { font-size:10px; align-items:flex-start; gap:7px; padding:9px; flex-wrap:wrap; }.fd-insight strong { flex:1; line-height:1.7; }.fd-insight button { margin-left:auto; padding:4px; }.fd-grid { gap:10px; margin-bottom:10px; }.fd-panel { padding:10px; }.fd-panel-title h2 { font-size:13px; }.fd-panel-title { gap:7px; }.fd-panel-title>span { font-size:9px; }.fd-chart-controls { gap:8px; }.fd-segment button { padding:7px 9px; font-size:10px; }.fd-level-list { padding:12px 6px; }.fd-level-list button { min-height:28px; }.fd-panel-foot { font-size:9px; }.fd-focus .fd-chip { font-size:9px; padding:3px 5px; }.fd-focus .fd-panel-title>button { font-size:10px; }.fd-analysis-banner { flex-direction:column; }.fd-quality { font-size:9px; align-items:flex-start; }.fd-detail-controls>label { width:100%; }.fd-detail-controls input { width:100%; }.fd-more-filters { gap:9px; padding:9px; }.fd-more-filters .fd-filter { flex-basis:100%; }.fd-page .fault-analysis-dialog { font-size:12px; } }
|
||||
@media(prefers-reduced-motion:reduce) { .fd-page * { scroll-behavior:auto; } }
|
||||
/* The workspace owns its breadcrumb, so the shell's fixed breadcrumb must not cover it. */
|
||||
.app-shell-content:has(>.fd-page)>.fixed.top-0 { display:none; }
|
||||
.fd-breadcrumb { margin-bottom:6px; line-height:16px; }.fd-heading-line h1 { line-height:1.25; }.fd-tabs { margin-top:10px; margin-bottom:10px; }
|
||||
.fd-date>button>span:nth-child(2)>span:first-child { display:none; }.fd-date>button>span:nth-child(2)>span:nth-child(2) { font-size:12px; gap:14px; }
|
||||
.fd-focus td { padding-top:6px; padding-bottom:6px; }
|
||||
@media(max-width:360px) {
|
||||
.fd-header { flex-wrap:wrap; gap:8px; }
|
||||
.fd-header-actions { width:100%; justify-content:flex-end; }
|
||||
.fd-kpi>button { grid-template-columns:21px 1fr 10px; gap:6px 4px; }
|
||||
.fd-kpi-icon { width:21px; height:21px; padding:4px; }
|
||||
.fd-kpi>button>span { font-size:10px; }
|
||||
}
|
||||
@media(max-width:360px) {
|
||||
.fd-toolbar>.fd-filter { flex-basis:100%; }
|
||||
.fd-toolbar>.fd-filter>span { min-width:42px; }
|
||||
}
|
||||
@media(min-width:901px) {
|
||||
.fd-grid-primary .fd-panel-title { margin-bottom:8px; }
|
||||
.fd-grid-primary .fd-caption { margin:5px 0; }
|
||||
.fd-level-list { gap:8px; padding:8px 12px; }
|
||||
.fd-grid-primary .fd-info { padding:6px 10px; }
|
||||
.fd-grid-secondary td,.fd-grid-secondary th { padding:4px 9px; }
|
||||
}
|
||||
|
||||
/* Overview shares the analysis palette while preserving its statistics and report layout. */
|
||||
.fault-overview-page button { cursor:pointer; }
|
||||
.fault-overview-page button:disabled { cursor:default; }
|
||||
.fault-overview-page button:focus-visible { outline:2px solid var(--fd-blue); outline-offset:3px; }
|
||||
.fault-overview-page>.fault-summary {
|
||||
padding:16px; border-color:var(--fd-border); border-radius:7px; box-shadow:none;
|
||||
}
|
||||
.fault-overview-page>.fault-trend { margin-top:12px; }
|
||||
.fault-overview-page .fault-summary-heading { margin-bottom:14px; }
|
||||
.fault-overview-page .fault-summary-heading h2,
|
||||
.fault-overview-page .fault-trend-heading h2 {
|
||||
border-left:4px solid var(--fd-blue); padding-left:9px; color:#172647;
|
||||
font-size:14px; font-weight:700; line-height:16px;
|
||||
}
|
||||
.fault-overview-page .fault-trend-heading h2>svg { display:none; }
|
||||
.fault-overview-page .fault-summary-heading>span,
|
||||
.fault-overview-page .fault-trend-definition,
|
||||
.fault-overview-page .fault-summary-note { color:var(--fd-muted); }
|
||||
.fault-overview-page .fault-metric { border-color:var(--fd-border); border-radius:7px; }
|
||||
.fault-overview-page .fault-metric-main { border-radius:7px; }
|
||||
.fault-overview-page .fault-metric:hover { border-color:#93c5fd; box-shadow:0 3px 10px rgb(9 101 255 / .05); }
|
||||
.fault-overview-page .fault-metric-label { color:#334a6d; font-weight:600; }
|
||||
.fault-overview-page .fault-metric-count strong { color:var(--fd-ink); }
|
||||
.fault-overview-page .fault-metric p { color:var(--fd-muted); }
|
||||
.fault-overview-page .fault-metric-link { color:var(--fd-blue); }
|
||||
.fault-overview-page .fault-metric-icon { color:var(--fd-blue); background:#eff6ff; }
|
||||
.fault-overview-page .fault-metric-closed .fault-metric-icon { color:#059669; background:#ecfdf5; }
|
||||
.fault-overview-page .fault-metric-open .fault-metric-icon { color:#d98308; background:#fff7e6; }
|
||||
.fault-overview-page .fault-metric-ledger .fault-metric-icon { color:#5645ef; background:#f2f0ff; }
|
||||
.fault-overview-page .fault-open-breakdown { border-color:var(--fd-border); }
|
||||
.fault-overview-page .fault-submetric { border-radius:4px; }
|
||||
.fault-overview-page .fault-submetric-waiting { color:#ad7615; background:#fff9e8; }
|
||||
.fault-overview-page .fault-submetric-processing { color:#1b65d8; background:#eff5ff; }
|
||||
.fault-overview-page .fault-submetric strong { color:var(--fd-ink); }
|
||||
.fault-overview-page .fault-overdue-subset { border-color:#fecdd3; border-radius:4px; background:#fff1f2; }
|
||||
.fault-overview-page .fault-report-actions .analysis-export-action button {
|
||||
border-color:#d8e3f2; border-radius:6px; color:#526584; box-shadow:none;
|
||||
}
|
||||
.fault-overview-page .fault-trend-heading [role=group] { border-radius:4px; background:#f3f6fa; padding:2px; gap:2px; }
|
||||
.fault-overview-page .fault-trend-heading [role=group] button { border-radius:3px; color:#5b7296; }
|
||||
.fault-overview-page .fault-trend-heading button[aria-pressed=true] { color:white; background:var(--fd-blue); box-shadow:none; }
|
||||
.fault-overview-page [data-testid=asset-flow-date-range] {
|
||||
border-color:#d8e3f2; border-radius:6px; padding:10px 12px; box-shadow:none;
|
||||
}
|
||||
.fault-overview-page [data-testid=asset-flow-date-range]>span:first-child { width:30px; height:30px; border-radius:6px; color:var(--fd-blue); background:#eff6ff; }
|
||||
.fault-overview-page [data-testid=asset-flow-date-range]>span:nth-child(3) { border-radius:4px; color:#687e9f; background:#f3f6fa; }
|
||||
.fault-overview-page .fault-trend-totals { border-color:var(--fd-border); border-radius:6px; background:#f7faff; }
|
||||
.fault-overview-page .fault-trend-totals button { border-color:var(--fd-border); }
|
||||
.fault-overview-page .fault-trend-all,
|
||||
.fault-overview-page .fault-trend-closed { color:#1764f4; }
|
||||
.fault-overview-page .fault-trend-unclosed { color:#d98308; }
|
||||
.fault-overview-page .fault-trend-expand { border-color:#c7ddff; border-radius:6px; color:var(--fd-blue); background:#eef6ff; }
|
||||
.fault-overview-page .fault-weekly-chart { border-color:var(--fd-border); border-radius:7px; }
|
||||
.fault-overview-page .fault-trend-table { border-color:var(--fd-border); border-radius:5px; }
|
||||
.fault-overview-page .fault-trend-table th { color:#637b9e; background:#f7f9fc; }
|
||||
.fault-overview-page .fault-trend-table td { border-color:#edf2f9; }
|
||||
@media(max-width:767px) {
|
||||
.fault-overview-page { padding:14px 12px 20px; }
|
||||
.fault-overview-page>.fault-summary { padding:12px; }
|
||||
.fault-overview-page .fault-summary-heading h2,
|
||||
.fault-overview-page .fault-trend-heading h2 { font-size:13px; }
|
||||
}
|
||||
.app-shell-content:has(>.fault-overview-page)>.fixed.top-0 { display:none; }
|
||||
.fd-kpis>.fd-kpi { display:flex; flex-direction:column; }
|
||||
.fd-kpis .fd-kpi>button { flex-shrink:0; }
|
||||
.fd-kpis .fd-kpi>p,
|
||||
.fd-kpis .fd-kpi-parts { margin-top:auto; min-height:24px; }
|
||||
.fd-kpis .fd-kpi>p { align-items:center; }
|
||||
.fd-kpis .fd-kpi-parts { gap:5px; }
|
||||
.fd-kpis .fd-kpi-parts button {
|
||||
height:24px; min-width:0; padding:3px 7px; line-height:18px; white-space:nowrap;
|
||||
}
|
||||
.fd-kpis .fd-kpi-parts b { line-height:18px; }
|
||||
.fd-kpis .fd-kpi-parts>span { line-height:24px; font-size:12px; }
|
||||
@media(max-width:1100px) {
|
||||
.fd-kpis { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
.fault-page { color:#334155; min-width:0; padding:24px; }
|
||||
.fault-page-header { position:relative; z-index:30; margin:-24px -24px 24px; padding:18px 24px 12px; background:rgb(255 255 255 / .95); border-bottom:1px solid #f1f5f9; text-align:center; }
|
||||
.fault-page-header h1 { font-size:16px; font-weight:600; letter-spacing:.04em; color:#1f2937; }
|
||||
.fault-page-header>div { display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:12px; margin-top:9px; font-size:10px; color:#94a3b8; }
|
||||
.fault-page-header button { display:inline-flex; align-items:center; gap:4px; color:#64748b; cursor:pointer; padding:4px; }
|
||||
.fault-summary { background:white; border:1px solid #f1f5f9; border-radius:16px; padding:20px; box-shadow:0 1px 3px rgb(15 23 42 / .02); }
|
||||
.fault-summary-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:20px; }
|
||||
.fault-summary-heading h2 { font-size:14px; font-weight:600; color:#1e293b; }
|
||||
.fault-summary-heading>span { color:#94a3b8; font-size:11px; }
|
||||
.fault-metrics { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:16px; align-items:start; }
|
||||
.fault-metric { text-align:left; padding:0; border:1px solid #e2e8f0; background:#fff; border-radius:12px; transition:border-color .15s,box-shadow .15s; min-width:0; }
|
||||
.fault-metric:hover:not(:disabled) { border-color:#93c5fd; box-shadow:0 4px 12px rgb(37 99 235 / .06); }
|
||||
.fault-metric-main:focus-visible { outline:2px solid #2563eb; outline-offset:3px; }
|
||||
.fault-metric-main:disabled { cursor:default; opacity:.6; }
|
||||
.fault-metric-label { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:500; }
|
||||
.fault-metric-label>svg { margin-left:auto; color:#94a3b8; }
|
||||
.fault-metric-icon { display:flex; align-items:center; justify-content:center; width:34px; height:34px; border-radius:9px; background:#eff6ff; color:#2563eb; flex-shrink:0; }
|
||||
.fault-metric-closed .fault-metric-icon { background:#ecfdf5; color:#059669; }
|
||||
.fault-metric-open .fault-metric-icon { background:#fff7ed; color:#d97706; }
|
||||
.fault-metric-ledger .fault-metric-icon { background:#f5f3ff; color:#7c3aed; }
|
||||
.fault-metric-count { display:flex; align-items:baseline; gap:8px; margin:18px 0 10px; }
|
||||
.fault-metric-count strong { font-size:32px; line-height:1.15; font-weight:650; color:#0f172a; font-variant-numeric:tabular-nums; }
|
||||
.fault-metric-count>span { font-size:11px; color:#94a3b8; }
|
||||
.fault-metric p { font-size:11px; color:#94a3b8; line-height:1.6; min-height:35px; }
|
||||
.fault-metric-link { display:block; font-size:11px; color:#3b82f6; margin-top:12px; }
|
||||
.fault-message { margin:12px 0; padding:14px; border:1px solid #fecaca; background:#fef2f2; border-radius:8px; color:#b91c1c; font-size:12px; }
|
||||
.fault-message button { margin-left:12px; text-decoration:underline; cursor:pointer; }
|
||||
.fault-parent-total,.fault-detail-table small { display:block; margin-top:5px; font-size:11px; color:#94a3b8; }
|
||||
.fault-detail-table table { min-width:1100px; }
|
||||
.fault-detail-table td:first-child { white-space:nowrap; }
|
||||
.fault-detail-table td:first-child strong { color:#1e293b; font-weight:600; }
|
||||
.fault-detail-table td:nth-child(2) { min-width:100px; max-width:180px; white-space:normal; overflow-wrap:anywhere; }
|
||||
.fault-detail-table td:nth-child(3) { min-width:180px; max-width:320px; white-space:normal; overflow-wrap:anywhere; }
|
||||
.fault-detail-search { display:flex; gap:6px; border:1px solid #e2e8f0; padding:8px; border-radius:6px; }
|
||||
.fault-detail-search input { min-width:0; width:220px; outline:none; }
|
||||
.fault-empty { text-align:center; padding:32px; color:#94a3b8; }
|
||||
.fault-analysis-note { margin-top:16px; color:#94a3b8; font-size:11px; line-height:1.7; }
|
||||
.fault-analysis-dialog .analysis-dimension-options { align-items:flex-start; }
|
||||
.fault-more { display:flex; margin:16px auto; }
|
||||
@media(max-width:1100px) { .fault-metrics { grid-template-columns:repeat(2,minmax(0,1fr)); } }
|
||||
@media(max-width:767px) {
|
||||
.fault-page { padding:12px; }
|
||||
.fault-page-header { position:relative; z-index:30; margin:-12px -12px 16px; padding:14px 12px 10px; }
|
||||
.fault-page-header h1 { font-size:15px; }
|
||||
.fault-summary { padding:14px; border-radius:12px; }
|
||||
.fault-summary-heading { align-items:flex-start; flex-direction:column; gap:6px; margin-bottom:14px; }
|
||||
.fault-metrics { gap:10px; }
|
||||
.fault-metric { padding:0; border-radius:10px; }
|
||||
.fault-metric-label { font-size:12px; gap:6px; }
|
||||
.fault-metric-icon { width:28px; height:28px; }
|
||||
.fault-metric-label>svg { display:none; }
|
||||
.fault-metric-count strong { font-size:29px; }
|
||||
.fault-metric p { font-size:10px; }
|
||||
.fault-detail-search { width:100%; }
|
||||
.fault-detail-search input { width:100%; }
|
||||
}
|
||||
|
||||
.fault-metric-main { display:block; width:100%; padding:18px; text-align:left; cursor:pointer; border-radius:12px; }
|
||||
/* Fixed compact height keeps every metric aligned, including the open-work breakdown. */
|
||||
.fault-metric { height:164px; display:flex; flex-direction:column; }
|
||||
.fault-metric-main { flex:1; min-height:0; display:flex; flex-direction:column; padding:12px 14px; }
|
||||
.fault-metric-label { width:100%; }
|
||||
.fault-metric-icon { width:28px; height:28px; border-radius:7px; }
|
||||
.fault-metric-count { margin:8px 0 5px; }
|
||||
.fault-metric-count strong { font-size:28px; }
|
||||
.fault-metric p { min-height:0; font-size:10px; }
|
||||
.fault-metric-link { margin-top:auto; padding-top:6px; font-size:10px; }
|
||||
.fault-metric-open { position:relative; }
|
||||
.fault-metric-open .fault-metric-main>p,.fault-metric-open .fault-metric-link { display:none; }
|
||||
.fault-open-breakdown { display:flex; align-items:center; gap:4px; margin:0 12px 12px; padding-top:8px; border-top:1px solid #f1f5f9; }
|
||||
.fault-submetric { flex:1; min-width:0; display:flex; flex-direction:column; align-items:center; gap:2px; padding:4px 2px; border-radius:5px; font-size:10px; cursor:pointer; color:#64748b; }
|
||||
.fault-submetric:hover { background:#eff6ff; color:#2563eb; }
|
||||
.fault-submetric:focus-visible { outline:2px solid #2563eb; outline-offset:1px; }
|
||||
.fault-submetric:disabled { opacity:.6; cursor:default; }
|
||||
.fault-submetric strong { font-variant-numeric:tabular-nums; font-size:16px; line-height:1.2; color:#334155; }
|
||||
.fault-submetric svg { display:none; }
|
||||
.fault-composition-plus { color:#94a3b8; font-size:18px; font-weight:400; }
|
||||
.fault-submetric-waiting { background:#fffbeb; color:#b45309; }
|
||||
.fault-submetric-processing { background:#eff6ff; color:#2563eb; }
|
||||
.fault-overdue-subset { position:absolute; right:14px; top:51px; display:flex; align-items:baseline; gap:4px; padding:5px 8px; border:1px solid #fee2e2; border-radius:6px; background:#fef2f2; color:#dc2626; cursor:pointer; }
|
||||
.fault-overdue-subset span,.fault-overdue-subset small { font-size:10px; }
|
||||
.fault-overdue-subset strong { font-size:16px; line-height:1.2; font-variant-numeric:tabular-nums; }
|
||||
.fault-overdue-subset:hover { background:#fee2e2; }
|
||||
.fault-overdue-subset:focus-visible { outline:2px solid #dc2626; outline-offset:2px; }
|
||||
.fault-overdue-subset:disabled { opacity:.6; cursor:default; }
|
||||
.fault-summary-note { margin-top:12px; font-size:10px; color:#94a3b8; line-height:1.6; }
|
||||
@media(max-width:767px) {
|
||||
.fault-metric { height:172px; }
|
||||
.fault-metric-main { padding:12px; }
|
||||
.fault-open-breakdown { margin:0 8px 10px; gap:2px; }
|
||||
.fault-submetric { min-height:40px; }
|
||||
.fault-overdue-subset { right:8px; top:53px; gap:3px; padding:5px; }
|
||||
.fault-overdue-subset span,.fault-overdue-subset small { font-size:9px; }
|
||||
}
|
||||
.fault-trend { margin-top:20px; }
|
||||
.fault-trend-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.fault-trend-heading h2 { display:flex; align-items:center; gap:7px; font-size:14px; font-weight:600; }
|
||||
.fault-trend-heading h2 svg { color:#2563eb; }
|
||||
.fault-trend-heading [role=group] { display:flex; padding:3px; border-radius:8px; background:#f1f5f9; }
|
||||
.fault-trend-heading button { padding:6px 12px; border-radius:6px; font-size:11px; color:#64748b; cursor:pointer; }
|
||||
.fault-trend-heading button[aria-pressed=true] { color:#2563eb; background:#fff; box-shadow:0 1px 3px #e2e8f0; }
|
||||
.fault-trend-definition { margin-top:7px; font-size:11px; line-height:1.7; color:#94a3b8; }
|
||||
.fault-trend-totals { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); margin:14px 0; padding:10px; border:1px solid #f1f5f9; border-radius:10px; background:#f8fafc; }
|
||||
.fault-trend-totals button { display:flex; flex-direction:column; align-items:center; gap:4px; cursor:pointer; border-right:1px solid #e2e8f0; padding:4px; }
|
||||
.fault-trend-totals button:last-child { border-right:0; }
|
||||
.fault-trend-totals strong { font-size:21px; line-height:1.2; font-weight:650; font-variant-numeric:tabular-nums; }
|
||||
.fault-trend-totals span { font-size:11px; }
|
||||
.fault-trend-all { color:#2563eb; }.fault-trend-closed { color:#059669; }.fault-trend-unclosed { color:#d97706; }
|
||||
.fault-trend button:disabled { cursor:default; opacity:.5; }
|
||||
.fault-trend button:focus-visible { outline:2px solid #2563eb; outline-offset:2px; }
|
||||
.fault-trend-chart { width:100%; min-width:0; height:240px; }
|
||||
.fault-trend-loading { min-height:120px; display:flex; align-items:center; justify-content:center; gap:8px; color:#94a3b8; font-size:12px; }
|
||||
.fault-trend-expand { display:flex; align-items:center; justify-content:space-between; gap:8px; width:100%; margin-top:12px; padding:12px; border:1px solid #bfdbfe; border-radius:8px; color:#2563eb; background:#eff6ff; font-size:12px; cursor:pointer; text-align:left; }
|
||||
.fault-trend-table { max-height:400px; overflow:auto; margin-top:12px; border:1px solid #e2e8f0; border-radius:8px; }
|
||||
.fault-trend-table table { width:100%; border-collapse:collapse; font-size:12px; white-space:nowrap; }
|
||||
.fault-trend-table th { text-align:right; padding:10px 12px; position:sticky; top:0; background:#f8fafc; color:#64748b; z-index:1; }
|
||||
.fault-trend-table td { padding:5px 12px; text-align:right; border-top:1px solid #f1f5f9; font-variant-numeric:tabular-nums; }
|
||||
.fault-trend-table th:first-child,.fault-trend-table td:first-child { text-align:left; }
|
||||
.fault-trend-table button { min-height:32px; min-width:32px; color:#2563eb; cursor:pointer; }
|
||||
@media(max-width:767px) { .fault-trend { margin-top:14px; }.fault-trend-heading h2 { font-size:12px; }.fault-trend-heading button { padding:7px 10px; }.fault-trend-table th,.fault-trend-table td { padding-left:6px; padding-right:6px; font-size:10px; }.fault-trend-table button { min-height:40px; }.fault-trend-expand { font-size:11px; } }
|
||||
.fault-weekly .fault-trend-totals { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.fault-weekly-unit { font-size:11px; color:#94a3b8; }
|
||||
.fault-weekly-reported,.fault-weekly-table button.fault-weekly-reported { color:#2563eb; }
|
||||
.fault-weekly-received,.fault-weekly-table button.fault-weekly-received { color:#d97706; }
|
||||
.fault-weekly-closed,.fault-weekly-table button.fault-weekly-closed { color:#059669; }
|
||||
.fault-weekly-table td small { display:inline-block; margin-left:8px; color:#94a3b8; font-size:10px; }
|
||||
.fault-weekly-table tfoot th { position:static; border-top:1px solid #e2e8f0; background:#f8fafc; padding-top:4px; padding-bottom:4px; }
|
||||
.fault-weekly-empty { margin-top:10px; font-size:12px; color:#94a3b8; text-align:center; }
|
||||
@media(max-width:767px) { .fault-weekly-table td small { display:block; margin:0; }.fault-weekly .fault-trend-totals span { font-size:10px; } }
|
||||
|
||||
.fault-weekly-chart { height:auto; padding:16px 12px 4px; border:1px solid #edf2f7; border-radius:12px; background:#fff; }
|
||||
.fault-chart-heading { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:12px; padding:0 4px 4px; font-size:12px; color:#334155; font-weight:600; }
|
||||
.fault-chart-heading small { margin-left:10px; font-size:10px; color:#94a3b8; font-weight:400; }
|
||||
.fault-chart-legend { display:flex; align-items:center; gap:18px; font-size:11px; color:#64748b; font-weight:400; }
|
||||
.fault-chart-legend>span { display:flex; align-items:center; gap:7px; }
|
||||
.fault-chart-legend i { position:relative; width:20px; height:2px; background:#2563eb; }
|
||||
.fault-chart-legend i::after { content:''; position:absolute; left:7px; top:-3px; width:8px; height:8px; border:2px solid #2563eb; border-radius:50%; background:#fff; }
|
||||
.fault-chart-key-closed i { background:#059669; }
|
||||
.fault-chart-key-closed i::after { border-color:#059669; background:#059669; }
|
||||
@media(max-width:767px) { .fault-weekly-chart { padding:14px 6px 2px; }.fault-chart-heading { gap:10px; }.fault-chart-legend { gap:12px; font-size:10px; } }
|
||||
|
||||
/* Compact mobile summary: preserve equal card heights and all drill-down targets. */
|
||||
@media(max-width:767px) {
|
||||
.fault-page-header { margin-bottom:10px; padding:10px 12px 6px; }
|
||||
.fault-page-header>div { gap:8px; margin-top:3px; }
|
||||
.fault-page-header button { min-height:32px; }
|
||||
.fault-page>.fault-summary:first-of-type { padding:12px; }
|
||||
.fault-summary-heading { flex-direction:row; flex-wrap:wrap; align-items:center; gap:4px 8px; margin-bottom:10px; }
|
||||
.fault-summary-heading h2 { font-size:13px; }
|
||||
.fault-summary-heading>span { font-size:10px; }
|
||||
.fault-metrics { gap:8px; }
|
||||
.fault-metric { height:132px; }
|
||||
.fault-metric-main { padding:10px; }
|
||||
.fault-metric-label { gap:6px; }
|
||||
.fault-metric-icon { width:24px; height:24px; border-radius:6px; }
|
||||
.fault-metric-icon svg { width:17px; height:17px; }
|
||||
.fault-metric-count { gap:5px; margin:5px 0 3px; }
|
||||
.fault-metric-count strong { font-size:26px; }
|
||||
.fault-metric-link { padding-top:4px; }
|
||||
.fault-open-breakdown { margin:0 8px 8px; padding-top:6px; gap:3px; }
|
||||
.fault-submetric { flex-direction:row; justify-content:center; gap:4px; min-height:32px; padding:4px 2px; }
|
||||
.fault-submetric strong { font-size:14px; }
|
||||
.fault-composition-plus { font-size:14px; }
|
||||
.fault-overdue-subset { top:41px; right:8px; min-height:32px; padding:4px; gap:3px; align-items:center; }
|
||||
.fault-overdue-subset .fault-overdue-prefix { display:none; }
|
||||
.fault-overdue-subset strong { font-size:14px; }
|
||||
.fault-page>.fault-summary:first-of-type>.fault-summary-note { margin-top:8px; font-size:10px; line-height:1.5; }
|
||||
}
|
||||
@media(max-width:359px) {
|
||||
.fault-open-breakdown { margin-right:6px; margin-left:6px; gap:2px; }
|
||||
.fault-submetric { gap:2px; padding-right:0; padding-left:0; font-size:9px; white-space:nowrap; }
|
||||
.fault-submetric strong { font-size:13px; }
|
||||
.fault-composition-plus { font-size:12px; }
|
||||
}
|
||||
|
||||
.fault-weekly-date { display:inline-flex; align-items:center; gap:6px; }
|
||||
.fault-weekly-table tr.fault-weekly-today { background:#f5f8ff; }
|
||||
.fault-weekly-today td:first-child { box-shadow:inset 2px 0 #bfd0ee; }
|
||||
.fault-today-label { padding:1px 5px; border-radius:4px; background:#e8effb; color:#536d96; font-size:10px; line-height:1.6; font-weight:500; }
|
||||
@media(max-width:767px) { .fault-weekly-date { gap:4px; }.fault-today-label { padding:1px 4px; font-size:9px; } }
|
||||
|
||||
/* Short, single-pass entrance: no bounce, list staggering, or animated blur. */
|
||||
.fault-analysis-overlay { animation:fault-backdrop-enter 160ms ease-out backwards; }
|
||||
.fault-analysis-dialog { --fault-enter-y:12px; --fault-enter-scale:.985; transform-origin:50% 60%; animation:fault-dialog-enter 260ms cubic-bezier(.16,1,.3,1) backwards; }
|
||||
@keyframes fault-backdrop-enter { from { opacity:0; } to { opacity:1; } }
|
||||
@keyframes fault-dialog-enter { from { opacity:0; transform:translateY(var(--fault-enter-y)) scale(var(--fault-enter-scale)); } to { opacity:1; transform:none; } }
|
||||
@media(max-width:767px) { .fault-analysis-dialog { --fault-enter-y:8px; --fault-enter-scale:1; animation-duration:220ms; } }
|
||||
@media(prefers-reduced-motion:reduce) { .fault-analysis-overlay,.fault-analysis-dialog { animation:none; } }
|
||||
|
||||
/* Scoped Apple-style semantic grays; keep the surrounding BI theme unchanged. */
|
||||
.fault-analysis-loading { --loading-label:rgba(60,60,67,.72); --loading-fill:rgba(118,118,128,.09); --loading-line:rgba(60,60,67,.08); --loading-radius:12px; padding:8px 0 4px; min-height:340px; }
|
||||
.fault-loading-status { display:flex; align-items:center; justify-content:center; gap:10px; min-height:40px; margin-bottom:20px; color:var(--loading-label); font-size:12px; letter-spacing:.01em; }
|
||||
.fault-loading-spinner { width:24px; height:24px; flex-shrink:0; }
|
||||
.fault-loading-spinner rect { opacity:.2; animation:fault-spinner-tick 1s linear infinite; }
|
||||
@keyframes fault-spinner-tick { from { opacity:1; } to { opacity:.15; } }
|
||||
.fault-loading-skeleton { animation:fault-skeleton-breathe 1.8s ease-in-out infinite; }
|
||||
@keyframes fault-skeleton-breathe { 0%,100% { opacity:.65; } 50% { opacity:1; } }
|
||||
.fault-loading-block { display:block; height:10px; border-radius:5px; background:var(--loading-fill); }
|
||||
.fault-loading-controls { padding:16px; border:1px solid var(--loading-line); border-radius:var(--loading-radius); }
|
||||
.fault-loading-title { width:84px; margin-bottom:14px; }
|
||||
.fault-loading-controls>div { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.fault-loading-controls>div>span { width:88px; height:28px; border-radius:7px; }
|
||||
.fault-loading-controls>div>span:nth-child(2n) { width:106px; }
|
||||
.fault-loading-summary { display:flex; align-items:center; gap:16px; padding:20px 0; }
|
||||
.fault-loading-summary>span:first-child { width:128px; height:18px; }.fault-loading-summary>span:last-child { width:66px; }
|
||||
.fault-loading-table { border:1px solid var(--loading-line); border-radius:var(--loading-radius); overflow:hidden; }
|
||||
.fault-loading-table-head,.fault-loading-row { display:grid; grid-template-columns:2fr 1fr 1fr; align-items:center; gap:24px; padding:16px; }
|
||||
.fault-loading-table-head { background:var(--loading-fill); }
|
||||
.fault-loading-row { border-top:1px solid var(--loading-line); }
|
||||
.fault-loading-table .fault-loading-block { width:60%; max-width:130px; }
|
||||
.fault-loading-row:nth-child(2n)>.fault-loading-block:first-child { width:80%; }
|
||||
@media(max-width:767px) { .fault-loading-status { margin-bottom:16px; }.fault-loading-controls { padding:12px; }.fault-loading-controls>div { flex-wrap:nowrap; overflow:hidden; }.fault-loading-controls>div>span { flex-shrink:0; width:76px; }.fault-loading-table-head,.fault-loading-row { padding:16px 12px; gap:16px; } }
|
||||
@media(prefers-reduced-motion:reduce) { .fault-loading-spinner rect,.fault-loading-skeleton { animation:none; }.fault-loading-spinner rect:nth-child(3n) { opacity:.65; } }
|
||||
@media(prefers-contrast:more) { .fault-analysis-loading { --loading-label:#334155; --loading-line:#94a3b8; --loading-fill:rgba(118,118,128,.18); } }
|
||||
|
||||
.fault-report-actions { display:flex; align-items:center; gap:10px; flex-wrap:wrap; justify-content:flex-end; }
|
||||
.fault-report-actions .analysis-export-action { display:flex; flex-direction:column; align-items:flex-end; gap:4px; }
|
||||
.fault-report-actions .analysis-export-action button { display:inline-flex; align-items:center; justify-content:center; gap:5px; min-height:32px; border:1px solid #dbe4f0; border-radius:7px; background:#fff; color:#475569; padding:6px 10px; white-space:nowrap; }
|
||||
.fault-report-actions .analysis-export-action button:hover:not(:disabled) { color:#2563eb; border-color:#93c5fd; background:#eff6ff; }
|
||||
.fault-report-actions .analysis-export-action small { font-size:10px; color:#dc2626; }
|
||||
@media(max-width:767px) { .fault-trend-heading { flex-wrap:wrap; gap:8px; }.fault-report-actions { gap:6px; margin-left:auto; }.fault-report-actions .analysis-export-action button { min-height:40px; padding:7px 8px; font-size:10px; }.fault-report-actions .fault-weekly-unit { display:none; } }
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { createFaultReportWorkbook, type FaultReport } from './report-export';
|
||||
|
||||
const now = new Date('2026-10-13T08:00:00Z');
|
||||
async function saved(report: FaultReport) {
|
||||
const book = await createFaultReportWorkbook(report, undefined, now);
|
||||
const reopened = new ExcelJS.Workbook();
|
||||
await reopened.xlsx.load(await book.xlsx.writeBuffer());
|
||||
return reopened.worksheets[0];
|
||||
}
|
||||
|
||||
test('trend export preserves monthly range, real dates, cohort counts and weighted closure rate after serialization', async () => {
|
||||
const sheet = await saved({ kind: 'trend', data: {
|
||||
start: '2026-09-14', end: '2026-10-13', granularity: 'month', updatedAt: now.toISOString(),
|
||||
totals: { reported: 10, closed: 7, unclosed: 3 },
|
||||
periods: [{ period: '2026-09', reported: 8, closed: 5, unclosed: 3 }, { period: '2026-10', reported: 2, closed: 2, unclosed: 0 }],
|
||||
} });
|
||||
assert.match(String(sheet.getCell('A2').value), /2026-09-14 至 2026-10-13.*按月/);
|
||||
assert.equal((sheet.getCell('A10').value as Date).toISOString(), '2026-10-01T00:00:00.000Z');
|
||||
assert.equal(sheet.getCell('A10').numFmt, 'yyyy-mm');
|
||||
assert.equal(sheet.getCell('B11').value, 8);
|
||||
assert.equal(sheet.getCell('C11').value, 5);
|
||||
assert.equal(sheet.getCell('D11').value, 3);
|
||||
assert.deepEqual(sheet.getCell('B12').value, { formula: 'SUM(B10:B11)', result: 10 });
|
||||
assert.deepEqual(sheet.getCell('E12').value, { formula: 'IFERROR(C12/B12,0)', result: .7 });
|
||||
assert.equal(sheet.autoFilter, 'A9:E11');
|
||||
assert.equal(sheet.views[0].state, 'frozen');
|
||||
assert.equal(sheet.getCell('A9').fill.type, 'pattern');
|
||||
});
|
||||
|
||||
test('weekly export keeps independent solved counts, descending dates and excludes receipt metrics', async () => {
|
||||
const sheet = await saved({ kind: 'weekly', data: {
|
||||
start: '2026-10-12', end: '2026-10-13', updatedAt: now.toISOString(),
|
||||
totals: { reported: 3, closed: 9, received: 99 },
|
||||
days: [{ date: '2026-10-12', reported: 1, closed: 3, received: 90 }, { date: '2026-10-13', reported: 2, closed: 6, received: 9 }],
|
||||
} });
|
||||
assert.deepEqual([1, 2, 3, 4].map(col => sheet.getCell(9, col).value), ['日期', '星期', '新增故障(单)', '解决故障(单)']);
|
||||
assert.equal((sheet.getCell('A10').value as Date).toISOString(), '2026-10-13T00:00:00.000Z');
|
||||
assert.match(sheet.getCell('A10').numFmt, /今天/);
|
||||
assert.equal(sheet.getCell('B10').value, '周二');
|
||||
assert.equal(sheet.getCell('D10').value, 6);
|
||||
assert.deepEqual(sheet.getCell('D12').value, { formula: 'SUM(D10:D11)', result: 9 });
|
||||
assert.deepEqual(sheet.getCell('C6').value, { formula: 'SUM(D10:D11)', result: 9 });
|
||||
assert.equal(sheet.columnCount, 4);
|
||||
});
|
||||
|
||||
test('zero-data trend exports valid guarded ratios and empty reports avoid inverted SUM ranges', async () => {
|
||||
for (const periods of [[], [{ period: '2026-10-13', reported: 0, closed: 0, unclosed: 0 }]]) {
|
||||
const sheet = await saved({ kind: 'trend', data: {
|
||||
start: '2026-10-13', end: '2026-10-13', granularity: 'day', updatedAt: now.toISOString(),
|
||||
totals: { reported: 0, closed: 0, unclosed: 0 }, periods,
|
||||
} });
|
||||
const total = 10 + periods.length;
|
||||
assert.equal(sheet.getCell(`E${total}`).formula, `IFERROR(C${total}/B${total},0)`);
|
||||
assert.equal(sheet.getCell(`B${total}`).formula, periods.length ? 'SUM(B10:B10)' : '0');
|
||||
assert.equal(sheet.getCell('A6').result, 0);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { FaultTrendResponse, FaultWeeklyResponse } from '../../../shared/assets/faults';
|
||||
import type { Cell, Worksheet } from 'exceljs';
|
||||
|
||||
export type FaultReport = ({ kind: 'trend'; data: FaultTrendResponse } | { kind: 'weekly'; data: FaultWeeklyResponse }) & { filterLabel?: string };
|
||||
interface ReportChartImage { dataUrl: string; width: number; height: number }
|
||||
const palette = { navy: '1E293B', text: '334155', muted: '64748B', blue: '2563EB', green: '059669', amber: 'D97706', line: 'E2E8F0', stripe: 'F8FAFC', today: 'EFF6FF', white: 'FFFFFF' };
|
||||
const fill = (color: string) => ({ type: 'pattern' as const, pattern: 'solid' as const, fgColor: { argb: `FF${color}` } });
|
||||
const excelDate = (date: string) => new Date(`${date.length === 7 ? `${date}-01` : date}T00:00:00Z`);
|
||||
const beijingTime = (date: string | Date) => new Date(date).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
|
||||
function merged(sheet: Worksheet, row: number, from: number, to: number, value: Cell['value']) {
|
||||
if (to > from) sheet.mergeCells(row, from, row, to);
|
||||
const cell = sheet.getCell(row, from); cell.value = value; return cell;
|
||||
}
|
||||
|
||||
/** Browser-independent workbook builder; export the displayed authorized snapshot without requerying. */
|
||||
export async function createFaultReportWorkbook(report: FaultReport, chartImage?: ReportChartImage, now = new Date()) {
|
||||
const ExcelJS = (await import('exceljs')).default;
|
||||
const book = new ExcelJS.Workbook();
|
||||
book.creator = '羚牛氢能 BI'; book.created = now; book.modified = now;
|
||||
const trend = report.kind === 'trend';
|
||||
const title = trend ? '故障处理趋势' : '故障运维周报';
|
||||
const headers = trend ? [report.data.granularity === 'month' ? '提报月份' : '提报日期', '新增故障(单)', '已闭环(单)', '未闭环(单)', '闭环率'] : ['日期', '星期', '新增故障(单)', '解决故障(单)'];
|
||||
const columns = headers.length;
|
||||
const sheet = book.addWorksheet(title, { properties: { defaultRowHeight: 22 }, pageSetup: { paperSize: 9, orientation: 'portrait', fitToPage: true, fitToWidth: 1, fitToHeight: 0, horizontalCentered: true, margins: { left: .3, right: .3, top: .4, bottom: .4, header: .2, footer: .2 } } });
|
||||
sheet.columns = (trend ? [24, 19, 19, 19, 18] : [26, 16, 25, 25]).map(width => ({ width }));
|
||||
const headerRow = 9;
|
||||
const periods = trend ? [...report.data.periods].reverse() : [...report.data.days].reverse();
|
||||
const start = headerRow + 1, end = headerRow + periods.length, totalRow = end + 1;
|
||||
const today = new Date(now.getTime() + 8 * 3600000).toISOString().slice(0, 10);
|
||||
const bottom = totalRow + 3;
|
||||
for (let row = 1; row <= bottom; row++) for (let col = 1; col <= columns; col++) {
|
||||
const cell = sheet.getCell(row, col);
|
||||
cell.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.text}` } };
|
||||
cell.alignment = { vertical: 'middle', horizontal: col === 1 ? 'left' : 'center' };
|
||||
}
|
||||
const titleCell = merged(sheet, 1, 1, columns, `羚牛氢能 · ${title}`);
|
||||
titleCell.font = { name: 'Microsoft YaHei', size: 22, bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
titleCell.fill = fill(palette.navy); titleCell.alignment = { vertical: 'middle', indent: 1 }; sheet.getRow(1).height = 48;
|
||||
merged(sheet, 2, 1, columns, `统计范围:${report.data.start} 至 ${report.data.end} · ${trend && report.data.granularity === 'month' ? '按月' : '按日'} · 单位:单`);
|
||||
const stamp = merged(sheet, 3, 1, columns, `数据时间:${beijingTime(report.data.updatedAt)}(北京时间)`); stamp.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } };
|
||||
sheet.getRow(2).height = 28; sheet.getRow(4).height = 10; sheet.getRow(7).height = 10;
|
||||
if (report.filterLabel) { const filter = merged(sheet, 4, 1, columns, `筛选条件:${report.filterLabel}`); filter.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } }; filter.alignment = { wrapText: true, vertical: 'middle' }; sheet.getRow(4).height = 32; }
|
||||
const sum = (column: string) => periods.length ? `SUM(${column}${start}:${column}${end})` : '0';
|
||||
const cards = trend
|
||||
? [{ from: 1, to: 2, label: '新增故障', col: 'B', value: report.data.totals.reported, color: palette.blue }, { from: 3, to: 4, label: '已闭环', col: 'C', value: report.data.totals.closed, color: palette.green }, { from: 5, to: 5, label: '未闭环', col: 'D', value: report.data.totals.unclosed, color: palette.amber }]
|
||||
: [{ from: 1, to: 2, label: '新增故障', col: 'C', value: report.data.totals.reported, color: palette.blue }, { from: 3, to: 4, label: '解决故障', col: 'D', value: report.data.totals.closed, color: palette.green }];
|
||||
for (const card of cards) {
|
||||
const label = merged(sheet, 5, card.from, card.to, card.label);
|
||||
const value = merged(sheet, 6, card.from, card.to, { formula: sum(card.col), result: card.value });
|
||||
for (const cell of [label, value]) { cell.fill = fill(palette.stripe); cell.alignment = { horizontal: 'center', vertical: 'middle' }; }
|
||||
label.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.muted}` } };
|
||||
value.font = { name: 'Microsoft YaHei', size: 26, bold: true, color: { argb: `FF${card.color}` } }; value.numFmt = '#,##0';
|
||||
}
|
||||
sheet.getRow(5).height = 25; sheet.getRow(6).height = 40;
|
||||
if (chartImage) {
|
||||
const chart = book.addWorksheet('图表', { views: [{ showGridLines: false }], pageSetup: { paperSize: 9, orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 1 } });
|
||||
chart.columns = headers.map(() => ({ width: 900 / columns / 7 }));
|
||||
chart.properties.defaultRowHeight = 24;
|
||||
const heading = merged(chart, 1, 1, columns, `羚牛氢能 · ${title}`);
|
||||
heading.font = { name: 'Microsoft YaHei', size: 22, bold: true, color: { argb: 'FFFFFFFF' } }; heading.fill = fill(palette.navy); heading.alignment = { vertical: 'middle', indent: 1 }; chart.getRow(1).height = 48;
|
||||
const period = merged(chart, 2, 1, columns, `${report.data.start} 至 ${report.data.end} · ${trend && report.data.granularity === 'month' ? '按月' : '按日'} · 单位:单`);
|
||||
period.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.text}` } };
|
||||
const key = merged(chart, 3, 1, columns, trend ? '绿色:已闭环 橙色:未闭环 柱总高为新增故障数' : '蓝色:新增故障 绿色:解决故障');
|
||||
key.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } };
|
||||
const id = book.addImage({ base64: chartImage.dataUrl, extension: 'png' });
|
||||
const scale = Math.min(900 / chartImage.width, 360 / chartImage.height);
|
||||
const width = chartImage.width * scale, height = chartImage.height * scale;
|
||||
// Keep circles and labels proportional for both desktop and mobile snapshots.
|
||||
chart.addImage(id, { tl: { col: (900 - width) / 2 / (900 / columns), row: 4 + (360 - height) / 64 }, ext: { width, height }, editAs: 'oneCell' });
|
||||
const caption = merged(chart, 17, 1, columns, `图表为当前页面快照,精确数量见「${title}」工作表。`);
|
||||
caption.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } };
|
||||
chart.pageSetup.printArea = `A1:${String.fromCharCode(64 + columns)}17`;
|
||||
}
|
||||
headers.forEach((label, i) => { const cell = sheet.getCell(headerRow, i + 1); cell.value = label; cell.fill = fill(palette.navy); cell.font = { name: 'Microsoft YaHei', size: 11, bold: true, color: { argb: 'FFFFFFFF' } }; });
|
||||
sheet.getRow(headerRow).height = 30;
|
||||
if (trend) [...report.data.periods].reverse().forEach((row, i) => {
|
||||
const n = start + i;
|
||||
sheet.getRow(n).values = [excelDate(row.period), row.reported, row.closed, row.unclosed, { formula: `IFERROR(C${n}/B${n},0)`, result: row.reported ? row.closed / row.reported : 0 }];
|
||||
sheet.getCell(n, 1).numFmt = report.data.granularity === 'month' ? 'yyyy-mm' : 'yyyy-mm-dd';
|
||||
sheet.getCell(n, 5).numFmt = '0.0%;-0.0%;"—"';
|
||||
});
|
||||
else [...report.data.days].reverse().forEach((row, i) => {
|
||||
sheet.getRow(start + i).values = [excelDate(row.date), excelDate(row.date).toLocaleDateString('zh-CN', { weekday: 'short', timeZone: 'UTC' }), row.reported, row.closed];
|
||||
sheet.getCell(start + i, 1).numFmt = row.date === today ? 'yyyy-mm-dd" 今天"' : 'yyyy-mm-dd';
|
||||
});
|
||||
for (let row = start; row <= end; row++) {
|
||||
const date = sheet.getCell(row, 1).value as Date;
|
||||
const highlight = date.toISOString().slice(0, 10) === today && (!trend || report.data.granularity === 'day');
|
||||
sheet.getRow(row).height = 26;
|
||||
for (let col = 1; col <= columns; col++) {
|
||||
const cell = sheet.getCell(row, col); cell.fill = fill(highlight ? palette.today : (row - start) % 2 ? palette.stripe : palette.white);
|
||||
cell.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.text}` } };
|
||||
cell.alignment = { vertical: 'middle', horizontal: col === 1 ? 'left' : 'center' };
|
||||
cell.border = { bottom: { style: 'hair', color: { argb: `FF${palette.line}` } } };
|
||||
if (col > (trend ? 1 : 2) && !(trend && col === 5)) {
|
||||
cell.numFmt = '#,##0'; const color = trend ? [palette.blue, palette.green, palette.amber][col - 2] : col === 3 ? palette.blue : palette.green;
|
||||
cell.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${color}` } };
|
||||
}
|
||||
}
|
||||
}
|
||||
sheet.getCell(totalRow, 1).value = '合计';
|
||||
cards.forEach(card => { sheet.getCell(`${card.col}${totalRow}`).value = { formula: sum(card.col), result: card.value }; sheet.getCell(`${card.col}${totalRow}`).numFmt = '#,##0'; });
|
||||
if (trend) { sheet.getCell(totalRow, 5).value = { formula: `IFERROR(C${totalRow}/B${totalRow},0)`, result: report.data.totals.reported ? report.data.totals.closed / report.data.totals.reported : 0 }; sheet.getCell(totalRow, 5).numFmt = '0.0%;-0.0%;"—"'; }
|
||||
for (let col = 1; col <= columns; col++) { const cell = sheet.getCell(totalRow, col); cell.fill = fill('E8EFF9'); cell.font = { name: 'Microsoft YaHei', size: 11, bold: true, color: { argb: `FF${palette.navy}` } }; }
|
||||
sheet.getRow(totalRow).height = 30;
|
||||
const note = merged(sheet, totalRow + 2, 1, columns, trend ? '统计口径:按提报日期统计同批故障;已归档为已闭环,其余状态(含挂起)为未闭环。按月仅统计所选日期范围。' : '统计口径:新增故障按提报日期;解决故障按解决日期统计,不受提报日期限制。同一故障可在不同日期分别计入两个指标。');
|
||||
note.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } }; note.alignment = { wrapText: true, vertical: 'middle' }; sheet.getRow(totalRow + 2).height = 36;
|
||||
const source = merged(sheet, bottom, 1, columns, `来源:羚牛氢能 BI 当前可见统计 · 导出时间:${beijingTime(now)}(北京时间)`); source.font = { name: 'Microsoft YaHei', size: 9, color: { argb: `FF${palette.muted}` } };
|
||||
sheet.autoFilter = { from: { row: headerRow, column: 1 }, to: { row: Math.max(headerRow, end), column: columns } };
|
||||
sheet.views = [{ state: 'frozen', ySplit: headerRow, topLeftCell: `A${start}`, showGridLines: false }];
|
||||
sheet.pageSetup.printArea = `A1:${String.fromCharCode(64 + columns)}${bottom}`;
|
||||
sheet.pageSetup.printTitlesRow = `${headerRow}:${headerRow}`;
|
||||
sheet.headerFooter.oddFooter = '&L羚牛氢能 · 故障管理&R第 &P 页 / 共 &N 页';
|
||||
return book;
|
||||
}
|
||||
|
||||
async function chartImage(svg: SVGSVGElement): Promise<ReportChartImage> {
|
||||
const width = svg.clientWidth || Number(svg.getAttribute('width')) || 800;
|
||||
const height = svg.clientHeight || Number(svg.getAttribute('height')) || 240;
|
||||
const clone = svg.cloneNode(true) as SVGSVGElement;
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); clone.setAttribute('width', String(width)); clone.setAttribute('height', String(height));
|
||||
clone.style.fontFamily = 'Arial, sans-serif';
|
||||
const url = URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(clone)], { type: 'image/svg+xml;charset=utf-8' }));
|
||||
try {
|
||||
const image = new Image();
|
||||
await new Promise<void>((resolve, reject) => { image.onload = () => resolve(); image.onerror = () => reject(new Error('图表生成失败')); image.src = url; });
|
||||
const canvas = document.createElement('canvas'); canvas.width = Math.ceil(width * 2); canvas.height = Math.ceil(height * 2);
|
||||
const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('图表生成失败');
|
||||
ctx.scale(2, 2); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, width, height); ctx.drawImage(image, 0, 0, width, height);
|
||||
return { dataUrl: canvas.toDataURL('image/png'), width, height };
|
||||
} finally { URL.revokeObjectURL(url); }
|
||||
}
|
||||
export async function downloadFaultReport(report: FaultReport, svg?: SVGSVGElement) {
|
||||
const png = svg ? await chartImage(svg) : undefined;
|
||||
const book = await createFaultReportWorkbook(report, png);
|
||||
const bytes = await book.xlsx.writeBuffer();
|
||||
const url = URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }));
|
||||
const link = document.createElement('a'); link.href = url;
|
||||
link.download = `${report.kind === 'trend' ? `故障处理趋势_${report.data.granularity === 'day' ? '按日' : '按月'}` : '故障运维周报'}_${report.data.start}_${report.data.end}.xlsx`;
|
||||
document.body.appendChild(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useEffect, useState, type MouseEvent } from 'react';
|
||||
import { ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LabelList } from 'recharts';
|
||||
import { ChevronRight, RefreshCw } from 'lucide-react';
|
||||
import { HISTORY_STATE_LABELS, historyTime, shanghaiMonth, type HistoryMode, type HistoryPoint, type HistoryTrend } from '../../../shared/assets/history';
|
||||
import { fetchHistoryLive, fetchHistoryTrend } from './api';
|
||||
import { defaultHistoryPeriod, displayHistoryCounts, displayHistoryDifference, signedCount, updateLivePoint } from './presentation';
|
||||
import { HistoryDetailsDialog } from './HistoryDetailsDialog';
|
||||
import { HistorySelect } from './HistorySelect';
|
||||
import './history.css';
|
||||
|
||||
export function AssetHistorySection({ subject, onSubjects }: { subject: string | null; onSubjects?: (names: string[]) => void }) {
|
||||
const [mode, setMode] = useState<HistoryMode>('monthly');
|
||||
const [year, setYear] = useState(() => Number(shanghaiMonth(new Date()).slice(0, 4)));
|
||||
const [data, setData] = useState<HistoryTrend | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [details, setDetails] = useState<HistoryPoint | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [retry, setRetry] = useState(0);
|
||||
const closeDetails = useCallback(() => setDetails(null), []);
|
||||
|
||||
useEffect(() => {
|
||||
const abort = new AbortController();
|
||||
let current: HistoryTrend | null = null;
|
||||
let busy = false;
|
||||
let observedMonth = shanghaiMonth(new Date());
|
||||
setData(null); setSelected(null); setDetails(null); setError(''); setLoading(true);
|
||||
const load = async () => {
|
||||
busy = true;
|
||||
try {
|
||||
const result = await fetchHistoryTrend(mode, year, subject, abort.signal);
|
||||
if (abort.signal.aborted) return;
|
||||
current = result; setData(result); setSelected(old => old && result.points.some(p => p.period === old) ? old : defaultHistoryPeriod(result.points));
|
||||
onSubjects?.(result.subjects); setError('');
|
||||
} catch (e) { if (!abort.signal.aborted) setError(e instanceof Error ? e.message : '趋势加载失败'); }
|
||||
finally { busy = false; if (!abort.signal.aborted) setLoading(false); }
|
||||
};
|
||||
void load();
|
||||
const interval = setInterval(async () => {
|
||||
if (busy || !current || abort.signal.aborted) return;
|
||||
const month = shanghaiMonth(new Date());
|
||||
if (month !== observedMonth) { observedMonth = month; await load(); return; }
|
||||
if (current.points.some(p => p.state === 'CAPTURING')) { await load(); return; }
|
||||
if (!current.points.some(p => p.state === 'LIVE')) return;
|
||||
busy = true;
|
||||
try {
|
||||
const live = await fetchHistoryLive(mode, subject, abort.signal);
|
||||
if (abort.signal.aborted) return;
|
||||
if (live.state !== 'LIVE') { await load(); return; }
|
||||
current = { ...current, points: updateLivePoint(current.points, live) }; setData(current); setError('');
|
||||
} catch (e) { if (!abort.signal.aborted) setError(e instanceof Error ? e.message : '实时数据刷新失败'); }
|
||||
finally { busy = false; }
|
||||
}, 5000);
|
||||
return () => { abort.abort(); clearInterval(interval); };
|
||||
}, [mode, year, subject, retry, onSubjects]);
|
||||
|
||||
const point = data?.points.find(p => p.period === selected);
|
||||
const hasComparison = point && (['total', 'inventory', 'operating'] as const).some(key => displayHistoryDifference(point, key) != null);
|
||||
const chart = data?.points.map(p => ({ ...p, total: displayHistoryCounts(p)?.total ?? null, operating: displayHistoryCounts(p)?.operating ?? null, inventory: displayHistoryCounts(p)?.inventory ?? null })) || [];
|
||||
const selectBar = (entry: { originalDataIndex: number }, _index: number, event: MouseEvent<SVGPathElement>) => {
|
||||
event.stopPropagation();
|
||||
const item = chart[entry.originalDataIndex];
|
||||
if (item) setSelected(item.period);
|
||||
};
|
||||
return <section className="asset-history" aria-labelledby="asset-history-title" data-testid="asset-history">
|
||||
<div className="asset-history-controls"><header className="asset-history-header"><h2 id="asset-history-title">资产历史趋势</h2>
|
||||
<div className="asset-history-modes" role="group" aria-label="历史统计方式">{(['annual', 'monthly'] as const).map(value => <button type="button" key={value} aria-pressed={mode === value} onClick={() => { setMode(value); setDetails(null); }}>{value === 'annual' ? '年度' : '月度'}</button>)}</div>
|
||||
</header>
|
||||
<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); }} />}
|
||||
{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 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>
|
||||
</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> : <>
|
||||
{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="运营数与库存数堆叠柱,资产总数折线;精确数值和缺失状态可通过上方统计期间选择框查看">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 480, height: 200 }}>
|
||||
<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} />
|
||||
<YAxis allowDecimals={false} width={44} tick={{ fontSize: 11, fill: '#64748b' }} axisLine={false} tickLine={false} />
|
||||
<Tooltip content={({ active, payload }) => {
|
||||
const item = payload?.[0]?.payload as HistoryPoint | undefined;
|
||||
const values = item && displayHistoryCounts(item);
|
||||
return active && item ? <div className="asset-history-tooltip"><strong>{item.label} · {HISTORY_STATE_LABELS[item.state]}</strong>{values ? <p>总数 {values.total ?? '待核实'} · 运营 {values.operating} · 库存 {values.inventory}</p> : <p>暂无数量</p>}</div> : null;
|
||||
}} />
|
||||
<Bar onClick={selectBar} dataKey="operating" name="运营数" stackId="assets" fill="#347df5" maxBarSize={32} isAnimationActive={false} />
|
||||
<Bar onClick={selectBar} dataKey="inventory" name="库存数" stackId="assets" fill="#9ed1fa" maxBarSize={32} isAnimationActive={false} />
|
||||
<Line dataKey="total" name="资产总数" stroke="#132e57" strokeWidth={2} connectNulls={false} dot={{ r: 3, fill: '#fff', strokeWidth: 2 }} activeDot={{ r: 5 }} isAnimationActive={false}><LabelList dataKey="total" position="top" offset={8} fontSize={10} fill="#132e57" /></Line>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</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">
|
||||
<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>
|
||||
{hasComparison ? <div className="asset-history-comparison"><span>{point.comparisonLabel}</span><div>{(['total', 'inventory', 'operating'] as const).map(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>;
|
||||
})}</div></div> : <p>{point.comparisonLabel}</p>}
|
||||
</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>
|
||||
</>}
|
||||
{details && <HistoryDetailsDialog point={details} subject={subject} close={closeDetails} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, Search, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useDialogFocus } from '../components/useDialogFocus';
|
||||
import { HISTORY_STATE_LABELS, historyTime, type HistoryCategory, type HistoryDetails, type HistoryPoint } from '../../../shared/assets/history';
|
||||
import { fetchHistoryDetails } from './api';
|
||||
import { displayHistoryCounts } from './presentation';
|
||||
|
||||
export function HistoryDetailsDialog({ point, subject, close }: { point: HistoryPoint; subject: string | null; close: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [category, setCategory] = useState<HistoryCategory>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [data, setData] = useState<HistoryDetails | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [retry, setRetry] = useState(0);
|
||||
useDialogFocus(true, ref, close);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => { setQuery(search.trim()); setPage(1); }, 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
useEffect(() => {
|
||||
const abort = new AbortController();
|
||||
setLoading(true); setError(''); setData(null);
|
||||
fetchHistoryDetails(point.month, point.batchId, subject, category, query, page, abort.signal)
|
||||
.then(result => { if (!abort.signal.aborted) setData(result); })
|
||||
.catch(e => { if (!abort.signal.aborted) setError(e instanceof Error ? e.message : '明细加载失败'); })
|
||||
.finally(() => { if (!abort.signal.aborted) setLoading(false); });
|
||||
return () => abort.abort();
|
||||
}, [point.month, point.batchId, subject, category, query, page, retry]);
|
||||
const pages = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;
|
||||
return createPortal(<div className="asset-history-overlay" onMouseDown={e => { if (e.target === e.currentTarget) close(); }}>
|
||||
<div className="asset-history-dialog" role="dialog" aria-modal="true" aria-labelledby="asset-history-detail-title" tabIndex={-1} ref={ref}>
|
||||
<header><div><h2 id="asset-history-detail-title">{point.month.replace('-', '年')}月资产明细</h2>
|
||||
<p>{subject || '全部公司'} · {HISTORY_STATE_LABELS[point.state]} · {point.reconstruction ? '参考时点' : point.state === 'ARCHIVED' ? '快照' : '截至'} {historyTime(point.reconstruction ? point.scheduledFor : data?.point.asOf || point.asOf)}</p></div>
|
||||
<button type="button" onClick={close} aria-label="关闭月度明细"><X size={20} /></button></header>
|
||||
<div className="asset-history-detail-tools">
|
||||
<label>车辆分类<select aria-label="月度明细分类" value={category} onChange={e => { setCategory(e.target.value as HistoryCategory); setPage(1); }}>
|
||||
<option value="all">{point.reconstruction ? '全部推算车辆' : '全部资产'}</option><option value="operating">运营车辆</option><option value="inventory">库存车辆</option></select></label>
|
||||
<label className="asset-history-search"><Search size={16} /><input aria-label="搜索历史车牌或VIN" placeholder="搜索车牌 / VIN" value={search} onChange={e => setSearch(e.target.value)} /></label>
|
||||
</div>
|
||||
<div className="asset-history-detail-body" aria-busy={loading} aria-live="polite">
|
||||
{loading ? <p className="asset-history-empty">正在加载月度明细…</p> : error ? <p className="asset-history-empty" role="alert">{error} <button type="button" onClick={() => setRetry(x => x + 1)}>重试</button></p> : data && <>
|
||||
<p className="asset-history-detail-note">{data.point.reconstruction ? '以下为核对表中的交还记录推算车辆,归属未知项留空。历史总数待核实,不能将两类相加当作历史资产总数。' : data.point.state === 'ARCHIVED' ? '以下为当时归档的车辆及归属信息。' : '以下为最新实时明细,数量可能随业务更新。'} 当前范围:总数 {displayHistoryCounts(data.point)?.total ?? '待核实'} · 运营 {displayHistoryCounts(data.point)?.operating} · 库存 {displayHistoryCounts(data.point)?.inventory}</p>
|
||||
{data.items.length ? <><div className="asset-history-detail-table"><table><thead><tr><th>车辆</th><th>分类</th><th>公司 / 部门</th><th>负责人 / 客户</th></tr></thead>
|
||||
<tbody>{data.items.map(item => <tr key={item.id}><td><strong>{item.plateNumber || '未登记车牌'}</strong><small>{item.model}</small><small>VIN:{item.vin || '未提供'}</small>{item.reconstructionBasis && <small>推算依据:{item.reconstructionBasis}</small>}</td>
|
||||
<td>{item.disposition === 'operating' ? '运营' : '库存'}</td><td>{item.subjectOrg || '未提供'}<small>{item.departmentName || (item.reconstructionBasis ? '历史未提供' : '未分配部门')}</small></td>
|
||||
<td>{item.customerManager || (item.reconstructionBasis ? '历史未提供' : '未分配')}<small>{item.customerName || (item.reconstructionBasis ? '历史未提供' : '无客户信息')}</small><small>{item.contractNo || ''}</small></td></tr>)}</tbody></table></div>
|
||||
<div className="asset-history-mobile-vehicles">{data.items.map(item => <article key={item.id}>
|
||||
<div className="asset-history-mobile-vehicle-heading"><strong>{item.plateNumber || '未登记车牌'}</strong><span>{item.disposition === 'operating' ? '运营' : '库存'}</span></div>
|
||||
<p>{item.model}</p><small>VIN:{item.vin || '未提供'}</small>
|
||||
{item.reconstructionBasis && <small>推算依据:{item.reconstructionBasis}</small>}
|
||||
<dl><div><dt>公司 / 部门</dt><dd>{item.subjectOrg || '未提供'}<small>{item.departmentName || (item.reconstructionBasis ? '历史未提供' : '未分配部门')}</small></dd></div>
|
||||
<div><dt>负责人 / 客户</dt><dd>{item.customerManager || (item.reconstructionBasis ? '历史未提供' : '未分配')}<small>{item.customerName || (item.reconstructionBasis ? '历史未提供' : '无客户信息')}</small></dd></div></dl>
|
||||
{item.contractNo && <small>合同:{item.contractNo}</small>}
|
||||
</article>)}</div></> : <p className="asset-history-empty">当前条件下没有车辆</p>}
|
||||
</>}
|
||||
</div>
|
||||
<footer><span>{data ? `${point.reconstruction ? '推算' : '筛选'}结果 ${data.total} 台 · 第 ${page} / ${pages} 页` : '每页 30 台'}</span><div>
|
||||
<button type="button" aria-label="上一页" disabled={loading || !data || page <= 1} onClick={() => setPage(x => x - 1)}><ChevronLeft size={16} /></button>
|
||||
<button type="button" aria-label="下一页" disabled={loading || !data || page >= pages} onClick={() => setPage(x => x + 1)}><ChevronRight size={16} /></button></div></footer>
|
||||
</div>
|
||||
</div>, document.body);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Select } from 'radix-ui';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
interface HistorySelectProps {
|
||||
label: string;
|
||||
value: string;
|
||||
options: { value: string; label: string; detail?: string }[];
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function HistorySelect({ label, value, options, onChange }: HistorySelectProps) {
|
||||
return <Select.Root value={value} onValueChange={onChange}>
|
||||
<Select.Trigger className="asset-history-select" aria-label={label}>
|
||||
<Select.Value />
|
||||
<Select.Icon className="asset-history-select-icon"><ChevronDown size={14} /></Select.Icon>
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content className="asset-history-select-menu" position="popper" align="start" sideOffset={6} collisionPadding={10}>
|
||||
<Select.ScrollUpButton className="asset-history-select-scroll"><ChevronUp size={14} /></Select.ScrollUpButton>
|
||||
<Select.Viewport className="asset-history-select-options">
|
||||
{options.map(option => <Select.Item className="asset-history-select-option" key={option.value} value={option.value} textValue={option.label}>
|
||||
<Select.ItemText>{option.label}</Select.ItemText>
|
||||
{option.detail && <span className="asset-history-option-detail">{option.detail}</span>}
|
||||
<Select.ItemIndicator className="asset-history-option-check"><Check size={14} /></Select.ItemIndicator>
|
||||
</Select.Item>)}
|
||||
</Select.Viewport>
|
||||
<Select.ScrollDownButton className="asset-history-select-scroll"><ChevronDown size={14} /></Select.ScrollDownButton>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { fetchAuthenticated } from '../../../auth/api-client';
|
||||
import type { HistoryMode, HistoryTrend, HistoryPoint, HistoryDetails, HistoryCategory } from '../../../shared/assets/history';
|
||||
|
||||
async function get<T>(path: string, params: Record<string, string | number | null | undefined>, signal: AbortSignal): Promise<T> {
|
||||
const query = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) if (value != null && value !== '') query.set(key, String(value));
|
||||
const response = await fetchAuthenticated(`/api/asset-history/${path}?${query}`, { signal });
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error || '历史趋势加载失败,请重试');
|
||||
return body as T;
|
||||
}
|
||||
export function fetchHistoryTrend(mode: HistoryMode, year: number, subject: string | null, signal: AbortSignal) {
|
||||
return get<HistoryTrend>('trend', { mode, year, subject }, signal);
|
||||
}
|
||||
export function fetchHistoryLive(mode: HistoryMode, subject: string | null, signal: AbortSignal) {
|
||||
return get<HistoryPoint>('live', { mode, subject }, signal);
|
||||
}
|
||||
export function fetchHistoryDetails(month: string, batchId: string | null, subject: string | null, category: HistoryCategory, search: string, page: number, signal: AbortSignal) {
|
||||
return get<HistoryDetails>('details', { month, batchId, subject, category, search, page, pageSize: 30 }, signal);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
.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; }
|
||||
.asset-history button:disabled,.asset-history-dialog button:disabled { cursor:default; opacity:.45; }
|
||||
.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:12px; }
|
||||
.asset-history-header h2 { margin:0; font-size:20px; font-weight:750; letter-spacing:-.5px; }
|
||||
.asset-history-modes { display:flex; gap:2px; background:#eef3fa; border:1px solid #e7edf6; border-radius:11px; padding:3px; }
|
||||
.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[aria-pressed=true] { background:#fff; color:#1559e8; box-shadow:0 1px 4px #193c6920, inset 0 0 0 1px #dce7f5; }
|
||||
.asset-history-toolbar { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin:8px 0 4px; font-size:12px; color:#64748b; }
|
||||
.asset-history-toolbar>span { margin-left:auto; }
|
||||
.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 button,.asset-history-empty button { color:#1559e8; text-decoration:underline; padding:6px; }
|
||||
.asset-history-unit { font-size:12px; color:#64748b; margin:6px 0 0; }
|
||||
.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-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:7px; }
|
||||
.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 .total { width:20px; height:2px; background:#132e57; }
|
||||
.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-selected-heading { display:flex; flex-direction:column; align-items:flex-start; gap:3px; min-width:0; }
|
||||
.asset-history-selected-heading>strong { color:#1559e8; font-size:17px; font-weight:750; }
|
||||
.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>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:last-child { border:0; }
|
||||
.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-selected>p { grid-column:1 / -1; color:#64748b; font-size:11px; text-align:center; padding-top:6px; }
|
||||
.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-footnote { display:flex; align-items:center; flex-wrap:wrap; gap:6px; color:#64748b; font-size:11px; margin-top:8px; }
|
||||
.asset-history-empty { padding:32px 12px; text-align:center; font-size:13px; color:#64748b; }
|
||||
.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>header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:20px 22px; border-bottom:1px solid #e6edf5; }
|
||||
.asset-history-dialog h2 { font-size:18px; font-weight:700; }
|
||||
.asset-history-dialog header p { font-size:12px; line-height:1.7; margin-top:5px; color:#64748b; }
|
||||
.asset-history-dialog header button { padding:8px; flex-shrink:0; }
|
||||
.asset-history-detail-tools { display:flex; flex-wrap:wrap; gap:12px; padding:14px 22px; border-bottom:1px solid #e6edf5; }
|
||||
.asset-history-detail-tools label { display:flex; align-items:center; gap:8px; font-size:12px; color:#64748b; }
|
||||
.asset-history-detail-tools select { border:1px solid #dce5f0; border-radius:7px; padding:8px; color:#183454; background:white; }
|
||||
.asset-history-search { border:1px solid #dce5f0; border-radius:7px; padding:6px 10px; flex:1; min-width:170px; }
|
||||
.asset-history-search input { width:100%; min-width:0; padding:3px 0; outline:none; color:#183454; }
|
||||
.asset-history-detail-body { overflow:auto; flex:1; min-height:100px; }
|
||||
.asset-history-detail-note { font-size:12px; line-height:1.8; padding:12px 22px; color:#64748b; background:#f8fbff; }
|
||||
.asset-history-detail-table { overflow:auto; }
|
||||
.asset-history-detail-table table { width:100%; border-collapse:collapse; min-width:650px; font-size:12px; }
|
||||
.asset-history-detail-table th { text-align:left; background:#f7faff; color:#64748b; font-weight:500; padding:12px 20px; }
|
||||
.asset-history-detail-table td { vertical-align:top; padding:14px 20px; border-bottom:1px solid #edf2f8; max-width:280px; overflow-wrap:anywhere; }
|
||||
.asset-history-detail-table td strong { font-size:13px; font-weight:650; }.asset-history-detail-table small { display:block; font-size:11px; color:#64748b; margin-top:5px; }
|
||||
.asset-history-mobile-vehicles { display:none; }
|
||||
.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; }
|
||||
@media(max-width:640px) {
|
||||
.asset-history { padding:12px; border-radius:16px; }
|
||||
.asset-history-header h2 { font-size:18px; }.asset-history-modes button { padding:7px 15px; min-height:38px; }
|
||||
.asset-history-toolbar { gap:6px; }.asset-history-toolbar>span { font-size:10px; }
|
||||
.asset-history-chart { height:170px; }
|
||||
.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-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-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-dialog :is(header,footer) button { min-height:44px; min-width:44px; }
|
||||
.asset-history-detail-table { display:none; }
|
||||
.asset-history-mobile-vehicles { display:block; padding:0 14px; }
|
||||
.asset-history-mobile-vehicles article { padding:16px 0; border-bottom:1px solid #e6edf5; overflow-wrap:anywhere; }
|
||||
.asset-history-mobile-vehicle-heading { display:flex; justify-content:space-between; align-items:center; gap:10px; }
|
||||
.asset-history-mobile-vehicle-heading strong { font-size:14px; font-weight:700; }.asset-history-mobile-vehicle-heading span { font-size:11px; color:#1559e8; background:#edf5ff; border-radius:5px; padding:3px 8px; }
|
||||
.asset-history-mobile-vehicles p { margin-top:6px; font-size:12px; color:#475569; }.asset-history-mobile-vehicles small { display:block; font-size:11px; color:#64748b; margin-top:5px; }
|
||||
.asset-history-mobile-vehicles dl { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:12px 0; font-size:12px; }
|
||||
.asset-history-mobile-vehicles dt { font-size:11px; color:#64748b; margin-bottom:5px; }.asset-history-mobile-vehicles dd { margin:0; }
|
||||
}
|
||||
|
||||
@media(min-width:900px) {
|
||||
.asset-history-controls { display:flex; align-items:center; flex-wrap:wrap; gap:8px 24px; }
|
||||
.asset-history-header { flex:1; gap:16px; }
|
||||
.asset-history-toolbar { margin:0; }
|
||||
.asset-history-selected { display:grid; grid-template-columns:minmax(150px,1fr) 3fr; align-items:center; gap:0 18px; }
|
||||
.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: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-icon { color:#8b9db5; display:flex; transition:transform .15s; }
|
||||
.asset-history-select[data-state=open] .asset-history-select-icon { transform:rotate(180deg); }
|
||||
.asset-history-select-menu { z-index:160; min-width:max(132px,var(--radix-select-trigger-width)); max-height:min(320px,var(--radix-select-content-available-height)); max-width:calc(100vw - 20px); overflow:hidden; padding:4px; border:1px solid #e0e8f4; border-radius:12px; background:#fff; color:#254365; box-shadow:0 12px 32px #18365420,0 2px 6px #18365408; }
|
||||
.asset-history-select-options { padding:2px; }
|
||||
.asset-history-select-option { position:relative; display:flex; align-items:center; gap:16px; min-height:36px; padding:8px 30px 8px 10px; border-radius:7px; outline:none; font-size:13px; cursor:pointer; user-select:none; }
|
||||
.asset-history-select-option[data-state=checked] { color:#1559e8; background:#f0f6ff; font-weight:600; }
|
||||
.asset-history-select-option[data-highlighted] { background:#eaf2ff; color:#1559e8; }
|
||||
.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-select-scroll { display:flex; align-items:center; justify-content:center; height:24px; color:#7b8da5; }
|
||||
.asset-history-detail-button>svg { padding:3px; width:24px; height:24px; border-radius:7px; background:#eaf2ff; color:#527eb7; }
|
||||
@media(hover:hover) {
|
||||
.asset-history-modes button:not([aria-pressed=true]):hover { color:#1559e8; background:#e5edf9; }
|
||||
.asset-history-select:hover { border-color:#adc5e8; }
|
||||
.asset-history-refresh:hover { color:#2563eb; background:#eef5ff; }
|
||||
.asset-history-detail-button:hover { background:#eef5ff; border-color:#bcd3f3; }
|
||||
}
|
||||
@media(max-width:640px) {
|
||||
.asset-history-select { min-height:44px; padding:8px 10px; font-size:12px; gap:10px; }
|
||||
.asset-history-select-option { min-height:44px; }
|
||||
}
|
||||
@media(prefers-reduced-motion:reduce) {
|
||||
.asset-history-modes button,.asset-history-select,.asset-history-select-icon,.asset-history-refresh { transition:none; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { defaultHistoryPeriod, displayHistoryCounts, displayHistoryDifference, signedCount, updateLivePoint } from './presentation.js';
|
||||
import { makePoint } from '../../../server/routes/asset-history/model.js';
|
||||
import { testBatch } from '../../../server/routes/asset-history/fixtures.js';
|
||||
|
||||
test('selection prefers live then latest archive; missing is never a numerical zero',()=>{
|
||||
const now=new Date('2026-10-19T00:00Z');
|
||||
const archived=makePoint('2026-09',now,testBatch('2026-09'),[]);
|
||||
const live=makePoint('2026-10',now);const missing=makePoint('2026-08',now);
|
||||
assert.equal(defaultHistoryPeriod([missing,archived,live]),'2026-10');
|
||||
assert.equal(defaultHistoryPeriod([missing,archived]),'2026-09');
|
||||
assert.equal(archived.counts!.total,0);assert.equal(missing.counts,null);
|
||||
assert.equal(signedCount(3),'+3');assert.equal(signedCount(-3),'-3');
|
||||
});
|
||||
test('estimated counts keep total unknown and live refresh cannot compare against a reconstructed cohort',()=>{
|
||||
const now=new Date('2026-10-19T00:00Z');
|
||||
const estimate={...makePoint('2026-09',now),state:'RECONSTRUCTED' as const,source:'RECONSTRUCTED' as const,
|
||||
reconstruction:{operating:3,inventory:0,unclassified:2,currentlyExited:1,sourceAsOf:'2026-10-19T14:38:33+08:00',sourceReference:'核对表',workbookSha256:'a'.repeat(64),difference:{operating:1,inventory:-1}}};
|
||||
assert.deepEqual(displayHistoryCounts(estimate),{total:null,operating:3,inventory:0});assert.equal(displayHistoryDifference(estimate,'total'),null);
|
||||
assert.equal(displayHistoryDifference(estimate,'inventory'),-1);assert.equal(defaultHistoryPeriod([estimate]),'2026-09');
|
||||
const live=makePoint('2026-10',now);live.counts={total:5,operating:2,inventory:3};
|
||||
assert.equal(updateLivePoint([estimate,live],live)[1].difference,null);
|
||||
});
|
||||
test('live refresh preserves January comparison with December outside visible year',()=>{
|
||||
const now=new Date('2026-01-19T00:00Z');const initial=makePoint('2026-01',now);
|
||||
initial.counts={total:10,operating:6,inventory:4};initial.difference={total:2,operating:1,inventory:1};
|
||||
const refreshed={...initial,counts:{total:12,operating:7,inventory:5},difference:null};
|
||||
const result=updateLivePoint([initial],refreshed)[0];
|
||||
assert.deepEqual(result.difference,{total:4,operating:2,inventory:2});assert.equal(result.comparisonLabel,'截至当前较上月末');
|
||||
});
|
||||
test('live refresh cannot mutate an archive, bridge a hole or overwrite a different period',()=>{
|
||||
const now=new Date('2026-10-19T00:00Z');const archive=makePoint('2026-09',now,testBatch('2026-09'),[]);
|
||||
const live=makePoint('2026-10',now);const missing=makePoint('2026-08',now);
|
||||
assert.equal(updateLivePoint([archive],live)[0],archive);
|
||||
assert.equal(updateLivePoint([missing,live],{...live,counts:{total:9,operating:4,inventory:5}})[1].difference,null);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { HistoryPoint } from '../../../shared/assets/history';
|
||||
|
||||
/** Accessible period buttons and chart clicks share one selection model. */
|
||||
export function defaultHistoryPeriod(points: HistoryPoint[]): string | null {
|
||||
return points.find(p => p.state === 'LIVE')?.period || [...points].reverse().find(p => p.state === 'ARCHIVED' || p.state === 'RECONSTRUCTED')?.period || points.at(-1)?.period || null;
|
||||
}
|
||||
export function updateLivePoint(points: HistoryPoint[], live: HistoryPoint): HistoryPoint[] {
|
||||
return points.map((point, index) => {
|
||||
if (point.state !== 'LIVE' || point.period !== live.period) return point;
|
||||
if (points[index - 1]?.state === 'RECONSTRUCTED') return { ...live, difference: null, comparisonLabel: '推算与实时或正式快照口径不同,暂无可比数据' };
|
||||
const baseline = points[index - 1]?.counts || (point.counts && point.difference ? {
|
||||
total: point.counts.total - point.difference.total,
|
||||
operating: point.counts.operating - point.difference.operating,
|
||||
inventory: point.counts.inventory - point.difference.inventory,
|
||||
} : null);
|
||||
return { ...live, difference: baseline && live.counts ? {
|
||||
total: live.counts.total - baseline.total,
|
||||
operating: live.counts.operating - baseline.operating,
|
||||
inventory: live.counts.inventory - baseline.inventory,
|
||||
} : null, comparisonLabel: baseline ? `截至当前较${live.period.length === 4 ? '上年末' : '上月末'}` : '暂无可比数据' };
|
||||
});
|
||||
}
|
||||
export const signedCount = (value: number) => value > 0 ? `+${value.toLocaleString()}` : value.toLocaleString();
|
||||
export function displayHistoryCounts(point: HistoryPoint) {
|
||||
return point.reconstruction ? { total: null, operating: point.reconstruction.operating, inventory: point.reconstruction.inventory } : point.counts;
|
||||
}
|
||||
export function displayHistoryDifference(point: HistoryPoint, key: 'total' | 'operating' | 'inventory'): number | null {
|
||||
return point.reconstruction ? key === 'total' ? null : point.reconstruction.difference?.[key] ?? null : point.difference?.[key] ?? null;
|
||||
}
|
||||
@@ -104,6 +104,7 @@ export interface InventoryTypeSummary {
|
||||
}
|
||||
|
||||
export interface VehicleListItem {
|
||||
opsManagers?: string | null;
|
||||
color?: string;
|
||||
operationStatus?: string | null;
|
||||
inventoryKey?: string;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
const query = '(max-width: 767px)';
|
||||
const subscribe = (notify: () => void) => {
|
||||
const media = window.matchMedia(query);
|
||||
media.addEventListener('change', notify);
|
||||
return () => media.removeEventListener('change', notify);
|
||||
};
|
||||
/** Keep the two mobile asset surfaces on the same breakpoint. */
|
||||
export function useMobileLayout() {
|
||||
return useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);
|
||||
}
|
||||
@@ -48,7 +48,6 @@ export default function MonitoringView() {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [fullscreenVehicles, setFullscreenVehicles] = useState<MonitoringVehicle[]>([]);
|
||||
const [fullscreenStats, setFullscreenStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
||||
const [fullscreenRefresh, setFullscreenRefresh] = useState(0);
|
||||
const [fullscreenLoading, setFullscreenLoading] = useState(false);
|
||||
|
||||
// New filters from image
|
||||
@@ -62,7 +61,6 @@ export default function MonitoringView() {
|
||||
const [filterRegion, setFilterRegion] = useState('All');
|
||||
const [filterBrands, setFilterBrands] = useState<string[]>([]);
|
||||
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
||||
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
|
||||
const [rangeStart, setRangeStart] = useState(defaultMileageDate);
|
||||
@@ -72,7 +70,7 @@ export default function MonitoringView() {
|
||||
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
|
||||
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 [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 [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -85,7 +83,7 @@ export default function MonitoringView() {
|
||||
const [relativeNow, setRelativeNow] = useState(() => Date.now());
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const monitoringQuery = useMemo<MonitoringQueryModel>(() => ({
|
||||
const draftQuery = useMemo<MonitoringQueryModel>(() => ({
|
||||
sortBy,
|
||||
sortOrder,
|
||||
searchTerm,
|
||||
@@ -98,35 +96,24 @@ export default function MonitoringView() {
|
||||
targetNames: filterTargetNames,
|
||||
region: filterRegion,
|
||||
plates: filterPlates,
|
||||
mileageRange: appliedMileageRange,
|
||||
mileageRange: filterMileageRange,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
brands: filterBrands,
|
||||
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
|
||||
// project, entity and mileage-range, so adding those states would change both
|
||||
// its request parameters and refresh triggers.
|
||||
const fullscreenQuery = useMemo<MonitoringQueryModel>(() => ({
|
||||
sortBy,
|
||||
sortOrder,
|
||||
searchTerm,
|
||||
department: filterDept,
|
||||
customer: filterCustomer,
|
||||
project: 'All',
|
||||
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]);
|
||||
const [monitoringQuery, setMonitoringQuery] = useState(draftQuery);
|
||||
const [queryError, setQueryError] = useState('');
|
||||
const hasPendingChanges = JSON.stringify(draftQuery) !== JSON.stringify(monitoringQuery);
|
||||
const queryDisabled = pageLoading || manualRefreshing || fullscreenLoading || !rangeStart || !rangeEnd || rangeStart > rangeEnd;
|
||||
|
||||
const handleQuery = () => {
|
||||
if (queryDisabled) return;
|
||||
setQueryError('');
|
||||
setMonitoringQuery({ ...draftQuery });
|
||||
setIsFilterOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setRelativeNow(Date.now()), 5000);
|
||||
@@ -145,45 +132,48 @@ export default function MonitoringView() {
|
||||
|
||||
const isHighMileageAlert = useCallback((v: MonitoringVehicle) => {
|
||||
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;
|
||||
}, [filterTargetNames]);
|
||||
}, [monitoringQuery.targetNames]);
|
||||
|
||||
const queryRequestRef = useRef(0);
|
||||
|
||||
// 加载首页数据
|
||||
const loadFirstPage = useCallback((showPageLoading = true) => {
|
||||
const loadFirstPage = useCallback((showPageLoading = true, refresh = false) => {
|
||||
const requestId = ++queryRequestRef.current;
|
||||
setQueryError('');
|
||||
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;
|
||||
setVehicles(d.vehicles);
|
||||
setStats(d.stats);
|
||||
setFilterOptions(d.filters);
|
||||
setRangeDailyTotals(d.rangeDailyTotals || []);
|
||||
setEffectiveRange(d.dateRange || { start: rangeStart, end: rangeEnd });
|
||||
setEffectiveRange(d.dateRange || { start: monitoringQuery.rangeStart, end: monitoringQuery.rangeEnd });
|
||||
setTotal(d.total);
|
||||
setPage(1);
|
||||
setHasMore(d.page < d.totalPages);
|
||||
setLastRefreshedAt(new Date());
|
||||
}).catch(() => {}).finally(() => {
|
||||
setLastRefreshedAt(d.queriedAt ? new Date(d.queriedAt) : new Date());
|
||||
}).catch(error => {
|
||||
if (requestId === queryRequestRef.current) setQueryError(error instanceof Error ? error.message : '里程查询失败,请点击查询重试');
|
||||
}).finally(() => {
|
||||
if (requestId === queryRequestRef.current) setPageLoading(false);
|
||||
});
|
||||
}, [monitoringQuery, rangeStart, rangeEnd]);
|
||||
}, [monitoringQuery]);
|
||||
|
||||
const handleManualRefresh = useCallback(async () => {
|
||||
if (manualRefreshing || pageLoading) return;
|
||||
if (manualRefreshing || pageLoading || hasPendingChanges) return;
|
||||
setManualRefreshing(true);
|
||||
try {
|
||||
await loadFirstPage(false);
|
||||
await loadFirstPage(false, true);
|
||||
} finally {
|
||||
setManualRefreshing(false);
|
||||
}
|
||||
}, [loadFirstPage, manualRefreshing, pageLoading]);
|
||||
}, [loadFirstPage, manualRefreshing, pageLoading, hasPendingChanges]);
|
||||
|
||||
// 加载更多
|
||||
const loadMore = useCallback(() => {
|
||||
if (pageLoading || loadingMore || !hasMore) return;
|
||||
if (pageLoading || manualRefreshing || loadingMore || !hasMore || hasPendingChanges || queryError) return;
|
||||
const requestId = queryRequestRef.current;
|
||||
const nextPage = page + 1;
|
||||
setLoadingMore(true);
|
||||
@@ -192,15 +182,17 @@ export default function MonitoringView() {
|
||||
setVehicles(prev => [...prev, ...d.vehicles]);
|
||||
setPage(nextPage);
|
||||
setHasMore(nextPage < d.totalPages);
|
||||
}).catch(() => {}).finally(() => setLoadingMore(false));
|
||||
}, [monitoringQuery, page, pageLoading, loadingMore, hasMore]);
|
||||
}).catch(error => {
|
||||
if (requestId === queryRequestRef.current) setQueryError(error instanceof Error ? error.message : '加载更多失败,请重试');
|
||||
}).finally(() => setLoadingMore(false));
|
||||
}, [monitoringQuery, page, pageLoading, manualRefreshing, loadingMore, hasMore, hasPendingChanges, queryError]);
|
||||
|
||||
// 筛选/排序变化时重新加载
|
||||
// 首次进入和确认查询后加载;编辑筛选条件不触发请求。
|
||||
useEffect(() => {
|
||||
loadFirstPage();
|
||||
}, [loadFirstPage]);
|
||||
|
||||
// 区域级联:plate 选项收窄后,剔除已选但已不属于该区域的车牌
|
||||
// 保留区域级联清理;清理后的草稿也必须确认后才能请求。
|
||||
useEffect(() => {
|
||||
if (filterPlates.length === 0) return;
|
||||
const valid = new Set(filterOptions.plates);
|
||||
@@ -209,24 +201,18 @@ export default function MonitoringView() {
|
||||
}, [filterOptions.plates, filterPlates]);
|
||||
|
||||
// 下载当前筛选结果为 xlsx
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (exporting) return;
|
||||
const handleDownload = useCallback(async (dailyDetails = false) => {
|
||||
if (exporting || hasPendingChanges || pageLoading || queryError) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
const d = await fetchMonitoring(buildExportMonitoringQuery(monitoringQuery));
|
||||
exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy });
|
||||
const d = await fetchMonitoring({ ...buildExportMonitoringQuery(monitoringQuery), dailyDetails });
|
||||
exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || monitoringQuery.rangeStart, endDate: d.dateRange?.end || monitoringQuery.rangeEnd, sortBy: monitoringQuery.sortBy });
|
||||
} catch (err) {
|
||||
console.error('export failed', err);
|
||||
setQueryError(err instanceof Error ? err.message : '导出失败,请重试');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [exporting, monitoringQuery, rangeStart, rangeEnd, sortBy]);
|
||||
|
||||
// 每分钟自动刷新
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => { void loadFirstPage(false); }, 60 * 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [loadFirstPage]);
|
||||
}, [exporting, monitoringQuery, hasPendingChanges, pageLoading, queryError]);
|
||||
|
||||
// 触底检测:用 IntersectionObserver 监听哨兵元素
|
||||
const loadMoreRef = useRef(loadMore);
|
||||
@@ -272,16 +258,20 @@ export default function MonitoringView() {
|
||||
setIsFullscreen(!isFullscreen);
|
||||
};
|
||||
|
||||
// 全屏时加载全部数据(无分页),筛选变化时重新加载
|
||||
useEffect(() => {
|
||||
if (!isFullscreen) return;
|
||||
// 全屏使用已确认条件,编辑筛选后仍需点击查询。
|
||||
const loadFullscreen = useCallback((refresh = false) => {
|
||||
setQueryError('');
|
||||
setFullscreenLoading(true);
|
||||
fetchMonitoring(buildFullscreenMonitoringQuery(fullscreenQuery)).then(d => {
|
||||
return fetchMonitoring({ ...buildFullscreenMonitoringQuery(monitoringQuery), refresh }).then(d => {
|
||||
setFullscreenVehicles(d.vehicles);
|
||||
setFullscreenStats(d.stats);
|
||||
setFilterOptions(d.filters);
|
||||
}).catch(() => {}).finally(() => setFullscreenLoading(false));
|
||||
}, [isFullscreen, fullscreenQuery, fullscreenRefresh]);
|
||||
}).catch(error => setQueryError(error instanceof Error ? error.message : '全屏查询失败')).finally(() => setFullscreenLoading(false));
|
||||
}, [monitoringQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFullscreen) void loadFullscreen();
|
||||
}, [isFullscreen, loadFullscreen]);
|
||||
|
||||
// 全屏时禁止背景滚动
|
||||
useEffect(() => {
|
||||
@@ -322,9 +312,13 @@ export default function MonitoringView() {
|
||||
forceLandscape={forceLandscape}
|
||||
fullscreenStats={fullscreenStats}
|
||||
sortBy={sortBy}
|
||||
sourcePriority={sourcePriority}
|
||||
onRefresh={() => { setFullscreenRefresh(n => n + 1); }}
|
||||
sourcePriority={monitoringQuery.sourcePriority}
|
||||
onQuery={handleQuery}
|
||||
queryDisabled={queryDisabled}
|
||||
hasPendingChanges={hasPendingChanges}
|
||||
onRefresh={() => { void loadFullscreen(true); }}
|
||||
fullscreenLoading={fullscreenLoading}
|
||||
error={queryError}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
filterTargetNames={filterTargetNames}
|
||||
onFilterTargetNamesChange={setFilterTargetNames}
|
||||
@@ -351,8 +345,13 @@ export default function MonitoringView() {
|
||||
|
||||
<MonitoringToolbar
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
onDownload={handleDownload}
|
||||
onDownload={() => { void handleDownload(); }}
|
||||
onDownloadDetails={() => { void handleDownload(true); }}
|
||||
exporting={exporting}
|
||||
onQuery={handleQuery}
|
||||
queryDisabled={queryDisabled}
|
||||
hasPendingChanges={hasPendingChanges}
|
||||
resultsDisabled={pageLoading || hasPendingChanges || !!queryError}
|
||||
lastRefreshedAt={lastRefreshedAt}
|
||||
onManualRefresh={handleManualRefresh}
|
||||
manualRefreshing={manualRefreshing}
|
||||
@@ -415,8 +414,8 @@ export default function MonitoringView() {
|
||||
onFilterPlatesChange={setFilterPlates}
|
||||
onFilterTargetNamesChange={setFilterTargetNames}
|
||||
onFilterRegionChange={setFilterRegion}
|
||||
onAppliedMileageRangeChange={setAppliedMileageRange}
|
||||
onFilterOpenChange={setIsFilterOpen}
|
||||
onQuery={handleQuery}
|
||||
queryDisabled={queryDisabled}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -445,8 +444,7 @@ export default function MonitoringView() {
|
||||
searchTerm={searchTerm}
|
||||
onSearchTermChange={setSearchTerm}
|
||||
onFilterMileageRangeChange={setFilterMileageRange}
|
||||
appliedMileageRange={appliedMileageRange}
|
||||
onAppliedMileageRangeChange={setAppliedMileageRange}
|
||||
mileageRange={filterMileageRange}
|
||||
filterPlatePrefix={filterPlatePrefix}
|
||||
onFilterPlatePrefixChange={setFilterPlatePrefix}
|
||||
rangeStart={rangeStart}
|
||||
@@ -455,10 +453,12 @@ export default function MonitoringView() {
|
||||
onRangeEndChange={setRangeEnd}
|
||||
/>
|
||||
|
||||
{queryError && <div role="alert" className="rounded-xl bg-red-50 p-3 text-sm text-red-700">{queryError}</div>}
|
||||
|
||||
{/* Sticky header: KPI + 清单标题 */}
|
||||
<MonitoringSummary
|
||||
pageLoading={pageLoading}
|
||||
sortBy={sortBy}
|
||||
sortBy={monitoringQuery.sortBy}
|
||||
isRangeMode={isRangeMode}
|
||||
stats={stats}
|
||||
rangeLabel={rangeLabel}
|
||||
@@ -482,8 +482,9 @@ export default function MonitoringView() {
|
||||
|
||||
<VehicleDetailModal
|
||||
vehicle={detailVehicle}
|
||||
selectedRange={effectiveRange}
|
||||
onClose={() => setDetailVehicle(null)}
|
||||
sourcePriority={sourcePriority}
|
||||
sourcePriority={monitoringQuery.sourcePriority}
|
||||
/>
|
||||
|
||||
{/* 回到顶部按钮 */}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { hasDailyMileage, recentMileageStats } from './mileage-display';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { motion, AnimatePresence, useDragControls } from 'motion/react';
|
||||
import { X, Truck } from 'lucide-react';
|
||||
@@ -12,11 +13,13 @@ interface Props {
|
||||
vehicle: MonitoringVehicle | null;
|
||||
onClose: () => void;
|
||||
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 }[] = [
|
||||
{ key: 'selected', label: '当前区间' },
|
||||
{ key: 'last15', label: '近 15 天' },
|
||||
{ key: 'month', label: '本月' },
|
||||
{ key: 'quarter', label: '本季度' },
|
||||
@@ -47,10 +50,6 @@ function rangeFor(key: RangeKey): { start: string; end: string; rangeLabel: stri
|
||||
return { start: fmtYmd(start), end, rangeLabel: '本季度' };
|
||||
}
|
||||
|
||||
function isToday(date: string): boolean {
|
||||
return date === fmtYmd(new Date());
|
||||
}
|
||||
|
||||
function formatLabel(date: string, key: RangeKey): string {
|
||||
// YYYY-MM-DD → MM-DD(季度时仍展示 MM-DD)
|
||||
void key;
|
||||
@@ -63,30 +62,32 @@ function daySourceLabel(day: VehicleRecentDay): string {
|
||||
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 [loading, setLoading] = useState(false);
|
||||
const [range, setRange] = useState<RangeKey>('last15');
|
||||
const [error, setError] = useState('');
|
||||
const [range, setRange] = useState<RangeKey>('selected');
|
||||
const dragControls = useDragControls();
|
||||
|
||||
// 切换车辆时重置区间为默认
|
||||
useEffect(() => {
|
||||
if (vehicle) setRange('last15');
|
||||
if (vehicle) setRange('selected');
|
||||
}, [vehicle?.plate]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 拉取数据(车辆或区间变化)
|
||||
useEffect(() => {
|
||||
if (!vehicle) return;
|
||||
const { start, end } = rangeFor(range);
|
||||
const { start, end } = range === 'selected' ? selectedRange : rangeFor(range);
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setDays([]);
|
||||
let cancelled = false;
|
||||
fetchVehicleRecent(vehicle.plate, { start, end, sourcePriority })
|
||||
.then(d => { if (!cancelled) setDays(d.days); })
|
||||
.catch(() => { if (!cancelled) setDays([]); })
|
||||
.catch(() => { if (!cancelled) setError('单车里程加载失败,请重新打开详情重试'); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
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(() => {
|
||||
@@ -95,24 +96,21 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [vehicle]);
|
||||
|
||||
// 排除"今日"列(数据未到位时易引起误读)
|
||||
const historyDays = useMemo(() => days.filter(d => !isToday(d.date)), [days]);
|
||||
const stats = useMemo(() => {
|
||||
const totalKm = historyDays.reduce((s, d) => s + d.dailyKm, 0);
|
||||
const synced = historyDays.filter(d => d.isDataSynced).length;
|
||||
const avg = synced > 0 ? totalKm / synced : 0;
|
||||
const max = Math.max(1, ...historyDays.map(d => d.dailyKm));
|
||||
return { totalKm, synced, avg, max, totalDays: historyDays.length };
|
||||
}, [historyDays]);
|
||||
// 与主列表区间一致;缺失日保留缺口,真实零保留为零。
|
||||
const historyDays = useMemo(() => days, [days]);
|
||||
const stats = useMemo(() => recentMileageStats(historyDays), [historyDays]);
|
||||
const chartDays = useMemo(() => historyDays.map(day => ({
|
||||
...day, dailyKm: hasDailyMileage(day) ? day.dailyKm : null,
|
||||
})), [historyDays]);
|
||||
|
||||
// 骨架天数:根据区间预估
|
||||
const skeletonCount = useMemo(() => {
|
||||
if (range === 'last15') return 15;
|
||||
const { start, end } = rangeFor(range);
|
||||
const { start, end } = range === 'selected' ? selectedRange : rangeFor(range);
|
||||
const s = new Date(start);
|
||||
const e = new Date(end);
|
||||
return Math.max(1, Math.round((e.getTime() - s.getTime()) / 86400000));
|
||||
}, [range]);
|
||||
}, [range, selectedRange.start, selectedRange.end]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
@@ -173,11 +171,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 -mr-1 text-slate-400 hover:text-slate-700 flex-shrink-0">
|
||||
<button aria-label="关闭车辆详情" onClick={onClose} className="p-2 -mr-1 text-slate-400 hover:text-slate-700 flex-shrink-0">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</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="relative inline-flex bg-slate-100 p-0.5 rounded-lg">
|
||||
@@ -206,14 +205,14 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
||||
<div className="text-[9px] font-bold text-slate-400 uppercase">区间合计</div>
|
||||
<div className="text-base font-black text-slate-900 leading-tight">
|
||||
{loading ? <span className="inline-block h-4 w-14 bg-slate-200 rounded animate-pulse align-middle" />
|
||||
: <>{Math.round(stats.totalKm).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
||||
: stats.synced === 0 ? '—' : <>{Math.round(stats.totalKm).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||
<div className="text-[9px] font-bold text-slate-400 uppercase">日均</div>
|
||||
<div className="text-base font-black text-slate-900 leading-tight">
|
||||
{loading ? <span className="inline-block h-4 w-10 bg-slate-200 rounded animate-pulse align-middle" />
|
||||
: <>{Math.round(stats.avg).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
||||
: stats.synced === 0 ? '—' : <>{Math.round(stats.avg).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||
@@ -237,7 +236,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
||||
<SkeletonBars count={Math.min(skeletonCount, 30)} />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={historyDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
||||
<BarChart data={chartDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(d) => formatLabel(d, range)}
|
||||
@@ -249,7 +248,8 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Tooltip
|
||||
formatter={(v) => [`${Math.round(Number(v) || 0).toLocaleString()} km`, '行驶里程']}
|
||||
filterNull={false}
|
||||
formatter={(v) => [v == null ? '—' : `${Math.round(Number(v)).toLocaleString()} km`, '行驶里程']}
|
||||
labelFormatter={(d) => `日期 ${d}`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 11, padding: '4px 8px' }}
|
||||
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
|
||||
@@ -306,15 +306,15 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
||||
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(d.dailyKm / stats.max) * 100}%` }}
|
||||
animate={{ width: `${hasDailyMileage(d) ? (d.dailyKm / stats.max) * 100 : 0}%` }}
|
||||
transition={{ delay: Math.min(i * 0.012, 0.4) + 0.1, duration: 0.4 }}
|
||||
className={`h-full rounded-full ${d.isDataSynced ? 'bg-blue-500' : 'bg-slate-200'}`}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-[11px] font-mono font-bold w-20 text-right ${d.isDataSynced ? 'text-slate-700' : 'text-amber-500/60'}`}>
|
||||
{d.isDataSynced
|
||||
<span className={`text-[11px] font-mono font-bold w-20 text-right ${hasDailyMileage(d) ? 'text-slate-700' : 'text-slate-400'}`}>
|
||||
{hasDailyMileage(d)
|
||||
? <>{Math.round(d.dailyKm).toLocaleString()} <span className="text-[9px] text-slate-400">km</span></>
|
||||
: <span className="text-[9px]">未对接</span>}
|
||||
: <span>—</span>}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
TargetVehicle,
|
||||
TrendPoint,
|
||||
} from './types';
|
||||
import { fetchJson } from '../../auth/api-client';
|
||||
import { fetchJson, fetchAuthenticated } from '../../auth/api-client';
|
||||
import type {
|
||||
DailyMileageReport,
|
||||
MileageReportHistoryResponse,
|
||||
@@ -26,6 +26,9 @@ export type {
|
||||
const BASE = '/api/mileage';
|
||||
|
||||
export async function fetchMonitoring(params?: {
|
||||
refresh?: boolean;
|
||||
includeDailyTrend?: boolean;
|
||||
dailyDetails?: boolean;
|
||||
sortBy?: 'today' | 'total' | 'statisticTime';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
limit?: number;
|
||||
@@ -50,6 +53,9 @@ export async function fetchMonitoring(params?: {
|
||||
sourcePriority?: MileageSourceGroup[];
|
||||
}): Promise<MonitoringData> {
|
||||
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?.sortOrder) query.set('sortOrder', params.sortOrder);
|
||||
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?.sourcePriority?.length) query.set('sourcePriority', params.sourcePriority.join(','));
|
||||
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[]> {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { monitoringMileageValue, recentMileageStats } from './mileage-display';
|
||||
import type { VehicleRecentDay } from './api';
|
||||
import type { MonitoringVehicle } from './types';
|
||||
|
||||
const day = (dailyKm: number, isDataSynced = true): VehicleRecentDay => ({
|
||||
date: '2026-10-12', dailyKm, isDataSynced, sourceProtocol: null, sourceCategory: null,
|
||||
});
|
||||
|
||||
test('详情合计、平均值和有数据天排除缺失日,保留真实零里程', () => {
|
||||
assert.deepEqual(recentMileageStats([day(100), day(900, false), day(200), day(0)]), {
|
||||
totalKm: 300, synced: 3, avg: 100, max: 200, totalDays: 4,
|
||||
});
|
||||
assert.equal(recentMileageStats([day(0, false)]).synced, 0);
|
||||
});
|
||||
|
||||
test('列表全区间缺失时显示横杠,即使存在累计里程', () => {
|
||||
const vehicle = { dailyKm: 0, isDataSynced: false, totalKm: 64413.6 } as MonitoringVehicle;
|
||||
assert.equal(monitoringMileageValue(vehicle), '—');
|
||||
assert.equal(monitoringMileageValue({ ...vehicle, isDataSynced: true }), 0);
|
||||
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}),'—');
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { MonitoringVehicle } from './types';
|
||||
import type { VehicleRecentDay } from './api';
|
||||
|
||||
export function monitoringMileageValue(vehicle: MonitoringVehicle): number | string {
|
||||
if (vehicle.periodMileageKm !== undefined) return vehicle.periodMileageKm ?? '—';
|
||||
if (vehicle.mileageAnomaly) return '数据异常';
|
||||
if (!vehicle.isDataSynced) return '—';
|
||||
return Math.max(0, vehicle.dailyKm || 0);
|
||||
}
|
||||
|
||||
export function hasDailyMileage(day: VehicleRecentDay): boolean {
|
||||
return day.isDataSynced && Number.isFinite(day.dailyKm) && day.dailyKm >= 0;
|
||||
}
|
||||
|
||||
export function recentMileageStats(days: VehicleRecentDay[]) {
|
||||
const validDays = days.filter(hasDailyMileage);
|
||||
const totalKm = validDays.reduce((sum, day) => sum + day.dailyKm, 0);
|
||||
return {
|
||||
totalKm,
|
||||
synced: validDays.length,
|
||||
avg: validDays.length ? totalKm / validDays.length : 0,
|
||||
max: Math.max(1, ...validDays.map(day => day.dailyKm)),
|
||||
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,
|
||||
onSearchTermChange,
|
||||
onFilterMileageRangeChange,
|
||||
appliedMileageRange,
|
||||
onAppliedMileageRangeChange,
|
||||
mileageRange,
|
||||
filterPlatePrefix,
|
||||
onFilterPlatePrefixChange,
|
||||
rangeStart,
|
||||
@@ -58,8 +57,7 @@ export default function ActiveFilterTags({
|
||||
searchTerm: string;
|
||||
onSearchTermChange: (value: string) => void;
|
||||
onFilterMileageRangeChange: Dispatch<SetStateAction<MileageRange>>;
|
||||
appliedMileageRange: MileageRange;
|
||||
onAppliedMileageRangeChange: Dispatch<SetStateAction<MileageRange>>;
|
||||
mileageRange: MileageRange;
|
||||
filterPlatePrefix: string;
|
||||
onFilterPlatePrefixChange: (value: string) => void;
|
||||
rangeStart: string;
|
||||
@@ -84,8 +82,8 @@ export default function ActiveFilterTags({
|
||||
if (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => onFilterProjectChange('All') });
|
||||
if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => onFilterEntityChange('All') });
|
||||
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 (appliedMileageRange.max) tags.push({ label: `里程≤${appliedMileageRange.max}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, max: '' })); onAppliedMileageRangeChange(prev => ({ ...prev, max: '' })); } });
|
||||
if (mileageRange.min) tags.push({ label: `里程≥${mileageRange.min}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, min: '' })); } });
|
||||
if (mileageRange.max) tags.push({ label: `里程≤${mileageRange.max}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, max: '' })); } });
|
||||
if (filterPlatePrefix !== 'All') tags.push({ label: `车牌段: ${filterPlatePrefix}`, onClear: () => onFilterPlatePrefixChange('All') });
|
||||
if (rangeStart || rangeEnd) tags.push({
|
||||
label: `区间: ${normalizeRangeLabel(rangeStart, rangeEnd)}`,
|
||||
@@ -99,7 +97,7 @@ export default function ActiveFilterTags({
|
||||
const clearAll = () => {
|
||||
onFilterDeptChange('All'); onFilterCustomerChange('All'); onFilterRentStatusChange('All'); onFilterProjectChange('All'); onFilterEntityChange('All');
|
||||
onFilterPlatesChange([]); onSearchTermChange(''); onFilterPlatePrefixChange('All'); onFilterTargetNamesChange([]); onFilterRegionChange('All');
|
||||
onFilterMileageRangeChange({ min: '', max: '' }); onAppliedMileageRangeChange({ min: '', max: '' });
|
||||
onFilterMileageRangeChange({ min: '', max: '' });
|
||||
onFilterBrandsChange([]);
|
||||
const today = defaultMileageDate();
|
||||
onRangeStartChange(today); onRangeEndChange(today);
|
||||
|
||||
@@ -35,8 +35,8 @@ export default function FilterPanel({
|
||||
onFilterPlatesChange,
|
||||
onFilterTargetNamesChange,
|
||||
onFilterRegionChange,
|
||||
onAppliedMileageRangeChange,
|
||||
onFilterOpenChange,
|
||||
onQuery,
|
||||
queryDisabled,
|
||||
}: {
|
||||
rangeStart: string;
|
||||
rangeEnd: string;
|
||||
@@ -65,8 +65,8 @@ export default function FilterPanel({
|
||||
onFilterPlatesChange: (value: string[]) => void;
|
||||
onFilterTargetNamesChange: (value: string[]) => void;
|
||||
onFilterRegionChange: (value: string) => void;
|
||||
onAppliedMileageRangeChange: (value: MileageRange) => void;
|
||||
onFilterOpenChange: (value: boolean) => void;
|
||||
onQuery: () => void;
|
||||
queryDisabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
@@ -82,6 +82,7 @@ export default function FilterPanel({
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
||||
<input
|
||||
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"
|
||||
value={rangeStart}
|
||||
onChange={(e) => onRangeStartChange(e.target.value)}
|
||||
@@ -89,11 +90,13 @@ export default function FilterPanel({
|
||||
<span className="text-[10px] font-bold text-slate-300">至</span>
|
||||
<input
|
||||
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"
|
||||
value={rangeEnd}
|
||||
onChange={(e) => onRangeEndChange(e.target.value)}
|
||||
/>
|
||||
</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">
|
||||
{([
|
||||
['today', '今天'],
|
||||
@@ -240,13 +243,13 @@ export default function FilterPanel({
|
||||
onFilterDeptChange('All');
|
||||
onFilterPlatesChange([]);
|
||||
onFilterCustomerChange('All');
|
||||
onFilterRentStatusChange('All');
|
||||
onFilterProjectChange('All');
|
||||
onFilterEntityChange('All');
|
||||
onFilterPlatePrefixChange('All');
|
||||
onFilterTargetNamesChange([]);
|
||||
onFilterRegionChange('All');
|
||||
onFilterMileageRangeChange({ min: '', max: '' });
|
||||
onAppliedMileageRangeChange({ min: '', max: '' });
|
||||
onFilterBrandsChange([]);
|
||||
const today = defaultMileageDate();
|
||||
onRangeStartChange(today);
|
||||
@@ -257,13 +260,11 @@ export default function FilterPanel({
|
||||
重置所有
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onAppliedMileageRangeChange({ ...filterMileageRange });
|
||||
onFilterOpenChange(false);
|
||||
}}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-xl text-xs font-bold shadow-lg shadow-blue-100"
|
||||
onClick={onQuery}
|
||||
disabled={queryDisabled}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-xl text-xs font-bold shadow-lg shadow-blue-100 disabled:opacity-50"
|
||||
>
|
||||
完成筛选
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { monitoringMileageValue, mileageQualityLabel } from '../../mileage-display';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { ArrowDown, ArrowUp, Minimize2, RotateCcw } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
@@ -14,8 +15,12 @@ export default function FullscreenMonitoring({
|
||||
fullscreenStats,
|
||||
sortBy,
|
||||
sourcePriority,
|
||||
onQuery,
|
||||
queryDisabled,
|
||||
hasPendingChanges,
|
||||
onRefresh,
|
||||
fullscreenLoading,
|
||||
error,
|
||||
onToggleFullscreen,
|
||||
filterTargetNames,
|
||||
onFilterTargetNamesChange,
|
||||
@@ -41,8 +46,12 @@ export default function FullscreenMonitoring({
|
||||
fullscreenStats: MonitoringStats;
|
||||
sortBy: SortBy;
|
||||
sourcePriority: MileageSourceGroup[];
|
||||
onQuery: () => void;
|
||||
queryDisabled: boolean;
|
||||
hasPendingChanges: boolean;
|
||||
onRefresh: () => void;
|
||||
fullscreenLoading: boolean;
|
||||
error: string;
|
||||
onToggleFullscreen: () => void;
|
||||
filterTargetNames: string[];
|
||||
onFilterTargetNamesChange: Dispatch<SetStateAction<string[]>>;
|
||||
@@ -92,13 +101,13 @@ export default function FullscreenMonitoring({
|
||||
<h2 className="text-white font-bold text-xs">全屏监控</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px]">
|
||||
<span className="text-slate-500">区间 <span className="text-white font-black">{Math.round(fullscreenStats.totalToday).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
|
||||
<span className="text-slate-500">区间 <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-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-500">车辆 <span className="text-white font-black">{fullscreenStats.vehicleCount}</span> 台</span>
|
||||
<span className="text-slate-700">|</span>
|
||||
<span className="text-slate-500">均 <span className="text-white font-black">{(fullscreenStats.vehicleCount > 0 ? (sortBy === 'total' ? fullscreenStats.totalAll : fullscreenStats.totalToday) / fullscreenStats.vehicleCount : 0).toFixed(0)}</span> <span className="text-blue-400">km</span></span>
|
||||
<span className="text-slate-500">均 <span className="text-white font-black">{(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-500">
|
||||
来源 <span className="text-white font-black">
|
||||
@@ -108,7 +117,11 @@ export default function FullscreenMonitoring({
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
aria-label="刷新全屏数据"
|
||||
disabled={fullscreenLoading || hasPendingChanges}
|
||||
onClick={onRefresh}
|
||||
className={`p-1.5 text-slate-500 hover:text-blue-400 transition-colors ${fullscreenLoading ? 'animate-spin' : ''}`}
|
||||
>
|
||||
@@ -120,6 +133,7 @@ export default function FullscreenMonitoring({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div role="alert" className="bg-red-950 px-3 py-2 text-sm text-red-200">{error}</div>}
|
||||
{/* 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 items-center gap-1 overflow-x-auto no-scrollbar">
|
||||
@@ -283,6 +297,8 @@ export default function FullscreenMonitoring({
|
||||
<tbody className="divide-y divide-slate-800/30">
|
||||
{fullscreenVehicles.map((v) => {
|
||||
const highMileageAlert = isHighMileageAlert(v);
|
||||
const mileageValue = monitoringMileageValue(v);
|
||||
const hasMileage = typeof mileageValue === 'number';
|
||||
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
||||
const sourceDisplay = vehicleSourceDisplay(v);
|
||||
return (
|
||||
@@ -295,7 +311,7 @@ export default function FullscreenMonitoring({
|
||||
<div className="text-xs font-bold text-white"><Blur>{v.plate}</Blur></div>
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
@@ -310,7 +326,7 @@ export default function FullscreenMonitoring({
|
||||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<span className={`text-xs font-mono font-bold ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-400' : 'text-blue-400') : 'text-amber-400'}`}>
|
||||
{(v.isDataSynced || v.totalKm != null) ? <>{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400/70' : 'text-slate-500'}`}>{v.mileageAnomaly ? '' : 'km'}</span></> : <span className="text-[8px] text-amber-500/50">未对接</span>}
|
||||
<>{mileageValue.toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400/70' : 'text-slate-500'}`}>{hasMileage ? 'km' : ''}</span></>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
|
||||
@@ -16,12 +16,12 @@ export default function MonitoringSummary({
|
||||
isRangeMode: boolean;
|
||||
stats: MonitoringStats;
|
||||
rangeLabel: string;
|
||||
rangeDailyTotals: { date: string; totalKm: number }[];
|
||||
rangeDailyTotals: { date: string; totalKm: number | null }[];
|
||||
total: number;
|
||||
}) {
|
||||
const averageDailyKm = rangeDailyTotals.length > 0
|
||||
? rangeDailyTotals.reduce((sum, item) => sum + item.totalKm, 0) / rangeDailyTotals.length
|
||||
: 0;
|
||||
const hasMileage = (sortBy === 'total' ? stats.totalMileageVehicleCount : stats.validVehicleCount) !== 0;
|
||||
const validDays = rangeDailyTotals.filter(item => item.totalKm != null);
|
||||
const averageDailyKm = validDays.length ? validDays.reduce((sum, item) => sum + item.totalKm!, 0) / validDays.length : null;
|
||||
|
||||
return (
|
||||
<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="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">
|
||||
{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 className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div>
|
||||
</div>
|
||||
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm">
|
||||
<div className="text-[7px] font-bold text-slate-400 uppercase">平均单车</div>
|
||||
<div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : (stats.vehicleCount > 0 ? (sortBy === 'total' ? stats.totalAll : stats.totalToday) / stats.vehicleCount : 0).toFixed(0)}</div>
|
||||
<div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <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>
|
||||
<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>
|
||||
<div className="h-[58px] min-w-0">
|
||||
{rangeDailyTotals.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center rounded-lg bg-slate-50 text-[10px] font-bold text-slate-300">暂无趋势</div>
|
||||
{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>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={58} minWidth={0}>
|
||||
<BarChart data={rangeDailyTotals} margin={{ top: 4, right: 2, bottom: 0, left: 2 }}>
|
||||
<XAxis dataKey="date" hide />
|
||||
<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)}`}
|
||||
contentStyle={{ borderRadius: 10, borderColor: '#e2e8f0', fontSize: 11 }}
|
||||
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" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<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-[9px] font-black text-slate-400">有效日均</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>
|
||||
</div>
|
||||
@@ -92,11 +92,11 @@ export default function MonitoringSummary({
|
||||
</div>
|
||||
<div className="flex flex-col justify-center rounded-lg bg-blue-50 px-2 py-1.5 text-right">
|
||||
<div className="text-[9px] font-black text-blue-400">当日总计</div>
|
||||
<div className="mt-1 text-xs font-black tabular-nums text-blue-700">{Math.round(stats.totalToday).toLocaleString()}</div>
|
||||
<div className="mt-1 text-xs font-black tabular-nums text-blue-700">{stats.validVehicleCount === 0 ? '—' : Math.round(stats.totalToday).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center rounded-lg bg-slate-50 px-2 py-1.5 text-right">
|
||||
<div className="text-[9px] font-black text-slate-400">日均单车</div>
|
||||
<div className="mt-1 text-xs font-black tabular-nums text-slate-800">{stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}</div>
|
||||
<div className="mt-1 text-xs font-black tabular-nums text-slate-800">{stats.validVehicleCount === 0 ? '—' : stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -11,8 +11,13 @@ type SortBy = 'today' | 'total' | 'statisticTime';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
|
||||
export default function MonitoringToolbar({
|
||||
onQuery,
|
||||
queryDisabled,
|
||||
hasPendingChanges,
|
||||
resultsDisabled,
|
||||
onToggleFullscreen,
|
||||
onDownload,
|
||||
onDownloadDetails,
|
||||
exporting,
|
||||
lastRefreshedAt,
|
||||
onManualRefresh,
|
||||
@@ -44,8 +49,13 @@ export default function MonitoringToolbar({
|
||||
onApplyRangePreset,
|
||||
rangeLabel,
|
||||
}: {
|
||||
onQuery: () => void;
|
||||
queryDisabled: boolean;
|
||||
hasPendingChanges: boolean;
|
||||
resultsDisabled: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
onDownload: () => void;
|
||||
onDownloadDetails: () => void;
|
||||
exporting: boolean;
|
||||
lastRefreshedAt: Date | null;
|
||||
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="全屏视图">
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
<button onClick={onDownload} disabled={exporting} className="p-1 text-slate-300 transition-colors hover:text-blue-600 disabled:text-slate-200" title="下载当前筛选结果">
|
||||
{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download size={14} />}
|
||||
</button>
|
||||
<details className="relative">
|
||||
<summary aria-label="导出" className="cursor-pointer list-none p-1 text-slate-400">{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download size={14} />}</summary>
|
||||
<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 className="mt-1 flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
|
||||
@@ -100,15 +114,15 @@ export default function MonitoringToolbar({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-1.5">
|
||||
<span className="hidden text-[9px] font-bold tabular-nums text-slate-400 sm:inline">
|
||||
<span title="查询结果获取时间" className="hidden text-[9px] font-bold tabular-nums text-slate-400 sm:inline">
|
||||
{lastRefreshedAt ? lastRefreshedAt.toLocaleTimeString('zh-CN', { hour12: false }) : '--:--:--'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
title="局部刷新当前筛选数据"
|
||||
title="重新获取当前筛选数据"
|
||||
>
|
||||
<RefreshCw size={11} className={manualRefreshing ? 'animate-spin' : ''} />
|
||||
<span>{manualRefreshing ? '刷新中' : '刷新数据'}</span>
|
||||
@@ -145,7 +159,8 @@ export default function MonitoringToolbar({
|
||||
</button>
|
||||
</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', '今天'],
|
||||
['yesterday', '昨天'],
|
||||
@@ -166,7 +181,10 @@ export default function MonitoringToolbar({
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { monitoringMileageValue, mileageQualityLabel } from '../../mileage-display';
|
||||
import type { RefObject } from 'react';
|
||||
import { Truck } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
@@ -54,6 +55,8 @@ export default function VehicleList({
|
||||
<div className="grid grid-cols-1 gap-1.5">
|
||||
{vehicles.map((v) => {
|
||||
const highMileageAlert = isHighMileageAlert(v);
|
||||
const mileageValue = monitoringMileageValue(v);
|
||||
const hasMileage = typeof mileageValue === 'number';
|
||||
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
||||
const sourceDisplay = vehicleSourceDisplay(v);
|
||||
return (
|
||||
@@ -84,7 +87,8 @@ export default function VehicleList({
|
||||
{sourceDisplay.label}
|
||||
</span>
|
||||
</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}>
|
||||
{statisticTime.label}
|
||||
</div>
|
||||
@@ -102,8 +106,8 @@ export default function VehicleList({
|
||||
</div>
|
||||
<div className="hidden text-right md:block">
|
||||
<div className={`text-sm font-black tabular-nums ${highMileageAlert ? 'text-red-600' : 'text-blue-600'}`}>
|
||||
{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()}
|
||||
<span className="ml-1 text-[9px] font-bold text-slate-400">{v.mileageAnomaly ? '' : 'km'}</span>
|
||||
{mileageValue.toLocaleString()}
|
||||
<span className="ml-1 text-[9px] font-bold text-slate-400">{hasMileage ? 'km' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden text-right md:block">
|
||||
@@ -119,7 +123,7 @@ export default function VehicleList({
|
||||
)}
|
||||
<span className="text-[7px] font-black text-blue-600/50 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none" title={isRangeMode ? '区间内里程' : '当日里程'}>区</span>
|
||||
<div className={`text-sm font-black leading-none ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-600' : 'text-blue-600') : 'text-amber-600'}`}>
|
||||
{(v.isDataSynced || v.totalKm != null) ? <>{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400' : 'text-slate-400'}`}>{v.mileageAnomaly ? '' : 'km'}</span></> : <span className="text-[7px] text-amber-500/70">未对接</span>}
|
||||
<>{mileageValue.toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400' : 'text-slate-400'}`}>{hasMileage ? 'km' : ''}</span></>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -135,10 +135,10 @@ test('four monitoring query builders preserve pagination and fullscreen differen
|
||||
assert.equal(initial.plate, '粤A00001,粤A00002');
|
||||
|
||||
const nextPage = buildNextPageMonitoringQuery(queryModel, 50, 3);
|
||||
assert.deepEqual(nextPage, { ...initial, page: 3 });
|
||||
assert.deepEqual(nextPage, { ...initial, page: 3, includeDailyTrend: false });
|
||||
|
||||
const exported = buildExportMonitoringQuery(queryModel);
|
||||
assert.deepEqual(exported, { ...initial, limit: 9999 });
|
||||
assert.deepEqual(exported, { ...initial, limit: 9999, includeDailyTrend: false });
|
||||
|
||||
const fullscreen = buildFullscreenMonitoringQuery(queryModel);
|
||||
assert.equal(fullscreen.limit, 9999);
|
||||
|
||||
@@ -82,15 +82,15 @@ export function buildNextPageMonitoringQuery(
|
||||
pageSize: number,
|
||||
page: number,
|
||||
): MonitoringRequestParams {
|
||||
return buildPagedQuery(model, pageSize, page);
|
||||
return { ...buildPagedQuery(model, pageSize, page), includeDailyTrend: false };
|
||||
}
|
||||
|
||||
export function buildExportMonitoringQuery(model: MonitoringQueryModel): MonitoringRequestParams {
|
||||
return buildPagedQuery(model, 9999, 1);
|
||||
return { ...buildPagedQuery(model, 9999, 1), includeDailyTrend: false };
|
||||
}
|
||||
|
||||
export function buildFullscreenMonitoringQuery(model: MonitoringQueryModel): MonitoringRequestParams {
|
||||
// Characterization boundary: fullscreen historically omits these four filters.
|
||||
// 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 };
|
||||
}
|
||||
|
||||
@@ -6,8 +6,15 @@ export interface MonitoringVehicle {
|
||||
plate: string;
|
||||
vin: string;
|
||||
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;
|
||||
dailyMileage?: Record<string, number>;
|
||||
dailyMileage?: Record<string, number | null>;
|
||||
totalKm: number | null;
|
||||
source: string;
|
||||
sourceProtocol: MileageSourceProtocol | null;
|
||||
@@ -35,6 +42,8 @@ export interface MonitoringStats {
|
||||
totalAll: number;
|
||||
vehicleCount: number;
|
||||
yesterdayTotal: number;
|
||||
validVehicleCount?: number;
|
||||
totalMileageVehicleCount?: number;
|
||||
}
|
||||
|
||||
export interface MonitoringFilters {
|
||||
@@ -54,11 +63,12 @@ export interface MonitoringData {
|
||||
vehicles: MonitoringVehicle[];
|
||||
stats: MonitoringStats;
|
||||
filters: MonitoringFilters;
|
||||
rangeDailyTotals?: { date: string; totalKm: number }[];
|
||||
rangeDailyTotals?: { date: string; totalKm: number | null }[];
|
||||
dateRange?: { start: string; end: string };
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
queriedAt?: string;
|
||||
updatedAt: string;
|
||||
sourcePriority: MileageSourceGroup[];
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { monitoringMileageValue } from './mileage-display';
|
||||
import * as XLSX from 'xlsx';
|
||||
import type { MonitoringVehicle } from './types';
|
||||
|
||||
@@ -10,7 +11,7 @@ interface ExportContext {
|
||||
|
||||
const BASE_HEADERS = [
|
||||
'状态', '数据来源', '车牌号', '客户', '业务部门', '项目', '租赁状态',
|
||||
'运营区域',
|
||||
'运营区域', '有效天数', '缺失天数', '异常天数', '质量说明',
|
||||
] as const;
|
||||
|
||||
function sourceLabel(v: MonitoringVehicle): string {
|
||||
@@ -21,16 +22,16 @@ function sourceLabel(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 '未对接';
|
||||
return v.isOnline ? '在线' : '离线';
|
||||
}
|
||||
|
||||
function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | number {
|
||||
if (kind === 'today') {
|
||||
if (!v.isDataSynced && v.totalKm == null) return '未对接';
|
||||
return Math.max(0, v.dailyKm || 0);
|
||||
return monitoringMileageValue(v);
|
||||
}
|
||||
return v.totalKm != null ? v.totalKm : '未对接';
|
||||
return v.totalKm != null ? v.totalKm : '—';
|
||||
}
|
||||
|
||||
export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void {
|
||||
@@ -62,9 +63,9 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
v.department || '',
|
||||
v.project || '',
|
||||
v.rentStatus || '',
|
||||
v.region || '',
|
||||
v.region || '', v.validDays ?? '', v.missingDays ?? '', v.anomalyDays ?? '', v.qualityReasons?.join(', ') || '',
|
||||
];
|
||||
const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? 0);
|
||||
const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? '—');
|
||||
return [
|
||||
...baseRow,
|
||||
...dailyCols,
|
||||
@@ -86,6 +87,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
{ wch: 16 }, // 项目
|
||||
{ wch: 10 }, // 租赁状态
|
||||
{ wch: 12 }, // 运营区域
|
||||
{ wch: 10 }, { wch: 10 }, { wch: 10 }, { wch: 36 },
|
||||
];
|
||||
for (let i = 0; i < dayKeys.length; i++) wsCols.push({ wch: 12 });
|
||||
wsCols.push({ wch: 14 }); // 区间合计
|
||||
@@ -135,8 +137,8 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
v.project || '',
|
||||
v.rentStatus || '',
|
||||
v.region || '',
|
||||
...dayKeys.map(day => v.dailyMileage?.[day] || 0),
|
||||
Math.max(0, v.dailyKm || 0),
|
||||
...dayKeys.map(day => v.dailyMileage?.[day] ?? '—'),
|
||||
mileageCell(v, 'today'),
|
||||
v.totalKm != null ? v.totalKm : '',
|
||||
]),
|
||||
];
|
||||
@@ -177,6 +179,6 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
: ctx.sortBy === 'statisticTime'
|
||||
? '统计时间'
|
||||
: isRange ? '区间' : '今日';
|
||||
const filename = `里程看板_${dateTag}_${hh}${mm}_${sortLabel}.xlsx`;
|
||||
const filename = `${dayKeys.length ? '里程逐日明细' : '里程区间汇总'}_${dateTag}_${hh}${mm}_${sortLabel}.xlsx`;
|
||||
XLSX.writeFile(wb, filename);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import schedulingRouter from './routes/scheduling/index.js';
|
||||
import vehicleHeatmapRouter from './routes/vehicle-heatmap.js';
|
||||
import vehiclesRouter from './routes/vehicles.js';
|
||||
import assetAssistantRouter from './routes/asset-assistant/index.js';
|
||||
import assetFaultRouter from './routes/asset-faults/index.js';
|
||||
import assetHistoryRouter from './routes/asset-history/index.js';
|
||||
|
||||
/**
|
||||
* 创建 HTTP 应用,但不监听端口、不访问数据库,也不启动后台任务。
|
||||
@@ -30,6 +32,8 @@ export function createApp(): Hono {
|
||||
app.use('/api/*', createPreviewMiddleware());
|
||||
|
||||
app.route('/api/vehicles', vehiclesRouter);
|
||||
app.route('/api/asset-history', assetHistoryRouter);
|
||||
app.route('/api/asset-faults', assetFaultRouter);
|
||||
app.route('/api/asset-assistant', assetAssistantRouter);
|
||||
app.route('/api/mileage', mileageRouter);
|
||||
app.route('/api/scheduling', schedulingRouter);
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Vehicle } from '../types.js';
|
||||
import { isInventoryVehicle } from '../routes/vehicles/model.js';
|
||||
|
||||
/** 当前库存为公共资产;非库存车辆继续遵守本人/部门权限。历史业务使用原权限过滤。 */
|
||||
export function filterAssetVehiclesByPermission(items: Vehicle[], user: AuthUser): Vehicle[] {
|
||||
export function filterAssetVehiclesByPermission<T extends Vehicle>(items: T[], user: AuthUser): T[] {
|
||||
if (user.permissionLevel === 'full') return items;
|
||||
const authorized = new Set(filterByPermission(items, user));
|
||||
return items.filter(vehicle => isInventoryVehicle(vehicle) || authorized.has(vehicle));
|
||||
|
||||
@@ -43,7 +43,7 @@ export function createPreviewRouter(service: Service = previewService) {
|
||||
return router;
|
||||
}
|
||||
|
||||
// These POSTs only query data (AI previews suppress gap recording). Everything else is denied by default.
|
||||
// These POSTs do not change business data. AI previews suppress gap feedback but retain attributed question audit records.
|
||||
const READ_POSTS = new Set(['/api/vehicles/analysis', '/api/vehicles/analysis/details', '/api/asset-assistant/ask', '/api/asset-assistant/export']);
|
||||
export function createPreviewMiddleware(service: Service = previewService) {
|
||||
return async (c: Context, next: Next) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
|
||||
import { startMileageBackgroundJobs } from './routes/mileage/index.js';
|
||||
import { startAssetHistoryScheduler } from './routes/asset-history/runtime.js';
|
||||
|
||||
/** 启动只应在服务进程中运行的数据库准备和定时任务。 */
|
||||
export function startBackgroundServices(): void {
|
||||
@@ -7,4 +8,5 @@ export function startBackgroundServices(): void {
|
||||
console.error('scheduling bootstrap error:', error);
|
||||
});
|
||||
startMileageBackgroundJobs();
|
||||
startAssetHistoryScheduler();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { beforeEach } from 'node:test';
|
||||
import { questionLog } from './question-log.js';
|
||||
beforeEach(t => {
|
||||
if (!('mock' in t)) throw new Error('Expected test context');
|
||||
t.mock.method(questionLog, 'start', async () => 'synthetic-question');
|
||||
t.mock.method(questionLog, 'finish', async () => {});
|
||||
});
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { Hono } from "hono";
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { beforeEach } from 'node:test';
|
||||
import { questionLog } from './question-log.js';
|
||||
beforeEach(t => {
|
||||
if (!('mock' in t)) throw new Error('Expected test context');
|
||||
t.mock.method(questionLog, 'start', async () => 'synthetic-question');
|
||||
t.mock.method(questionLog, 'finish', async () => {});
|
||||
});
|
||||
import assert from "node:assert/strict";
|
||||
import test, { type TestContext } from "node:test";
|
||||
import { Hono } from "hono";
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { beforeEach } from 'node:test';
|
||||
import { questionLog } from './question-log.js';
|
||||
beforeEach(t => {
|
||||
if (!('mock' in t)) throw new Error('Expected test context');
|
||||
t.mock.method(questionLog, 'start', async () => 'synthetic-question');
|
||||
t.mock.method(questionLog, 'finish', async () => {});
|
||||
});
|
||||
import assert from "node:assert/strict";
|
||||
import test, { type TestContext } from "node:test";
|
||||
import { Hono } from "hono";
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { beforeEach } from 'node:test';
|
||||
import { questionLog } from './question-log.js';
|
||||
beforeEach(t => {
|
||||
if (!('mock' in t)) throw new Error('Expected test context');
|
||||
t.mock.method(questionLog, 'start', async () => 'synthetic-question');
|
||||
t.mock.method(questionLog, 'finish', async () => {});
|
||||
});
|
||||
import assert from "node:assert/strict";
|
||||
import test, { type TestContext } from "node:test";
|
||||
import { Hono } from "hono";
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { beforeEach } from 'node:test';
|
||||
import { questionLog } from './question-log.js';
|
||||
beforeEach(t => {
|
||||
if (!('mock' in t)) throw new Error('Expected test context');
|
||||
t.mock.method(questionLog, 'start', async () => 'synthetic-question');
|
||||
t.mock.method(questionLog, 'finish', async () => {});
|
||||
});
|
||||
import assert from "node:assert/strict";
|
||||
import test, { type TestContext } from "node:test";
|
||||
import { Hono } from "hono";
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "./session.js";
|
||||
import { resultScope, followupSuggestions } from "./presentation.js";
|
||||
import { gapStore } from "./gaps.js";
|
||||
import { questionLog, questionOutcome, finishQuestionLog } from "./question-log.js";
|
||||
import {
|
||||
CATEGORY_LABELS,
|
||||
GAP_STATUSES,
|
||||
@@ -99,6 +100,10 @@ router.post("/feedback", async (c) => {
|
||||
const session = getSession(c.get("user"), body.subject, body.sessionId),
|
||||
node = getNode(session, body.resultId)!;
|
||||
if (!node.flagged) {
|
||||
const logId = await questionLog.start(c.get("user"), { message: node.question || node.demand,
|
||||
subject: body.subject, sessionId: session.id, baseResultId: node.id }, "feedback");
|
||||
const saved = await finishQuestionLog(logId, { outcome: "feedback", reason: "unsatisfied", resultId: node.id });
|
||||
if (!saved) return c.json({ error: "反馈已接收,处理状态暂未保存,请稍后重试。" }, 503);
|
||||
await gapStore.record(c.get("user").userId, node.demand, "unsatisfied");
|
||||
node.flagged = true;
|
||||
}
|
||||
@@ -220,15 +225,24 @@ router.post("/ask", async (c) => {
|
||||
)
|
||||
return c.json({ error: "请输入有效问题或选择分析操作。" }, 400);
|
||||
const input: AssistantRequest = { ...raw, subject: raw.subject || null };
|
||||
let logId: string;
|
||||
try {
|
||||
logId = await questionLog.start(user, input);
|
||||
} catch {
|
||||
return c.json({ error: "提问记录暂不可用,本次查询未执行,请稍后重试。" }, 503);
|
||||
}
|
||||
const release = acquire(user.userId);
|
||||
if (!release)
|
||||
if (!release) {
|
||||
await finishQuestionLog(logId, { outcome: "rejected", reason: "busy" });
|
||||
return c.json({ error: "上一条查询仍在执行,请稍后重试。" }, 429);
|
||||
}
|
||||
let session, base;
|
||||
try {
|
||||
session = getSession(user, input.subject, input.sessionId);
|
||||
base = getNode(session, input.baseResultId);
|
||||
} catch {
|
||||
release();
|
||||
await finishQuestionLog(logId, { outcome: "rejected", reason: "session_expired" });
|
||||
return c.json({ error: "会话已过期或范围已变化,请新建对话。" }, 409);
|
||||
}
|
||||
const currentSession = session,
|
||||
@@ -238,6 +252,7 @@ router.post("/ask", async (c) => {
|
||||
stream.onAbort(() => controller.abort());
|
||||
const emit = (event: object) =>
|
||||
stream.writeSSE({ data: JSON.stringify(event) });
|
||||
let logFinished = false;
|
||||
let demand = "查询执行",
|
||||
reason: GapReason | undefined;
|
||||
try {
|
||||
@@ -516,8 +531,17 @@ router.post("/ask", async (c) => {
|
||||
type: "step",
|
||||
label: "分析完成",
|
||||
});
|
||||
logFinished = true;
|
||||
const saved = await finishQuestionLog(logId, { outcome: questionOutcome(result, gapReason),
|
||||
reason: gapReason, sessionId: currentSession.id, result });
|
||||
if (!saved) result.warning = [result.warning, "提问已记录,处理结果暂未保存。"].filter(Boolean).join(" ");
|
||||
await emit({ type: "result", result });
|
||||
} catch {
|
||||
if (!logFinished) {
|
||||
logFinished = true;
|
||||
await finishQuestionLog(logId, { outcome: controller.signal.aborted ? "cancelled" : "failed",
|
||||
reason: controller.signal.aborted ? "client_aborted" : "generation", sessionId: currentSession.id });
|
||||
}
|
||||
if (!controller.signal.aborted) {
|
||||
let recorded = false;
|
||||
try {
|
||||
@@ -534,6 +558,8 @@ router.post("/ask", async (c) => {
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (!logFinished) await finishQuestionLog(logId, { outcome: controller.signal.aborted ? "cancelled" : "failed",
|
||||
reason: controller.signal.aborted ? "client_aborted" : "unfinished", sessionId: currentSession.id });
|
||||
release();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { beforeEach } from 'node:test';
|
||||
import { questionLog } from './question-log.js';
|
||||
beforeEach(t => {
|
||||
if (!('mock' in t)) throw new Error('Expected test context');
|
||||
t.mock.method(questionLog, 'start', async () => 'synthetic-question');
|
||||
t.mock.method(questionLog, 'finish', async () => {});
|
||||
});
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { Hono } from "hono";
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../../db.js';
|
||||
import router from './index.js';
|
||||
import { questionLog, questionOutcome, businessTypes, finishQuestionLog, redactQuestion, type QuestionCompletion } from './question-log.js';
|
||||
import { vehicleRepository } from '../vehicles/repository.js';
|
||||
import { gapStore } from './gaps.js';
|
||||
import { acquire } from './session.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import type { AssistantResult } from '../../../shared/assets/assistant.js';
|
||||
|
||||
const user: AuthUser = { userId: 'log-test', userName: '测试用户', loginName: 'test', roles: [], permissionLevel: 'personal', depCode: 'D', depName: '测试部门' };
|
||||
const result = (extra: Partial<AssistantResult> = {}): AssistantResult => ({
|
||||
plan: { kind: 'assets', category: 'inventory', groupBy: 'region', view: 'table', filters: {} },
|
||||
title: '库存', summary: '库存共 2 台', rows: [], groups: [], metrics: [], source: 'test', fetchedAt: '', ...extra,
|
||||
});
|
||||
function appFor(u = user) {
|
||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
app.use('*', async (c, next) => { c.set('user', u); await next(); });
|
||||
app.route('/ai', router);
|
||||
return app;
|
||||
}
|
||||
function ask(app: ReturnType<typeof appFor>, body: object = { message: '库存有多少车?', subject: null }) {
|
||||
return app.request('/ai/ask', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
test('each question has its own durable ID, full text and attributed preview identity; SQL is parameterized', async t => {
|
||||
const calls: { sql: string; values: unknown[] }[] = [];
|
||||
t.mock.method(pool, 'query', async (options: { sql: string }, values: unknown[]) => { calls.push({ sql: options.sql, values }); return [{ affectedRows: 1 }, []]; });
|
||||
const preview = { ...user, preview: { actorId: 'real-actor', sessionId: 'preview-session' } };
|
||||
const message = '按区域统计库存'.repeat(100) + " ' OR 1=1 --";
|
||||
const first = await questionLog.start(preview, { message, subject: '公司甲' });
|
||||
const second = await questionLog.start(preview, { message, subject: '公司甲' });
|
||||
assert.notEqual(first, second);
|
||||
assert.equal(calls[0].values[1], 'real-actor');
|
||||
assert.equal(calls[0].values[2], user.userId);
|
||||
assert.equal(calls[0].values[6], 1);
|
||||
assert.equal(calls[0].values[12], message);
|
||||
assert.ok(!calls[0].sql.includes(message));
|
||||
await questionLog.finish(first, { outcome: 'success', sessionId: 'session', result: result() });
|
||||
assert.match(calls[2].sql, /outcome='processing'/);
|
||||
assert.equal(calls[2].values[4], '["inventory"]');
|
||||
assert.match(String(calls[2].values[6]), /库存共 2 台/);
|
||||
assert.ok(!String(calls[2].values[6]).includes('rows'));
|
||||
});
|
||||
|
||||
test('outcomes and business categories separate partial, help, empty, unsupported and failure', () => {
|
||||
assert.equal(questionOutcome(result()), 'success');
|
||||
for (const value of ['partial', 'empty', 'unsupported', 'help', 'clarification'] as const) {
|
||||
const r = result({ outcome: value });
|
||||
if (value === 'clarification') r.plan.kind = 'clarify';
|
||||
assert.equal(questionOutcome(r), value);
|
||||
}
|
||||
assert.equal(questionOutcome(result(), 'generation'), 'failed');
|
||||
assert.deepEqual(businessTypes(result({ sections: [
|
||||
{ id: 'a', title: '库存', question: '库存', display: 'table', result: result() },
|
||||
{ id: 'b', title: '交车', question: '交车', display: 'table', result: result({ plan: { ...result().plan, kind: 'flow' } }) },
|
||||
] })), ['inventory', 'flow']);
|
||||
assert.equal(redactQuestion('库存 sk-12345678 password=secret123 Bearer token123'), '库存 [凭据已隐藏] password=[凭据已隐藏] Bearer [凭据已隐藏]');
|
||||
});
|
||||
|
||||
test('storage outage rejects the request before querying data or invoking AI', async t => {
|
||||
t.mock.method(questionLog, 'start', async () => { throw new Error('private DB error'); });
|
||||
const query = t.mock.method(vehicleRepository, 'getVehicles', async () => []);
|
||||
const response = await ask(appFor());
|
||||
assert.equal(response.status, 503);
|
||||
assert.equal(query.mock.callCount(), 0);
|
||||
assert.ok(!(await response.text()).includes('private DB'));
|
||||
});
|
||||
|
||||
test('concurrency rejection and expired session are still durable classified questions', async t => {
|
||||
const start = t.mock.method(questionLog, 'start', async () => 'id');
|
||||
const finish = t.mock.method(questionLog, 'finish', async (_id: string, _completion: QuestionCompletion) => {});
|
||||
const release = acquire(user.userId)!;
|
||||
try { assert.equal((await ask(appFor())).status, 429); } finally { release(); }
|
||||
assert.equal((await ask(appFor(), { message: '追问', subject: null, sessionId: 'missing' })).status, 409);
|
||||
assert.equal(start.mock.callCount(), 2);
|
||||
assert.equal(finish.mock.calls[0].arguments[1].reason, 'busy');
|
||||
assert.equal(finish.mock.calls[1].arguments[1].reason, 'session_expired');
|
||||
});
|
||||
|
||||
test('failed queries are logged for preview users without creating gap feedback', async t => {
|
||||
const start = t.mock.method(questionLog, 'start', async () => 'id');
|
||||
const finish = t.mock.method(questionLog, 'finish', async (_id: string, _completion: QuestionCompletion) => {});
|
||||
const gap = t.mock.method(gapStore, 'record', async () => {});
|
||||
t.mock.method(vehicleRepository, 'getVehicles', async () => { throw new Error('private failure'); });
|
||||
const response = await ask(appFor({ ...user, preview: { actorId: 'actor', sessionId: 'preview' } }));
|
||||
const text = await response.text();
|
||||
assert.match(text, /查询未完成/);
|
||||
assert.ok(!text.includes('private failure'));
|
||||
assert.equal(start.mock.callCount(), 1);
|
||||
assert.equal(finish.mock.calls[0].arguments[1].outcome, 'failed');
|
||||
assert.equal(gap.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
test('finalization retries transient failures and reports persistent failures', async t => {
|
||||
let calls = 0, alwaysFail = false;
|
||||
t.mock.method(questionLog, 'finish', async () => { if (++calls === 1 || alwaysFail) throw new Error('transient'); });
|
||||
assert.equal(await finishQuestionLog('id', { outcome: 'success' }), true);
|
||||
assert.equal(calls, 2);
|
||||
alwaysFail = true;
|
||||
const warning = t.mock.method(console, 'error', () => {});
|
||||
assert.equal(await finishQuestionLog('id', { outcome: 'failed' }), false);
|
||||
assert.deepEqual(warning.mock.calls[0].arguments, ['asset_ai_question_completion_failed', 'id']);
|
||||
});
|
||||
|
||||
test('successful questions, follow-ups, actions and feedback keep distinct operation types', async t => {
|
||||
const statements: unknown[][] = [];
|
||||
t.mock.method(pool, 'query', async (options: { sql?: string } | string, values: unknown[]) => {
|
||||
if (typeof options !== 'string' && options.sql?.includes('bi_asset_ai_questions')) statements.push(values);
|
||||
return [[], []];
|
||||
});
|
||||
t.mock.method(vehicleRepository, 'getVehicles', async () => []);
|
||||
t.mock.method(gapStore, 'record', async () => {});
|
||||
const old = process.env.ASSET_AI_EXTERNAL_ENABLED;
|
||||
process.env.ASSET_AI_EXTERNAL_ENABLED = '0';
|
||||
t.after(() => { if (old === undefined) delete process.env.ASSET_AI_EXTERNAL_ENABLED; else process.env.ASSET_AI_EXTERNAL_ENABLED = old; });
|
||||
const app = appFor({ ...user, userId: 'success-test' });
|
||||
const response = await ask(app);
|
||||
const events = (await response.text()).split('\n').filter(line => line.startsWith('data:')).map(line => JSON.parse(line.slice(5)));
|
||||
const r = events.find(event => event.type === 'result')?.result as AssistantResult;
|
||||
assert.ok(r, JSON.stringify(events));
|
||||
assert.equal(statements[0][11], 'question');
|
||||
assert.ok(['success', 'empty'].includes(String(statements[1][0])));
|
||||
assert.equal(statements[1][2], r.sessionId);
|
||||
assert.equal(statements[1][3], r.resultId);
|
||||
assert.deepEqual(JSON.parse(String(statements[1][4])), ['inventory']);
|
||||
await questionLog.start(user, { message: '按区域看', subject: null, sessionId: r.sessionId });
|
||||
assert.equal(statements.at(-1)![11], 'followup');
|
||||
await questionLog.start(user, { subject: null, sessionId: r.sessionId, baseResultId: r.resultId, action: { type: 'category', category: 'abnormal' } });
|
||||
assert.equal(statements.at(-1)![11], 'action');
|
||||
assert.deepEqual(JSON.parse(String(statements.at(-1)![13])), { type: 'category', category: 'abnormal' });
|
||||
const feedback = await app.request('/ai/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: r.sessionId, resultId: r.resultId, subject: null }) });
|
||||
assert.equal(feedback.status, 200);
|
||||
assert.equal(statements.at(-2)![11], 'feedback');
|
||||
assert.equal(statements.at(-1)![0], 'feedback');
|
||||
});
|
||||
|
||||
test('client cancellation finalizes the durable question and releases the user query lock', async t => {
|
||||
t.mock.method(questionLog, 'start', async () => 'cancel-id');
|
||||
let complete!: (value: QuestionCompletion) => void;
|
||||
const completed = new Promise<QuestionCompletion>(resolve => { complete = resolve; });
|
||||
t.mock.method(questionLog, 'finish', async (_id: string, completion: QuestionCompletion) => { complete(completion); });
|
||||
let continueQuery!: () => void;
|
||||
t.mock.method(vehicleRepository, 'getVehicles', async () => { await new Promise<void>(resolve => { continueQuery = resolve; }); return []; });
|
||||
const cancelledUser = { ...user, userId: 'cancel-test' };
|
||||
const response = await ask(appFor(cancelledUser));
|
||||
const reader = response.body!.getReader();
|
||||
await reader.read();
|
||||
await reader.cancel();
|
||||
continueQuery();
|
||||
const completion = await completed;
|
||||
assert.equal(completion.outcome, 'cancelled');
|
||||
// Finalizer releases the lock after the asynchronous database update.
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const release = acquire(cancelledUser.userId);
|
||||
assert.ok(release);
|
||||
release();
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import pool from '../../db.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import type { AssistantRequest, AssistantResult, GapReason } from '../../../shared/assets/assistant.js';
|
||||
|
||||
export type QuestionOutcome = 'processing' | 'success' | 'empty' | 'partial' | 'clarification' | 'help' | 'unsupported' | 'failed' | 'cancelled' | 'rejected' | 'feedback';
|
||||
export type QuestionCompletion = {
|
||||
outcome: QuestionOutcome;
|
||||
sessionId?: string;
|
||||
resultId?: string;
|
||||
result?: AssistantResult;
|
||||
reason?: string;
|
||||
};
|
||||
// Credentials are never useful for question analysis. Keep the rest of the accepted question intact.
|
||||
export function redactQuestion(value: string): string {
|
||||
return value.replace(/sk-[a-z0-9_-]{8,}/gi, '[凭据已隐藏]')
|
||||
.replace(/(Bearer\s+)[\w.+/=-]+/gi, '$1[凭据已隐藏]')
|
||||
.replace(/((?:password|passwd|api[_-]?key|access[_-]?token|secret|密码|口令)\s*[:=:]\s*)[^\s,,;;]+/gi, '$1[凭据已隐藏]');
|
||||
}
|
||||
export function questionOutcome(result: AssistantResult, reason?: GapReason): QuestionOutcome {
|
||||
if (result.outcome === 'partial' || result.unanswered?.length) return 'partial';
|
||||
if (reason === 'unsupported' || result.outcome === 'unsupported') return 'unsupported';
|
||||
if (reason === 'generation') return 'failed';
|
||||
if (result.outcome === 'help' || result.answerKind === 'help') return 'help';
|
||||
if (result.plan.kind === 'clarify') return 'clarification';
|
||||
if (result.outcome === 'empty' || reason === 'empty') return 'empty';
|
||||
return 'success';
|
||||
}
|
||||
export function businessTypes(result?: AssistantResult): string[] {
|
||||
if (!result) return [];
|
||||
const items = result.sections?.flatMap(section => section.result ? [section.result] : []) || [result];
|
||||
return [...new Set(items.map(item => item.plan.kind === 'flow' ? 'flow' : item.plan.kind === 'clarify' ? 'general' : item.plan.category))];
|
||||
}
|
||||
export const questionLog = {
|
||||
async start(user: AuthUser, input: AssistantRequest, eventType?: 'feedback'): Promise<string> {
|
||||
const id = randomUUID();
|
||||
await pool.query({ sql: `INSERT INTO bi_asset_ai_questions
|
||||
(id,actor_id,user_id,login_name,user_name,permission_level,is_preview,preview_session_id,
|
||||
session_id,base_result_id,subject,event_type,question,action_json,outcome,started_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,'processing',UTC_TIMESTAMP(3))`, timeout: 5000 }, [
|
||||
id, user.preview?.actorId || user.userId, user.userId, user.loginName, user.userName,
|
||||
user.permissionLevel, user.preview ? 1 : 0, user.preview?.sessionId || null,
|
||||
input.sessionId || null, input.baseResultId || null, input.subject,
|
||||
eventType || (input.action ? 'action' : input.sessionId ? 'followup' : 'question'),
|
||||
input.message ? redactQuestion(input.message) : null,
|
||||
input.action ? JSON.stringify(input.action, (_key, value) => typeof value === "string" ? redactQuestion(value) : value) : null,
|
||||
]);
|
||||
return id;
|
||||
},
|
||||
async finish(id: string, completion: QuestionCompletion): Promise<void> {
|
||||
const result = completion.result;
|
||||
// Persist the textual answer and classification, not a duplicate of vehicle detail datasets.
|
||||
await pool.query({ sql: `UPDATE bi_asset_ai_questions SET outcome=?,reason=?,session_id=COALESCE(?,session_id),
|
||||
result_id=?,business_types=?,engine=?,answer=?,finished_at=UTC_TIMESTAMP(3),
|
||||
duration_ms=TIMESTAMPDIFF(MICROSECOND,started_at,UTC_TIMESTAMP(3)) DIV 1000
|
||||
WHERE id=? AND outcome='processing'`, timeout: 5000 }, [
|
||||
completion.outcome, completion.reason || null, completion.sessionId || null,
|
||||
completion.resultId || result?.resultId || null, JSON.stringify(businessTypes(result)),
|
||||
result?.engine || null, result ? redactQuestion(JSON.stringify({ summary: result.summary, warning: result.warning,
|
||||
unanswered: result.unanswered, sections: result.sections?.map(section => ({ title: section.title, summary: section.result?.summary })) })) : null, id,
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
/** A failed update never erases the durable original question. Retry once and expose failure to the caller. */
|
||||
export async function finishQuestionLog(id: string, completion: QuestionCompletion): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try { await questionLog.finish(id, completion); return true; } catch { /* retry transient failures */ }
|
||||
}
|
||||
console.error('asset_ai_question_completion_failed', id);
|
||||
return false;
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
import { beforeEach } from 'node:test';
|
||||
import { questionLog } from './question-log.js';
|
||||
beforeEach(t => {
|
||||
if (!('mock' in t)) throw new Error('Expected test context');
|
||||
t.mock.method(questionLog, 'start', async () => 'synthetic-question');
|
||||
t.mock.method(questionLog, 'finish', async () => {});
|
||||
});
|
||||
import assert from "node:assert/strict";
|
||||
import test, { type TestContext } from "node:test";
|
||||
import { Hono } from "hono";
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Hono } from 'hono';
|
||||
import { mapFault } from './model.js';
|
||||
import { faultDashboard, dashboardAnalysis, filterDashboardRows } from './dashboard.js';
|
||||
import { createFaultRouter } from './index.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
const updatedAt='2026-10-13T08:00:00Z';
|
||||
const q={start:'2026-10-12',end:'2026-10-13'};
|
||||
const fault=(id:string,extra:Record<string,unknown>={})=>mapFault({id,fault_code:`F${id}`,matched_vehicle_id:'v1',plate_number:'沪A001',model_name:'车型A',province_name:'上海市',customer_name:'客户A',business_dept:'部门A',reported_at:'2026-10-13 08:00:00',handling_status:'待处理',fault_level:1,fault_categories:'底盘故障,三电故障',...extra},new Map());
|
||||
const rows=[fault('1'),fault('2',{handling_status:'已归档',archived_at:'2026-10-13 12:00:00'}),fault('3',{reported_at:'2026-10-01 08:00:00',handling_status:'处理中',deadline_at:'2026-10-12 15:00:00',matched_vehicle_id:'v2'}),fault('4',{matched_vehicle_id:null,plate_number:null,fault_level:null,fault_categories:null,customer_name:null}),fault('5',{reported_at:'2026-10-10 08:00:00',handling_status:'挂起'})];
|
||||
test('dashboard separates reporting cohort from all-date current workload, preserves unknowns and deduplicates vehicles',()=>{
|
||||
const d=faultDashboard(rows,q,updatedAt);
|
||||
assert.equal(d.totals.reported,3);assert.equal(d.totals.vehicles,1);assert.equal(d.totals.customers,1);
|
||||
assert.equal(d.totals.open,3);assert.equal(d.totals.waiting+d.totals.processing,d.totals.open);assert.equal(d.totals.overdue,1);assert.equal(d.totals.longestOverdueDays,2);
|
||||
assert.equal(d.totals.previous,1);assert.equal(d.totals.change,200);
|
||||
assert.equal(d.quality.missingLevel,1);assert.equal(d.quality.missingVehicle,1);assert.equal(d.quality.missingCategory,1);
|
||||
assert.equal(d.focus[0].id,'3');assert.equal(d.distributions.region.reduce((n,r)=>n+r.reported,0),3);
|
||||
const m=rows[0].assetCell.model;assert.equal(d.matrix.totals[m],3);assert.equal(d.matrix.cells.find(c=>c.model===m&&c.category==='底盘故障')?.count,2);
|
||||
assert.equal(d.matrix.cells.find(c=>c.model===m&&c.category==='三电故障')?.count,2);
|
||||
});
|
||||
test('dashboard drill scope and all filters reconcile to visible aggregates without leaking internal fields',()=>{
|
||||
const d=faultDashboard(rows,q,updatedAt);const group=d.distributions.customer.find(r=>r.label!=='未关联客户')!;
|
||||
const details=dashboardAnalysis(rows,{...q,customer:group.value},updatedAt);
|
||||
assert.equal(details.rows.length,group.reported);assert.ok(details.rows.every(r=>!('vehicleKey'in r)&&!('managerId'in r)));
|
||||
assert.deepEqual(dashboardAnalysis(rows,{...q,scope:'overdue'},updatedAt).rows.map(r=>r.id),['3']);
|
||||
assert.equal(dashboardAnalysis(rows,{...q,scope:'ledger',id:'5'},updatedAt).rows.length,1);
|
||||
assert.equal(filterDashboardRows(rows,{level:'未标注',category:'未标注'}).length,1);
|
||||
assert.equal(dashboardAnalysis(rows,{...q,scope:'cohort',level:'未标注'},updatedAt).rows.length,1);
|
||||
const empty=faultDashboard(rows,{...q,region:'不存在'},updatedAt);assert.equal(empty.totals.reported,0);assert.equal(empty.totals.change,null);assert.equal(empty.totals.longestOverdueDays,0);
|
||||
});
|
||||
test('dashboard and report endpoints enforce authentication, permissions, dates and shared filters',async()=>{
|
||||
let loads=0;const router=createFaultRouter(async()=>{loads++;return{rows,updatedAt};});const anonymous=new Hono();anonymous.route('/f',router);
|
||||
assert.equal((await anonymous.request('/f/dashboard')).status,401);assert.equal(loads,0);
|
||||
const user:AuthUser={userId:'test',loginName:'test',userName:'测试',roles:[],permissionLevel:'department',depName:'部门A',depCode:'a'};
|
||||
const app=new Hono<{Variables:{user:AuthUser}}>();app.use('*',async(c,next)=>{c.set('user',user);await next();});app.route('/f',router);
|
||||
assert.equal((await app.request('/f/dashboard?start=bad')).status,400);assert.equal(loads,0);
|
||||
assert.equal((await app.request('/f/dashboard-analysis?scope=unknown')).status,400);assert.equal(loads,0);
|
||||
const report=await(await app.request('/f/dashboard?start=2026-10-12&end=2026-10-13')).json();assert.equal(report.totals.reported,2);assert.equal(report.options.level.some((o:{value:string})=>o.value==='未标注'),false);
|
||||
const trend=await(await app.request('/f/trend?start=2026-10-12&end=2026-10-13&level=未标注')).json();assert.equal(trend.totals.reported,0);
|
||||
const weekly=await(await app.request('/f/weekly-report?start=2026-10-12&end=2026-10-13&level=未标注')).json();assert.equal(weekly.totals.reported,0);
|
||||
});
|
||||
|
||||
test('focus contains only L1 unclosed faults across all dates and keeps totals and full drill consistent', () => {
|
||||
const focusRows = [
|
||||
fault('waiting', { reported_at: '2026-09-01 08:00:00' }),
|
||||
fault('processing', { handling_status: '处理中', deadline_at: '2026-10-12 15:00:00' }),
|
||||
fault('paused', { handling_status: '挂起', deadline_at: '2026-10-01 08:00:00' }),
|
||||
fault('review', { handling_status: '审核中' }),
|
||||
fault('unknown', { handling_status: '其他状态' }),
|
||||
fault('sixth'),
|
||||
fault('seventh'),
|
||||
fault('closed', { handling_status: '已归档' }),
|
||||
fault('l2-overdue', { fault_level: 2, deadline_at: '2026-10-01 08:00:00' }),
|
||||
fault('l3', { fault_level: 3 }),
|
||||
fault('l4', { fault_level: 4 }),
|
||||
fault('unmarked', { fault_level: null }),
|
||||
];
|
||||
const dashboard = faultDashboard(focusRows, q, updatedAt);
|
||||
assert.deepEqual(dashboard.focusTotals, { total: 7, overdue: 1 });
|
||||
assert.equal(dashboard.focus.length, 5);
|
||||
assert.equal(dashboard.focus[0].id, 'processing');
|
||||
assert.ok(dashboard.focus.every(row => row.level === 'L1-特急' && row.status !== '已归档'));
|
||||
const allFocus = dashboardAnalysis(focusRows, { ...q, scope: 'focus' }, updatedAt).rows;
|
||||
assert.equal(allFocus.length, dashboard.focusTotals.total);
|
||||
assert.ok(allFocus.some(row => row.id === 'waiting'));
|
||||
assert.ok(allFocus.some(row => row.id === 'paused'));
|
||||
assert.ok(allFocus.some(row => row.id === 'review'));
|
||||
assert.ok(allFocus.every(row => !('vehicleKey' in row) && !('managerId' in row)));
|
||||
assert.deepEqual(dashboardAnalysis(focusRows, { ...q, scope: 'focus', id: 'closed' }, updatedAt).rows, []);
|
||||
const filtered = faultDashboard(focusRows, { ...q, level: 'L2-紧急' }, updatedAt);
|
||||
assert.deepEqual(filtered.focus, []);
|
||||
assert.deepEqual(filtered.focusTotals, { total: 0, overdue: 0 });
|
||||
assert.deepEqual(dashboardAnalysis(focusRows, { ...q, scope: 'focus', level: 'L2-紧急' }, updatedAt).rows, []);
|
||||
});
|
||||
test('focus dashboard and full drill enforce permissions and business filters before selecting L1 faults', async () => {
|
||||
const scopedRows = [fault('allowed'), fault('outside', { business_dept: '部门B' }), fault('closed', { handling_status: '已归档' }), fault('lower', { fault_level: 2 })];
|
||||
const user: AuthUser = { userId: 'test', loginName: 'test', userName: '测试', roles: [], permissionLevel: 'department', depName: '部门A', depCode: 'a' };
|
||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
app.use('*', async (c, next) => { c.set('user', user); await next(); });
|
||||
app.route('/f', createFaultRouter(async () => ({ rows: scopedRows, updatedAt })));
|
||||
const report = await (await app.request('/f/dashboard?start=2026-10-12&end=2026-10-13')).json();
|
||||
assert.deepEqual(report.focus.map((row: { id: string }) => row.id), ['allowed']);
|
||||
assert.deepEqual(report.focusTotals, { total: 1, overdue: 0 });
|
||||
const response = await app.request('/f/dashboard-analysis?start=2026-10-12&end=2026-10-13&scope=focus');
|
||||
assert.equal(response.status, 200);
|
||||
const detail = await response.json();
|
||||
assert.deepEqual(detail.rows.map((row: { id: string }) => row.id), ['allowed']);
|
||||
const empty = await (await app.request('/f/dashboard-analysis?scope=focus&department=不存在')).json();
|
||||
assert.deepEqual(empty.rows, []);
|
||||
});
|
||||
|
||||
test('report source follows explicit OneOS report method before legacy task linkage', () => {
|
||||
const cases: [Record<string, unknown>, 'operations' | 'customer'][] = [
|
||||
[{ report_method: '运维上报', source_work_order_id: '9007199254740993' }, 'operations'],
|
||||
[{ report_method: '客户上报', source_work_order_id: null }, 'customer'],
|
||||
[{ report_method: ' 客户上报 ', source_work_order_id: '123' }, 'customer'],
|
||||
[{ report_method: null, source_work_order_id: '9007199254740993' }, 'customer'],
|
||||
[{ report_method: '', source_work_order_id: null }, 'operations'],
|
||||
[{ report_method: null, source_work_order_id: null, fault_source: 6 }, 'operations'],
|
||||
];
|
||||
for (const [raw, expected] of cases) assert.equal(fault('source', raw).reportMethod, expected);
|
||||
});
|
||||
test('reported source counts reconcile with cohort, filtered drills, permissions and export labels', async () => {
|
||||
const sourceRows = [
|
||||
fault('ops-linked', { report_method: '运维上报', source_work_order_id: '123' }),
|
||||
fault('ai-explicit', { report_method: '客户上报', source_work_order_id: null }),
|
||||
fault('ai-legacy', { report_method: null, source_work_order_id: '9007199254740993', handling_status: '已归档' }),
|
||||
fault('ops-legacy', { report_method: null, source_work_order_id: null }),
|
||||
fault('previous-ai', { report_method: '客户上报', reported_at: '2026-10-10 10:00:00' }),
|
||||
fault('hidden-ai', { report_method: '客户上报', business_dept: '部门B' }),
|
||||
];
|
||||
const user: AuthUser = { userId: 'test', loginName: 'test', userName: '测试', roles: [], permissionLevel: 'department', depName: '部门A', depCode: 'a' };
|
||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
app.use('*', async (c, next) => { c.set('user', user); await next(); });
|
||||
app.route('/f', createFaultRouter(async () => ({ rows: sourceRows, updatedAt })));
|
||||
const query = new URLSearchParams(q);
|
||||
const report = await (await app.request(`/f/dashboard?${query}`)).json();
|
||||
assert.equal(report.totals.reported, 4);
|
||||
assert.equal(report.totals.operationsReported, 2);
|
||||
assert.equal(report.totals.customerReported, 2);
|
||||
assert.equal(report.totals.operationsReported + report.totals.customerReported, report.totals.reported);
|
||||
assert.equal(report.options.reportMethod.find((o: { value: string }) => o.value === 'customer').label, 'AI提报');
|
||||
assert.equal(report.options.reportMethod.find((o: { value: string }) => o.value === 'operations').label, '运维提报');
|
||||
for (const [method, expected] of [['operations', ['ops-linked', 'ops-legacy']], ['customer', ['ai-explicit', 'ai-legacy']]] as const) {
|
||||
const response = await app.request(`/f/dashboard-analysis?${query}&scope=cohort&reportMethod=${method}`);
|
||||
assert.equal(response.status, 200);
|
||||
const detail = await response.json();
|
||||
assert.deepEqual(detail.rows.map((row: { id: string }) => row.id).sort(), [...expected].sort());
|
||||
const filtered = await (await app.request(`/f/dashboard?${query}&reportMethod=${method}`)).json();
|
||||
assert.equal(filtered.totals.reported, expected.length);
|
||||
assert.equal(filtered.totals.operationsReported + filtered.totals.customerReported, filtered.totals.reported);
|
||||
}
|
||||
const empty = await (await app.request(`/f/dashboard?${query}®ion=不存在`)).json();
|
||||
assert.equal(empty.totals.operationsReported, 0); assert.equal(empty.totals.customerReported, 0);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { FAULT_REPORT_METHODS, FAULT_DASHBOARD_FILTERS, type FaultDashboardDimension, type FaultDashboardResponse, type FaultDashboardScope } from '../../../shared/assets/faults.js';
|
||||
import { faultAnalysis, faultOverview, isOverdueFault, type ScopedFault } from './model.js';
|
||||
import { parseFaultTrendQuery, selectFaultCohort } from './trend.js';
|
||||
|
||||
export function dashboardValues(row: ScopedFault, key: FaultDashboardDimension): string[] {
|
||||
if (key === 'handler') return row.handlers.map(h => h.value);
|
||||
if (key === 'level') return [row.level || '未标注'];
|
||||
if (key === 'category') return row.categories?.length ? row.categories : ['未标注'];
|
||||
if (key === 'inventory') return [row.inventory];
|
||||
if (key === 'reportMethod') return [row.reportMethod || 'operations'];
|
||||
return [row.assetCell[key]];
|
||||
}
|
||||
function label(row: ScopedFault, key: FaultDashboardDimension, value: string) {
|
||||
if (key === 'reportMethod') return FAULT_REPORT_METHODS[value as keyof typeof FAULT_REPORT_METHODS] || value;
|
||||
return key === 'customer' ? row.customerLabel : key === 'handler' ? row.handlers.find(h => h.value === value)?.label || value : value;
|
||||
}
|
||||
export function filterDashboardRows(rows: ScopedFault[], params: Record<string, string>) {
|
||||
return rows.filter(row => (Object.keys(FAULT_DASHBOARD_FILTERS) as FaultDashboardDimension[]).every(key => !params[key] || dashboardValues(row, key).includes(params[key])));
|
||||
}
|
||||
export function parseDashboardQuery(params: Record<string, string>) {
|
||||
const query = parseFaultTrendQuery({ start: params.start, end: params.end });
|
||||
const scope = params.scope || 'cohort';
|
||||
if (!['focus', 'cohort', 'open', 'waiting', 'processing', 'overdue', 'ledger', 'reported', 'closed'].includes(scope)) throw new Error('无效统计范围');
|
||||
return { ...query, scope: scope as FaultDashboardScope };
|
||||
}
|
||||
function selectFocusFaults(rows: ScopedFault[]) {
|
||||
return rows.filter(row => row.level === 'L1-特急' && row.status !== '已归档');
|
||||
}
|
||||
export function dashboardAnalysis(rows: ScopedFault[], params: Record<string, string>, updatedAt: string) {
|
||||
const query = parseDashboardQuery(params);
|
||||
let selected = filterDashboardRows(rows, params);
|
||||
if (params.id) selected = selected.filter(row => row.id === params.id);
|
||||
if (query.scope === 'cohort') selected = selectFaultCohort(selected, query);
|
||||
if (query.scope === 'focus') selected = selectFocusFaults(selected);
|
||||
return faultAnalysis(selected, query.scope === 'cohort' || query.scope === 'focus' ? 'ledger' : query.scope, updatedAt);
|
||||
}
|
||||
export function faultDashboard(visible: ScopedFault[], params: Record<string, string>, updatedAt: string): FaultDashboardResponse {
|
||||
const query = parseDashboardQuery(params);
|
||||
const all = filterDashboardRows(visible, params);
|
||||
const cohort = selectFaultCohort(all, query);
|
||||
const overview = faultOverview(all, updatedAt).totals;
|
||||
const open = all.filter(r => ['待处理', '处理中'].includes(r.status));
|
||||
const overdue = open.filter(r => isOverdueFault(r, updatedAt));
|
||||
const vehicles = (rows: ScopedFault[]) => new Set(rows.map(r => r.vehicleKey).filter(Boolean)).size;
|
||||
const options = Object.fromEntries((Object.keys(FAULT_DASHBOARD_FILTERS) as FaultDashboardDimension[]).map(key => [key,
|
||||
[...new Map(visible.flatMap(row => dashboardValues(row, key).map(value => [value, { value, label: label(row, key, value) }] as const))).values()].sort((a, b) => a.label.localeCompare(b.label, 'zh-CN')),
|
||||
])) as FaultDashboardResponse['options'];
|
||||
function groups(key: 'region' | 'level' | 'customer' | 'model') {
|
||||
const buckets = new Map<string, ScopedFault[]>();
|
||||
cohort.forEach(row => { const value = dashboardValues(row, key)[0]; const group = buckets.get(value) || []; group.push(row); buckets.set(value, group); });
|
||||
return [...buckets].map(([value, rows]) => ({ value, label: label(rows[0], key, value), reported: rows.length,
|
||||
closed: rows.filter(r => r.status === '已归档').length, unclosed: rows.filter(r => r.status !== '已归档').length,
|
||||
vehicles: vehicles(rows), open: open.filter(r => dashboardValues(r, key).includes(value)).length,
|
||||
})).sort((a, b) => b.reported - a.reported || a.label.localeCompare(b.label, 'zh-CN'));
|
||||
}
|
||||
const distributions = { region: groups('region'), level: groups('level'), customer: groups('customer'), model: groups('model') };
|
||||
const categoryKeys = [...new Set(cohort.flatMap(row => dashboardValues(row, 'category')))].sort((a,b)=>a.localeCompare(b,'zh-CN'));
|
||||
const matrix = { models: distributions.model.map(r => ({value:r.value,label:r.label})), categories: categoryKeys.map(value => ({value,label:value})),
|
||||
cells: distributions.model.flatMap(model => categoryKeys.map(category => ({model:model.value,category,count:cohort.filter(r=>r.assetCell.model===model.value&&dashboardValues(r,'category').includes(category)).length}))),
|
||||
totals: Object.fromEntries(distributions.model.map(r=>[r.value,r.reported])),
|
||||
};
|
||||
const duration = Date.parse(query.end) - Date.parse(query.start) + 86400000;
|
||||
const previousStart = new Date(Date.parse(query.start) - duration).toISOString().slice(0,10);
|
||||
const previous = all.filter(r => r.reportedAt && r.reportedAt.slice(0,10) >= previousStart && r.reportedAt.slice(0,10) < query.start).length;
|
||||
const focusRows = selectFocusFaults(all);
|
||||
const focusTotals = { total: focusRows.length, overdue: focusRows.filter(row => isOverdueFault(row, updatedAt)).length };
|
||||
const focus = faultAnalysis([...focusRows].sort((a,b)=> Number(isOverdueFault(b,updatedAt))-Number(isOverdueFault(a,updatedAt)) || (b.reportedAt||'').localeCompare(a.reportedAt||'')).slice(0,5), 'ledger', updatedAt).rows;
|
||||
return { start:query.start,end:query.end,updatedAt,options,distributions,matrix,focus,focusTotals,
|
||||
totals: { reported:cohort.length,operationsReported:cohort.filter(r=>r.reportMethod!=='customer').length,customerReported:cohort.filter(r=>r.reportMethod==='customer').length,vehicles:vehicles(cohort),customers:new Set(cohort.filter(r=>!['unlinked','inventory-unlinked'].includes(r.customer)).map(r=>r.customer)).size,
|
||||
previous,change:previous ? (cohort.length-previous)/previous*100 : null,open:overview.open,waiting:overview.waiting,processing:overview.processing,overdue:overview.overdue,
|
||||
longestOverdueDays:Math.max(0,...overdue.map(r=>Math.ceil((Date.parse(updatedAt)-Date.parse(`${r.deadlineAt!.replace(' ','T')}+08:00`))/86400000))),
|
||||
todayReported:overview.reported,todayClosed:overview.closed,ledger:all.length },
|
||||
quality:{missingLevel:cohort.filter(r=>!r.level||r.level==='未标注').length,missingCategory:cohort.filter(r=>!r.categories?.length).length,missingVehicle:cohort.filter(r=>!r.vehicleKey).length,missingDeadline:open.filter(r=>!r.deadlineAt).length,missingReport:all.filter(r=>!r.reportedAt).length},
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user