10 Commits
Author SHA1 Message Date
shishengliang 187a72d6b0 chore(release): bump version to 1.1.20
ci/woodpecker/push/woodpecker Pipeline was successful
2026-10-22 11:06:43 +08:00
shishengliang 3657661fa7 Merge fault_1013_improve into feature/asset-statistics 2026-10-22 11:06:41 +08:00
shishengliang dbe0421bfe fix(assets): 移除故障提报来源展示
ci/woodpecker/push/woodpecker Pipeline was successful
2026-10-16 18:23:57 +08:00
shishengliang 1674f052d0 feat(assets): 完善故障提报来源与重点跟进展示
ci/woodpecker/push/woodpecker Pipeline was successful
2026-10-16 18:15:44 +08:00
shishengliang f107e96d3c feat(assets): 完善故障风险多维分析与管理总览
ci/woodpecker/push/woodpecker Pipeline was successful
2026-10-16 12:38:52 +08:00
shishengliang 4dec824285 feat(assets): 优化移动端分析体验并记录 AI 提问日志 2026-10-13 16:24:28 +08:00
shishengliang 00c09d7452 fix(mileage): skip missing daily mileage in interval totals
ci/woodpecker/push/woodpecker Pipeline was successful
2026-10-13 13:20:14 +08:00
shishengliang 5751d1af15 fix(assets): emphasize daily flow details and sort newest first
ci/woodpecker/push/woodpecker Pipeline was successful
2026-10-10 21:56:06 +08:00
shishengliang 24984fe033 fix(assets): show inventory operations managers and bump v1.1.19 2026-10-10 21:56:06 +08:00
shishengliang 7dd7e67602 fix(energy): connect Foshan external vehicle hydrogen orders
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-29 10:03:45 +08:00
52 changed files with 1537 additions and 212 deletions
+79
View File
@@ -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:后续如提供统一品牌资产,可单独统一应用级导航标识;当前保持现有产品导航。
+23
View File
@@ -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 识别长期未完成,不能将其算作成功。
模拟用户提问同样记录,但不新增需求缺口反馈,也不改变原有只读业务权限。没有新增公开查询接口;数据库查看权限沿用现有管理制度。新表不能补回此前仅在内存中保存的历史问题。
+21 -1
View File
@@ -11,7 +11,7 @@
| 当前在办 | 全部待处理、处理中故障,不限制提报日期;按最新确认,在办 = 待处理 + 处理中 | | 当前在办 | 全部待处理、处理中故障,不限制提报日期;按最新确认,在办 = 待处理 + 处理中 |
| 累计台账 | 全部有效故障台账,包括归档、未闭环和其他状态 | | 累计台账 | 全部有效故障台账,包括归档、未闭环和其他状态 |
「当前在办」卡片以“待处理 + 处理中”展示构成,逾期以总量旁的“其中逾期”独立标记,均使用故障多维分析与故障明细下钻。按最新确认,在办仅为待处理与处理中之和,挂起保留在累计台账;旧兼容接口的全部未闭环口径保持不变。 「当前在办」卡片以“待处理 + 处理中”展示构成,逾期以总量旁的“逾期”独立标记,均使用故障多维分析与故障明细下钻。按最新确认,在办仅为待处理与处理中之和,挂起保留在累计台账;旧兼容接口的全部未闭环口径保持不变。
- 待处理/处理中:按 OneOS 标准化任务状态分别统计,兼容待处置、未处理、处置中等历史值。 - 待处理/处理中:按 OneOS 标准化任务状态分别统计,兼容待处置、未处理、处置中等历史值。
- 逾期:待处理或处理中,且保存的 `deadline_at` 严格早于本次数据时间。按北京时间解释截止时间,挂起和已归档不计入;无截止时间不自行以提报时间或固定 30 天推算。与 OneOS `selectSupervisorRows.overdueCount` 汇总口径一致,存储的截止时间包含业务端 SLA 配置及挂起顺延结果。 - 逾期:待处理或处理中,且保存的 `deadline_at` 严格早于本次数据时间。按北京时间解释截止时间,挂起和已归档不计入;无截止时间不自行以提报时间或固定 30 天推算。与 OneOS `selectSupervisorRows.overdueCount` 汇总口径一致,存储的截止时间包含业务端 SLA 配置及挂起顺延结果。
@@ -79,3 +79,23 @@
- 周报:日期、星期、新增故障、解决故障,按日期倒序;不导出接收处理指标。 - 周报:日期、星期、新增故障、解决故障,按日期倒序;不导出接收处理指标。
- XLSX 包含带标题、范围、数据时间、汇总指标的统计表;当前有图表时附加「图表」工作表,以图片保存页面图表快照并保持比例。图表为静态快照,修改统计表不会联动图表。 - XLSX 包含带标题、范围、数据时间、汇总指标的统计表;当前有图表时附加「图表」工作表,以图片保存页面图表快照并保持比例。图表为静态快照,修改统计表不会联动图表。
- 统一深色表头、指标配色、交替底色、今日浅色标记;真实 Excel 日期和数字格式、合计及闭环率公式、冻结表头、筛选与打印设置。ExcelJS 按导出操作动态加载。 - 统一深色表头、指标配色、交替底色、今日浅色标记;真实 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,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;
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ln-bi", "name": "ln-bi",
"version": "1.1.19", "version": "1.1.20",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ln-bi", "name": "ln-bi",
"version": "1.1.19", "version": "1.1.20",
"dependencies": { "dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1", "@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0", "@hono/node-server": "^1.13.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ln-bi", "name": "ln-bi",
"private": true, "private": true,
"version": "1.1.19", "version": "1.1.20",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", "dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
+16
View File
@@ -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();
}
}
@@ -34,7 +34,9 @@ import { useAuth } from "../../../auth/useAuth";
import { ThinkingState } from "./ThinkingState"; import { ThinkingState } from "./ThinkingState";
import { CompoundResultView } from "./CompoundResultView"; import { CompoundResultView } from "./CompoundResultView";
import { BUSINESS_QUESTIONS, conversationTitle, findConversationResult, replaceAnalysisResult } from "./conversation-presentation"; import { BUSINESS_QUESTIONS, conversationTitle, findConversationResult, replaceAnalysisResult } from "./conversation-presentation";
import { useMobileLayout } from "../useMobileLayout";
import "./assistant.css"; import "./assistant.css";
import "./mobile-assistant.css";
type Turn = { type Turn = {
id: number; id: number;
@@ -55,24 +57,28 @@ const PROMPTS = [
{ {
icon: BarChart3, icon: BarChart3,
title: "现在多少车在运营、多少车在库存?", title: "现在多少车在运营、多少车在库存?",
mobileTitle: "运营与库存",
text: BUSINESS_QUESTIONS[0], text: BUSINESS_QUESTIONS[0],
detail: "总资产 · 运营 · 库存 · 运营率", detail: "总资产 · 运营 · 库存 · 运营率",
}, },
{ {
icon: Truck, icon: Truck,
title: "库存集中在哪些车型和区域?", title: "库存集中在哪些车型和区域?",
mobileTitle: "库存分布",
text: BUSINESS_QUESTIONS[1], text: BUSINESS_QUESTIONS[1],
detail: "库存分布 · 继续查看车辆", detail: "库存分布 · 继续查看车辆",
}, },
{ {
icon: History, icon: History,
title: "最近7天交车、还车分别多少?", title: "最近7天交车、还车分别多少?",
mobileTitle: "近 7 天交还车",
text: BUSINESS_QUESTIONS[2], text: BUSINESS_QUESTIONS[2],
detail: "按日趋势 · 业务明细", detail: "按日趋势 · 业务明细",
}, },
{ {
icon: ListIcon, icon: ListIcon,
title: "有哪些车辆需要跟进?", title: "有哪些车辆需要跟进?",
mobileTitle: "待跟进车辆",
text: BUSINESS_QUESTIONS[3], text: BUSINESS_QUESTIONS[3],
detail: "异动详情 · 预计完成时间", detail: "异动详情 · 预计完成时间",
}, },
@@ -94,6 +100,8 @@ export default function AssetAssistant({
const dataScopeLabel = !user ? "正在确认数据范围" const dataScopeLabel = !user ? "正在确认数据范围"
: user.permissionLevel === "full" ? "全量数据" : user.permissionLevel === "full" ? "全量数据"
: user.permissionLevel === "department" ? "部门数据" : "本人数据"; : user.permissionLevel === "department" ? "部门数据" : "本人数据";
const mobile = useMobileLayout();
const [contextExpanded, setContextExpanded] = useState(false);
const page = useRef<HTMLElement>(null); const page = useRef<HTMLElement>(null);
useLayoutEffect(() => { useLayoutEffect(() => {
const viewport = window.visualViewport; const viewport = window.visualViewport;
@@ -557,7 +565,7 @@ export default function AssetAssistant({
<p.icon size={18} aria-hidden="true" /> <p.icon size={18} aria-hidden="true" />
<ArrowUpRight size={14} aria-hidden="true" /> <ArrowUpRight size={14} aria-hidden="true" />
</div> </div>
<strong>{p.title}</strong> <strong>{mobile ? p.mobileTitle : p.title}</strong>
<span>{p.detail}</span> <span>{p.detail}</span>
</button> </button>
))} ))}
@@ -745,7 +753,7 @@ export default function AssetAssistant({
)} )}
</div> </div>
<div className="aq-composer-area"> <div className="aq-composer-area">
{turns.length > 0 && ( {turns.length > 0 && (!mobile || (viewedTurn !== undefined && viewedTurn !== turns.at(-1)?.id)) && (
<button <button
className="aq-latest-answer" className="aq-latest-answer"
onClick={() => revealTurn(turns[turns.length - 1].id)} onClick={() => revealTurn(turns[turns.length - 1].id)}
@@ -754,8 +762,8 @@ export default function AssetAssistant({
</button> </button>
)} )}
{lastPlan && ( {lastPlan && (
<div className="aq-context"> <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>正在分析</span> <span className="aq-context-heading">当前条件</span>
<span> <span>
{lastPlan.kind === "flow" {lastPlan.kind === "flow"
? "交退换车" ? "交退换车"
@@ -847,7 +855,7 @@ export default function AssetAssistant({
<Plus size={12} /> <Plus size={12} />
换个话题 换个话题
</button> </button>
</div> </div></details>
)} )}
{independent && ( {independent && (
<p className="aq-topic-notice"> <p className="aq-topic-notice">
@@ -6,6 +6,8 @@ import type {
} from "../../../shared/assets/assistant"; } from "../../../shared/assets/assistant";
import { ResultView, type ResultViewProps } from "./ResultView"; import { ResultView, type ResultViewProps } from "./ResultView";
import { GenerativeResultView } from "./GenerativeResultView"; import { GenerativeResultView } from "./GenerativeResultView";
import { useMobileLayout } from '../useMobileLayout';
import { mobileResultSummary } from './mobile-presentation';
import { copyResultText } from "./result-presentation"; import { copyResultText } from "./result-presentation";
export type CompoundResultViewProps = Omit< export type CompoundResultViewProps = Omit<
@@ -34,6 +36,7 @@ export function CompoundResultView({
activate, activate,
...props ...props
}: CompoundResultViewProps) { }: CompoundResultViewProps) {
const mobile = useMobileLayout();
const [selection, setSelection] = useState<{ const [selection, setSelection] = useState<{
sectionId: string; sectionId: string;
observedActiveId?: string; observedActiveId?: string;
@@ -72,7 +75,7 @@ export function CompoundResultView({
externallyActive || externallyActive ||
sections[0]; sections[0];
const selectedIndex = sections.indexOf(selected); const selectedIndex = sections.indexOf(selected);
const stacked = result.layout === "stack"; const stacked = result.layout === "stack" && !mobile;
const renderSection = (section: typeof selected) => { const renderSection = (section: typeof selected) => {
const child = section.result; const child = section.result;
const correction = child?.resultId const correction = child?.resultId
@@ -120,11 +123,11 @@ export function CompoundResultView({
className="aq-compound-heading" className="aq-compound-heading"
id={result.resultId ? `aq-query-${result.resultId}` : undefined} id={result.resultId ? `aq-query-${result.resultId}` : undefined}
> >
<div> <div className="aq-compound-title">
<strong>{result.title || "综合分析"}</strong> <strong>{result.title || "综合分析"}</strong>
<span>{sections.length}项分析</span> <span>{sections.length}项分析</span>
</div> </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} {result.summary}
</p> </p>
</header> </header>
@@ -219,7 +222,7 @@ export function CompoundResultView({
<span className="aq-section-preview"> <span className="aq-section-preview">
<strong>{section.title}</strong> <strong>{section.title}</strong>
<span> <span>
{section.result?.summary || section.error || "本项暂无结果"} {section.result ? (mobile ? mobileResultSummary(section.result) : section.result.summary) : section.error || "本项暂无结果"}
</span> </span>
</span> </span>
{chosen ? <Check size={14} /> : <ArrowUpRight size={14} />} {chosen ? <Check size={14} /> : <ArrowUpRight size={14} />}
@@ -45,6 +45,10 @@ import {
type PresentationDatum, type PresentationDatum,
} from "./generative-presentation"; } from "./generative-presentation";
import { useMobileLayout } from '../useMobileLayout';
import { mobileBlockGroups, mobileResultSummary } from './mobile-presentation';
import { ResultActions } from './ResultActions';
const COLORS = [ const COLORS = [
"#4e806a", "#4e806a",
"#527da5", "#527da5",
@@ -69,6 +73,7 @@ function DataTable({
operate, operate,
disabled, disabled,
}: DataBlockProps) { }: DataBlockProps) {
const mobile = useMobileLayout();
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const data = presentationData(result, block.dataset); const data = presentationData(result, block.dataset);
const columns = presentationFields(block); const columns = presentationFields(block);
@@ -78,7 +83,16 @@ function DataTable({
); );
return ( 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> <table>
<caption className="aq-sr-only">{block.title || "分析数据"}</caption> <caption className="aq-sr-only">{block.title || "分析数据"}</caption>
<thead> <thead>
@@ -132,7 +146,7 @@ function DataTable({
})} })}
</tbody> </tbody>
</table> </table>
</div> </div>}
{data.length > 8 && ( {data.length > 8 && (
<div className="aq-generated-pagination"> <div className="aq-generated-pagination">
<span> <span>
@@ -159,6 +173,7 @@ function DataTable({
); );
} }
function DataChart(props: DataBlockProps) { function DataChart(props: DataBlockProps) {
const mobile = useMobileLayout();
const { block, result, interaction, operate, disabled } = props; const { block, result, interaction, operate, disabled } = props;
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const allData = presentationData(result, block.dataset); const allData = presentationData(result, block.dataset);
@@ -186,6 +201,15 @@ function DataChart(props: DataBlockProps) {
}; };
if (!series.length) return <DataTable {...props} />; if (!series.length) return <DataTable {...props} />;
const common = { data, margin: { top: 8, right: 16, bottom: 0, left: 0 } }; 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 ( return (
<> <>
<div <div
@@ -294,7 +318,7 @@ function DataChart(props: DataBlockProps) {
: `当前展示前5项 · 查看全部${allData.length}项`} : `当前展示前5项 · 查看全部${allData.length}项`}
</button> </button>
)} )}
<details className="aq-generated-data"> {!mobile && <details className="aq-generated-data">
<summary>查看图表数据</summary> <summary>查看图表数据</summary>
<DataTable <DataTable
{...props} {...props}
@@ -309,7 +333,7 @@ function DataChart(props: DataBlockProps) {
], ],
}} }}
/> />
</details> </details>}
</> </>
); );
} }
@@ -319,6 +343,9 @@ function Block({
operate, operate,
disabled, disabled,
}: Omit<DataBlockProps, "interaction">) { }: Omit<DataBlockProps, "interaction">) {
const mobile = useMobileLayout();
const [expandedMetrics, setExpandedMetrics] = useState(false);
const metrics = presentationMetrics(result, block.metrics);
const interaction = result.presentation?.interactions.find( const interaction = result.presentation?.interactions.find(
(item) => item.id === block.interactionId, (item) => item.id === block.interactionId,
); );
@@ -345,8 +372,8 @@ function Block({
) : block.kind === "narrative" ? ( ) : block.kind === "narrative" ? (
<p className="aq-generated-narrative">{block.text}</p> <p className="aq-generated-narrative">{block.text}</p>
) : block.kind === "metrics" ? ( ) : block.kind === "metrics" ? (
<div className="aq-generated-metrics"> <><div className="aq-generated-metrics">
{presentationMetrics(result, block.metrics).map((metric) => ( {(mobile && !expandedMetrics ? metrics.slice(0, 4) : metrics).map((metric) => (
<div key={metric.label}> <div key={metric.label}>
<span>{metric.label}</span> <span>{metric.label}</span>
<strong> <strong>
@@ -355,10 +382,10 @@ function Block({
</strong> </strong>
</div> </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"> <p className="aq-generated-interaction">
点击{block.dataset === "daily" ? "日期" : "数据项"} →{" "} 点击{block.dataset === "daily" ? "日期" : "数据项"} →{" "}
{interaction.label} {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({ function GenerativeResultViewContent({
result, result,
operate, operate,
@@ -403,6 +436,7 @@ function GenerativeResultViewContent({
correctionResultId, correctionResultId,
showCorrection, showCorrection,
}: ResultViewProps) { }: ResultViewProps) {
const mobile = useMobileLayout();
const [noticeByResult, setNoticeByResult] = useState<{ const [noticeByResult, setNoticeByResult] = useState<{
key: string; key: string;
text: string; text: string;
@@ -536,7 +570,7 @@ function GenerativeResultViewContent({
已纠正上一条回答:{result.correction.message} 已纠正上一条回答:{result.correction.message}
</p> </p>
)} )}
{presentation.title && ( {!mobile && presentation.title && (
<h3 className="aq-generated-title">{presentation.title}</h3> <h3 className="aq-generated-title">{presentation.title}</h3>
)} )}
{result.outcome === "empty" && ( {result.outcome === "empty" && (
@@ -552,15 +586,11 @@ function GenerativeResultViewContent({
{result.outcome === "unsupported" && ( {result.outcome === "unsupported" && (
<p className="aq-generated-notice">本次需求暂不能完整生成。</p> <p className="aq-generated-notice">本次需求暂不能完整生成。</p>
)} )}
{presentation.blocks.map((block) => ( {mobile ? <>
<Block <p className="aq-answer aq-mobile-lead" id={result.resultId ? `aq-answer-${result.resultId}` : undefined}>{mobileResultSummary(result)}</p>
key={block.id} {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} />)}
block={block} <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>
result={result} </> : presentation.blocks.map((block) => <Block key={block.id} block={block} result={result} operate={operate} disabled={disabled || !!correctionMessage} />)}
operate={operate}
disabled={disabled || !!correctionMessage}
/>
))}
{!!result.unanswered?.length && ( {!!result.unanswered?.length && (
<div className="aq-compound-unanswered"> <div className="aq-compound-unanswered">
<strong>尚未回答</strong> <strong>尚未回答</strong>
@@ -611,7 +641,12 @@ function GenerativeResultViewContent({
{result.contextNote && <p>{result.contextNote}</p>} {result.contextNote && <p>{result.contextNote}</p>}
<p>来源:{result.source}</p> <p>来源:{result.source}</p>
</details> </details>
<div className="aq-answer-actions"> <ResultActions primary={<button
disabled={disabled || active || !!correctionMessage}
onClick={activate}
>
{mobile ? (active ? "当前追问范围" : "继续追问") : (active ? "正在基于此结果追问" : "基于此结果追问")}
</button>}>
<button <button
disabled={disabled || saving} disabled={disabled || saving}
onClick={() => void save("export")} onClick={() => void save("export")}
@@ -619,12 +654,7 @@ function GenerativeResultViewContent({
<ArrowDownToLine size={14} /> <ArrowDownToLine size={14} />
导出明细 导出明细
</button> </button>
<button
disabled={disabled || active || !!correctionMessage}
onClick={activate}
>
{active ? "正在基于此结果追问" : "基于此结果追问"}
</button>
<button <button
disabled={saving || flagged} disabled={saving || flagged}
onClick={() => void save("feedback")} onClick={() => void save("feedback")}
@@ -642,7 +672,7 @@ function GenerativeResultViewContent({
<Copy size={14} /> <Copy size={14} />
复制完整结果 复制完整结果
</button> </button>
</div> </ResultActions>
{notice && ( {notice && (
<p className="aq-notice" role="status"> <p className="aq-notice" role="status">
{notice} {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>;
}
+22 -13
View File
@@ -26,7 +26,12 @@ import {
compactAnalysisPath, compactAnalysisPath,
} from "./result-presentation"; } from "./result-presentation";
import { useMobileLayout } from '../useMobileLayout';
import { mobileResultSummary } from './mobile-presentation';
import { ResultActions } from './ResultActions';
function Records({ rows, flow }: { rows: AssistantRow[]; flow: boolean }) { function Records({ rows, flow }: { rows: AssistantRow[]; flow: boolean }) {
const mobile = useMobileLayout();
const [page, setPage] = useState(0), const [page, setPage] = useState(0),
[search, setSearch] = useState(""); [search, setSearch] = useState("");
const filtered = rows.filter((r) => const filtered = rows.filter((r) =>
@@ -52,7 +57,7 @@ function Records({ rows, flow }: { rows: AssistantRow[]; flow: boolean }) {
</label> </label>
<span>{filtered.length}条</span> <span>{filtered.length}条</span>
</div> </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> <table>
<thead> <thead>
<tr> <tr>
@@ -90,7 +95,7 @@ function Records({ rows, flow }: { rows: AssistantRow[]; flow: boolean }) {
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>}
{!filtered.length && <p className="aq-no-data">没有符合条件的明细</p>} {!filtered.length && <p className="aq-no-data">没有符合条件的明细</p>}
<div className="aq-pagination"> <div className="aq-pagination">
<span> <span>
@@ -148,6 +153,7 @@ function ResultViewContent({
showCorrection, showCorrection,
display: preferredDisplay, display: preferredDisplay,
}: ResultViewProps) { }: ResultViewProps) {
const mobile = useMobileLayout();
const [expandedGroupResult, setExpandedGroupResult] = useState<string | null>( const [expandedGroupResult, setExpandedGroupResult] = useState<string | null>(
null, null,
), ),
@@ -360,7 +366,7 @@ function ResultViewContent({
className="aq-answer" className="aq-answer"
id={result.resultId ? `aq-answer-${result.resultId}` : undefined} id={result.resultId ? `aq-answer-${result.resultId}` : undefined}
> >
{result.summary} {mobile ? mobileResultSummary(result) : result.summary}
</p> </p>
{!result.conciseCount && !!visibleMetrics.length && ( {!result.conciseCount && !!visibleMetrics.length && (
<div className="aq-metrics aq-primary-metrics"> <div className="aq-metrics aq-primary-metrics">
@@ -790,6 +796,8 @@ function ResultViewContent({
点击分组继续下钻 · 筛选条件自动用于下一次追问 点击分组继续下钻 · 筛选条件自动用于下一次追问
</p> </p>
</div> </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"> <div className="aq-table-scroll">
<table> <table>
@@ -859,6 +867,7 @@ function ResultViewContent({
<summary> <summary>
统计口径、权限与数据时间{result.warning ? " · 有补充说明" : ""} 统计口径、权限与数据时间{result.warning ? " · 有补充说明" : ""}
</summary> </summary>
{mobile && <p>{result.summary}</p>}
<p> <p>
公司:{scope.company} · 权限:{scope.permission} 公司:{scope.company} · 权限:{scope.permission}
</p> </p>
@@ -877,7 +886,14 @@ function ResultViewContent({
))} ))}
<p>来源:{result.source}</p> <p>来源:{result.source}</p>
</details> </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) && ( {plan.kind !== "clarify" && (!result.conciseCount || result.metrics[0]?.value > 0) && (
<button <button
disabled={disabled || saving} disabled={disabled || saving}
@@ -888,14 +904,7 @@ function ResultViewContent({
导出明细 导出明细
</button> </button>
)} )}
{plan.kind !== "clarify" && (
<button
disabled={disabled || active || !!correctionMessage}
onClick={activate}
>
{active ? "正在基于此结果追问" : "基于此结果追问"}
</button>
)}
<button disabled={saving || flagged} onClick={() => void flag()}> <button disabled={saving || flagged} onClick={() => void flag()}>
{flagged ? "已记录" : "未解决我的问题"} {flagged ? "已记录" : "未解决我的问题"}
</button> </button>
@@ -910,7 +919,7 @@ function ResultViewContent({
<Copy size={14} /> <Copy size={14} />
复制完整结果 复制完整结果
</button> </button>
</div> </ResultActions>
{notice && ( {notice && (
<p className="aq-notice" role="status"> <p className="aq-notice" role="status">
{notice} {notice}
@@ -3,6 +3,8 @@
import { useEffect, useId, useState } from "react"; import { useEffect, useId, useState } from "react";
import { Check, ChevronDown, CircleAlert, LoaderCircle, Pause, Sparkles } from "lucide-react"; import { Check, ChevronDown, CircleAlert, LoaderCircle, Pause, Sparkles } from "lucide-react";
import { useMobileLayout } from '../useMobileLayout';
export function ThinkingState({ export function ThinkingState({
steps, steps,
working, working,
@@ -18,6 +20,7 @@ export function ThinkingState({
stopped?: boolean; stopped?: boolean;
outcome?: string; outcome?: string;
}) { }) {
const mobile = useMobileLayout();
const traceId = useId(); const traceId = useId();
const [manualExpanded, setExpanded] = useState<boolean | null>(null); const [manualExpanded, setExpanded] = useState<boolean | null>(null);
const [elapsed, setElapsed] = useState(0); const [elapsed, setElapsed] = useState(0);
@@ -28,7 +31,7 @@ export function ThinkingState({
const timer = window.setInterval(update, 1000); const timer = window.setInterval(update, 1000);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [working, started]); }, [working, started]);
const expanded = manualExpanded ?? working; const expanded = manualExpanded ?? (working && !mobile);
const StatusIcon = working ? LoaderCircle : stopped ? Pause : failed ? CircleAlert : Sparkles; const StatusIcon = working ? LoaderCircle : stopped ? Pause : failed ? CircleAlert : Sparkles;
return ( return (
<div className={`aq-thinking${working ? " aq-thinking-working" : ""}`} data-state={working ? "working" : stopped ? "stopped" : failed ? "failed" : "complete"}> <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" /> <StatusIcon size={16} className={working ? "aq-spin" : ""} aria-hidden="true" />
<span className={`aq-thinking-title${working ? " aq-shimmer" : ""}`}> <span className={`aq-thinking-title${working ? " aq-shimmer" : ""}`}>
{working {working
? "正在分析" ? (mobile ? steps.at(-1) || "正在分析" : "正在分析")
: stopped : stopped
? "已由用户停止" ? "已由用户停止"
: failed : 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> <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"> <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"> {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> <dd className="whitespace-pre-wrap break-words font-medium text-slate-800 [overflow-wrap:anywhere]"><Blur>{value}</Blur></dd>
</div>)} </div>)}
</dl> </dl>
@@ -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 { ArrowLeft, X } from 'lucide-react';
import type { FlowDetailItem, FlowType } from '../api'; import type { FlowDetailItem, FlowType } from '../api';
import type { FlowSelection } from '../model'; import type { FlowSelection } from '../model';
@@ -12,6 +12,10 @@ import { AnalysisExportButton } from './analysis/AnalysisExportButton';
import { downloadAnalysisWorkbook, statisticsExportRows } from '../analysis-export'; import { downloadAnalysisWorkbook, statisticsExportRows } from '../analysis-export';
import './analysis/asset-analysis.css'; import './analysis/asset-analysis.css';
import './flow-analysis-modal.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 { interface FlowAnalysisModalProps {
selectedFlow: FlowSelection | null; selectedFlow: FlowSelection | null;
@@ -58,6 +62,7 @@ export function FlowAnalysisModal(props: FlowAnalysisModalProps) {
} }
function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlow }: Omit<FlowAnalysisModalProps, 'selectedFlow'> & { selectedFlow: FlowSelection }) { function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlow }: Omit<FlowAnalysisModalProps, 'selectedFlow'> & { selectedFlow: FlowSelection }) {
const mobile = useMobileLayout();
const [dimensions, setDimensions] = useState<Dimension[]>(['date']); const [dimensions, setDimensions] = useState<Dimension[]>(['date']);
const [mergeParents, setMergeParents] = useState(true); const [mergeParents, setMergeParents] = useState(true);
const mergeActive = mergeParents && dimensions.length > 1; const mergeActive = mergeParents && dimensions.length > 1;
@@ -87,6 +92,9 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo
const detailGroup = useMemo(() => detailKey === null ? null : groups.find(group => group.key === detailKey), [groups, detailKey]); const detailGroup = useMemo(() => detailKey === null ? null : groups.find(group => group.key === detailKey), [groups, detailKey]);
const detailRows = detailGroup?.rows ?? EMPTY_FLOW_ROWS; const detailRows = detailGroup?.rows ?? EMPTY_FLOW_ROWS;
const showDetails = detailKey !== null; 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 detailLabel = detailGroup && dimensions.length ? detailGroup.values.map((value, i) => `${DIMENSION_LABELS[dimensions[i]]}:${dimensionLabel(dimensions[i], value)}`).join(' · ') : '当前筛选范围';
const selectedRows = showDetails ? detailRows : filteredRows; const selectedRows = showDetails ? detailRows : filteredRows;
const { replacedCount, departmentCount } = useMemo(() => ({ const { replacedCount, departmentCount } = useMemo(() => ({
@@ -96,7 +104,7 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo
const rangeLabel = 'date' in selectedFlow ? selectedFlow.date : `${selectedFlow.start} 至 ${selectedFlow.end}`; 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 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 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 detailProgress = useProgressiveRows({ total: detailRows.length, rootRef: scrollRoot, resetKey: detailRows, enabled: showDetails });
const shownGroups = useMemo(() => groups.slice(0, groupProgress.visibleCount), [groups, groupProgress.visibleCount]); 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]); const completeSpans = useMemo(() => mergeActive ? dimensionRowSpans(groups, dimensions.length, group => group.values) : [], [groups, dimensions.length, mergeActive]);
@@ -124,19 +132,19 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo
return <div className="bi-flow-modal-backdrop" onClick={event => { if (event.target === event.currentTarget) close(); }}> 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"> <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"> <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> <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>
<p className="bi-flow-modal-definition">交车包含替换交车;业务类型按实际记录拆分,同车多笔业务分别计次。</p> {!mobile && <p className="bi-flow-modal-definition">交车包含替换交车;业务类型按实际记录拆分,同车多笔业务分别计次。</p>}
</header> </header>
<div ref={scrollRoot} className="bi-flow-modal-body"> <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="选择交还车统计维度"> <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)} />)} {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> <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} {activeFilters.length ? <button type="button" className="bi-flow-modal-clear" onClick={() => { setFilters({}); resetResult(); }}>清空筛选</button> : null}
</div></div> </div></div>
<p className="bi-flow-modal-control-hint">点击维度名切换分组,点击右侧箭头筛选;同维度多选为“或”,不同维度为“且”。</p> <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 className="bi-flow-modal-summary">
<div><span>{showDetails ? '当前分组' : '当前筛选'}{FLOW_META[selectedFlow.type].label}</span><strong>{countLabel(selectedRows.length)}<small>车次</small></strong></div> <div><span>{showDetails ? '当前分组' : '当前筛选'}{FLOW_META[selectedFlow.type].label}</span><strong>{countLabel(selectedRows.length)}<small>车次</small></strong></div>
@@ -144,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>} {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>
<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> {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(); }} />}
{!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 && <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"> <div className="analysis-export-actions">
<AnalysisExportButton disabled={!selectedRows.length} onExport={exportWorkbook} /> {(!mobile || selectedRows.length > 0) && <AnalysisExportButton disabled={!selectedRows.length} onExport={exportWorkbook} />}
</div> </div>
</div> </div>
{!showDetails ? groups.length ? <> {!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> <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}> {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) => { {dimensions.length ? group.values.map((value, index) => {
@@ -159,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>; 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>当前筛选范围</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-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>)} </tr>)}
</tbody></table></div> </tbody></table></div>}
<ProgressiveRowsStatus {...groupProgress} unit="组" /> <ProgressiveRowsStatus {...groupProgress} unit="组" />
</> : <div className="bi-flow-modal-empty">当前筛选范围暂无交还车数据,请调整筛选。</div> </> : <div className="bi-flow-modal-empty">{activeFilters.length ? '当前筛选范围暂无交还车数据,可调整筛选。' : '当前时段暂无交还车记录。'}</div>
: detailRows.length ? <> : 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="条" /> <ProgressiveRowsStatus {...detailProgress} unit="条" />
</> : <div className="bi-flow-modal-empty">当前分组暂无业务明细,请返回统计调整条件。</div>} </> : <div className="bi-flow-modal-empty">当前分组暂无业务明细,请返回统计调整条件。</div>}
</div> </div>
{mobile && <footer className="analysis-footer"><details><summary>统计口径</summary><p>按运维签章时间统计。交车包含替换交车;同车多笔业务分别计次。仅展示当前可查看范围与筛选条件内的记录。</p></details></footer>}
</div> </div>
</div>; </div>;
} }
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, Filter, Plus, RotateCcw, Search, X } from 'lucide-react'; import { Check, ChevronDown, Filter, Plus, RotateCcw, Search, X } from 'lucide-react';
import type { AssetAnalysisCell, AssetAnalysisDimension as Dimension } from '../../../../shared/assets/analytics'; 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 = { type Props = {
view: AnalysisView; view: AnalysisView;
@@ -15,6 +17,7 @@ type Props = {
}; };
export function AnalysisControls({ view, cells, available, dimensions, setDimensions, filters, setFilters, reset }: Props) { export function AnalysisControls({ view, cells, available, dimensions, setDimensions, filters, setFilters, reset }: Props) {
const mobile = useMobileLayout();
const root = useRef<HTMLElement>(null); const root = useRef<HTMLElement>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null); const triggerRef = useRef<HTMLButtonElement | null>(null);
const [position, setPosition] = useState({ top: 0, left: 0 }); 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]; if (draft.length) next[editor] = draft; else delete next[editor];
setFilters(next); closeEditor(); 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="自定义分析设置"> 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-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="选择分析维度"> <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 { ArrowLeft, BarChart3, Filter, Loader2, Search, X } from 'lucide-react';
import { fetchJson } from '../../../../auth/api-client'; import { fetchJson } from '../../../../auth/api-client';
import Blur from '../../../../components/Blur'; import Blur from '../../../../components/Blur';
@@ -15,8 +15,12 @@ import type { VehicleListItem } from '../../types';
import { AbnormalVehicleDetails, PendingVehicleDetails } from '../AbnormalVehicleDetails'; import { AbnormalVehicleDetails, PendingVehicleDetails } from '../AbnormalVehicleDetails';
import { useDialogFocus } from '../useDialogFocus'; import { useDialogFocus } from '../useDialogFocus';
import { AnalysisExportButton } from './AnalysisExportButton'; import { AnalysisExportButton } from './AnalysisExportButton';
import { MobileAnalysisControls } from './MobileAnalysisControls';
import { MobileGroupedTable } from './MobileGroupedTable';
import { downloadAnalysisWorkbook, statisticsExportRows, vehicleExportRows } from '../../analysis-export'; import { downloadAnalysisWorkbook, statisticsExportRows, vehicleExportRows } from '../../analysis-export';
import { useMobileLayout } from '../../useMobileLayout';
import './asset-analysis.css'; import './asset-analysis.css';
import './mobile-analysis.css';
type Props = { selection: VehicleModalSelection | null; subject: string | null; close: () => void }; type Props = { selection: VehicleModalSelection | null; subject: string | null; close: () => void };
const number = (value: number) => value.toLocaleString('zh-CN'); 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 }) { function AnalysisDialog({ selection, subject, close }: Props & { selection: VehicleModalSelection }) {
const mobile = useMobileLayout();
const dialog = useRef<HTMLDivElement>(null); const dialog = useRef<HTMLDivElement>(null);
const scrollRoot = useRef<HTMLDivElement>(null); const scrollRoot = useRef<HTMLDivElement>(null);
useDialogFocus(true, dialog, close); useDialogFocus(true, dialog, close);
@@ -51,6 +56,9 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
const [mergeParents, setMergeParents] = useState(true); const [mergeParents, setMergeParents] = useState(true);
const [detailGroup, setDetailGroup] = useState<CustomAnalysisRow | null>(null); const [detailGroup, setDetailGroup] = useState<CustomAnalysisRow | null>(null);
const showDetails = detailGroup !== 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 [vehicles, setVehicles] = useState<VehicleListItem[]>([]);
const [detailBusy, setDetailBusy] = useState(false); const [detailBusy, setDetailBusy] = useState(false);
const [detailError, setDetailError] = useState(''); const [detailError, setDetailError] = useState('');
@@ -107,7 +115,7 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
: analysis.rows, [analysis, merged, sort]); : 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 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 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 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 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]); const spans = useMemo(() => visibleDimensionRowSpans(completeSpans, groupProgress.visibleCount), [completeSpans, groupProgress.visibleCount]);
@@ -132,17 +140,21 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
}; };
return <div className="asset-analysis-overlay"> 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' : ''}`}> <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> {(!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} /> : <> {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 && <div className="analysis-navigation"><button className="analysis-back" onClick={() => setDetailGroup(null)}><ArrowLeft size={14} />返回统计分析</button><span className="analysis-detail-scope">{detailGroup.label}</span></div>} {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"> <div ref={scrollRoot} className="analysis-scroll">
{!showDetails && <> {!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> <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 === '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>} {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} />}
<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> {!mobile && view === 'abnormal' && <p className="analysis-definition">已异动时长=统计时间-审批通过时间;区间左含右不含,缺失时间单列。{data && !data.approvalTimeAvailable && ' 当前未取得可用审批时间。'}</p>}
{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> : <> <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> <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> <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}> <tbody>{shownGroups.map((row, rowIndex) => <tr key={row.key} className={merged && spans[rowIndex][0] > 0 ? 'analysis-group-start' : undefined}>
@@ -153,14 +165,15 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
<td className="analysis-numeric" data-label="数量(台)"><strong>{number(row.count)}</strong></td> <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></>} {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-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> </tr>)}</tbody>
</table></div> </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> <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="组" /> <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> <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> : <> {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>} {vehicles.length !== detailGroup.count && <p className="analysis-definition">车辆状态可能已更新:统计快照 {detailGroup.count} 台,当前明细 {vehicles.length} 台。</p>}
@@ -169,19 +182,26 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
</>} </>}
</div>} </div>}
</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>
</div>; </div>;
} }
function VehicleRows({ vehicles, inventory = false }: { vehicles: VehicleListItem[]; inventory?: boolean }) { function VehicleRows({ vehicles, inventory = false }: { vehicles: VehicleListItem[]; inventory?: boolean }) {
const mobile = useMobileLayout();
if (!vehicles.length) return <div className="analysis-message">暂无匹配车辆</div>; if (!vehicles.length) return <div className="analysis-message">暂无匹配车辆</div>;
const stateLabel = (vehicle: VehicleListItem) => { const stateLabel = (vehicle: VehicleListItem) => {
const operation = ({ '1': '租赁', '2': '自营', '3': '可运营', '4': '待运营' } as Record<string, string>)[vehicle.operationStatus || ''] || vehicle.operationStatus; 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; const business = ({ Abnormal: '异动', Pending: '待交车', Inventory: '在库', Operating: '运营中' } as Record<string, string>)[vehicle.status] || vehicle.status;
return [business, operation].filter(Boolean).join(' · '); return [business, operation].filter(Boolean).join(' · ');
}; };
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>; 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>;
} }
@@ -191,7 +211,8 @@ const EMPTY_WEEKLY_ROWS: WeeklyDetailItem[] = [];
const weeklyDimensionValue = (row: WeeklyDetailItem, dimension: WeeklyDimension) => dimension === 'date' const weeklyDimensionValue = (row: WeeklyDetailItem, dimension: WeeklyDimension) => dimension === 'date'
? row.handover_date?.slice(0, 10) || '' : row.contract_type?.trim() || ''; ? 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; type Dimension = WeeklyDimension;
const dimensionsAvailable = WEEKLY_DIMENSIONS; const dimensionsAvailable = WEEKLY_DIMENSIONS;
const scrollRoot = useRef<HTMLDivElement>(null); const scrollRoot = useRef<HTMLDivElement>(null);
@@ -222,12 +243,15 @@ function WeeklyAnalysis({ rows }: { rows: WeeklyDetailItem[] }) {
const detailGroup = detailKey === null ? null : grouped.get(detailKey); const detailGroup = detailKey === null ? null : grouped.get(detailKey);
const detailRows = detailGroup?.rows ?? EMPTY_WEEKLY_ROWS; const detailRows = detailGroup?.rows ?? EMPTY_WEEKLY_ROWS;
const showDetails = detailKey !== null; 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 const groupLabel = (group: { values: string[] }) => dimensions.length
? dimensions.map((dimension, index) => `${labels[dimension]}:${label(dimension, group.values[index])}`).join(' · ') ? 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 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 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 detailProgress = useProgressiveRows({ total: detailRows.length, rootRef: scrollRoot, resetKey: detailRows, enabled: showDetails });
const shownGroups = useMemo(() => groups.slice(0, groupProgress.visibleCount), [groups, groupProgress.visibleCount]); 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]); 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 })); const details = selectedRows.map(row => ({ 车牌: row.plate_number, 日期: row.handover_date, 合同类型: row.contract_type, 客户: row.customer_name }));
return downloadAnalysisWorkbook(statistics, details, `交还车分析-${detailGroup ? groupLabel(detailGroup) : '当前筛选结果'}`); 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"> return <div ref={scrollRoot} className="analysis-scroll">
{!showDetails ? <> {!showDetails ? <>
<div className="analysis-builder"> <div className="analysis-builder">
@@ -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 { interface ProgressiveRowsOptions {
total: number; total: number;
@@ -6,11 +6,14 @@ interface ProgressiveRowsOptions {
resetKey: unknown; resetKey: unknown;
enabled?: boolean; enabled?: boolean;
batchSize?: number; batchSize?: number;
preserveOnDisable?: boolean;
} }
/** Render a cumulative prefix, loading another batch near the actual scroll body's bottom. */ /** 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) { export function useProgressiveRows({ total, rootRef, resetKey, enabled = true, batchSize = 40, preserveOnDisable = false }: ProgressiveRowsOptions) {
const context = useMemo(() => ({ resetKey, enabled, batchSize }), [resetKey, enabled, batchSize]); 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 [progress, setProgress] = useState(() => ({ context, limit: batchSize }));
const [sentinel, setSentinel] = useState<HTMLDivElement | null>(null); const [sentinel, setSentinel] = useState<HTMLDivElement | null>(null);
const visibleCount = Math.min(total, progress.context === context ? progress.limit : batchSize); 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]); }, [enabled, context, total, batchSize]);
useEffect(() => { useEffect(() => {
if (enabled && rootRef.current) rootRef.current.scrollTop = 0; if (enabled && rootRef.current && (!preserveOnDisable || scrollContext.current !== context)) rootRef.current.scrollTop = 0;
}, [context, enabled, rootRef]); scrollContext.current = context;
}, [context, enabled, rootRef, preserveOnDisable]);
useEffect(() => { useEffect(() => {
const root = rootRef.current; const root = rootRef.current;
@@ -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>;
}
@@ -17,7 +17,7 @@ import '../components/analysis/mobile-analysis.css';
const available = FAULT_ANALYSIS_DIMENSIONS; const available = FAULT_ANALYSIS_DIMENSIONS;
// Page-lifetime memory only: closing a modal retains settings; a full reload clears them. // Page-lifetime memory only: closing a modal retains settings; a full reload clears them.
const analysisSettings = new Map<string, { dimensions: FaultAnalysisDimension[]; filters: FaultAnalysisFilters }>(); const analysisSettings = new Map<string, { dimensions: FaultAnalysisDimension[]; filters: FaultAnalysisFilters }>();
export interface FaultAnalysisSource { url: string; title: string; periodLabel: string; definition?: string; defaultDimensions?: FaultAnalysisDimension[] } 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 }) { export function FaultAnalysisModal({ metric, onClose, source }: { metric: FaultOverviewMetric; onClose: () => void; source?: FaultAnalysisSource }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useDialogFocus(true, ref, onClose); useDialogFocus(true, ref, onClose);
@@ -38,7 +38,7 @@ export function FaultAnalysisModal({ metric, onClose, source }: { metric: FaultO
const controller = new AbortController(); const controller = new AbortController();
setError(''); setData(null); setError(''); setData(null);
fetchJson<FaultAnalysisResponse>(source?.url || `/api/asset-faults/analysis?metric=${metric}`, { signal: controller.signal }) fetchJson<FaultAnalysisResponse>(source?.url || `/api/asset-faults/analysis?metric=${metric}`, { signal: controller.signal })
.then(result => { if (!controller.signal.aborted) setData(result); }).catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); }); .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(); return () => controller.abort();
}, [metric, revision, source?.url]); }, [metric, revision, source?.url]);
const records = useMemo(() => data?.rows || [], [data]); const records = useMemo(() => data?.rows || [], [data]);
+67 -60
View File
@@ -1,64 +1,71 @@
import { Fragment, useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { ArrowUpRight, CheckCircle2, ClipboardList, Clock3, PlusCircle, RefreshCw } from 'lucide-react'; import { ArrowUpRight, CarFront, ClipboardList, Clock3, Filter, Info, RefreshCw, Search, TriangleAlert } from 'lucide-react';
import { fetchJson } from '../../../auth/api-client'; import { fetchJson } from '../../../auth/api-client';
import { FAULT_OVERVIEW_METRICS, type FaultOverview, type FaultOverviewMetric } from '../../../shared/assets/faults'; import { FAULT_DASHBOARD_FILTERS, type FaultAnalysisResponse, type FaultDashboardDimension, type FaultDashboardFilters, type FaultDashboardResponse, type FaultDashboardScope } from '../../../shared/assets/faults';
import { FaultAnalysisModal } from './FaultAnalysisModal'; import { FlowDateRangePicker } from '../components/overview/FlowDateRangePicker';
import { FaultWeeklyReport } from './FaultWeeklyReport'; import { AnalysisExportButton } from '../components/analysis/AnalysisExportButton';
import { FaultTrendCard } from './FaultTrendCard'; 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 './fault-management.css';
import './dashboard.css';
export const FAULT_DEFINITIONS = { const tabs = ['管理总览','多维分析','故障明细'] as const;
reported: '按今日提报时间统计', closed: '按今日解决时间统计', 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};}
open: '当前在办 = 待处理 + 处理中', ledger: '全部有效故障台账', export default function FaultManagement(){
}; const [range,setRange]=useState(initialRange),[filters,setFilters]=useState<FaultDashboardFilters>({});
const icons = { reported: PlusCircle, closed: CheckCircle2, open: Clock3, ledger: ClipboardList }; const [applied,setApplied]=useState(()=>({...initialRange(),filters:{} as FaultDashboardFilters}));
const metrics = ['reported', 'closed', 'open', 'ledger'] as const; const [tab,setTab]=useState<typeof tabs[number]>('管理总览'),[more,setMore]=useState(false);
export default function FaultManagement() { const [data,setData]=useState<FaultDashboardResponse|null>(null),[loading,setLoading]=useState(true),[error,setError]=useState('');
const [data, setData] = useState<FaultOverview | null>(null); const [revision,setRevision]=useState(0),[source,setSource]=useState<FaultAnalysisSource|null>(null);
const [error, setError] = useState(''); const [detailScope,setDetailScope]=useState<FaultDashboardScope>('cohort'),[detailData,setDetailData]=useState<FaultAnalysisResponse|null>(null),[detailError,setDetailError]=useState(''),[detailLoading,setDetailLoading]=useState(false);
const [loading, setLoading] = useState(true); const [search,setSearch]=useState(''),[limit,setLimit]=useState(50);
const [revision, setRevision] = useState(0); const close=useCallback(()=>setSource(null),[]);
const [metric, setMetric] = useState<FaultOverviewMetric | null>(null); const query=new URLSearchParams({start:applied.start,end:applied.end,...applied.filters}).toString();
const close = useCallback(() => setMetric(null), []); 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(() => { 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 controller = new AbortController(); 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'?'按北京时间今日解决时间统计,不限制提报日期。':'不限制提报日期,按当前状态及已应用条件统计;当前在办 = 待处理 + 处理中。'});};
setLoading(true); setError(''); const t=data?.totals;
fetchJson<FaultOverview>('/api/asset-faults/overview', { signal: controller.signal }) 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}));
.then(setData).catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); }) const dirty=range.start!==applied.start||range.end!==applied.end||JSON.stringify(filters)!==JSON.stringify(applied.filters);
.finally(() => { if (!controller.signal.aborted) setLoading(false); }); const count=(n?:number)=>loading?'—':n?.toLocaleString('zh-CN')??'—';
return () => controller.abort(); 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>;
}, [revision]); const showAllOpen=()=>{setDetailScope('open');setTab('故障明细');setSearch('');};
return <div className="fault-page"> const showAllFaults=()=>{setFilters({});setApplied({...range,filters:{}});setDetailScope('ledger');setTab('故障明细');setSearch('');};
<header className="fault-page-header"><h1>羚牛氢能-故障管理</h1><div><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> 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>;
{error && <div className="fault-message" role="alert">{error}<button onClick={() => setRevision(v => v + 1)}>重试</button></div>} if(tab==='管理总览')return <FaultOverview navigation={navigation}/>;
<section className="fault-summary" aria-label="故障总统计" aria-busy={loading}> return <div className="fault-page fd-page">
<div className="fault-summary-heading"><h2>故障数据实时汇总</h2><span>{data?.date || '—'} · 北京时间</span></div> <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>
<div className="fault-metrics">{metrics.map(key => { {navigation}
const Icon = icons[key]; <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>
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] ?? '加载中'} 单,查看分析`}> <p className="fd-scope-note">期间指标按提报日期统计;当前在办不限制提报日期,状态截至更新时间。{dirty&&<strong>条件已修改,点击查询后生效。</strong>}</p>
<div className="fault-metric-label"><span className="fault-metric-icon"><Icon size={19} /></span><span>{FAULT_OVERVIEW_METRICS[key]}</span><ArrowUpRight size={15} /></div> {error&&<div className="fault-message" role="alert">{error}<button onClick={()=>setRevision(v=>v+1)}>重试</button></div>}
<div className="fault-metric-count"><strong>{loading ? '—' : data?.totals[key].toLocaleString('zh-CN') ?? '—'}</strong><span>单</span></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}>
<p>{FAULT_DEFINITIONS[key]}</p><span className="fault-metric-link">查看分析与明细 →</span> {tab==='多维分析'&&<>
</button> <section className="fd-kpis" aria-label="故障总统计">
{key === 'open' && <> <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>
<button className="fault-overdue-subset" disabled={!data || loading || !!error} onClick={() => setMetric('overdue')} aria-label={`其中逾期 ${data?.totals.overdue ?? '加载中'} 单,查看分析`}> <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>
<span><span className="fault-overdue-prefix">其中</span>逾期</span><strong>{loading ? '—' : data?.totals.overdue?.toLocaleString('zh-CN') ?? '—'}</strong><small>单</small> <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>
</button> <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>
<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> </section>
<FaultTrendCard refresh={revision} /> <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>
<FaultWeeklyReport refresh={revision} /> <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>
{metric && <FaultAnalysisModal key={metric} metric={metric} onClose={close} />} {tab==='多维分析'&&<div className="fd-analysis-banner"><div><h2>组合维度,追溯故障</h2><p>支持运维处置人、车牌、工单状态、车型、区域、客户等 12 个维度,多选筛选与明细下钻。</p></div><button className="fd-query" onClick={()=>drill('本期故障多维分析')}>打开多维分析</button></div>}
</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>;
}
+11 -8
View File
@@ -1,3 +1,4 @@
import type { FaultDashboardFilters } from '../../../shared/assets/faults';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { CalendarDays, ChevronDown, RefreshCw } from 'lucide-react'; import { CalendarDays, ChevronDown, RefreshCw } from 'lucide-react';
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
@@ -14,9 +15,11 @@ function initialRange() {
} }
const statusLabels = { all: '新增故障', closed: '其中已闭环', unclosed: '其中未闭环' }; const statusLabels = { all: '新增故障', closed: '其中已闭环', unclosed: '其中未闭环' };
const countKeys = { all: 'reported', closed: 'closed', unclosed: 'unclosed' } as const; const countKeys = { all: 'reported', closed: 'closed', unclosed: 'unclosed' } as const;
export function FaultTrendCard({ refresh }: { refresh: number }) { export function FaultTrendCard({ refresh, scope = {}, scopeLabel = '', period }: { refresh: number; scope?: FaultDashboardFilters; scopeLabel?: string; period?: { start: string; end: string } }) {
const [range, setRange] = useState(initialRange); const [range, setRange] = useState(() => period || initialRange());
const [granularity, setGranularity] = useState<'day' | 'month'>('day'); 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 [data, setData] = useState<FaultTrendResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -28,22 +31,22 @@ export function FaultTrendCard({ refresh }: { refresh: number }) {
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
setLoading(true); setError(''); setData(null); setLoading(true); setError(''); setData(null);
const params = new URLSearchParams({ ...range, granularity }); const params = new URLSearchParams({ ...range, granularity, ...scope });
fetchJson<FaultTrendResponse>(`/api/asset-faults/trend?${params}`, { signal: controller.signal }) fetchJson<FaultTrendResponse>(`/api/asset-faults/trend?${params}`, { signal: controller.signal })
.then(result => { if (!controller.signal.aborted) setData(result); }) .then(result => { if (!controller.signal.aborted) setData(result); })
.catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); }) .catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); })
.finally(() => { if (!controller.signal.aborted) setLoading(false); }); .finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort(); return () => controller.abort();
}, [range.start, range.end, granularity, revision, refresh]); }, [range.start, range.end, granularity, revision, refresh, scopeKey]);
const drill = (status: FaultTrendStatus, period?: string) => { const drill = (status: FaultTrendStatus, period?: string) => {
if (!data || loading) return; if (!data || loading) return;
const params = new URLSearchParams({ start: data.start, end: data.end, granularity: data.granularity, status }); const params = new URLSearchParams({ start: data.start, end: data.end, granularity: data.granularity, status, ...scope });
if (period) params.set('period', period); if (period) params.set('period', period);
setSource({ url: `/api/asset-faults/trend-analysis?${params}`, title: `故障处理趋势 · ${statusLabels[status]}`, defaultDimensions: ['handler', 'faultStatus'], setSource({ url: `/api/asset-faults/trend-analysis?${params}`, title: `故障处理趋势 · ${statusLabels[status]}`, defaultDimensions: ['handler', 'faultStatus'],
periodLabel: `${data.start} 至 ${data.end}${period ? ` · ${period}` : ''} · 按提报日期` }); periodLabel: `${data.start} 至 ${data.end}${period ? ` · ${period}` : ''} · 按提报日期` });
}; };
return <section className="fault-trend fault-summary" aria-label="故障处理趋势" aria-busy={loading}> 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 }, 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> <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> <p className="fault-trend-definition">按提报日期统计新增故障,展示这批故障截至当前的闭环情况。新增 = 已闭环 + 未闭环。点击柱内色块,可按运维处置人、故障工单状态查看对应日期的故障。</p>
<FlowDateRangePicker dateLabel="故障提报时间" value={range} onChange={setRange} /> <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> : <> {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> : <>
@@ -56,8 +59,8 @@ export function FaultTrendCard({ refresh }: { refresh: number }) {
<YAxis allowDecimals={false} tick={{ fontSize: 10, fill: '#64748b' }} 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]} /> <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 }} /> <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="#10b981" stackId="faults" maxBarSize={34} isAnimationActive={false} /> <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="#f59e0b" 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> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> : <p className="fault-empty">所选时间范围暂无新增故障</p>} </div> : <p className="fault-empty">所选时间范围暂无新增故障</p>}
@@ -1,3 +1,4 @@
import type { FaultDashboardFilters } from '../../../shared/assets/faults';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { ClipboardList, RefreshCw } from 'lucide-react'; import { ClipboardList, RefreshCw } from 'lucide-react';
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
@@ -11,8 +12,10 @@ import { FaultAnalysisModal, type FaultAnalysisSource } from './FaultAnalysisMod
const metrics: FaultWeeklyMetric[] = ['reported', 'closed']; const metrics: FaultWeeklyMetric[] = ['reported', 'closed'];
const definition = '新增故障按提报时间统计,解决故障按解决时间统计;同一故障可在不同日期分别计入两个指标。'; const definition = '新增故障按提报时间统计,解决故障按解决时间统计;同一故障可在不同日期分别计入两个指标。';
export function FaultWeeklyReport({ refresh }: { refresh: number }) { export function FaultWeeklyReport({ refresh, scope = {}, scopeLabel = '', period }: { refresh: number; scope?: FaultDashboardFilters; scopeLabel?: string; period?: { start: string; end: string } }) {
const [range, setRange] = useState(() => getWeeklyFlowRange()); 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 [data, setData] = useState<FaultWeeklyResponse | null>(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -24,22 +27,22 @@ export function FaultWeeklyReport({ refresh }: { refresh: number }) {
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
setLoading(true); setData(null); setError(''); setLoading(true); setData(null); setError('');
fetchJson<FaultWeeklyResponse>(`/api/asset-faults/weekly-report?${new URLSearchParams({ start: range.start, end: range.end })}`, { signal: controller.signal }) 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); }) .then(result => { if (!controller.signal.aborted) setData(result); })
.catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); }) .catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); })
.finally(() => { if (!controller.signal.aborted) setLoading(false); }); .finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort(); return () => controller.abort();
}, [range.start, range.end, revision, refresh]); }, [range.start, range.end, revision, refresh, scopeKey]);
const drill = (metric: FaultWeeklyMetric, day?: string) => { const drill = (metric: FaultWeeklyMetric, day?: string) => {
if (!data || loading) return; if (!data || loading) return;
const params = new URLSearchParams({ start: data.start, end: data.end, metric }); const params = new URLSearchParams({ start: data.start, end: data.end, metric, ...scope });
if (day) params.set('day', day); if (day) params.set('day', day);
setSource({ url: `/api/asset-faults/weekly-report-analysis?${params}`, title: `故障运维周报 · ${FAULT_WEEKLY_METRICS[metric]}`, setSource({ url: `/api/asset-faults/weekly-report-analysis?${params}`, title: `故障运维周报 · ${FAULT_WEEKLY_METRICS[metric]}`,
periodLabel: `${day || `${data.start} 至 ${data.end}`} · 按事件发生时间`, definition }); periodLabel: `${day || `${data.start} 至 ${data.end}`} · 按事件发生时间`, definition });
}; };
return <section className="fault-weekly fault-trend fault-summary" aria-label="故障运维周报" aria-busy={loading}> 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 }, chartRef.current?.querySelector<SVGSVGElement>('svg.recharts-surface[role=application]') || undefined) : undefined} /></div></div> <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">按日展示新增故障和解决故障。默认业务周为周六至周五,可自选日期。</p> <p className="fault-trend-definition">按日展示新增故障和解决故障。{period ? '统计周期沿用查询范围,可在下方单独调整。' : '默认业务周为周六至周五,可自选日期。'}</p>
<FlowDateRangePicker dateLabel="提报 / 解决时间" value={range} onChange={setRange} /> <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> : <> {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 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>
@@ -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);
}
+123
View File
@@ -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)); }
}
+2 -1
View File
@@ -1,7 +1,7 @@
import type { FaultTrendResponse, FaultWeeklyResponse } from '../../../shared/assets/faults'; import type { FaultTrendResponse, FaultWeeklyResponse } from '../../../shared/assets/faults';
import type { Cell, Worksheet } from 'exceljs'; import type { Cell, Worksheet } from 'exceljs';
export type FaultReport = { kind: 'trend'; data: FaultTrendResponse } | { kind: 'weekly'; data: FaultWeeklyResponse }; export type FaultReport = ({ kind: 'trend'; data: FaultTrendResponse } | { kind: 'weekly'; data: FaultWeeklyResponse }) & { filterLabel?: string };
interface ReportChartImage { dataUrl: string; width: number; height: number } 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 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 fill = (color: string) => ({ type: 'pattern' as const, pattern: 'solid' as const, fgColor: { argb: `FF${color}` } });
@@ -39,6 +39,7 @@ export async function createFaultReportWorkbook(report: FaultReport, chartImage?
merged(sheet, 2, 1, columns, `统计范围:${report.data.start} 至 ${report.data.end} · ${trend && report.data.granularity === 'month' ? '按月' : '按日'} · 单位:单`); 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}` } }; 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; 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 sum = (column: string) => periods.length ? `SUM(${column}${start}:${column}${end})` : '0';
const cards = trend 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: '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 }]
+7 -29
View File
@@ -28,6 +28,7 @@ export default function HydrogenDaily() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setError(null); setError(null);
setRows(null);
const query = pick === 'custom' const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end } ? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick }; : { range: pick };
@@ -81,34 +82,13 @@ export default function HydrogenDaily() {
<div className="grid grid-cols-2 gap-3 md:grid-cols-4"> <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} /> <MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" /> <MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper={customer === 'external' ? '佛山南海羚牛加氢站 · 未关联车辆的订单' : '当前筛选口径'} tone="emerald" />
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" /> <MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" /> <MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
</div> </div>
{/* 外部车辆:新系统数据还没准备好 */} {/* 时段加氢量柱图 */}
{customer === 'external' && rows !== null && totalKg === 0 && ( {!error && trendData.length > 0 && totalKg > 0 && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
>
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3 relative">
<Plug size={22} className="text-blue-500" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
</div>
<div className="text-sm font-bold text-slate-700 mb-1">外部车辆 · 数据未就绪</div>
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
新系统的外部车辆加氢数据还在准备中
<br />
上线后此处将展示完整明细
</div>
</motion.div>
)}
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
<SurfaceCard> <SurfaceCard>
<div className="flex items-center justify-between px-4 pt-4 mb-2"> <div className="flex items-center justify-between px-4 pt-4 mb-2">
<span className="text-sm font-bold text-slate-700">每日加氢量</span> <span className="text-sm font-bold text-slate-700">每日加氢量</span>
@@ -184,8 +164,7 @@ export default function HydrogenDaily() {
</SurfaceCard> </SurfaceCard>
)} )}
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */} {/* 日明细:加载失败、无订单与正常数据分别展示。 */}
{!(customer === 'external' && rows !== null && totalKg === 0) && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden"> <div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
{/* 表头 */} {/* 表头 */}
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500"> <div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
@@ -206,8 +185,8 @@ export default function HydrogenDaily() {
<div className="p-3"><ErrorState message={error} /></div> <div className="p-3"><ErrorState message={error} /></div>
) : rows === null ? ( ) : rows === null ? (
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div> <div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
) : rows.length === 0 ? ( ) : rows.length === 0 || totalKg === 0 ? (
<div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div> <div className="p-3"><EmptyState title="所选时段暂无加氢数据" description={customer === 'external' ? '佛山南海羚牛加氢站在该时段没有未关联车辆的加氢订单,请切换时间范围' : '请切换时间范围或车辆归属'} /></div>
) : rows.map(r => { ) : rows.map(r => {
const open = expanded.has(r.date); const open = expanded.has(r.date);
const isAbnormal = Math.abs(r.chainPct) >= 0.3; const isAbnormal = Math.abs(r.chainPct) >= 0.3;
@@ -270,7 +249,6 @@ export default function HydrogenDaily() {
); );
})} })}
</div> </div>
)}
</div> </div>
); );
+1 -1
View File
@@ -43,7 +43,7 @@ export function createPreviewRouter(service: Service = previewService) {
return router; 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']); 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) { export function createPreviewMiddleware(service: Service = previewService) {
return async (c: Context, next: Next) => { return async (c: Context, next: Next) => {
@@ -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 assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { Hono } from "hono"; 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 assert from "node:assert/strict";
import test, { type TestContext } from "node:test"; import test, { type TestContext } from "node:test";
import { Hono } from "hono"; 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 assert from "node:assert/strict";
import test, { type TestContext } from "node:test"; import test, { type TestContext } from "node:test";
import { Hono } from "hono"; 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 assert from "node:assert/strict";
import test, { type TestContext } from "node:test"; import test, { type TestContext } from "node:test";
import { Hono } from "hono"; 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 assert from "node:assert/strict";
import test, { type TestContext } from "node:test"; import test, { type TestContext } from "node:test";
import { Hono } from "hono"; import { Hono } from "hono";
+27 -1
View File
@@ -21,6 +21,7 @@ import {
} from "./session.js"; } from "./session.js";
import { resultScope, followupSuggestions } from "./presentation.js"; import { resultScope, followupSuggestions } from "./presentation.js";
import { gapStore } from "./gaps.js"; import { gapStore } from "./gaps.js";
import { questionLog, questionOutcome, finishQuestionLog } from "./question-log.js";
import { import {
CATEGORY_LABELS, CATEGORY_LABELS,
GAP_STATUSES, GAP_STATUSES,
@@ -99,6 +100,10 @@ router.post("/feedback", async (c) => {
const session = getSession(c.get("user"), body.subject, body.sessionId), const session = getSession(c.get("user"), body.subject, body.sessionId),
node = getNode(session, body.resultId)!; node = getNode(session, body.resultId)!;
if (!node.flagged) { 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"); await gapStore.record(c.get("user").userId, node.demand, "unsatisfied");
node.flagged = true; node.flagged = true;
} }
@@ -220,15 +225,24 @@ router.post("/ask", async (c) => {
) )
return c.json({ error: "请输入有效问题或选择分析操作。" }, 400); return c.json({ error: "请输入有效问题或选择分析操作。" }, 400);
const input: AssistantRequest = { ...raw, subject: raw.subject || null }; 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); const release = acquire(user.userId);
if (!release) if (!release) {
await finishQuestionLog(logId, { outcome: "rejected", reason: "busy" });
return c.json({ error: "上一条查询仍在执行,请稍后重试。" }, 429); return c.json({ error: "上一条查询仍在执行,请稍后重试。" }, 429);
}
let session, base; let session, base;
try { try {
session = getSession(user, input.subject, input.sessionId); session = getSession(user, input.subject, input.sessionId);
base = getNode(session, input.baseResultId); base = getNode(session, input.baseResultId);
} catch { } catch {
release(); release();
await finishQuestionLog(logId, { outcome: "rejected", reason: "session_expired" });
return c.json({ error: "会话已过期或范围已变化,请新建对话。" }, 409); return c.json({ error: "会话已过期或范围已变化,请新建对话。" }, 409);
} }
const currentSession = session, const currentSession = session,
@@ -238,6 +252,7 @@ router.post("/ask", async (c) => {
stream.onAbort(() => controller.abort()); stream.onAbort(() => controller.abort());
const emit = (event: object) => const emit = (event: object) =>
stream.writeSSE({ data: JSON.stringify(event) }); stream.writeSSE({ data: JSON.stringify(event) });
let logFinished = false;
let demand = "查询执行", let demand = "查询执行",
reason: GapReason | undefined; reason: GapReason | undefined;
try { try {
@@ -516,8 +531,17 @@ router.post("/ask", async (c) => {
type: "step", type: "step",
label: "分析完成", 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 }); await emit({ type: "result", result });
} catch { } 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) { if (!controller.signal.aborted) {
let recorded = false; let recorded = false;
try { try {
@@ -534,6 +558,8 @@ router.post("/ask", async (c) => {
}); });
} }
} finally { } finally {
if (!logFinished) await finishQuestionLog(logId, { outcome: controller.signal.aborted ? "cancelled" : "failed",
reason: controller.signal.aborted ? "client_aborted" : "unfinished", sessionId: currentSession.id });
release(); 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 assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { Hono } from "hono"; 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 assert from "node:assert/strict";
import test, { type TestContext } from "node:test"; import test, { type TestContext } from "node:test";
import { Hono } from "hono"; 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}&region=不存在`)).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},
};
}
+15 -2
View File
@@ -1,4 +1,5 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { dashboardAnalysis, faultDashboard, filterDashboardRows, parseDashboardQuery } from './dashboard.js';
import { FAULT_OVERVIEW_METRICS, type FaultOverviewMetric } from '../../../shared/assets/faults.js'; import { FAULT_OVERVIEW_METRICS, type FaultOverviewMetric } from '../../../shared/assets/faults.js';
import type { AuthUser } from '../../auth/types.js'; import type { AuthUser } from '../../auth/types.js';
import { analyzeFaults, faultOverview, faultAnalysis, parseFaultQuery, visibleFaults } from './model.js'; import { analyzeFaults, faultOverview, faultAnalysis, parseFaultQuery, visibleFaults } from './model.js';
@@ -8,6 +9,18 @@ import { faultTrend, faultTrendAnalysis, parseFaultTrendQuery } from './trend.js
export function createFaultRouter(load = loadFaults) { export function createFaultRouter(load = loadFaults) {
const app = new Hono<{ Variables: { user: AuthUser } }>(); const app = new Hono<{ Variables: { user: AuthUser } }>();
for (const path of ['/dashboard', '/dashboard-analysis'] as const) {
app.get(path, async c => {
const user = c.get('user');
if (!user) return c.json({ error: 'Unauthorized' }, 401);
const params = c.req.query();
try { parseDashboardQuery(params); } catch(error) { return c.json({error:(error as Error).message},400); }
try {
const data = await load(); const rows = visibleFaults(data.rows,user); c.header('Cache-Control','no-store');
return c.json(path === '/dashboard' ? faultDashboard(rows,params,data.updatedAt) : dashboardAnalysis(rows,params,data.updatedAt));
} catch { return c.json({error:'故障分析暂时不可用,请稍后重试'},503); }
});
}
for (const path of ['/trend', '/trend-analysis'] as const) { for (const path of ['/trend', '/trend-analysis'] as const) {
app.get(path, async c => { app.get(path, async c => {
const user = c.get('user'); const user = c.get('user');
@@ -17,7 +30,7 @@ export function createFaultRouter(load = loadFaults) {
catch (error) { return c.json({ error: (error as Error).message }, 400); } catch (error) { return c.json({ error: (error as Error).message }, 400); }
try { try {
const data = await load(); const data = await load();
const rows = visibleFaults(data.rows, user); const rows = filterDashboardRows(visibleFaults(data.rows, user), c.req.query());
c.header('Cache-Control', 'no-store'); c.header('Cache-Control', 'no-store');
return c.json(path === '/trend' ? faultTrend(rows, query, data.updatedAt) : faultTrendAnalysis(rows, query, data.updatedAt)); return c.json(path === '/trend' ? faultTrend(rows, query, data.updatedAt) : faultTrendAnalysis(rows, query, data.updatedAt));
} catch (error) { } catch (error) {
@@ -35,7 +48,7 @@ export function createFaultRouter(load = loadFaults) {
catch (error) { return c.json({ error: (error as Error).message }, 400); } catch (error) { return c.json({ error: (error as Error).message }, 400); }
try { try {
const data = await load(); const data = await load();
const rows = visibleFaults(data.rows, user); const rows = filterDashboardRows(visibleFaults(data.rows, user), c.req.query());
c.header('Cache-Control', 'no-store'); c.header('Cache-Control', 'no-store');
return c.json(path === '/weekly-report' ? faultWeeklyReport(rows, query, data.updatedAt) : faultWeeklyAnalysis(rows, query, data.updatedAt)); return c.json(path === '/weekly-report' ? faultWeeklyReport(rows, query, data.updatedAt) : faultWeeklyAnalysis(rows, query, data.updatedAt));
} catch (error) { } catch (error) {
+15 -3
View File
@@ -1,5 +1,5 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { FAULT_DIMENSIONS, FAULT_OVERVIEW_METRICS, type FaultDimension, type FaultOption, type FaultRecord, type FaultQuery, type FaultResponse } from '../../../shared/assets/faults.js'; import { FAULT_DIMENSIONS, FAULT_OVERVIEW_METRICS, type FaultDimension, type FaultOption, type FaultRecord, type FaultQuery, type FaultResponse, type FaultReportMethod } from '../../../shared/assets/faults.js';
import type { AuthUser } from '../../auth/types.js'; import type { AuthUser } from '../../auth/types.js';
import { filterByPermission, maskCustomerName } from '../../auth/permissions.js'; import { filterByPermission, maskCustomerName } from '../../auth/permissions.js';
import type { AssetAnalysisCell } from '../../../shared/assets/analytics.js'; import type { AssetAnalysisCell } from '../../../shared/assets/analytics.js';
@@ -9,6 +9,7 @@ export type RawFault = Record<string, unknown>;
export interface ScopedFault extends FaultRecord { export interface ScopedFault extends FaultRecord {
subject: string; departmentName: string; managerId: string; missingVehicle: boolean; subject: string; departmentName: string; managerId: string; missingVehicle: boolean;
assetCell: AssetAnalysisCell; assetCell: AssetAnalysisCell;
vehicleKey?: string;
} }
export function normalizeFaultStatus(value: unknown): string { export function normalizeFaultStatus(value: unknown): string {
const status = String(value ?? '').trim(); const status = String(value ?? '').trim();
@@ -27,6 +28,13 @@ function array(value: unknown, preserveIds = false): unknown[] {
} }
const text = (value: unknown) => String(value ?? '').trim(); const text = (value: unknown) => String(value ?? '').trim();
const positiveId = (value: unknown) => /^[1-9]\d*$/.test(text(value)); const positiveId = (value: unknown) => /^[1-9]\d*$/.test(text(value));
// Match OneOS reportMethod: explicit source wins, task linkage is a legacy fallback.
export function faultReportMethod(row: RawFault): FaultReportMethod {
const method = text(row.report_method);
if (method === '运维上报') return 'operations';
if (method === '客户上报') return 'customer';
return positiveId(row.source_work_order_id) ? 'customer' : 'operations';
}
export function faultHandlers(row: RawFault, directory: Map<string, string>): FaultOption[] { export function faultHandlers(row: RawFault, directory: Map<string, string>): FaultOption[] {
const candidates = [ const candidates = [
[array(row.work_ids, true), array(row.work_names)], [array(row.work_ids, true), array(row.work_names)],
@@ -77,6 +85,10 @@ export function mapFault(row: RawFault, directory: Map<string, string>): ScopedF
manager: text(row.business_manager) || '未分配', customer: customerKey, abnormalType: '不适用', abnormalAge: 'unknown', quantity: 1, manager: text(row.business_manager) || '未分配', customer: customerKey, abnormalType: '不适用', abnormalAge: 'unknown', quantity: 1,
}; };
return { return {
vehicleKey: text(row.matched_vehicle_id) || (text(row.plate_number) ? `plate:${text(row.plate_number)}` : ''),
level: text(row.level_label) || ({ '1': 'L1-特急', '2': 'L2-紧急', '3': 'L3-一般', '4': 'L4-提示' } as Record<string, string>)[text(row.fault_level)] || '未标注',
categories: [...new Set(text(row.fault_categories).split(/[,,、;;]/).map(v => v.trim()).filter(Boolean))],
progress: text(row.solve_process), reportMethod: faultReportMethod(row),
id: text(row.id), code: text(row.fault_code), plate: text(row.plate_number) || '未关联车辆', id: text(row.id), code: text(row.fault_code), plate: text(row.plate_number) || '未关联车辆',
description: text(row.fault_desc), reportedAt: text(row.reported_at) || null, archivedAt: text(row.archived_at) || null, deadlineAt: text(row.deadline_at) || null, receivedAt: text(row.received_at) || null, description: text(row.fault_desc), reportedAt: text(row.reported_at) || null, archivedAt: text(row.archived_at) || null, deadlineAt: text(row.deadline_at) || null, receivedAt: text(row.received_at) || null,
city, status: normalizeFaultStatus(row.handling_status), assetCell, city, status: normalizeFaultStatus(row.handling_status), assetCell,
@@ -148,7 +160,7 @@ export function analyzeFaults(visible: ScopedFault[], query: FaultQuery, updated
quality: { missingReport: rows.filter(r => !r.reportedAt).length, quality: { missingReport: rows.filter(r => !r.reportedAt).length,
invalidChronology: rows.filter(r => r.reportedAt && r.archivedAt && r.archivedAt < r.reportedAt).length, invalidChronology: rows.filter(r => r.reportedAt && r.archivedAt && r.archivedAt < r.reportedAt).length,
missingVehicle: rows.filter(r => r.missingVehicle).length }, missingVehicle: rows.filter(r => r.missingVehicle).length },
details: { rows: details.slice((page - 1) * pageSize, page * pageSize).map(({ subject, departmentName, managerId, missingVehicle, assetCell, ...row }) => row), total: details.length, page, pageSize }, details: { rows: details.slice((page - 1) * pageSize, page * pageSize).map(({ subject, departmentName, managerId, missingVehicle, vehicleKey, assetCell, ...row }) => row), total: details.length, page, pageSize },
}; };
} }
@@ -187,5 +199,5 @@ export function faultAnalysis(rows: ScopedFault[], metric: import('../../../shar
const date = faultBusinessDate(updatedAt); const date = faultBusinessDate(updatedAt);
const selected = selectFaultMetric(rows, metric, date, updatedAt); const selected = selectFaultMetric(rows, metric, date, updatedAt);
return { date, updatedAt, metric, customerLabels: Object.fromEntries(selected.map(row => [row.customer, row.customerLabel])), return { date, updatedAt, metric, customerLabels: Object.fromEntries(selected.map(row => [row.customer, row.customerLabel])),
rows: selected.map(({ subject, departmentName, managerId, missingVehicle, assetCell, ...row }) => ({ ...row, cell: assetCell })) }; rows: selected.map(({ subject, departmentName, managerId, missingVehicle, vehicleKey, assetCell, ...row }) => ({ ...row, cell: assetCell })) };
} }
@@ -4,6 +4,8 @@ import { mapFault, type RawFault } from './model.js';
// OneOS fault ledger: latest delivery first; do not reuse the active-asset WHERE clause. // OneOS fault ledger: latest delivery first; do not reuse the active-asset WHERE clause.
export const FAULT_SQL = `SELECT export const FAULT_SQL = `SELECT
f.fault_level, f.fault_categories, f.solve_process, f.report_method,
CAST(f.source_work_order_id AS CHAR) source_work_order_id,
CAST(f.id AS CHAR) id, f.fault_code, f.plate_number, f.fault_desc, f.handling_status, CAST(f.id AS CHAR) id, f.fault_code, f.plate_number, f.fault_desc, f.handling_status,
DATE_FORMAT(f.fault_report_time, '%Y-%m-%d %H:%i:%s') reported_at, DATE_FORMAT(f.fault_report_time, '%Y-%m-%d %H:%i:%s') reported_at,
DATE_FORMAT(f.archived_at, '%Y-%m-%d %H:%i:%s') archived_at, DATE_FORMAT(f.archived_at, '%Y-%m-%d %H:%i:%s') archived_at,
+1 -1
View File
@@ -39,7 +39,7 @@ export function registerHydrogenDailyRoute(
HYDROGEN_BASE_WHERE_B, HYDROGEN_BASE_WHERE_B,
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`, `b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
dateRangeClause(`b.${HYDROGEN_LOCAL}`), dateRangeClause(`b.${HYDROGEN_LOCAL}`),
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'), customerClause(customer),
].join(' AND '); ].join(' AND ');
// 站点级聚合(每日 × 每站)。前端组装成 day → stations // 站点级聚合(每日 × 每站)。前端组装成 day → stations
+26 -4
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { DatabaseSync } from 'node:sqlite';
import { import {
customerClause, customerClause,
dateRangeClause, dateRangeClause,
@@ -7,10 +8,31 @@ import {
resolveDateRange, resolveDateRange,
} from './query-model.js'; } from './query-model.js';
test('客户范围继续让外部车辆返回空数据', () => { test('外部车辆只取佛山羚牛站未关联车辆的订单,车牌文字和收费不影响分类', () => {
assert.equal(customerClause('external'), '1=0'); const db = new DatabaseSync(':memory:');
assert.equal(customerClause('lingniu'), '1=1'); try {
assert.equal(customerClause('all'), '1=1'); db.exec(`CREATE TABLE orders (
id INTEGER, station_id INTEGER, vehicle_id INTEGER,
license_plate TEXT, customer_price REAL, del_flag TEXT
);
INSERT INTO orders VALUES
(1, 341, NULL, '粤A外部', 38, '0'),
(2, 341, NULL, '', 0, '0'),
(3, 341, 123, '粤A羚牛', 38, '0'),
(4, 52, NULL, '粤A其他站', 38, '0'),
(5, NULL, NULL, '', 0, '0'),
(6, NULL, 456, '粤A未关联站点', 0, '0'),
(7, 341, NULL, '粤A已删除', 38, '1');`);
const ids = (kind: 'external' | 'lingniu' | 'all') => db
.prepare(`SELECT b.id FROM orders b WHERE b.del_flag = '0' AND ${customerClause(kind)} ORDER BY b.id`)
.all().map(row => row.id);
assert.deepEqual(ids('external'), [1, 2]);
assert.deepEqual(ids('lingniu'), [3, 4, 5, 6]);
assert.deepEqual(ids('all'), [1, 2, 3, 4, 5, 6]);
assert.deepEqual([...ids('external'), ...ids('lingniu')].sort(), ids('all'));
} finally {
db.close();
}
}); });
test('能源日期快捷区间继续按本地自然日计算', () => { test('能源日期快捷区间继续按本地自然日计算', () => {
+7 -3
View File
@@ -8,10 +8,14 @@ export interface EnergyDateRange {
const YMD_RE = /^\d{4}-\d{2}-\d{2}$/; const YMD_RE = /^\d{4}-\d{2}-\d{2}$/;
// 新账本目前只承载羚牛车辆订单;外部车辆仍需返回空集合而不是混用羚牛数据。 // 主数据 ID 341:佛山南海羚牛加氢站。车牌文字存在不代表已关联车辆。
const FOSHAN_LINGNIU_STATION_ID = 341;
// 日明细账本使用别名 b;两类互斥,其他订单继续保留在原羚牛范围内。
export function customerClause(customer: CustomerKind): string { export function customerClause(customer: CustomerKind): string {
if (customer === 'external') return '1=0'; const external = `(COALESCE(b.station_id, 0) = ${FOSHAN_LINGNIU_STATION_ID} AND b.vehicle_id IS NULL)`;
if (customer === 'lingniu') return '1=1'; if (customer === 'external') return external;
if (customer === 'lingniu') return `NOT ${external}`;
return '1=1'; return '1=1';
} }
+7 -2
View File
@@ -105,6 +105,11 @@ test('四个处理器保持缓存和参数契约,加氢站点使用新主数
'/hydrogen/daily?startDate=2025-01-01&endDate=2025-01-02&customer=external&force=1', '/hydrogen/daily?startDate=2025-01-01&endDate=2025-01-02&customer=external&force=1',
); );
assert.equal(dailyResponse.status, 200); assert.equal(dailyResponse.status, 200);
assert.deepEqual(await dailyResponse.json(), [
{ date: '2025-01-02', totalKg: 10, chainPct: 0, customerType: 'external',
stations: [{ name: '测试站', pricePerKg: 35, kg: 10, chainPct: 0 }] },
{ date: '2025-01-01', totalKg: 0, chainPct: 0, customerType: 'external', stations: [] },
]);
const electricApp = new Hono(); const electricApp = new Hono();
const electricDependencies = { const electricDependencies = {
@@ -127,7 +132,7 @@ test('四个处理器保持缓存和参数契约,加氢站点使用新主数
{ key: 'electric/monthly?customer=external&start=2025-01-01&end=2025-01-02', force: true }, { key: 'electric/monthly?customer=external&start=2025-01-01&end=2025-01-02', force: true },
]); ]);
// 指纹来自拆分前的 13 条 SQL,连空白符变化也会使测试失败。 // 保留查询契约指纹;日明细已更新为佛山羚牛站未关联车辆的外部订单筛选。
assert.deepEqual( assert.deepEqual(
[...hydrogenCalls, ...electricCalls].map(call => sqlHash(call.sql)), [...hydrogenCalls, ...electricCalls].map(call => sqlHash(call.sql)),
[ [
@@ -138,7 +143,7 @@ test('四个处理器保持缓存和参数契约,加氢站点使用新主数
'256ac8309d5a', '256ac8309d5a',
'dac9e86fc527', 'dac9e86fc527',
'a71da8f05765', 'a71da8f05765',
'24b23780a259', 'd1620280f520',
'226fd8f950a1', '226fd8f950a1',
'6e4031b10926', '6e4031b10926',
'4fc77463294c', '4fc77463294c',
+19
View File
@@ -8,12 +8,15 @@ export type FaultDimension = keyof typeof FAULT_DIMENSIONS;
export type FaultMetric = 'reported' | 'closed' | 'open'; export type FaultMetric = 'reported' | 'closed' | 'open';
export interface FaultOption { value: string; label: string } export interface FaultOption { value: string; label: string }
export interface FaultCounts { reported: number; closed: number; open: number } export interface FaultCounts { reported: number; closed: number; open: number }
export const FAULT_REPORT_METHODS = { operations: '运维提报', customer: 'AI提报' } as const;
export type FaultReportMethod = keyof typeof FAULT_REPORT_METHODS;
export interface FaultRecord { export interface FaultRecord {
id: string; code: string; plate: string; description: string; id: string; code: string; plate: string; description: string;
reportedAt: string | null; archivedAt: string | null; deadlineAt: string | null; receivedAt: string | null; reportedAt: string | null; archivedAt: string | null; deadlineAt: string | null; receivedAt: string | null;
city: string; status: string; inventory: string; city: string; status: string; inventory: string;
customer: string; customerLabel: string; department: string; customer: string; customerLabel: string; department: string;
handlers: FaultOption[]; handlers: FaultOption[];
level?: string; categories?: string[]; progress?: string; reportMethod?: FaultReportMethod;
} }
export interface FaultQuery { export interface FaultQuery {
start: string; end: string; groupBy: FaultDimension; start: string; end: string; groupBy: FaultDimension;
@@ -63,3 +66,19 @@ export interface FaultWeeklyResponse {
totals: Record<FaultWeeklyMetric, number>; totals: Record<FaultWeeklyMetric, number>;
days: (Record<FaultWeeklyMetric, number> & { date: string })[]; days: (Record<FaultWeeklyMetric, number> & { date: string })[];
} }
export const FAULT_DASHBOARD_FILTERS = { region: '区域', customer: '客户', model: '品牌车型', city: '城市', department: '业务部门', handler: '运维处置人', level: '故障程度', category: '故障部位', inventory: '库存状态', reportMethod: '提报来源' } as const;
export type FaultDashboardDimension = keyof typeof FAULT_DASHBOARD_FILTERS;
export type FaultDashboardFilters = Partial<Record<FaultDashboardDimension, string>>;
export type FaultDashboardScope = 'focus' | 'cohort' | 'open' | 'waiting' | 'processing' | 'overdue' | 'ledger' | 'reported' | 'closed';
export interface FaultDistribution { value: string; label: string; reported: number; closed: number; unclosed: number; vehicles: number; open: number }
export interface FaultDashboardResponse {
start: string; end: string; updatedAt: string;
totals: { reported: number; operationsReported: number; customerReported: number; vehicles: number; customers: number; previous: number; change: number | null; open: number; waiting: number; processing: number; overdue: number; longestOverdueDays: number; todayReported: number; todayClosed: number; ledger: number };
options: Record<FaultDashboardDimension, FaultOption[]>;
distributions: Record<'region' | 'level' | 'customer' | 'model', FaultDistribution[]>;
matrix: { models: FaultOption[]; categories: FaultOption[]; cells: { model: string; category: string; count: number }[]; totals: Record<string, number> };
focus: FaultAnalysisRecord[];
focusTotals: { total: number; overdue: number };
quality: { missingLevel: number; missingCategory: number; missingVehicle: number; missingDeadline: number; missingReport: number };
}