feat(assets): 合入完整故障管理并升级至 v1.2.3
ci/woodpecker/push/woodpecker Pipeline was successful

Integrate fault management from fault_1013_improve at 1674f05, including overview, trend and weekly reports, multidimensional analysis, authorized fault details, L1 follow-up, source breakdown and Excel exports. Adapt database imports, cross-layer tests and workbook helpers to the main architecture; retain unrelated main functionality.

Validation: TypeScript check, 502 tests, production build, desktop/mobile interactions and six-sheet Excel export passed.
This commit is contained in:
shishengliang
2026-10-16 18:18:36 +08:00
parent 589f06391f
commit 097e882886
38 changed files with 4665 additions and 8 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ version: '3.8'
services:
ln-bi:
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.2.2
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.2.3
network_mode: host
environment:
DEEPSEEK_API_KEY: "${DEEPSEEK_API_KEY:-}"
+101
View File
@@ -0,0 +1,101 @@
# 故障管理
2026-10-13:故障管理为左侧独立业务入口 `/asset#faults`,与资产管理并列。首页展示四项总统计及故障趋势统计,不展示旧版维度排行或常驻故障明细。点击任一卡片进入多维分析,再进入故障明细。
## 四项指标
| 指标 | 口径 |
| --- | --- |
| 今日新增 | `fault_report_time` 在北京时间今日自然日内 |
| 本日解决 | 状态已归档且 `archived_at` 在北京时间今日内,与提报日无关 |
| 当前在办 | 全部待处理、处理中故障,不限制提报日期;按最新确认,在办 = 待处理 + 处理中 |
| 累计台账 | 全部有效故障台账,包括归档、未闭环和其他状态 |
「当前在办」卡片以“待处理 + 处理中”展示构成,逾期以总量旁的“逾期”独立标记,均使用故障多维分析与故障明细下钻。按最新确认,在办仅为待处理与处理中之和,挂起保留在累计台账;旧兼容接口的全部未闭环口径保持不变。
- 待处理/处理中:按 OneOS 标准化任务状态分别统计,兼容待处置、未处理、处置中等历史值。
- 逾期:待处理或处理中,且保存的 `deadline_at` 严格早于本次数据时间。按北京时间解释截止时间,挂起和已归档不计入;无截止时间不自行以提报时间或固定 30 天推算。与 OneOS `selectSupervisorRows.overdueCount` 汇总口径一致,存储的截止时间包含业务端 SLA 配置及挂起顺延结果。
- 逾期是当前在办的重叠子集,不与待处理、处理中相加。下钻明细不展示处置截止时间,也不提供该字段的统计维度;该字段仅保留用于逾期计算。
- 四张卡片使用统一紧凑高度:桌面 164px、移动端 132px。
所有数量以故障主键去重、单位为“单”;同车多故障分别统计。有效范围为 `vehicle_fault_manage.del_flag='0' AND is_submit IN (0,1)`。保留退出运营、缺失车辆关联的历史故障;缺失日期不以创建时间补齐。
## 下钻
在资产管理九个维度(车型、型号、区域、城市、部门、业务负责人、客户、资产用途、运营状态)基础上,增加独立的「运维处置人」「车牌」「故障工单状态」,共十二个维度,「运维处置人」固定为第二个维度选项,「车牌」为第三个。车牌取故障台账车牌,同车多故障按故障单计数,缺失车牌归入「未关联车辆」。支持组合分组、多选筛选、合并单元格、组内小计、排序、筛选此组、故障明细搜索和分批加载。运维处置人与业务负责人独立,按人员 ID 分组,同名不同 ID 不合并;多人共同处置分别计入各人,总数、上级小计和明细按故障去重,人员行不可直接相加。
车辆相关维度使用车辆当前资料,运维处置人取故障或关联工单的当前处置分配。城市使用 OneOS `vehicle_info.operation_city` 对应的城市,不使用车机 GPS;区域采用 Asset BI 的宏观区域映射。客户/部门/业务负责人取每车最新 `vehicle_lease_order_record`,按 `last_delivery_time DESC,id DESC`,型号与车型复用资产管理的别名和分类函数。无匹配资料保留未知组。客户键采用散列,展示名称脱敏,不因相同脱敏名称合并不同客户。
明细第一列主显示车牌、辅显示故障单号,第二列为运维处置人;其后依次展示故障描述、状态、提报时间、归档时间、运营城市、客户 / 部门。处置人来源优先级:有效来源任务工单多人→任务工单单人→故障多人→故障单人或历史姓名;用户 BIGINT ID 保持字符串精度,停用/删除人员保留历史姓名。
## 权限与接口
- `GET /api/asset-faults/overview`:北京时间日期、数据时间、四项总数。
- `GET /api/asset-faults/analysis?metric=reported|closed|open|waiting|processing|suspended|overdue|ledger`:所选指标的授权记录及分析单元,供前端在同份数据上分组和下钻。
- 旧 `GET /api/asset-faults` 查询接口保留兼容,首页不再使用。
接口以只读一致性事务读取数据,不修改 OneOS 业务表,不运行定时任务或迁移。汇总及下钻先应用既有全量/部门/本人权限;历史故障不继承公共库存的权限放宽规则;缺车辆资料仅全量权限可见。无共享响应缓存,HTTP `no-store`。401 未认证、400 无效参数、503 查询失败。首页和下钻分别读取最新数据,实时变化可能造成短暂数量差异。
源码依据(`oneos/ln-asset-management`):`FaultLedgerStatus`、`FaultLedgerQuerySupport`、`FaultManualCreateSupport`、`FaultHandlerAssignmentService.resolveForSupervisorBatch`、`VehicleFaultDispositionServiceImpl.archiveNow`、`VehicleFaultDispositionMapper.xml`、`VehicleLeaseOrderRecordMapper.xml`。
## 验证
故障模型与路由测试覆盖北京时间午夜边界、跨日归档、全部日期在办、累计范围、权限隔离、客户脱敏、同车多故障、资产维度映射及总数与明细一致。同步运行导航、资产分组/筛选/合并单元格回归、TypeScript检查、生产构建及桌面/手机 Playwright 交互验证。仅本地开发,未部署。
## 故障趋势统计
复用交还车的日期选择器,默认北京时间近30天,支持自选日期(最多370天)、按日/按月切换。以 `fault_report_time` 划分新增批次,统计同一批故障截至查询时刻的已闭环(已归档)和未闭环(其他状态,含挂起)。不将该日归档的其他批次故障混入,归档时间可在提报区间之后。
堆叠柱总高度为新增数,新增 = 已闭环 + 未闭环。零新增周期补零;无提报时间不进入趋势;月聚合严格保持起止边界,不扩展整月。每日/每月统计表按新日期在前展示新增、已闭环、未闭环和闭环率(零分母显示横线)。汇总与周期数字均可下钻到故障多维分析及故障明细。点击柱图内已闭环 / 未闭环色块,按对应日或月和状态下钻;趋势下钻默认按「运维处置人 → 故障工单状态」分组,重置也恢复这两个维度,按月仍保留所选日期边界。
接口 `GET /api/asset-faults/trend?start=YYYY-MM-DD&end=YYYY-MM-DD&granularity=day|month` 返回区间汇总与周期序列。`GET /api/asset-faults/trend-analysis` 使用同样区间及粒度,可附 `period=YYYY-MM-DD|YYYY-MM` 和 `status=all|closed|unclosed`。两个接口均先应用原有数据权限,范围/粒度/周期校验在查询数据库前执行。总览和下钻按请求分别读取最新数据。
## 故障运维周报
周报复用故障处理趋势的日期选择器,默认业务周(周六至周五),支持自选最多370天。按最新要求仅展示「新增故障」「解决故障」两项区间合计、日期倒序的每日数量及表尾合计,均可下钻。汇总下方增加每日折线图,蓝色实线表示新增故障,绿色实线表示解决故障,与日期选择器、每日统计表使用同一份数据;补齐零值日期、纵轴从零开始,保留表格提供精确数量与下钻。
- 新增故障:按 `fault_report_time` 当日计单。
- 解决故障:已归档故障按 `archived_at` 当日计单,不受提报日期限制。
已移除接收处理汇总、每日列、说明及故障明细中的接收时间列。现有接口暂保留接收指标兼容能力,页面不使用;不更改业务数据。
接口:`GET /api/asset-faults/weekly-report?start=YYYY-MM-DD&end=YYYY-MM-DD`;下钻 `GET /api/asset-faults/weekly-report-analysis` 传 `metric=reported|closed`,可附 `day=YYYY-MM-DD`。所有计数和下钻均先应用既有业务数据权限,沿用只读事务及 HTTP no-store。
页面时间标签统一为「解决时间」,底层仍取 `archived_at`,统计口径不变。周报折线图仍按时间正序,采用平滑实线、顶部图例、浅网格与统一悬浮提示。
故障工单状态独立于车辆运营状态:已归档为「已闭环」,其他状态(含待处理、处理中、挂起)均为「未闭环」,与故障处理趋势口径一致。所有故障下钻入口均可按该维度分组、多选筛选、查看明细。
所有下钻明细按提报时间倒序展示,无提报时间排在末尾;同一提报时间按故障 ID 倒序稳定排序。先筛选、排序,再分页,搜索和加载更多保持该顺序。
客户展示:已关联车辆且当前运营状态为库存、客户为空时,客户分组、筛选和明细显示「库存车」(独立键 `inventory-unlinked`);非库存或缺失车辆资料的空客户仍为「未关联客户」。有客户名称时仍按原客户展示和脱敏。此调整不改业务数据及权限。
下钻维度和筛选按入口保存在当前页面内存中;关闭、误点遮罩或 Esc 后重新打开恢复已应用设置。各入口互不覆盖,桌面与手机共用。点击重置恢复该入口默认维度并清空筛选;刷新整个页面后全部清除,不写入 localStorage 或跨刷新存储。每次重开仍重新请求实时数据。
## 报表导出
故障处理趋势和故障运维周报标题右侧均提供「导出 Excel」。导出当前已加载且经过权限过滤的统计快照;日期范围、趋势按日/按月粒度与页面一致,不重新查询或扩大数据范围。加载中及请求失败时禁用导出,生成文件期间显示进度状态并防止重复点击,失败可重试。
- 趋势:提报日期/月份、新增故障、已闭环、未闭环、闭环率,包含全部统计日期,按日期倒序。
- 周报:日期、星期、新增故障、解决故障,按日期倒序;不导出接收处理指标。
- XLSX 包含带标题、范围、数据时间、汇总指标的统计表;当前有图表时附加「图表」工作表,以图片保存页面图表快照并保持比例。图表为静态快照,修改统计表不会联动图表。
- 统一深色表头、指标配色、交替底色、今日浅色标记;真实 Excel 日期和数字格式、合计及闭环率公式、冻结表头、筛选与打印设置。ExcelJS 按导出操作动态加载。
## 风险与服务分析总览(2026-10-13)
故障管理按最新确认保留「管理总览 / 多维分析 / 故障明细」三个页签。管理总览与处理效率合并,恢复原有页面及统计:今日新增、本日解决、当前在办(待处理 + 处理中,逾期为子集)、累计台账,沿用 `/overview` 接口;下方直接展示原有故障处理趋势与故障运维周报。趋势默认近30天,周报默认周六至周五,分别选择日期、导出和下钻。管理总览不受其他页签的业务筛选影响。多维分析、故障明细保留新增分析能力及统一日期、区域、客户、品牌车型查询;更多筛选包含城市、业务部门、运维处置人、故障程度、故障部位、库存状态,点击查询生效。
- 期间新增故障:所选提报日期内的有效故障。卡片增加“运维提报”和“AI提报”数量及下钻;两者之和等于期间新增,沿用日期、权限及业务筛选。来源优先读取 `report_method`:运维上报显示“运维提报”,客户上报显示“AI提报”;旧数据未标明有效上报方式时,有真实 `source_work_order_id` 关联归客户上报,否则归运维上报,与 OneOS 历史展示逻辑一致。该分类不使用 `fault_source` 故障来源字典,也不推断所有客户上报均由 AI 自动创建。对比上期采用紧邻本期、天数相同的区间;上期为零时显示无可比基数。
- 受影响车辆:同批故障按关联车辆 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 工作表:管理总览、区域分布、故障程度、客户影响、车型分布、车型与部位。导出已加载统计快照,包含日期、业务筛选、数据时间、冻结表头、交替底色和数字格式。趋势、周报的既有导出保留,并增加业务筛选说明。
+2069 -3
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ln-bi",
"private": true,
"version": "1.2.2",
"version": "1.2.3",
"type": "module",
"scripts": {
"dev:local": "concurrently -k -n server,client \"npm run dev:local:server\" \"npm run dev:local:client\"",
@@ -25,6 +25,7 @@
"cn": "^0.4.0",
"dotenv": "^16.4.0",
"echarts": "^6.1.0",
"exceljs": "^4.4.0",
"hono": "^4.7.0",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.546.0",
+2 -1
View File
@@ -12,9 +12,10 @@ function heatmapChildIds(roles?: string[]): string[] {
}
test('keeps the base asset navigation and role-gated scheduling module', () => {
assert.deepEqual(moduleIds(), ['assets', 'mileage', 'heatmap']);
assert.deepEqual(moduleIds(), ['assets', 'faults', 'mileage', 'heatmap']);
assert.deepEqual(moduleIds(['BI-SCHEDULE-OPT']), [
'assets',
'faults',
'mileage',
'heatmap',
'scheduling',
+3
View File
@@ -5,6 +5,7 @@ import {
MapPinned,
Route,
Truck,
Wrench,
Wallet,
Zap,
} from 'lucide-react';
@@ -13,6 +14,7 @@ import { canAccessEnergy, canAccessScheduling } from '../shared/auth/roles';
import type { PathSet } from './routing';
const AssetsModule = lazy(() => import('../modules/assets/AssetsModule'));
const FaultManagement = lazy(() => import('../modules/assets/faults/FaultManagement'));
const MileageModule = lazy(() => import('../modules/mileage/MileageModule'));
const SchedulingModule = lazy(() => import('../modules/scheduling/SchedulingModule'));
const HydrogenModule = lazy(() => import('../modules/energy/hydrogen/index'));
@@ -23,6 +25,7 @@ const HydrogenHeatmapModule = lazy(() => import('../modules/hydrogen-heatmap/Hyd
const ASSET_MODULES: ModuleConfig[] = [
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
{ id: 'faults', label: '故障管理', icon: Wrench, component: FaultManagement },
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
];
+77
View File
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { mapFault, faultHandlers } from '../server/routes/asset-faults/model.js';
import type { FaultAnalysisRecord, FaultOption } from '../shared/assets/faults';
import { analyzeFaultRecords, filterFaultRecords, faultDimensionValues, faultParentTotals, FAULT_ANALYSIS_DIMENSIONS, FAULT_ANALYSIS_LABELS } from '../modules/assets/faults/analysis-model.js';
function fault(id: string, handlers: FaultOption[], manager = '客户业务经理', city = '上海市'): FaultAnalysisRecord {
const row = mapFault({ id, business_manager: manager, city_name: city }, new Map());
return { ...row, handlers, cell: row.assetCell };
}
const a = { value: 'id:1234567890123456789', label: '处置甲' };
const b = { value: 'id:2234567890123456789', label: '处置乙' };
const data = [fault('shared', [a, b, a]), fault('single', [a]), fault('none', [])];
test('运维处置人是独立维度,未分配不以业务负责人兜底', () => {
assert.ok(FAULT_ANALYSIS_DIMENSIONS.includes('handler')); assert.ok(FAULT_ANALYSIS_DIMENSIONS.includes('manager'));
assert.equal(FAULT_ANALYSIS_LABELS.handler, '运维处置人');
assert.deepEqual(faultDimensionValues(data[0], 'handler'), [a.value, b.value]);
assert.deepEqual(faultDimensionValues(data[0], 'manager'), ['客户业务经理']);
assert.deepEqual(faultDimensionValues(data[2], 'handler'), ['unassigned']);
assert.deepEqual(faultHandlers({ business_manager: '客户业务经理' }, new Map()), [{ value: 'unassigned', label: '未指派' }]);
});
test('多人各自成组,总数和组内明细按故障ID去重,父级小计取并集', () => {
const result = analyzeFaultRecords([...data, data[0]], ['city', 'handler']);
assert.equal(result.total, 3);
assert.equal(result.rows.find(row => row.values.handler === a.value)?.count, 2);
assert.equal(result.rows.find(row => row.values.handler === b.value)?.count, 1);
assert.equal(result.rows.reduce((n, row) => n + row.count, 0), 4);
assert.ok(faultParentTotals(result.rows, ['city', 'handler']).every(row => row[0] === 3));
for (const group of result.rows) assert.equal(new Set(group.recordIds).size, group.count);
});
test('多选处置人取并集,不分组时不放大;筛选甲后不出现共享故障的乙分组', () => {
assert.equal(filterFaultRecords(data, { handler: [a.value, b.value] }).length, 2);
assert.equal(analyzeFaultRecords(data, ['city'], { handler: [a.value, b.value] }).rows[0].count, 2);
const result = analyzeFaultRecords(data, ['handler'], { handler: [a.value] });
assert.equal(result.total, 2); assert.equal(result.rows.length, 1); assert.equal(result.rows[0].values.handler, a.value);
assert.equal(filterFaultRecords(data, { handler: [a.value], manager: ['其他业务经理'] }).length, 0);
assert.equal(filterFaultRecords(data, { handler: ['unassigned'] })[0].id, 'none');
});
test('同名处置人保持ID独立,组合筛选与组明细对应,空数据安全', () => {
const rows = [fault('one', [{ ...a, label: '同名' }]), fault('two', [{ ...b, label: '同名' }])];
const result = analyzeFaultRecords(rows, ['manager', 'handler']);
assert.equal(result.rows.length, 2);
for (const group of result.rows) {
const matched = filterFaultRecords(rows, { manager: [group.values.manager!], handler: [group.values.handler!] });
assert.deepEqual(matched.map(row => row.id), group.recordIds);
}
assert.deepEqual(analyzeFaultRecords([], ['handler']), { total: 0, rows: [] });
});
test('故障工单状态按归档闭环,与车辆运营状态独立;组合分组、筛选和去重口径一致', () => {
const states = ['已归档', '待处理', '处理中', '挂起', '未知状态'];
const rows = states.map((status, i) => ({ ...fault(String(i), i === 0 ? [a, b] : [a]), status }));
assert.deepEqual(rows.map(row => faultDimensionValues(row, 'faultStatus')), [['closed'], ['unclosed'], ['unclosed'], ['unclosed'], ['unclosed']]);
const result = analyzeFaultRecords(rows, ['handler', 'faultStatus']);
assert.equal(result.total, 5);
assert.equal(result.rows.find(row => row.values.handler === a.value && row.values.faultStatus === 'closed')?.count, 1);
assert.equal(result.rows.find(row => row.values.handler === a.value && row.values.faultStatus === 'unclosed')?.count, 4);
const parentTotals = faultParentTotals(result.rows, ['handler', 'faultStatus']);
result.rows.forEach((row, i) => assert.equal(parentTotals[i][0], row.values.handler === a.value ? 5 : 1));
assert.deepEqual(filterFaultRecords(rows, { faultStatus: ['closed'], handler: [a.value, b.value] }).map(row => row.id), ['0']);
assert.equal(filterFaultRecords(rows, { faultStatus: ['closed', 'unclosed'] }).length, 5);
assert.deepEqual(filterFaultRecords(rows, { faultStatus: ['unclosed'], handler: [b.value] }), []);
for (const group of result.rows) assert.deepEqual(filterFaultRecords(rows, { handler: [group.values.handler!], faultStatus: [group.values.faultStatus!] }).map(row => row.id), group.recordIds);
});
test('车牌位于第三个维度,同车多故障按单计数,支持组合筛选和缺失车牌', () => {
assert.equal(FAULT_ANALYSIS_DIMENSIONS[2], 'plate');
const rows = [{ ...fault('1', [a]), plate: '沪A12345' }, { ...fault('2', [b]), plate: '沪A12345' }, { ...fault('3', [a]), plate: '粤A12345' }, { ...fault('4', []), plate: '' }];
const grouped = analyzeFaultRecords(rows, ['plate']);
assert.equal(grouped.total, 4);
assert.equal(grouped.rows.find(row => row.values.plate === '沪A12345')?.count, 2);
assert.deepEqual(filterFaultRecords(rows, { plate: ['沪A12345'], handler: [b.value] }).map(row => row.id), ['2']);
assert.equal(filterFaultRecords(rows, { plate: ['沪A12345', '粤A12345'] }).length, 3);
assert.deepEqual(filterFaultRecords(rows, { plate: ['未关联车辆'] }).map(row => row.id), ['4']);
});
+194
View File
@@ -0,0 +1,194 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Hono } from 'hono';
import { analyzeFaults, faultAnalysis, faultHandlers, faultInventory, mapFault, normalizeFaultStatus, parseFaultQuery, visibleFaults, type ScopedFault } from '../server/routes/asset-faults/model.js';
import { createFaultRouter } from '../server/routes/asset-faults/index.js';
import type { AuthUser } from '../server/auth/types.js';
const full: AuthUser = { userId: 'm1', userName: 'User', loginName: 'user', depCode: '', depName: '业务一部', permissionLevel: 'full', roles: [] };
function fault(id: string, overrides: Partial<ScopedFault> = {}): ScopedFault {
return { assetCell: mapFault({ id }, new Map()).assetCell, id, code: id, plate: '测试车', description: '', reportedAt: '2026-10-01 12:00:00', archivedAt: null, deadlineAt: null, receivedAt: null,
city: '广州市', status: '待处理', inventory: '不适用', customer: 'customer1', customerLabel: '测试***户', department: '业务一部',
handlers: [{ value: 'id:1', label: '甲' }], subject: 'A', departmentName: '业务一部', managerId: 'm1', missingVehicle: false, ...overrides };
}
const query = (params: Record<string, string> = {}) => parseFaultQuery({ start: '2026-10-13', end: '2026-10-13', ...params });
test('跨日完结独立于提报日期,全部挂单不受日期筛选影响,挂起和审核中均未闭环', () => {
const rows = [fault('old'), fault('suspended', { status: '挂起' }), fault('review', { status: '处理中' }),
fault('closed', { status: '已归档', archivedAt: '2026-10-13 00:00:00' }),
fault('new', { reportedAt: '2026-10-13 23:59:59' }), fault('tomorrow', { status: '已归档', archivedAt: '2026-10-14 00:00:00' })];
const result = analyzeFaults(rows, query(), 'now');
assert.deepEqual(result.totals, { reported: 1, closed: 1, open: 4 });
assert.equal(result.details.total, 4);
assert.deepEqual(result.daily, [{ date: '2026-10-13', reported: 1, closed: 1 }]);
assert.equal(analyzeFaults(rows, query({ start: '2025-01-01', end: '2025-01-02', day: '2025-01-01' }), 'now').totals.open, 4);
assert.equal(analyzeFaults(rows, query({ metric: 'closed' }), 'now').details.rows[0].id, 'closed');
});
test('多人负责按人员计单,总计去重;人员筛选与人员分组下钻一致', () => {
const row = fault('shared', { handlers: [{ value: 'id:1', label: '甲' }, { value: 'id:2', label: '乙' }] });
const result = analyzeFaults([row], query({ groupBy: 'handler' }), 'now');
assert.equal(result.totals.open, 1); assert.equal(result.groups.reduce((sum, r) => sum + r.open, 0), 2);
assert.equal(analyzeFaults([row], query({ handler: 'id:2' }), 'now').details.total, 1);
assert.equal(analyzeFaults([row], query({ groupBy: 'handler', groupValue: 'id:2' }), 'now').details.total, 1);
});
test('处置人优先来源任务工单,兼容历史姓名,保留 BIGINT 精度并去重', () => {
const directory = new Map([['1234567890123456789', '当前姓名']]);
const row = { work_ids: '[1234567890123456789,1234567890123456789]', work_names: '["旧名","旧名"]', handler_id: '2', handler_name: '非当前处置人' };
assert.deepEqual(faultHandlers(row, directory), [{ value: 'id:1234567890123456789', label: '当前姓名' }]);
assert.deepEqual(faultHandlers({ work_names: '["历史姓名"]', handler_id: '2' }, directory), [{ value: 'name:历史姓名', label: '历史姓名' }]);
assert.deepEqual(faultHandlers({ handler_ids: 'bad-json', handler_id: '99' }, directory), [{ value: 'id:99', label: '历史处理人(99)' }]);
assert.deepEqual(faultHandlers({ handler_ids: '[99,99]', handler_names: '["历史[123]","另一姓名"]' }, directory), [{ value: 'id:99', label: '历史[123]' }]);
assert.deepEqual(faultHandlers({}, directory), [{ value: 'unassigned', label: '未指派' }]);
});
test('库存先判断运营状态,再细分;未知状态不自动计为归档', () => {
assert.equal(faultInventory('1', '14', true), '不适用');
assert.equal(faultInventory('3', '14', true), '异动');
assert.equal(faultInventory('4', '4', true), '待交车');
assert.equal(faultInventory('3', '2', true), '可运营');
assert.equal(faultInventory('4', '2', true), '待运营');
assert.equal(faultInventory('5', '2', true), '不适用');
assert.equal(faultInventory(null, null, false), '未知');
assert.equal(normalizeFaultStatus('待处置'), '待处理');
assert.equal(normalizeFaultStatus('已挂起'), '挂起');
assert.equal(analyzeFaults([fault('odd', { status: '其他状态' })], query(), 'now').totals.open, 0);
});
test('缺失车辆和日期保留总量及可用归档事件,客户名称脱敏且不传递服务端权限字段', () => {
const row = mapFault({ id: 'lost', fault_code: 'F', customer_name: '需要脱敏的客户名称', handling_status: '已归档', archived_at: '2026-10-13 08:00:00' }, new Map());
const result = analyzeFaults([row], query({ metric: 'closed' }), 'now');
assert.deepEqual(result.totals, { reported: 0, closed: 1, open: 0 });
assert.equal(result.quality.missingReport, 1); assert.equal(result.quality.missingVehicle, 1);
assert.equal(result.details.rows[0].city, '未知');
assert.ok(!JSON.stringify(result).includes('需要脱敏的客户名称'));
assert.ok(!('managerId' in result.details.rows[0]));
});
test('故障历史使用严格人员/部门权限,库存不放宽;全权限保留失联车辆', () => {
const rows = [fault('mine'), fault('private', { managerId: 'm2', departmentName: '其他部', department: '其他部', inventory: '可运营' }), fault('lost', { managerId: '', departmentName: '', missingVehicle: true })];
assert.equal(visibleFaults(rows, full).length, 3);
assert.deepEqual(visibleFaults(rows, { ...full, permissionLevel: 'personal' }).map(r => r.id), ['mine']);
assert.deepEqual(visibleFaults(rows, { ...full, permissionLevel: 'department' }).map(r => r.id), ['mine']);
assert.equal(visibleFaults(rows, { ...full, permissionLevel: 'department', depName: '' }).length, 0);
assert.equal(visibleFaults(rows, full, 'B').length, 0);
});
test('日期合法性、范围限制和分页边界', () => {
const invalid: Record<string, string>[] = [{ start: '2026-02-30' }, { start: '2027-01-01' }, { start: '2023-01-01' }, { page: '-1' }, { groupBy: '__proto__' }, { metric: 'unknown' }, { day: '2026-10-14' }];
for (const params of invalid) assert.throws(() => query(params));
const rows = Array.from({ length: 51 }, (_, i) => fault(String(i)));
const result = analyzeFaults(rows, query({ page: '99' }), 'now');
assert.equal(result.details.page, 2); assert.equal(result.details.rows.length, 1);
});
test('API 在汇总、筛选项和明细前应用权限,未认证和无效日期不查询数据库', async () => {
let calls = 0;
const router = createFaultRouter(async () => { calls++; return { updatedAt: 'now', rows: [fault('mine'), fault('secret', { managerId: 'm2', city: '保密城市' })] }; });
assert.equal((await router.request('/')).status, 401); assert.equal(calls, 0);
const app = new Hono<{ Variables: { user: AuthUser } }>();
app.use('*', async (c, next) => { c.set('user', { ...full, permissionLevel: 'personal' }); await next(); });
app.route('/faults', router);
assert.equal((await app.request('/faults?start=bad')).status, 400); assert.equal(calls, 0);
const response = await app.request('/faults?start=2026-10-13&end=2026-10-13');
assert.equal(response.status, 200); assert.equal(response.headers.get('Cache-Control'), 'no-store');
const body = await response.json(); assert.equal(body.totals.open, 1); assert.ok(!JSON.stringify(body).includes('保密城市'));
});
test('四个总指标使用北京时间自然日,全部台账包含未知状态和无日期故障', async () => {
const { faultOverview, faultAnalysis } = await import('../server/routes/asset-faults/model.js');
const rows = [fault('old'), fault('today', { reportedAt: '2026-10-14 00:00:00' }),
fault('closed', { status: '已归档', archivedAt: '2026-10-14 23:59:59' }),
fault('unknown', { status: '未知状态', reportedAt: null, missingVehicle: true })];
const at = '2026-10-13T16:00:00Z';
const summary = faultOverview(rows, at);
assert.equal(summary.date, '2026-10-14');
assert.deepEqual(summary.totals, { reported: 1, closed: 1, open: 2, waiting: 2, processing: 0, suspended: 0, overdue: 0, ledger: 4 });
for (const metric of ['reported', 'closed', 'open', 'ledger'] as const) {
const analysis = faultAnalysis(rows, metric, at);
assert.equal(analysis.rows.length, summary.totals[metric]);
assert.equal(analysis.rows.reduce((sum, row) => sum + row.cell.quantity, 0), summary.totals[metric]);
assert.ok(!JSON.stringify(analysis).includes('managerId'));
}
assert.equal(faultOverview(rows, '2026-10-13T15:59:59Z').totals.reported, 0);
});
test('故障下钻与资产维度映射同步,同车多故障逐单统计,脱敏客户保持独立', async () => {
const { faultAnalysis } = await import('../server/routes/asset-faults/model.js');
const { getAvailableDimensions, getCustomAnalysis } = await import('../modules/assets/analytics-model.js');
const raw = { matched_vehicle_id: 'V1', model_name: '公务用车/小客车', brand: 'denza', vehicle_source: '0',
city_name: '上海市', province_name: '上海市', operation_status: '2', business_dept: '业务部', business_manager: '经理甲' };
const rows = [mapFault({ ...raw, id: 'F1', customer_name: '客户甲' }, new Map()), mapFault({ ...raw, id: 'F2', customer_name: '客户乙' }, new Map())];
const data = faultAnalysis(rows, 'ledger', '2026-10-13T06:00:00Z');
assert.equal(data.rows[0].cell.model, '公务车/挂靠车');
assert.equal(data.rows[0].cell.department, '公务车');
assert.equal(data.rows[0].cell.region, '华东');
assert.equal(data.rows[0].cell.manager, '经理甲');
assert.equal(data.rows[0].cell.assetDisposition, '自营');
assert.equal(getCustomAnalysis(data.rows.map(row => row.cell), { view: 'assets', dimensions: ['model'] }).rows[0].count, 2);
assert.equal(getCustomAnalysis(data.rows.map(row => row.cell), { view: 'assets', dimensions: ['customer'] }).rows.length, 2);
assert.equal(getAvailableDimensions('assets').length, 9);
});
test('总览与下钻接口先应用权限,无效指标和未认证不查询', async () => {
let calls = 0;
const router = createFaultRouter(async () => { calls++; return { updatedAt: '2026-10-13T06:00:00Z', rows: [fault('mine'), fault('secret', { managerId: 'm2', customer: 'secret-customer', customerLabel: '保密客户' })] }; });
for (const url of ['/overview', '/analysis?metric=ledger']) assert.equal((await router.request(url)).status, 401);
assert.equal(calls, 0);
const app = new Hono<{ Variables: { user: AuthUser } }>();
app.use('*', async (c, next) => { c.set('user', { ...full, permissionLevel: 'personal' }); await next(); });
app.route('/faults', router);
assert.equal((await app.request('/faults/analysis?metric=__proto__')).status, 400);
assert.equal(calls, 0);
const summary = await (await app.request('/faults/overview')).json();
assert.equal(summary.totals.ledger, 1);
const response = await app.request('/faults/analysis?metric=ledger');
const data = await response.json();
assert.equal(response.headers.get('Cache-Control'), 'no-store');
assert.equal(data.rows.length, summary.totals.ledger);
assert.ok(!JSON.stringify(data).includes('secret'));
});
test('在办按状态细分;逾期以北京时间截止时间严格比较,排除挂起、归档和无截止时间', async () => {
const { faultOverview, faultAnalysis } = await import('../server/routes/asset-faults/model.js');
const at = '2026-10-13T06:00:00Z';
const rows = [fault('waiting-overdue', { deadlineAt: '2026-10-13 13:59:59' }),
fault('processing-overdue', { status: '处理中', deadlineAt: '2026-10-12 14:00:00' }),
fault('boundary', { deadlineAt: '2026-10-13 14:00:00' }),
fault('future', { status: '处理中', deadlineAt: '2026-10-13 14:00:01' }),
fault('missing'), fault('invalid', { deadlineAt: 'invalid' }),
fault('suspended', { status: '挂起', deadlineAt: '2026-01-01 00:00:00' }),
fault('archived', { status: '已归档', deadlineAt: '2026-01-01 00:00:00' })];
const summary = faultOverview(rows, at);
assert.equal(summary.totals.waiting, 4);
assert.equal(summary.totals.processing, 2);
assert.equal(summary.totals.suspended, 1);
assert.equal(summary.totals.open, 6);
assert.equal(summary.totals.overdue, 2);
assert.equal(summary.totals.open, summary.totals.waiting + summary.totals.processing);
for (const metric of ['open', 'waiting', 'processing', 'suspended', 'overdue'] as const) {
assert.equal(faultAnalysis(rows, metric, at).rows.length, summary.totals[metric]);
}
const openIds = new Set(faultAnalysis(rows, 'open', at).rows.map(r => r.id));
assert.ok(faultAnalysis(rows, 'overdue', at).rows.every(row => openIds.has(row.id)));
assert.deepEqual(faultAnalysis(rows, 'overdue', at).rows.map(r => r.id), ['waiting-overdue', 'processing-overdue']);
});
test('未关联客户的库存车单独展示,不混入非库存、未知车辆或已有客户', () => {
const make = (extra: Record<string, unknown>) => mapFault({ id: 'stock', matched_vehicle_id: 'v1', operation_status: '3', ...extra }, new Map());
for (const operation_status of ['3', '4', '可运营', '待运营', '在库']) {
const row = make({ operation_status });
assert.equal(row.customer, 'inventory-unlinked');
assert.equal(row.assetCell.customer, 'inventory-unlinked');
assert.equal(row.customerLabel, '库存车');
}
for (const extra of [{ operation_status: '1' }, { operation_status: '5' }, { operation_status: null }, { matched_vehicle_id: null }]) {
const row = make(extra); assert.equal(row.customer, 'unlinked'); assert.equal(row.customerLabel, '未关联客户');
}
const assigned = make({ customer_name: '测试客户名称' });
assert.notEqual(assigned.customer, 'inventory-unlinked');assert.notEqual(assigned.customerLabel, '库存车');
const result = faultAnalysis([make({}), make({ id: 'other', operation_status: '1' })], 'ledger', '2026-10-13T00:00:00Z');
assert.equal(result.customerLabels['inventory-unlinked'], '库存车');assert.equal(result.customerLabels.unlinked, '未关联客户');assert.equal(result.rows.length, 2);
});
@@ -0,0 +1,71 @@
import { useId, useRef, useState } from 'react';
import { ArrowLeft, Check, ChevronDown, Filter, Search, X } from 'lucide-react';
export type MobileDimension = { key: string; label: string; options: { value: string; label: string; count?: number }[] };
type Filters = Partial<Record<string, string[]>>;
type Props = {
available: MobileDimension[];
dimensions: string[];
filters: Filters;
defaults: string[];
apply: (dimensions: string[], filters: Filters) => void;
};
/** Draft all mobile settings together; cancelling never changes the current result. */
export function MobileAnalysisControls({ available, dimensions, filters, defaults, apply }: Props) {
const [mode, setMode] = useState<'group' | 'filter' | null>(null);
const [draftDimensions, setDraftDimensions] = useState(dimensions);
const [draftFilters, setDraftFilters] = useState(filters);
const [editing, setEditing] = useState<string | null>(null);
const [query, setQuery] = useState('');
const trigger = useRef<HTMLButtonElement | null>(null);
const panel = useRef<HTMLDivElement>(null);
const id = useId();
const selected = available.filter(d => filters[d.key]?.length);
const editor = available.find(d => d.key === editing);
const options = editor?.options.filter(o => o.label.toLocaleLowerCase().includes(query.toLocaleLowerCase())) || [];
const close = () => { setMode(null); trigger.current?.focus(); };
const open = (next: 'group' | 'filter', button: HTMLButtonElement) => {
trigger.current = button; setDraftDimensions([...dimensions]); setDraftFilters({ ...filters });
setEditing(null); setQuery(''); setMode(next);
requestAnimationFrame(() => panel.current?.focus());
};
const toggle = (key: string, value: string) => {
const values = draftFilters[key] || [];
setDraftFilters({ ...draftFilters, [key]: values.includes(value) ? values.filter(v => v !== value) : [...values, value] });
};
return <div className="analysis-mobile-controls">
<div className="analysis-mobile-dimension-heading"><strong>分析维度</strong><span>可多选 · 左右滑动</span><button onClick={e => open('group', e.currentTarget)} aria-expanded={mode === 'group'} aria-controls={id}>分组设置</button></div>
<div className="analysis-mobile-toolbar">
<div className="analysis-dimension-strip" role="group" aria-label="分析维度">
{available.map(d => <button key={d.key} type="button" aria-pressed={dimensions.includes(d.key)} onClick={() => apply(dimensions.includes(d.key) ? dimensions.filter(key => key !== d.key) : [...dimensions, d.key], filters)}>{d.label}{dimensions.includes(d.key) && <small>{dimensions.indexOf(d.key) + 1}</small>}</button>)}
<button type="button" aria-pressed={!dimensions.length} onClick={() => apply([], filters)}>仅汇总</button>
</div>
<button type="button" onClick={e => open('filter', e.currentTarget)} aria-expanded={mode === 'filter'} aria-controls={id}><Filter size={16} />筛选{selected.length ? ` ${selected.length}` : ''}</button>
</div>
{!!selected.length && <div className="analysis-mobile-chips" aria-label="已选筛选条件">{selected.map(d => <button key={d.key} title={(filters[d.key] || []).map(v => d.options.find(o => o.value === v)?.label || v).join('、')} aria-label={`移除${d.label}筛选`} onClick={() => { const next = { ...filters }; delete next[d.key]; apply(dimensions, next); }}><span>{d.label}:{filters[d.key]?.length === 1 ? d.options.find(o => o.value === filters[d.key]?.[0])?.label || filters[d.key]?.[0] : `${filters[d.key]?.length}项`}</span><X size={14} /></button>)}</div>}
{mode && <div className="analysis-settings-backdrop" onClick={e => { if (e.target === e.currentTarget) close(); }}>
<div ref={panel} id={id} role="dialog" aria-modal="true" aria-label={mode === 'group' ? '分组设置' : '筛选条件'} tabIndex={-1} className="analysis-settings-panel" onKeyDown={e => {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); if (editing) { setEditing(null); setQuery(''); } else close(); }
if (e.key === 'Tab') {
e.stopPropagation();
const items = Array.from(panel.current?.querySelectorAll<HTMLElement>('button:not([disabled]),input') || []).filter(el => el.getClientRects().length);
const first = items[0], last = items.at(-1);
if (e.shiftKey && (document.activeElement === first || document.activeElement === panel.current)) { e.preventDefault(); last?.focus(); }
else if (!e.shiftKey && (document.activeElement === last || document.activeElement === panel.current)) { e.preventDefault(); first?.focus(); }
}
}}>
<header><button aria-label={editing ? '返回筛选条件' : '取消设置'} onClick={() => editing ? (setEditing(null), setQuery('')) : close()}><ArrowLeft size={20} /></button><h3>{editor?.label || (mode === 'group' ? '分组设置' : '筛选条件')}</h3><button aria-label="关闭设置" onClick={close}><X size={20} /></button></header>
{editor && <label className="analysis-settings-search"><Search size={17} /><input aria-label={`搜索${editor.label}`} placeholder={`搜索${editor.label}`} value={query} onChange={e => setQuery(e.target.value)} /></label>}
<div className="analysis-settings-content">
{mode === 'group' ? <><p>可选择多个维度,按选择顺序组合统计。</p><div className="analysis-settings-groups">{available.map(d => <button key={d.key} aria-pressed={draftDimensions.includes(d.key)} onClick={() => setDraftDimensions(draftDimensions.includes(d.key) ? draftDimensions.filter(v => v !== d.key) : [...draftDimensions, d.key])}><span>{d.label}</span>{draftDimensions.includes(d.key) && <span className="analysis-dimension-order">{draftDimensions.indexOf(d.key) + 1}</span>}</button>)}</div><button className="analysis-settings-row" aria-pressed={!draftDimensions.length} onClick={() => setDraftDimensions([])}>仅汇总,不分组{!draftDimensions.length && <Check size={18} />}</button></> : editor ? <>
<div className="analysis-settings-select"><button onClick={() => setDraftFilters({ ...draftFilters, [editor.key]: [...new Set([...(draftFilters[editor.key] || []), ...options.map(o => o.value)])] })}>全选{query ? '搜索结果' : ''}</button><button onClick={() => setDraftFilters({ ...draftFilters, [editor.key]: [] })}>清除选择</button><span>{draftFilters[editor.key]?.length || 0} 项已选</span></div>
{options.map(o => <label className="analysis-settings-option" key={o.value}><input type="checkbox" checked={draftFilters[editor.key]?.includes(o.value) || false} onChange={() => toggle(editor.key, o.value)} /><span>{o.label}</span>{o.count !== undefined && <small>{o.count.toLocaleString()}</small>}</label>)}
{!options.length && <p>没有匹配选项,可调整搜索。</p>}
</> : <><p>同一维度多选取并集,不同维度同时满足。未选择表示不限。</p>{available.map(d => <button className="analysis-settings-row" key={d.key} onClick={() => { setEditing(d.key); setQuery(''); }}><span>{d.label}</span><small>{draftFilters[d.key]?.length ? `已选 ${draftFilters[d.key]?.length} 项` : '不限'}</small><ChevronDown size={16} /></button>)}</>}
</div>
<footer><button onClick={() => mode === 'group' ? setDraftDimensions([...defaults]) : setDraftFilters({})}>重置{mode === 'group' ? '分组' : '筛选'}</button><button className="analysis-settings-apply" onClick={() => { const clean = Object.fromEntries(Object.entries(draftFilters).filter(([, values]) => values?.length)); apply(draftDimensions, clean); close(); }}>查看结果</button></footer>
</div>
</div>}
</div>;
}
@@ -0,0 +1,40 @@
import type { ReactNode } from 'react';
import { Filter } from 'lucide-react';
type Row = { key: string; label: string; values: ReactNode[]; count: number; share: number; note?: ReactNode };
type Props = {
dimensions: string[];
rows: Row[];
unit: string;
merged: boolean;
setMerged: (value: boolean) => void;
spans: number[][];
parentTotals: number[][];
onDetails: (index: number) => void;
onFilter?: (index: number) => void;
};
/** Retain table semantics and actual parent row spans at narrow viewport widths. */
export function MobileGroupedTable({ dimensions, rows, unit, merged, setMerged, spans, parentTotals, onDetails, onFilter }: Props) {
const multiple = dimensions.length > 1;
return <section className={`analysis-mobile-grid${onFilter ? '' : ' without-filter'}`}>
<div className="analysis-mobile-grid-heading"><strong>分组统计</strong>{multiple ? <label><input type="checkbox" role="switch" checked={merged} onChange={e => setMerged(e.target.checked)} /><span>合并单元格</span></label> : <span>数量 · 占比</span>}</div>
{multiple && <p className="analysis-mobile-grid-hint">左右滑动查看完整表格{merged ? ' · 相同上级已合并' : ' · 每行独立显示'}</p>}
<div className="analysis-mobile-grid-scroll" role="region" aria-label="分组统计表,可左右滑动" tabIndex={0}>
<table style={{ minWidth: multiple ? `${dimensions.length * 108 + 192}px` : '100%' }}>
<thead><tr>{dimensions.length ? dimensions.map((label, i) => <th scope="col" key={i}>{label}</th>) : <th scope="col">范围</th>}<th scope="col" className="mobile-grid-number">数量<small>({unit})</small></th><th scope="col" className="mobile-grid-share">占比</th><th scope="col" className="mobile-grid-actions">操作</th></tr></thead>
<tbody>{rows.map((row, rowIndex) => <tr key={row.key} className={merged && spans[rowIndex]?.[0] > 0 ? 'mobile-grid-group-start' : undefined}>
{dimensions.length ? row.values.map((value, colIndex) => {
const span = merged ? spans[rowIndex]?.[colIndex] ?? 1 : 1;
if (!span) return null;
const parent = merged && colIndex < dimensions.length - 1;
return <td key={colIndex} rowSpan={span} className={parent ? 'mobile-grid-parent' : 'mobile-grid-label'}>{value}{parent && <small>合计 <b>{parentTotals[rowIndex]?.[colIndex]?.toLocaleString('zh-CN')}</b> {unit}</small>}</td>;
}) : <td className="mobile-grid-label">当前结果</td>}
<td className="mobile-grid-number"><strong>{row.count.toLocaleString('zh-CN')}</strong>{row.note && <small>{row.note}</small>}</td>
<td className="mobile-grid-share"><span>{row.share.toFixed(1)}%</span><i aria-hidden="true"><i style={{ width: `${row.share}%` }} /></i></td>
<td className="mobile-grid-actions"><div>{onFilter && dimensions.length > 0 && <button className="mobile-grid-filter" aria-label={`筛选分组 ${row.label}`} title="筛选此组" onClick={() => onFilter(rowIndex)}><Filter size={15} /></button>}<button aria-label={`查看明细 ${row.label}`} onClick={() => onDetails(rowIndex)}>明细</button></div></td>
</tr>)}</tbody>
</table>
</div>
</section>;
}
@@ -0,0 +1,157 @@
/* Shared mobile presentation for asset statistics, filters and vehicle details. */
@media (max-width: 767px) {
.asset-analysis-overlay, .bi-flow-modal-backdrop { padding: 0; align-items: stretch; }
.asset-analysis-dialog, .bi-flow-modal { width: 100%; max-width: none; height: 100dvh; max-height: 100dvh; min-height: 0; border-radius: 0; font-size: 13px; }
.analysis-header, .bi-flow-modal-header { padding: max(6px, env(safe-area-inset-top)) 10px 6px; background: #fff; color: var(--color-slate-800, #1e293b); border-bottom: 1px solid var(--color-slate-200, #e2e8f0); }
.analysis-header { gap: 8px; }
.analysis-header > div { flex: 1; min-width: 0; }
.analysis-header h2, .bi-flow-modal-header h3 { font-size: 15px; }
.analysis-header h2 > svg { display: none; }
.analysis-header h2, .bi-flow-modal-header h3 { border-left: 3px solid var(--color-blue-600, #2563eb); padding-left: 8px; }
.analysis-header p, .bi-flow-modal-header p { font-size: 11px; line-height: 1.5; color: var(--color-slate-500, #64748b); }
.analysis-header button, .bi-flow-modal-close, .bi-flow-mobile-back { display: grid; place-items: center; flex-shrink: 0; width: 44px; height: 44px; padding: 0; color: var(--color-slate-500, #64748b); background: var(--color-slate-50, #f8fafc); border-radius: 8px; }
.analysis-header button:hover, .bi-flow-modal-close:hover { background: var(--color-blue-50, #eff6ff); }
.analysis-scroll, .bi-flow-modal-body { padding: 12px 16px max(16px, env(safe-area-inset-bottom)); background: var(--app-bg, #f4f7fb); }
.analysis-result-bar { padding: 10px 14px; gap: 8px; font-size: 12px; background: var(--color-blue-50, #eff6ff); border: 1px solid var(--color-blue-100, #dbeafe); border-radius: 10px; color: var(--color-slate-600, #475569); margin-bottom: 10px; }
.analysis-result-bar strong { font-size: 28px; margin: 0 4px; color: var(--color-blue-600, #2563eb); }
.analysis-result-dimensions { display: none; }
.analysis-inventory-subsets { gap: 5px 12px; font-size: 11px; margin: -11px 0 8px; padding: 8px 14px; background: var(--color-blue-50, #eff6ff); border: 1px solid var(--color-blue-100, #dbeafe); border-top: 0; border-radius: 0 0 10px 10px; }
.analysis-inventory-subsets small { width: 100%; font-size: 11px; }
.analysis-mobile-toolbar { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 8px; }
.analysis-mobile-toolbar > button { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; border: 1px solid #d8e0eb; border-radius: 9px; padding: 8px 12px; background: #f8fafc; color: #334155; font-size: 13px; }
.analysis-mobile-toolbar > button:first-child { justify-content: space-between; }
.analysis-mobile-toolbar > button > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.analysis-mobile-toolbar svg { flex-shrink: 0; }
.analysis-mobile-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.analysis-mobile-chips button { display: flex; align-items: center; gap: 6px; max-width: 100%; min-height: 44px; padding: 6px 10px; border-radius: 8px; color: var(--color-blue-700, #1d4ed8); background: #eff6ff; font-size: 11px; }
.analysis-mobile-chips span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.analysis-mobile-chips svg { flex-shrink: 0; }
.analysis-overview-tools > p, .analysis-overview-tools .analysis-merge-toggle { display: none; }
.analysis-overview-tools.is-empty { display: none; }
.analysis-overview-tools .analysis-table-options { width: 100%; justify-content: space-between; gap: 6px; }
.analysis-table-tools { margin: 8px 0; }
.analysis-table-tools label, .analysis-table-tools select, .analysis-export-action button { font-size: 11px; }
.analysis-table-tools select, .analysis-export-action button { min-height: 44px; }
.analysis-table-tools h3 { font-size: 14px; }
.analysis-table-tools h3 small { font-size: 12px; }
.analysis-table-total { border: 0; border-radius: 0; background: none; padding: 8px 0; font-size: 11px; line-height: 1.6; }
.analysis-table-total > span:last-child { display: none; }
.analysis-mobile-detail-scope { font-size: 12px; color: #64748b; margin-bottom: 8px; }
.analysis-search { width: 100%; min-height: 44px; padding: 9px 12px; border-radius: 8px; }
.analysis-search input { font-size: 16px; width: 100%; }
.analysis-vehicle-cards { display: grid; gap: 8px; margin-top: 10px; }
.analysis-vehicle-card { min-width: 0; padding: 12px; border: 1px solid #e2e8f0; border-radius: 10px; background: #fff; }
.analysis-vehicle-card header { display: flex; align-items: flex-start; flex-wrap: wrap; gap: 6px 12px; justify-content: space-between; }
.analysis-vehicle-card h4 { color: var(--color-blue-700, #1d4ed8); font-size: 14px; font-weight: 600; line-height: 1.5; overflow-wrap: anywhere; }
.analysis-vehicle-card header > span { font-size: 11px; padding: 3px 6px; border-radius: 4px; color: #475569; background: #f1f5f9; }
.analysis-vehicle-card > p { font-size: 12px; color: #64748b; line-height: 1.6; margin-top: 6px; overflow-wrap: anywhere; }
.analysis-vehicle-card summary { display: flex; align-items: center; min-height: 44px; font-size: 12px; color: var(--color-blue-600, #2563eb); cursor: pointer; }
.analysis-vehicle-card summary::after { content: '+'; margin-left: auto; }
.analysis-vehicle-card details[open] summary::after { content: '−'; }
.analysis-vehicle-card dl > div { display: grid; grid-template-columns: 90px minmax(0,1fr); gap: 10px; padding: 4px 0; font-size: 12px; line-height: 1.6; }
.analysis-vehicle-card dt { color: #64748b; }
.analysis-vehicle-card dd { color: #334155; overflow-wrap: anywhere; }
.analysis-footer { padding: 0 16px max(0px, env(safe-area-inset-bottom)); background: #fff; font-size: 11px; }
.analysis-footer details { width: 100%; max-height: 28dvh; overflow-y: auto; }
.analysis-footer summary { display: flex; align-items: center; min-height: 44px; cursor: pointer; }
.analysis-footer details p { margin-bottom: 10px; line-height: 1.6; }
.analysis-message { font-size: 13px; min-height: 240px; }
.analysis-message h3 { font-size: 15px; }
.analysis-definition { font-size: 11px; }
.analysis-details .text-base { font-size: 14px; }
.analysis-details .text-sm { font-size: 13px; }
.analysis-details .text-xs { font-size: 11px; }
.analysis-details .space-y-3.px-3 { padding: 0; }
.analysis-details .space-y-3 article { box-shadow: none; }
.analysis-settings-backdrop { position: fixed; inset: 0; z-index: 30; background: rgb(15 23 42 / 38%); display: flex; align-items: flex-end; }
.analysis-settings-panel { display: flex; flex-direction: column; width: 100%; height: min(680px, 90dvh); min-height: 0; background: #fff; border-radius: 12px 12px 0 0; color: #334155; box-shadow: 0 -12px 36px #0f172a20; outline: none; }
.analysis-settings-panel header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid #e2e8f0; }
.analysis-settings-panel header h3 { flex: 1; font-size: 15px; font-weight: 600; }
.analysis-settings-panel header button { width: 44px; height: 44px; display: grid; place-items: center; }
.analysis-settings-search { display: flex; align-items: center; gap: 8px; margin: 12px 16px 0; padding: 10px 12px; border: 1px solid #cbd5e1; border-radius: 8px; }
.analysis-settings-search input { min-width: 0; width: 100%; font-size: 16px; outline: none; }
.analysis-settings-content { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 12px 16px; }
.analysis-settings-content > p { color: #64748b; font-size: 12px; line-height: 1.6; margin: 0 0 12px; }
.analysis-settings-groups { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; margin-bottom: 18px; }
.analysis-settings-groups button { min-height: 48px; display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border: 1px solid #d8e0eb; border-radius: 8px; font-size: 13px; }
.analysis-settings-groups button[aria-pressed=true] { color: var(--color-blue-700, #1d4ed8); background: #eff6ff; border-color: #93c5fd; }
.analysis-settings-groups .analysis-dimension-order { width: 20px; height: 20px; font-size: 11px; }
.analysis-settings-row { display: flex; gap: 10px; width: 100%; align-items: center; min-height: 48px; padding: 8px 0; border-bottom: 1px solid #e2e8f0; text-align: left; font-size: 13px; }
.analysis-settings-row > span { flex: 1; }
.analysis-settings-row > small { font-size: 12px; color: #64748b; }
.analysis-settings-select { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; font-size: 12px; }
.analysis-settings-select button { min-height: 44px; color: var(--color-blue-600, #2563eb); }
.analysis-settings-select > span { margin-left: auto; color: #64748b; }
.analysis-settings-option { display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 8px 0; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
.analysis-settings-option input { width: 18px; height: 18px; accent-color: var(--color-blue-600, #2563eb); flex-shrink: 0; }
.analysis-settings-option span { flex: 1; overflow-wrap: anywhere; }
.analysis-settings-option small { color: #64748b; font-size: 12px; }
.analysis-settings-panel footer { display: grid; grid-template-columns: 1fr 2fr; gap: 12px; padding: 12px 16px max(12px, env(safe-area-inset-bottom)); border-top: 1px solid #e2e8f0; }
.analysis-settings-panel footer button { min-height: 46px; border-radius: 9px; background: #f1f5f9; font-size: 13px; }
.analysis-settings-panel footer .analysis-settings-apply { background: var(--color-blue-600, #2563eb); color: #fff; }
}
@media (max-width: 767px) {
.bi-flow-modal-header-row { align-items: center; gap: 8px; }
.bi-flow-modal-header-row > div { flex: 1; min-width: 0; }
.bi-flow-modal-summary { margin: 0 0 12px; background: var(--color-blue-50, #eff6ff); border-color: var(--color-blue-100, #dbeafe); }
.bi-flow-modal-summary > div { padding: 10px 8px; gap: 6px; }
.bi-flow-modal-summary span, .bi-flow-modal strong small { font-size: 11px; }
.bi-flow-modal-summary strong { font-size: 23px; }
.bi-flow-modal-section-heading { margin: 12px 0 4px; }
.bi-flow-modal-section-heading h4 { font-size: 14px; }
.bi-flow-modal-section-heading p { font-size: 11px; }
.bi-flow-modal-empty { font-size: 13px; line-height: 1.7; color: #64748b; }
}
@media (max-width: 767px) {
.analysis-details .space-y-3 dt { font-size: 11px; line-height: 1.8; }
}
@media (max-width: 767px) {
.analysis-mobile-controls { position: sticky; top: -12px; z-index: 5; background: var(--app-bg, #f4f7fb); padding: 0 0 6px; }
.analysis-mobile-dimension-heading { display: flex; align-items: center; gap: 8px; color: var(--color-slate-700, #334155); font-size: 12px; }
.analysis-mobile-dimension-heading > span { color: var(--color-slate-500, #64748b); font-size: 11px; }
.analysis-mobile-dimension-heading > button { margin-left: auto; min-height: 44px; color: var(--color-blue-600, #2563eb); font-size: 11px; }
.analysis-mobile-toolbar { grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
.analysis-mobile-toolbar > button { align-self: start; height: 44px; padding: 0 8px; gap: 4px; white-space: nowrap; color: var(--color-blue-600, #2563eb); border-color: var(--color-blue-100, #dbeafe); background: var(--color-blue-50, #eff6ff); }
.analysis-dimension-strip { display: flex; gap: 6px; min-width: 0; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; scrollbar-color: var(--color-slate-300, #cbd5e1) transparent; padding-bottom: 4px; }
.analysis-dimension-strip button { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 44px; padding: 0 11px; border: 1px solid var(--color-slate-200, #e2e8f0); border-radius: 8px; background: #fff; color: var(--color-slate-600, #475569); font-size: 12px; }
.analysis-dimension-strip button[aria-pressed=true] { background: var(--color-blue-600, #2563eb); border-color: var(--color-blue-600, #2563eb); color: #fff; box-shadow: 0 2px 4px rgb(37 99 235 / 12%); }
.analysis-dimension-strip small { display: grid; place-items: center; width: 17px; height: 17px; border-radius: 50%; background: #ffffff2b; font-size: 10px; }
.analysis-mobile-grid { border: 1px solid var(--color-slate-200, #e2e8f0); border-radius: 10px; overflow: hidden; background: #fff; box-shadow: 0 2px 6px rgb(15 23 42 / 3%); }
.analysis-mobile-grid-heading { min-height: 44px; display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 0 12px; background: var(--color-blue-50, #eff6ff); color: var(--color-slate-700, #334155); font-size: 12px; }
.analysis-mobile-grid-heading > span { font-size: 11px; color: var(--color-slate-500, #64748b); }
.analysis-mobile-grid-heading label { display: inline-flex; align-items: center; gap: 7px; min-height: 44px; font-size: 11px; cursor: pointer; }
.analysis-mobile-grid-heading input { appearance: none; width: 30px; height: 18px; border-radius: 10px; background: var(--color-slate-400, #94a3b8); position: relative; cursor: pointer; }
.analysis-mobile-grid-heading input::after { content: ''; position: absolute; top: 3px; left: 3px; width: 12px; height: 12px; border-radius: 50%; background: #fff; }
.analysis-mobile-grid-heading input:checked { background: var(--color-blue-600, #2563eb); }
.analysis-mobile-grid-heading input:checked::after { left: 15px; }
.analysis-mobile-grid-hint { padding: 6px 12px; font-size: 11px; color: var(--color-slate-500, #64748b); border-bottom: 1px solid var(--color-slate-200, #e2e8f0); background: var(--color-slate-50, #f8fafc); }
.analysis-mobile-grid-scroll { overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; }
.analysis-mobile-grid table { width: 100%; border-collapse: separate; border-spacing: 0; table-layout: fixed; font-size: 12px; }
.analysis-mobile-grid th { background: var(--color-slate-50, #f8fafc); color: var(--color-slate-500, #64748b); font-weight: 500; padding: 7px 6px; text-align: left; border-bottom: 1px solid var(--color-slate-200, #e2e8f0); }
.analysis-mobile-grid th:first-child { padding-left: 12px; }
.analysis-mobile-grid td { padding: 4px 6px; border-bottom: 1px solid var(--color-slate-100, #f1f5f9); vertical-align: middle; overflow-wrap: anywhere; color: var(--color-slate-700, #334155); line-height: 1.5; }
.analysis-mobile-grid tr:nth-child(even) td { background: var(--color-slate-50, #f8fafc); }
.analysis-mobile-grid tr:last-child td { border-bottom: 0; }
.analysis-mobile-grid td:first-child { padding-left: 12px; }
.analysis-mobile-grid .mobile-grid-parent { background: var(--color-blue-50, #eff6ff); border-right: 1px solid var(--color-blue-100, #dbeafe); font-weight: 600; vertical-align: top; padding-top: 14px; }
.analysis-mobile-grid .mobile-grid-parent small { display: block; margin-top: 6px; font-size: 11px; font-weight: 400; color: var(--color-slate-500, #64748b); }
.analysis-mobile-grid .mobile-grid-group-start:not(:first-child) td { border-top: 1px solid var(--color-slate-300, #cbd5e1); }
.analysis-mobile-grid .mobile-grid-number { width: 60px; text-align: right; font-variant-numeric: tabular-nums; }
.analysis-mobile-grid th small { display: block; font-size: 10px; }
.analysis-mobile-grid td.mobile-grid-number strong { font-size: 17px; color: var(--color-blue-600, #2563eb); font-weight: 600; }
.analysis-mobile-grid td.mobile-grid-number small { display: block; font-size: 10px; color: var(--color-amber-600, #d97706); }
.analysis-mobile-grid .mobile-grid-share { width: 58px; text-align: right; font-size: 11px; font-variant-numeric: tabular-nums; }
.mobile-grid-share > i { display: block; margin-top: 5px; height: 3px; border-radius: 3px; overflow: hidden; background: var(--color-blue-100, #dbeafe); }
.mobile-grid-share > i > i { display: block; height: 100%; background: var(--color-blue-500, #3b82f6); }
.analysis-mobile-grid .mobile-grid-actions { width: 90px; text-align: center; padding-left: 0; padding-right: 4px; }
.analysis-mobile-grid.without-filter .mobile-grid-actions { width: 56px; }
.mobile-grid-actions > div { display: flex; justify-content: flex-end; }
.mobile-grid-actions button { display: grid; place-items: center; min-width: 44px; height: 44px; color: var(--color-blue-600, #2563eb); font-size: 11px; border-radius: 7px; }
.mobile-grid-actions button:active { background: #dbeafe; }
.mobile-grid-actions .mobile-grid-filter { color: var(--color-slate-400, #94a3b8); }
.analysis-table-tools { margin-top: 0; }
.analysis-mobile-detail-scope { padding: 10px 12px; border-left: 3px solid var(--color-blue-600, #2563eb); border-radius: 0 6px 6px 0; background: var(--color-blue-50, #eff6ff); color: var(--color-slate-600, #475569); }
.analysis-vehicle-card { border-color: var(--color-slate-200, #e2e8f0); border-top: 3px solid var(--color-blue-100, #dbeafe); }
.bi-flow-modal-summary strong { color: var(--color-blue-600, #2563eb); }
}
@@ -8,7 +8,7 @@ const parseDate = (value: string) => new Date(`${value}T12:00:00`);
const shiftDay = (date: Date, offset: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + offset);
const dayCount = (start: string, end: string) => Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000) + 1;
export function FlowDateRangePicker({ value, onChange }: { value: DateRange; onChange: (range: DateRange) => void }) {
export function FlowDateRangePicker({ value, onChange, dateLabel = '运维签章时间' }: { value: DateRange; onChange: (range: DateRange) => void; dateLabel?: string }) {
const dialog = useRef<HTMLDialogElement>(null);
const [draft, setDraft] = useState(value);
const [selecting, setSelecting] = useState<'start' | 'end'>('start');
@@ -69,7 +69,7 @@ export function FlowDateRangePicker({ value, onChange }: { value: DateRange; onC
className="mt-3 flex w-full items-center gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-left shadow-sm transition hover:border-blue-300 hover:bg-blue-50/40 focus-visible:outline-2 focus-visible:outline-blue-500">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600"><CalendarDays size={18} /></span>
<span className="min-w-0 flex-1">
<span className="block text-[10px] font-medium text-slate-400">统计日期 · 运维签章时间</span>
<span className="block text-[10px] font-medium text-slate-400">统计日期 · {dateLabel}</span>
<span className="mt-1 flex items-center gap-2 text-[13px] font-bold text-slate-700"><span>{value.start.replaceAll('-', '/')}</span><ArrowRight size={13} className="shrink-0 text-slate-300" /><span>{value.end.replaceAll('-', '/')}</span></span>
</span>
<span className="hidden rounded-lg bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-500 sm:block">{dayCount(value.start, value.end)} 天</span>
@@ -0,0 +1,42 @@
import { useState } from 'react';
import { Bar, BarChart, CartesianGrid, LabelList, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { ArrowUpRight, Info } from 'lucide-react';
import type { FaultAnalysisRecord, FaultDashboardDimension, FaultDashboardResponse, FaultDashboardScope, FaultDistribution } from '../../../shared/assets/faults';
export type DashboardDrill = (title: string, scope?: FaultDashboardScope, filters?: Record<string, string>) => void;
const colors = ['#ef4444','#f59e0b','#2563eb','#93c5fd','#94a3b8'];
export function DashboardPanels({data, drill}:{data:FaultDashboardResponse;drill:DashboardDrill}) {
const [dimension,setDimension]=useState<'region'|'level'|'customer'|'model'>('region');
const [metric,setMetric]=useState<'reported'|'vehicles'>('reported');
const [expanded,setExpanded]=useState(false),[matrixExpanded,setMatrixExpanded]=useState(false),[customersExpanded,setCustomersExpanded]=useState(false);
const groups=data.distributions[dimension],shown=expanded?groups:groups.slice(0,8);
const levels=[...data.distributions.level].sort((a,b)=>(a.value==='未标注'?1:0)-(b.value==='未标注'?1:0)||a.value.localeCompare(b.value,'zh-CN'));
const models=matrixExpanded?data.matrix.models:data.matrix.models.slice(0,3);
const max=Math.max(1,...data.matrix.cells.map(c=>c.count));
const customers=[...data.distributions.customer].sort((a,b)=>b.vehicles-a.vehicles||b.reported-a.reported);
const chartDrill=(datum:unknown,key:FaultDashboardDimension,title:string)=>{const row=(datum as {payload?:FaultDistribution})?.payload;if(row)drill(`${title} · ${row.label}`,'cohort',{[key]:row.value});};
return <>
<div className="fd-grid fd-grid-primary">
<section className="fd-panel" aria-label="故障分布与对比"><div className="fd-panel-title"><h2>故障分布与对比</h2><span>按本期提报队列统计,截至当前的闭环状态。</span></div>
<div className="fd-chart-controls"><div className="fd-segment" aria-label="故障分布维度">{([['region','区域'],['level','程度'],['customer','客户'],['model','车型']] as const).map(([key,label])=><button key={key} aria-pressed={dimension===key} onClick={()=>{setDimension(key);setExpanded(false);}}>{label}</button>)}</div><div className="fd-segment" aria-label="故障分布指标"><button aria-pressed={metric==='reported'} onClick={()=>setMetric('reported')}>故障量</button><button aria-pressed={metric==='vehicles'} onClick={()=>setMetric('vehicles')}>受影响车辆</button><button disabled title="缺少同期服务车辆规模,暂不计算发生频率">发生频率 · 待补分母</button></div></div>
<div className="fd-legend">{metric==='reported'?<><span className="fd-blue">已闭环 {groups.reduce((n,r)=>n+r.closed,0)}</span><span className="fd-orange">未闭环 {groups.reduce((n,r)=>n+r.unclosed,0)}</span></>:<span className="fd-blue">按车辆去重 · 辆</span>}</div>
{shown.length?<div className="fd-distribution-chart"><ResponsiveContainer width="100%" height={Math.max(150,shown.length*26)} minWidth={0}><BarChart data={shown} layout="vertical" margin={{top:4,right:30,left:0,bottom:0}}>
<CartesianGrid horizontal={false} stroke="#edf2fa"/><XAxis type="number" allowDecimals={false} axisLine={false} tickLine={false} tick={{fontSize:10,fill:'#7c8ba7'}}/><YAxis type="category" dataKey="label" interval={0} width={116} axisLine={false} tickLine={false} tick={{fontSize:11,fill:'#334155'}} tickFormatter={v=>v.length>12?`${v.slice(0,11)}…`:v}/><Tooltip cursor={{fill:'#f4f7fc'}} contentStyle={{borderRadius:8,borderColor:'#e2e8f0',fontSize:12}}/>
{metric==='reported'?<><Bar dataKey="closed" name="已闭环" stackId="faults" fill="#2475ff" maxBarSize={18} isAnimationActive={false} cursor="pointer" onClick={d=>chartDrill(d,dimension,'故障分布')}><LabelList dataKey={(row:FaultDistribution)=>row.unclosed===0?row.reported:''} position="right" fontSize={10} fill="#334155"/></Bar><Bar dataKey="unclosed" name="未闭环" stackId="faults" fill="#ff981a" maxBarSize={18} isAnimationActive={false} cursor="pointer" onClick={d=>chartDrill(d,dimension,'故障分布')}><LabelList dataKey="reported" position="right" fontSize={10} fill="#334155"/></Bar></>:<Bar dataKey="vehicles" name="受影响车辆" fill="#2475ff" maxBarSize={18} isAnimationActive={false} cursor="pointer" onClick={d=>chartDrill(d,dimension,'受影响车辆对应故障')}><LabelList dataKey="vehicles" position="right" fontSize={10} fill="#334155"/></Bar>}</BarChart></ResponsiveContainer></div>:<p className="fd-empty">所选条件下暂无故障。</p>}
<div className="fd-panel-foot"><span>点击图表查看对应故障;车辆跨组可能重复,不直接累加。</span>{groups.length>8&&<button onClick={()=>setExpanded(!expanded)}>{expanded?'收起':`全部 ${groups.length} 组`}</button>}</div>
<details className="fd-chart-data"><summary>查看精确数量与下钻</summary><table><thead><tr><th>分组</th><th>故障量</th><th>已闭环</th><th>未闭环</th><th>车辆</th></tr></thead><tbody>{groups.map(r=><tr key={r.value}><td><button onClick={()=>drill(r.label,'cohort',{[dimension]:r.value})}>{r.label}</button></td><td>{r.reported}</td><td>{r.closed}</td><td>{r.unclosed}</td><td>{r.vehicles}</td></tr>)}</tbody></table></details>
</section>
<section className="fd-panel" aria-label="故障程度"><div className="fd-panel-title"><h2>故障程度</h2><span className="fd-chip">现有等级口径</span></div><p className="fd-caption">按本期故障数量统计,点击等级查看明细。</p>
<div className="fd-level-list">{levels.map(row=><button key={row.value} onClick={()=>drill(`故障程度 · ${row.label}`,'cohort',{level:row.value})}><span>{row.label}</span><span className="fd-level-track"><span style={{width:`${row.reported/Math.max(1,...levels.map(r=>r.reported))*100}%`,background:colors[/^L[1-4]/.test(row.value)?Number(row.value[1])-1:4]}}/></span><b>{row.reported}</b><ArrowUpRight size={12}/></button>)}</div>
{!levels.length&&<p className="fd-empty">暂无等级统计。</p>}
<p className="fd-info"><Info size={15}/>等级不等于实际停运影响,请结合车辆状态与服务记录判断。</p><p className="fd-caption">未标注等级 {data.quality.missingLevel} 单;停运车辆与修复时长待补数据。</p>
</section>
</div>
<div className="fd-grid fd-grid-secondary">
<section className="fd-panel" aria-label="品牌车型与故障部位"><div className="fd-panel-title"><h2>品牌车型 × 故障部位</h2><span>点击交叉单元格查看对应故障。</span></div><div className="fd-table-scroll"><table className="fd-matrix"><thead><tr><th>品牌车型</th>{data.matrix.categories.map(c=><th key={c.value}>{c.label}</th>)}<th>合计</th></tr></thead><tbody>{models.map(m=><tr key={m.value}><th title={m.label}>{m.label}</th>{data.matrix.categories.map(c=>{const count=data.matrix.cells.find(cell=>cell.model===m.value&&cell.category===c.value)?.count||0;return <td key={c.value} style={{background:count?`rgba(36,117,255,${.08+.52*count/max})`:undefined}}><button disabled={!count} aria-label={`${m.label},${c.label},${count} 单`} onClick={()=>drill(`${m.label} · ${c.label}`,'cohort',{model:m.value,category:c.value})}>{count||'—'}</button></td>})}<td><button onClick={()=>drill(m.label,'cohort',{model:m.value})}>{data.matrix.totals[m.value]}</button></td></tr>)}</tbody></table></div>{!models.length&&<p className="fd-empty">暂无车型与部位数据。</p>}<div className="fd-panel-foot"><span>一单多部位分别计入,合计按故障单去重。</span>{data.matrix.models.length>3&&<button onClick={()=>setMatrixExpanded(!matrixExpanded)}>{matrixExpanded?'收起':`全部 ${data.matrix.models.length} 个车型`}</button>}</div></section>
<section className="fd-panel" aria-label="客户受影响情况"><div className="fd-panel-title"><h2>客户受影响情况</h2><span>同一车辆在同一客户下去重。</span></div><div className="fd-table-scroll"><table className="fd-customers"><thead><tr><th>#</th><th>客户名称</th><th>本期影响车辆</th><th>当前在办</th></tr></thead><tbody>{(customersExpanded?customers:customers.slice(0,4)).map((r,i)=><tr key={r.value}><td>{i+1}</td><td><button onClick={()=>drill(r.label,'cohort',{customer:r.value})}>{r.label}</button></td><td><button className="fd-customer-meter" onClick={()=>drill(`${r.label} · 本期故障`,'cohort',{customer:r.value})}><progress max={Math.max(1,customers[0]?.vehicles||0)} value={r.vehicles}/><b>{r.vehicles}</b></button></td><td><button className="fd-danger" disabled={!r.open} onClick={()=>drill(`${r.label} · 当前在办`,'open',{customer:r.value})}>{r.open}</button></td></tr>)}</tbody></table></div>{!customers.length&&<p className="fd-empty">暂无客户影响数据。</p>}<div className="fd-panel-foot"><span>当前在办不限制提报日期;库存车单独展示。</span>{customers.length>4&&<button onClick={()=>setCustomersExpanded(!customersExpanded)}>{customersExpanded?'收起':`全部 ${customers.length} 组`}</button>}</div></section>
</div>
</>;
}
export function DashboardTicketTable({rows,updatedAt,drill,emptyMessage='当前条件下暂无故障。'}:{rows:FaultAnalysisRecord[];updatedAt:string;drill:DashboardDrill;emptyMessage?:string}) {
return <div className="fd-table-scroll"><table className="fd-tickets"><thead><tr><th>车辆 / 故障单号</th><th>运维处置人</th><th>区域 / 客户</th><th>等级</th><th>当前状态</th><th>处置记录 / 故障描述</th><th>提报时间</th><th>操作</th></tr></thead><tbody>{rows.map(row=>{const overdue=['待处理','处理中'].includes(row.status)&&row.deadlineAt&&Date.parse(`${row.deadlineAt.replace(' ','T')}+08:00`)<Date.parse(updatedAt);return <tr key={row.id}><td><strong>{row.plate}</strong><small>{row.code}</small></td><td>{row.handlers.map(h=>h.label).join('、')}</td><td>{row.cell.region}<small>{row.customerLabel}</small></td><td><span className={`fd-grade fd-grade-${row.level?.slice(0,2)}`}>{row.level||'未标注'}</span></td><td><span className={row.status==='已归档'?'fd-green':'fd-orange'}>{row.status==='已归档'?'已闭环':row.status}</span>{overdue&&<small className="fd-overdue-tag">已逾期</small>}</td><td className="fd-description" title={row.progress||row.description}>{row.progress||row.description||'暂无处置记录'}</td><td>{row.reportedAt||'未记录'}</td><td><button onClick={()=>drill(`${row.plate} · ${row.code}`,'ledger',{id:row.id})}>查看</button></td></tr>})}</tbody></table>{!rows.length&&<p className="fd-empty">{emptyMessage}</p>}</div>;
}
@@ -0,0 +1,16 @@
/** Neutral placeholders communicate structure without suggesting partial statistics. */
export function FaultAnalysisLoading() {
return <div className="fault-analysis-loading" role="status" aria-live="polite">
<div className="fault-loading-status">
<svg className="fault-loading-spinner" viewBox="0 0 32 32" aria-hidden="true">
{Array.from({ length: 12 }, (_, i) => <rect key={i} x="14.75" y="2" width="2.5" height="7" rx="1.25" fill="currentColor" transform={`rotate(${i * 30} 16 16)`} style={{ animationDelay: `${i / 12 - 1}s` }} />)}
</svg>
<span>正在加载故障数据…</span>
</div>
<div className="fault-loading-skeleton" aria-hidden="true">
<div className="fault-loading-controls"><span className="fault-loading-block fault-loading-title" /><div>{Array.from({ length: 6 }, (_, i) => <span className="fault-loading-block" key={i} />)}</div></div>
<div className="fault-loading-summary"><span className="fault-loading-block" /><span className="fault-loading-block" /></div>
<div className="fault-loading-table"><div className="fault-loading-table-head">{Array.from({ length: 3 }, (_, i) => <span className="fault-loading-block" key={i} />)}</div>{Array.from({ length: 5 }, (_, i) => <div className="fault-loading-row" key={i}><span className="fault-loading-block" /><span className="fault-loading-block" /><span className="fault-loading-block" /></div>)}</div>
</div>
</div>;
}
@@ -0,0 +1,100 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowLeft, Search, Wrench, X } from 'lucide-react';
import { fetchJson } from '../../../auth/api-client';
import { FAULT_OVERVIEW_METRICS, type FaultAnalysisResponse, type FaultOverviewMetric } from '../../../shared/assets/faults';
import { getDimensionValueLabel } from '../analytics-model';
import { FAULT_CLOSURE_LABELS, FAULT_ANALYSIS_DIMENSIONS, FAULT_ANALYSIS_LABELS as DIMENSION_LABELS, analyzeFaultRecords, filterFaultRecords, faultDimensionValues, faultParentTotals, type FaultAnalysisDimension, type FaultAnalysisFilters, type FaultAnalysisGroup } from './analysis-model';
import { dimensionRowSpans, orderDimensionGroups } from '../dimension-layout';
import { FaultAnalysisLoading } from './FaultAnalysisLoading';
import { DimensionPicker } from '../components/analysis/DimensionPicker';
import { MobileAnalysisControls } from '../components/analysis/MobileAnalysisControls';
import { MobileGroupedTable } from '../components/analysis/MobileGroupedTable';
import { useDialogFocus } from '../components/useDialogFocus';
import { useMobileLayout } from '../useMobileLayout';
import '../components/analysis/asset-analysis.css';
import '../components/analysis/mobile-analysis.css';
const available = FAULT_ANALYSIS_DIMENSIONS;
// Page-lifetime memory only: closing a modal retains settings; a full reload clears them.
const analysisSettings = new Map<string, { dimensions: FaultAnalysisDimension[]; filters: FaultAnalysisFilters }>();
export interface FaultAnalysisSource { url: string; title: string; periodLabel: string; definition?: string; defaultDimensions?: FaultAnalysisDimension[]; initialDetails?: boolean }
export function FaultAnalysisModal({ metric, onClose, source }: { metric: FaultOverviewMetric; onClose: () => void; source?: FaultAnalysisSource }) {
const ref = useRef<HTMLDivElement>(null);
useDialogFocus(true, ref, onClose);
const mobile = useMobileLayout();
const [data, setData] = useState<FaultAnalysisResponse | null>(null);
const [error, setError] = useState('');
const [revision, setRevision] = useState(0);
const defaultDimensions: FaultAnalysisDimension[] = source?.defaultDimensions || ['type'];
const settingsKey = source?.url || `overview:${metric}`;
const [dimensions, setDimensions] = useState<FaultAnalysisDimension[]>(() => analysisSettings.get(settingsKey)?.dimensions || defaultDimensions);
const [filters, setFilters] = useState<FaultAnalysisFilters>(() => analysisSettings.get(settingsKey)?.filters || {});
const [merged, setMerged] = useState(true);
const [sort, setSort] = useState<'count-desc' | 'count-asc' | 'label'>('count-desc');
const [group, setGroup] = useState<FaultAnalysisGroup | null>(null);
const [search, setSearch] = useState('');
const [limit, setLimit] = useState(50);
useEffect(() => {
const controller = new AbortController();
setError(''); setData(null);
fetchJson<FaultAnalysisResponse>(source?.url || `/api/asset-faults/analysis?metric=${metric}`, { signal: controller.signal })
.then(result => { if (!controller.signal.aborted) { setData(result); if (source?.initialDetails) setGroup({key:"all", values:{}, count:result.rows.length, share:100, recordIds:result.rows.map(r=>r.id)}); } }).catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); });
return () => controller.abort();
}, [metric, revision, source?.url]);
const records = useMemo(() => data?.rows || [], [data]);
const handlerLabels = useMemo(() => new Map(records.flatMap(row => row.handlers.map(h => [h.value, h.label] as const))), [records]);
const label = (d: FaultAnalysisDimension, value: string) => d === 'plate' ? value : d === 'faultStatus' ? FAULT_CLOSURE_LABELS[value] || value : d === 'handler' ? handlerLabels.get(value) || '未指派' : d === 'customer' ? data?.customerLabels[value] || '未关联客户' : getDimensionValueLabel(d, value);
const options = available.map(key => {
const otherFilters = { ...filters }; delete otherFilters[key];
const counts = new Map<string, number>();
filterFaultRecords(records, otherFilters).forEach(row => faultDimensionValues(row, key).forEach(value => counts.set(value, (counts.get(value) || 0) + 1)));
// Keep applied selections removable even if another filter excludes them.
filters[key]?.forEach(value => { if (!counts.has(value)) counts.set(value, 0); });
return { key, label: DIMENSION_LABELS[key], options: [...counts].map(([value, count]) => ({ value, count, label: label(key, value) })).sort((a, b) => a.label.localeCompare(b.label, 'zh-CN')) };
});
const analysis = useMemo(() => analyzeFaultRecords(records, dimensions, filters), [records, dimensions, filters]);
const values = (row: FaultAnalysisGroup) => dimensions.map(d => row.values[d]!);
const ordered = orderDimensionGroups(analysis.rows, dimensions.length, values, row => row.count, sort,
(a, b, i) => label(dimensions[i], values(a)[i]).localeCompare(label(dimensions[i], values(b)[i]), 'zh-CN'));
const rows = ordered.slice(0, limit);
const spans = dimensionRowSpans(rows, dimensions.length, values);
const totals = faultParentTotals(ordered, dimensions);
const groupLabel = (row: FaultAnalysisGroup) => dimensions.map(d => label(d, row.values[d]!)).join(' · ') || '合计';
const apply = (dims: FaultAnalysisDimension[], nextFilters: FaultAnalysisFilters) => {
const saved = { dimensions: [...dims], filters: Object.fromEntries(Object.entries(nextFilters).filter(([, values]) => values?.length).map(([key, values]) => [key, [...values!]])) };
analysisSettings.set(settingsKey, saved);
setDimensions(saved.dimensions); setFilters(saved.filters); setLimit(50);
};
const reset = () => { analysisSettings.delete(settingsKey); setDimensions([...defaultDimensions]); setFilters({}); setLimit(50); };
const showDetails = (row: FaultAnalysisGroup) => { setGroup(row); setSearch(''); setLimit(50); };
const filterGroup = (row: FaultAnalysisGroup) => apply(dimensions, { ...filters, ...Object.fromEntries(dimensions.map(d => [d, [row.values[d]!]])) });
const groupIds = useMemo(() => new Set(group?.recordIds || []), [group]);
const details = group ? records.filter(row => groupIds.has(row.id) &&
`${row.code} ${row.plate} ${row.description} ${row.status} ${row.handlers.map(h => h.label).join(' ')}`.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()))
.sort((a, b) => (b.reportedAt || '').localeCompare(a.reportedAt || '') || b.id.localeCompare(a.id, undefined, { numeric: true })) : [];
return <div className="asset-analysis-overlay fault-analysis-overlay" onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
<div className="asset-analysis-dialog fault-analysis-dialog" ref={ref} role="dialog" aria-modal="true" aria-labelledby="fault-analysis-title" tabIndex={-1}>
<header className="analysis-header"><div><h2 id="fault-analysis-title"><Wrench size={18} />{source?.title || FAULT_OVERVIEW_METRICS[metric]} · {group ? '故障明细' : '多维分析'}</h2><p>{source?.periodLabel || `${data?.date || '正在读取数据'} · ${metric !== 'reported' && metric !== 'closed' ? '全部日期' : '北京时间自然日'}`} · 按故障单统计</p></div><button onClick={onClose} aria-label="关闭故障分析"><X size={20} /></button></header>
<div className="analysis-scroll">
{error ? <div className="fault-message" role="alert">{error}<button onClick={() => setRevision(v => v + 1)}>重试</button></div> : !data ? <FaultAnalysisLoading /> : group ? <>
<div className="analysis-table-tools"><button className="analysis-button" onClick={() => { setGroup(null); setLimit(50); }}><ArrowLeft size={14} />返回分组</button><span>{groupLabel(group)} · {details.length.toLocaleString()} 单</span><label className="fault-detail-search"><Search size={14} /><input aria-label="搜索故障明细" placeholder="搜索单号、车牌、描述、处置人" value={search} onChange={e => { setSearch(e.target.value); setLimit(50); }} /></label></div>
<div className="analysis-group-table is-merged fault-detail-table"><table><thead><tr>{['车牌 / 故障单号', '运维处置人', '故障描述', '状态', '提报时间', '解决时间', '运营城市', '客户 / 部门'].map(h => <th key={h}>{h}</th>)}</tr></thead><tbody>{details.slice(0, limit).map(row => <tr key={row.id}><td><strong>{row.plate || '未关联车辆'}</strong><small>{row.code || '未编号'}</small></td><td>{row.handlers.map(h => h.label).join('、') || '未指派'}</td><td>{row.description || '—'}</td><td>{row.status}</td><td>{row.reportedAt || '—'}</td><td>{row.archivedAt || '—'}</td><td>{row.city}</td><td>{row.customerLabel}<small>{row.department}</small></td></tr>)}</tbody></table></div>
{!details.length && <p className="fault-empty">没有符合条件的故障明细</p>}
{details.length > limit && <button className="analysis-button fault-more" onClick={() => setLimit(v => v + 50)}>加载更多明细(已显示 {limit} / {details.length})</button>}
</> : <>
{mobile ? <MobileAnalysisControls available={options} dimensions={dimensions} filters={filters} defaults={defaultDimensions} apply={(dims, next) => apply(dims as FaultAnalysisDimension[], next)} /> : <div className="analysis-builder"><div className="analysis-builder-heading"><h3>分析维度</h3><span>点击维度分组,展开箭头可多选筛选</span><button className="analysis-reset" onClick={reset}>重置</button></div><div className="analysis-dimension-options">{options.map(d => <DimensionPicker key={d.key} label={d.label} grouped={dimensions.includes(d.key)} order={dimensions.indexOf(d.key) + 1} options={d.options} selectedValues={filters[d.key]} onToggleGroup={() => apply(dimensions.includes(d.key) ? dimensions.filter(v => v !== d.key) : [...dimensions, d.key], filters)} onApplyFilter={selected => apply(dimensions, { ...filters, [d.key]: selected })} />)}<button aria-pressed={!dimensions.length} onClick={() => apply([], filters)}>仅汇总</button></div></div>}
<div className="analysis-result-bar"><span>当前结果<strong>{analysis.total.toLocaleString()}</strong>单</span><span>{analysis.rows.length.toLocaleString()} 个分组</span>{mobile && <button className="analysis-text-button" onClick={reset}>重置</button>}<button className="analysis-text-button" onClick={() => showDetails({ key: 'all', values: {}, count: analysis.total, share: 100, recordIds: filterFaultRecords(records, filters).map(row => row.id) })} disabled={dimensions.length > 0 || !analysis.total} hidden={dimensions.length > 0}>查看全部明细</button></div>
{dimensions.includes('handler') && <p className="fault-analysis-note">运维处置人来自故障或关联工单的当前处置分配。多人共同处置分别计入各人,总数和上级小计按故障去重,人员行不可直接相加。</p>}
<div className="analysis-table-tools">{!mobile && <h3>分组统计</h3>}<label>排序<select aria-label="分组排序" value={sort} onChange={e => { setSort(e.target.value as typeof sort); setLimit(50); }}><option value="count-desc">数量从多到少</option><option value="count-asc">数量从少到多</option><option value="label">名称顺序</option></select></label>{!mobile && dimensions.length > 1 && <label><input type="checkbox" checked={merged} onChange={e => setMerged(e.target.checked)} />合并单元格</label>}</div>
{mobile ? <MobileGroupedTable dimensions={dimensions.map(d => DIMENSION_LABELS[d])} rows={rows.map(row => ({ ...row, label: groupLabel(row), values: dimensions.map(d => label(d, row.values[d]!)) }))} unit="单" merged={merged} setMerged={setMerged} spans={spans} parentTotals={totals} onDetails={i => showDetails(rows[i])} onFilter={i => filterGroup(rows[i])} /> : <div className="analysis-group-table"><table><thead><tr>{dimensions.length ? dimensions.map(d => <th key={d}>{DIMENSION_LABELS[d]}</th>) : <th>范围</th>}<th>数量(单)</th><th>占比</th><th>操作</th></tr></thead><tbody>{rows.map((row, ri) => <tr key={row.key}>{dimensions.length ? dimensions.map((d, ci) => {
const span = merged ? spans[ri][ci] : 1;
return span ? <td key={d} rowSpan={span}>{label(d, row.values[d]!)}{merged && ci < dimensions.length - 1 && <small className="fault-parent-total">合计 {totals[ri][ci].toLocaleString()} 单</small>}</td> : null;
}) : <td>合计</td>}<td><strong>{row.count.toLocaleString()}</strong></td><td>{row.share.toFixed(1)}%</td><td>{dimensions.length > 0 && <button className="analysis-text-button" onClick={() => filterGroup(row)}>筛选此组</button>}<button className="analysis-text-button" onClick={() => showDetails(row)} aria-label={`查看明细 ${groupLabel(row)}`}>查看明细</button></td></tr>)}</tbody></table></div>}
{!rows.length && <p className="fault-empty">暂无符合条件的故障,可调整筛选条件。</p>}
{ordered.length > limit && <button className="analysis-button fault-more" onClick={() => setLimit(v => v + 50)}>加载更多分组(已显示 {limit} / {ordered.length})</button>}
<p className="fault-analysis-note">{metric === 'overdue' && '逾期仅统计待处理、处理中且处置截止时间早于本次数据时间的故障;挂起停表,无截止时间不计入。'}{source ? source.definition || '按提报日期筛选同批故障,闭环状态截至本次数据时间;已归档为已闭环,其余为未闭环。' : '维度按车辆当前信息归属;本日解决按解决时间统计。'}下钻读取最新台账,实时变动可能与首页数量存在差异。</p>
</>}
</div>
</div>
</div>;
}
@@ -0,0 +1,71 @@
import { useCallback, useEffect, useState } from 'react';
import { ArrowUpRight, CarFront, ClipboardList, Clock3, Filter, Info, RefreshCw, Search, TriangleAlert } from 'lucide-react';
import { fetchJson } from '../../../auth/api-client';
import { FAULT_DASHBOARD_FILTERS, type FaultAnalysisResponse, type FaultDashboardDimension, type FaultDashboardFilters, type FaultDashboardResponse, type FaultDashboardScope } from '../../../shared/assets/faults';
import { FlowDateRangePicker } from '../components/overview/FlowDateRangePicker';
import { AnalysisExportButton } from '../components/analysis/AnalysisExportButton';
import { FaultAnalysisModal, type FaultAnalysisSource } from './FaultAnalysisModal';
import { FaultOverview } from './FaultOverview';
import { DashboardPanels, DashboardTicketTable, type DashboardDrill } from './DashboardPanels';
import { downloadDashboard } from './dashboard-export';
import './fault-management.css';
import './dashboard.css';
const tabs = ['管理总览','多维分析','故障明细'] as const;
function initialRange(){const end=new Date(Date.now()+8*3600000).toISOString().slice(0,10);return {start:new Date(Date.parse(end)-29*86400000).toISOString().slice(0,10),end};}
export default function FaultManagement(){
const [range,setRange]=useState(initialRange),[filters,setFilters]=useState<FaultDashboardFilters>({});
const [applied,setApplied]=useState(()=>({...initialRange(),filters:{} as FaultDashboardFilters}));
const [tab,setTab]=useState<typeof tabs[number]>('管理总览'),[more,setMore]=useState(false);
const [data,setData]=useState<FaultDashboardResponse|null>(null),[loading,setLoading]=useState(true),[error,setError]=useState('');
const [revision,setRevision]=useState(0),[source,setSource]=useState<FaultAnalysisSource|null>(null);
const [detailScope,setDetailScope]=useState<FaultDashboardScope>('cohort'),[detailData,setDetailData]=useState<FaultAnalysisResponse|null>(null),[detailError,setDetailError]=useState(''),[detailLoading,setDetailLoading]=useState(false);
const [search,setSearch]=useState(''),[limit,setLimit]=useState(50);
const close=useCallback(()=>setSource(null),[]);
const query=new URLSearchParams({start:applied.start,end:applied.end,...applied.filters}).toString();
useEffect(()=>{if(tab==='管理总览')return;const c=new AbortController();setLoading(true);setError('');fetchJson<FaultDashboardResponse>(`/api/asset-faults/dashboard?${query}`,{signal:c.signal}).then(d=>{if(!c.signal.aborted)setData(d);}).catch(e=>{if(!c.signal.aborted)setError(e instanceof Error?e.message:'加载失败');}).finally(()=>{if(!c.signal.aborted)setLoading(false);});return()=>c.abort();},[query,revision,tab]);
useEffect(()=>{if(tab!=='故障明细')return;const c=new AbortController();setDetailData(null);setDetailError('');setDetailLoading(true);setLimit(50);fetchJson<FaultAnalysisResponse>(`/api/asset-faults/dashboard-analysis?${query}&scope=${detailScope}`,{signal:c.signal}).then(d=>{if(!c.signal.aborted)setDetailData(d);}).catch(()=>{if(!c.signal.aborted)setDetailError('故障明细加载失败,请重试。');}).finally(()=>{if(!c.signal.aborted)setDetailLoading(false);});return()=>c.abort();},[tab,query,detailScope,revision]);
const drill:DashboardDrill=(title,scope='cohort',extra={})=>{if(loading||error||!data)return;const params=new URLSearchParams({start:data.start,end:data.end,...applied.filters,...extra,scope});setSource({url:`/api/asset-faults/dashboard-analysis?${params}`,title,periodLabel:scope==='cohort'?`${data.start} 至 ${data.end} · 按提报日期`:scope==='reported'||scope==='closed'?'北京时间今日':'全部提报日期 · 当前状态',defaultDimensions:extra.id?[]:['handler','faultStatus'],initialDetails:!!extra.id,definition:scope==='focus'?'仅包含 L1-特急且未归档的故障(含待处理、处理中、挂起等状态),不限制提报日期。':scope==='cohort'?'按提报日期筛选同批故障,已归档为已闭环,其余状态为未闭环。':scope==='overdue'?'逾期仅包含待处理、处理中且处置截止时间早于数据时间的故障;挂起、未设置截止时间不计入。':scope==='reported'?'按北京时间今日提报时间统计。':scope==='closed'?'按北京时间今日解决时间统计,不限制提报日期。':'不限制提报日期,按当前状态及已应用条件统计;当前在办 = 待处理 + 处理中。'});};
const t=data?.totals;
const detailRows=(detailData?.rows||[]).filter(r=>`${r.code} ${r.plate} ${r.description} ${r.handlers.map(h=>h.label).join(' ')}`.toLowerCase().includes(search.trim().toLowerCase())).sort((a,b)=>(b.reportedAt||'').localeCompare(a.reportedAt||'')||b.id.localeCompare(a.id,'en',{numeric:true}));
const dirty=range.start!==applied.start||range.end!==applied.end||JSON.stringify(filters)!==JSON.stringify(applied.filters);
const count=(n?:number)=>loading?'—':n?.toLocaleString('zh-CN')??'—';
const select=(key:FaultDashboardDimension)=><label className="fd-filter" key={key}><span>{FAULT_DASHBOARD_FILTERS[key]}</span><select aria-label={FAULT_DASHBOARD_FILTERS[key]} value={filters[key]||''} onChange={e=>setFilters(v=>({...v,[key]:e.target.value}))}><option value="">全部</option>{data?.options[key].map(o=><option key={o.value} value={o.value}>{o.label}</option>)}</select></label>;
const showAllOpen=()=>{setDetailScope('open');setTab('故障明细');setSearch('');};
const showAllFaults=()=>{setFilters({});setApplied({...range,filters:{}});setDetailScope('ledger');setTab('故障明细');setSearch('');};
const navigation=<nav className="fd-tabs" aria-label="故障分析页面">{tabs.map(name=><button key={name} aria-current={tab===name?'page':undefined} onClick={()=>setTab(name)}>{name}</button>)}</nav>;
if(tab==='管理总览')return <FaultOverview navigation={navigation}/>;
return <div className="fault-page fd-page">
<header className="fd-header"><div><p className="fd-breadcrumb">羚牛氢能 BI / 故障分析</p><div className="fd-heading-line"><h1>故障风险与服务分析</h1><p>从问题分布,追溯到车辆与处理进展。</p></div></div><div className="fd-header-actions"><span>更新时间:{data?new Date(data.updatedAt).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false}):'读取中'}</span><button disabled={loading} aria-label="刷新故障统计" onClick={()=>setRevision(v=>v+1)}><RefreshCw size={14} className={loading?'animate-spin':''}/>刷新</button><AnalysisExportButton disabled={loading||!data||!!error} onExport={()=>data?downloadDashboard(data,Object.entries(applied.filters).filter(([,v])=>v).map(([k,v])=>`${FAULT_DASHBOARD_FILTERS[k as FaultDashboardDimension]}:${data.options[k as FaultDashboardDimension].find(o=>o.value===v)?.label||v}`).join(' · ')):undefined}/></div></header>
{navigation}
<form className="fd-toolbar" onSubmit={e=>{e.preventDefault();setApplied({...range,filters:{...filters}});}}><div className="fd-date"><FlowDateRangePicker value={range} onChange={setRange} dateLabel="故障提报时间"/></div>{(['region','customer','model'] as const).map(select)}<button className="fd-filter-toggle" type="button" aria-expanded={more} onClick={()=>setMore(!more)}><Filter size={15}/>更多筛选{Object.entries(filters).filter(([k,v])=>!['region','customer','model'].includes(k)&&v).length?` (${Object.entries(filters).filter(([k,v])=>!['region','customer','model'].includes(k)&&v).length})`:''}</button><button className="fd-query" type="submit" disabled={loading}>查询</button>{more&&<div className="fd-more-filters">{(['city','department','handler','level','category','inventory'] as const).map(select)}<button type="button" onClick={()=>{setFilters({});setApplied({...range,filters:{}});}}>重置筛选</button></div>}</form>
<p className="fd-scope-note">期间指标按提报日期统计;当前在办不限制提报日期,状态截至更新时间。{dirty&&<strong>条件已修改,点击查询后生效。</strong>}</p>
{error&&<div className="fault-message" role="alert">{error}<button onClick={()=>setRevision(v=>v+1)}>重试</button></div>}
{!data&&loading?<div className="fd-loading" role="status"><RefreshCw className="animate-spin" size={20}/>正在汇总故障分析…</div>:data&&<div className={loading||error?'fd-content is-loading':'fd-content'} aria-busy={loading}>
{tab==='多维分析'&&<>
<section className="fd-kpis" aria-label="故障总统计">
<article className="fd-kpi fd-reported-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><div className="fd-kpi-parts fd-report-parts" aria-label="期间新增故障提报来源"><button disabled={loading||!!error} onClick={()=>drill('运维提报','cohort',{reportMethod:'operations'})}>运维提报 <b>{count(t?.operationsReported)}</b></button><span>+</span><button disabled={loading||!!error} onClick={()=>drill('AI提报','cohort',{reportMethod:'customer'})}>AI提报 <b>{count(t?.customerReported)}</b></button></div></article>
<article className="fd-kpi"><button disabled={loading||!!error} onClick={()=>drill('受影响车辆对应故障')}><CarFront className="fd-kpi-icon fd-purple"/><span>受影响车辆</span><ArrowUpRight size={14}/><strong>{count(t?.vehicles)}<small>辆</small></strong></button><p>涉及 <b>{t?.customers}</b> 家已关联客户<small>按车辆去重</small></p></article>
<article className="fd-kpi"><button disabled={loading||!!error} onClick={()=>drill('当前在办','open')}><Clock3 className="fd-kpi-icon fd-orange"/><span>当前在办</span><ArrowUpRight size={14}/><strong>{count(t?.open)}<small>单</small></strong></button><div className="fd-kpi-parts"><button disabled={loading||!!error} onClick={()=>drill('待处理','waiting')}>待处理 <b>{count(t?.waiting)}</b></button><span>+</span><button disabled={loading||!!error} onClick={()=>drill('处理中','processing')}>处理中 <b>{count(t?.processing)}</b></button></div></article>
<article className="fd-kpi"><button disabled={loading||!!error} onClick={()=>drill('逾期','overdue')}><TriangleAlert className="fd-kpi-icon fd-danger"/><span>逾期</span><ArrowUpRight size={14}/><strong className="fd-danger">{count(t?.overdue)}<small>单</small></strong></button><p>最长逾期 <b className="fd-danger">{t?.longestOverdueDays} 天</b><small>在办子集</small></p></article>
</section>
<div className="fd-today-stats"><span>实时统计</span><button onClick={()=>drill('今日新增','reported')}>今日新增 <b>{t?.todayReported}</b></button><button onClick={()=>drill('本日解决','closed')}>本日解决 <b>{t?.todayClosed}</b></button></div>
<div className="fd-insight"><Info size={18}/><strong>{data.distributions.region[0]?`本期关注:${data.distributions.region[0].label}故障量最高,共 ${data.distributions.region[0].reported} 单;当前 ${t?.overdue} 单在办故障已逾期。`:'所选条件下暂无本期故障,可调整日期或筛选范围。'}</strong><span>按数量描述,不代表故障发生率。</span><button disabled={!t?.open} onClick={showAllOpen}>查看关联故障 <ArrowUpRight size={13}/></button></div>
{tab==='多维分析'&&<div className="fd-analysis-banner"><div><h2>组合维度,追溯故障</h2><p>支持运维处置人、车牌、工单状态、车型、区域、客户等 12 个维度,多选筛选与明细下钻。</p></div><button className="fd-query" onClick={()=>drill('本期故障多维分析')}>打开多维分析</button></div>}
<DashboardPanels data={data} drill={drill}/>
<section className="fd-panel fd-focus" aria-label="重点故障跟进">
<div className="fd-panel-title">
<h2>重点故障跟进</h2>
<span className="fd-chip fd-orange">L1 未闭环 {data.focusTotals.total}</span>
<span className="fd-chip fd-danger">逾期 {data.focusTotals.overdue}</span>
<button onClick={showAllFaults} title="查看当前权限内的全部故障记录">查看全部 <ArrowUpRight size={13}/></button>
</div>
<DashboardTicketTable rows={data.focus} updatedAt={data.updatedAt} drill={drill} emptyMessage="当前筛选范围内暂无 L1 未闭环故障。"/>
<p className="fd-caption">仅展示 L1-特急且未闭环(未归档)的故障;沿用当前业务筛选,不限制提报日期。逾期优先,同优先级按提报时间倒序,最多展示 5 单。“查看全部”进入当前权限内的全部故障台账,并清除业务筛选和搜索条件,不限等级、状态及提报日期。</p>
</section>
</>}
{tab==='故障明细'&&<section className="fd-panel fd-details"><div className="fd-panel-title"><h2>故障明细</h2><span>按提报时间倒序 · {detailRows.length} 单</span><button onClick={()=>drill('故障明细多维分析',detailScope)}>多维分析 <ArrowUpRight size={13}/></button></div><div className="fd-detail-controls"><div className="fd-segment">{([['cohort','本期提报'],['open','当前在办'],['overdue','逾期故障'],['ledger','累计台账']] as const).map(([key,title])=><button key={key} aria-pressed={detailScope===key} onClick={()=>{setDetailScope(key);setSearch('');}}>{title}</button>)}</div><label><Search size={15}/><input placeholder="搜索车牌、单号、处置人" aria-label="搜索故障明细列表" value={search} onChange={e=>{setSearch(e.target.value);setLimit(50);}}/></label></div>{detailLoading?<p className="fd-loading" role="status">正在加载故障明细…</p>:detailError?<p role="alert" className="fault-message">{detailError}<button onClick={()=>setRevision(v=>v+1)}>重试</button></p>:<><DashboardTicketTable rows={detailRows.slice(0,limit)} updatedAt={detailData?.updatedAt||data.updatedAt} drill={drill}/>{detailRows.length>limit&&<button className="fd-load-more" onClick={()=>setLimit(v=>v+50)}>加载更多({Math.min(limit,detailRows.length)} / {detailRows.length})</button>}</>}</section>}
<footer className="fd-quality"><Info size={13}/>数据完整性:本期未标注等级 {data.quality.missingLevel} 单,未标注部位 {data.quality.missingCategory} 单,缺少车辆标识 {data.quality.missingVehicle} 单;在办中 {data.quality.missingDeadline} 单未设置截止时间,不计入逾期。</footer>
</div>}
{source&&<FaultAnalysisModal key={source.url} metric="ledger" source={source} onClose={close}/>}
</div>;
}
@@ -0,0 +1,69 @@
import type { ReactNode } from 'react';
import { Fragment, useCallback, useEffect, useState } from 'react';
import { ArrowUpRight, CheckCircle2, ClipboardList, Clock3, PlusCircle, RefreshCw } from 'lucide-react';
import { fetchJson } from '../../../auth/api-client';
import { FAULT_OVERVIEW_METRICS, type FaultOverview, type FaultOverviewMetric } from '../../../shared/assets/faults';
import { FaultAnalysisModal } from './FaultAnalysisModal';
import { FaultWeeklyReport } from './FaultWeeklyReport';
import { FaultTrendCard } from './FaultTrendCard';
import './fault-management.css';
export const FAULT_DEFINITIONS = {
reported: '按今日提报时间统计', closed: '按今日解决时间统计',
open: '当前在办 = 待处理 + 处理中', ledger: '全部有效故障台账',
};
const icons = { reported: PlusCircle, closed: CheckCircle2, open: Clock3, ledger: ClipboardList };
const metrics = ['reported', 'closed', 'open', 'ledger'] as const;
export function FaultOverview({ navigation }: { navigation: ReactNode }) {
const [data, setData] = useState<FaultOverview | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [revision, setRevision] = useState(0);
const [metric, setMetric] = useState<FaultOverviewMetric | null>(null);
const close = useCallback(() => setMetric(null), []);
useEffect(() => {
const controller = new AbortController();
setLoading(true); setError('');
fetchJson<FaultOverview>('/api/asset-faults/overview', { signal: controller.signal })
.then(result => { if (!controller.signal.aborted) setData(result); }).catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); })
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [revision]);
return <div className="fault-page fault-overview-page">
<header className="fd-header">
<div><p className="fd-breadcrumb">羚牛氢能 BI / 故障分析</p><div className="fd-heading-line"><h1>羚牛氢能-故障管理</h1><p>实时掌握故障数量、闭环趋势与运维进展。</p></div></div>
<div className="fd-header-actions"><span>{data ? `更新时间:${new Date(data.updatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}` : '正在读取故障台账'}</span><button aria-label="刷新故障统计" disabled={loading} onClick={() => setRevision(v => v + 1)}><RefreshCw size={14} className={loading ? 'animate-spin' : ''} />刷新</button></div>
</header>
{navigation}
{error && <div className="fault-message" role="alert">{error}<button onClick={() => setRevision(v => v + 1)}>重试</button></div>}
<section className="fault-summary" aria-label="故障总统计" aria-busy={loading}>
<div className="fault-summary-heading"><h2>故障数据实时汇总</h2><span>{data?.date || '—'} · 北京时间</span></div>
<div className="fault-metrics">{metrics.map(key => {
const Icon = icons[key];
return <article key={key} className={`fault-metric fault-metric-${key}`}><button className="fault-metric-main" disabled={!data || loading || !!error} onClick={() => setMetric(key)} aria-label={`${FAULT_OVERVIEW_METRICS[key]} ${data?.totals[key] ?? '加载中'} 单,查看分析`}>
<div className="fault-metric-label"><span className="fault-metric-icon"><Icon size={19} /></span><span>{FAULT_OVERVIEW_METRICS[key]}</span><ArrowUpRight size={15} /></div>
<div className="fault-metric-count"><strong>{loading ? '—' : data?.totals[key].toLocaleString('zh-CN') ?? '—'}</strong><span>单</span></div>
<p>{FAULT_DEFINITIONS[key]}</p><span className="fault-metric-link">查看分析与明细 →</span>
</button>
{key === 'open' && <>
<button className="fault-overdue-subset" disabled={!data || loading || !!error} onClick={() => setMetric('overdue')} aria-label={`逾期 ${data?.totals.overdue ?? '加载中'} 单,查看分析`}>
<span>逾期</span><strong>{loading ? '—' : data?.totals.overdue?.toLocaleString('zh-CN') ?? '—'}</strong><small>单</small>
</button>
<div className="fault-open-breakdown" aria-label="当前在办等于待处理加处理中">
{(['waiting', 'processing'] as const).map((part, index) => <Fragment key={part}>
{index > 0 && <span className="fault-composition-plus" aria-hidden="true">+</span>}
<button className={`fault-submetric fault-submetric-${part}`} disabled={!data || loading || !!error} onClick={() => setMetric(part)} aria-label={`${FAULT_OVERVIEW_METRICS[part]} ${data?.totals[part] ?? '加载中'} 单,查看分析`}>
<span>{FAULT_OVERVIEW_METRICS[part]}</span><strong>{loading ? '—' : data?.totals[part]?.toLocaleString('zh-CN') ?? '—'}</strong>
</button>
</Fragment>)}
</div>
</>}
</article>;
})}</div>
<p className="fault-summary-note">当前在办 = 待处理 + 处理中,逾期为在办子集,不重复累加;按处置截止时间统计,挂起停表,未设置截止时间不计入逾期。</p>
</section>
<FaultTrendCard refresh={revision} />
<FaultWeeklyReport refresh={revision} />
{metric && <FaultAnalysisModal key={metric} metric={metric} onClose={close} />}
</div>;
}
@@ -0,0 +1,73 @@
import type { FaultDashboardFilters } from '../../../shared/assets/faults';
import { useCallback, useEffect, useRef, useState } from 'react';
import { CalendarDays, ChevronDown, RefreshCw } from 'lucide-react';
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { fetchJson } from '../../../auth/api-client';
import type { FaultTrendResponse, FaultTrendStatus } from '../../../shared/assets/faults';
import { AnalysisExportButton } from '../components/analysis/AnalysisExportButton';
import { downloadFaultReport } from './report-export';
import { FlowDateRangePicker } from '../components/overview/FlowDateRangePicker';
import { FaultAnalysisModal, type FaultAnalysisSource } from './FaultAnalysisModal';
function initialRange() {
const end = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10);
return { start: new Date(Date.parse(end) - 29 * 86400000).toISOString().slice(0, 10), end };
}
const statusLabels = { all: '新增故障', closed: '其中已闭环', unclosed: '其中未闭环' };
const countKeys = { all: 'reported', closed: 'closed', unclosed: 'unclosed' } as const;
export function FaultTrendCard({ refresh, scope = {}, scopeLabel = '', period }: { refresh: number; scope?: FaultDashboardFilters; scopeLabel?: string; period?: { start: string; end: string } }) {
const [range, setRange] = useState(() => period || initialRange());
const [granularity, setGranularity] = useState<'day' | 'month'>('day');
const scopeKey = new URLSearchParams(scope).toString();
useEffect(() => { if (period) setRange(period); }, [period?.start, period?.end]);
const [data, setData] = useState<FaultTrendResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [revision, setRevision] = useState(0);
const [expanded, setExpanded] = useState(false);
const [source, setSource] = useState<FaultAnalysisSource | null>(null);
const chartRef = useRef<HTMLDivElement>(null);
const close = useCallback(() => setSource(null), []);
useEffect(() => {
const controller = new AbortController();
setLoading(true); setError(''); setData(null);
const params = new URLSearchParams({ ...range, granularity, ...scope });
fetchJson<FaultTrendResponse>(`/api/asset-faults/trend?${params}`, { signal: controller.signal })
.then(result => { if (!controller.signal.aborted) setData(result); })
.catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); })
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [range.start, range.end, granularity, revision, refresh, scopeKey]);
const drill = (status: FaultTrendStatus, period?: string) => {
if (!data || loading) return;
const params = new URLSearchParams({ start: data.start, end: data.end, granularity: data.granularity, status, ...scope });
if (period) params.set('period', period);
setSource({ url: `/api/asset-faults/trend-analysis?${params}`, title: `故障处理趋势 · ${statusLabels[status]}`, defaultDimensions: ['handler', 'faultStatus'],
periodLabel: `${data.start} 至 ${data.end}${period ? ` · ${period}` : ''} · 按提报日期` });
};
return <section className="fault-trend fault-summary" aria-label="故障处理趋势" aria-busy={loading}>
<div className="fault-trend-heading"><h2><CalendarDays size={16} />故障处理趋势</h2><div className="fault-report-actions"><AnalysisExportButton disabled={loading || !data || !!error} onExport={() => data ? downloadFaultReport({ kind: 'trend', data, filterLabel: scopeLabel }, chartRef.current?.querySelector<SVGSVGElement>('svg.recharts-surface[role=application]') || undefined) : undefined} /><div role="group" aria-label="趋势时间粒度">{(['day', 'month'] as const).map(value => <button key={value} aria-pressed={granularity === value} onClick={() => setGranularity(value)}>{value === 'day' ? '按日' : '按月'}</button>)}</div></div></div>
<p className="fault-trend-definition">按提报日期统计新增故障,展示这批故障截至当前的闭环情况。新增 = 已闭环 + 未闭环。点击柱内色块,可按运维处置人、故障工单状态查看对应日期的故障。</p>
<FlowDateRangePicker dateLabel="故障提报时间" value={range} onChange={setRange} />
{error ? <div className="fault-message" role="alert">故障趋势加载失败,请稍后重试。<button onClick={() => setRevision(v => v + 1)}>重试</button></div> : loading || !data ? <div className="fault-trend-loading" role="status"><RefreshCw size={16} className="animate-spin" />正在加载故障趋势…</div> : <>
<div className="fault-trend-totals">{(['all', 'closed', 'unclosed'] as const).map(status => <button key={status} className={`fault-trend-${status}`} disabled={!data.totals[countKeys[status]]} onClick={() => drill(status)} aria-label={`${statusLabels[status]} ${data.totals[countKeys[status]]} 单,查看趋势分析`}><strong>{data.totals[countKeys[status]].toLocaleString()}</strong><span>{statusLabels[status]} ↗</span></button>)}</div>
{data.totals.reported ? <div ref={chartRef} className="fault-trend-chart" role="region" aria-label={`故障${granularity === 'day' ? '日' : '月'}趋势堆叠柱图,新增${data.totals.reported}单,已闭环${data.totals.closed}单,未闭环${data.totals.unclosed}单;精确数量可展开统计表查看`}>
<ResponsiveContainer width="100%" height={240} minWidth={0}>
<BarChart data={data.periods} margin={{ top: 12, right: 8, left: -18, bottom: 0 }} accessibilityLayer>
<CartesianGrid strokeDasharray="4 6" vertical={false} stroke="#e8edf3" />
<XAxis dataKey="period" tick={{ fontSize: 10, fill: '#64748b' }} tickFormatter={v => data.granularity === 'day' ? v.slice(5) : v} minTickGap={24} axisLine={false} tickLine={false} />
<YAxis allowDecimals={false} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
<Tooltip cursor={{ fill: '#f1f5f9', opacity: 0.7 }} contentStyle={{ border: '1px solid #e2e8f0', borderRadius: 12, boxShadow: '0 8px 24px rgb(15 23 42 / .08)', fontSize: 12 }} labelFormatter={(_, payload) => { const point = payload?.[0]?.payload; return point ? `${point.period} · 新增 ${point.reported} 单` : ''; }} formatter={(value, name) => [`${value} 单`, name]} />
<Legend iconType="circle" iconSize={8} wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="closed" name="已闭环" cursor="pointer" onClick={bar => { const period = data.periods.find(row => row.period === bar.payload?.period); if (period?.closed) drill('closed', period.period); }} fill="#2475ff" stackId="faults" maxBarSize={34} isAnimationActive={false} />
<Bar dataKey="unclosed" name="未闭环" cursor="pointer" onClick={bar => { const period = data.periods.find(row => row.period === bar.payload?.period); if (period?.unclosed) drill('unclosed', period.period); }} fill="#ff981a" stackId="faults" maxBarSize={34} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div> : <p className="fault-empty">所选时间范围暂无新增故障</p>}
<button className="fault-trend-expand" aria-expanded={expanded} aria-controls="fault-trend-periods" onClick={() => setExpanded(v => !v)}><span>{expanded ? '收起' : '展开'}{data.granularity === 'day' ? '每日' : '每月'}统计 · 点击数量查看故障闭环明细</span><ChevronDown size={16} style={{ transform: expanded ? 'rotate(180deg)' : undefined }} /></button>
{expanded && <div id="fault-trend-periods" className="fault-trend-table"><table><thead><tr><th>{data.granularity === 'day' ? '提报日期' : '提报月份'}</th><th>新增</th><th>已闭环</th><th>未闭环</th><th>闭环率</th></tr></thead><tbody>{[...data.periods].reverse().map(row => <tr key={row.period}><td>{row.period}</td>{(['all', 'closed', 'unclosed'] as const).map(status => <td key={status}><button disabled={!row[countKeys[status]]} onClick={() => drill(status, row.period)} aria-label={`${row.period} ${statusLabels[status]} ${row[countKeys[status]]} 单,查看明细`}>{row[countKeys[status]].toLocaleString()}</button></td>)}<td>{row.reported ? `${(row.closed / row.reported * 100).toFixed(1)}%` : '—'}</td></tr>)}</tbody></table></div>}
<p className="fault-summary-note">已归档视为已闭环,其余状态(含挂起)为未闭环;无提报时间不纳入趋势。按月仍仅统计所选日期范围,不扩展至整月。状态截至 {new Date(data.updatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}。</p>
</>}
{source && <FaultAnalysisModal key={source.url} metric="ledger" source={source} onClose={close} />}
</section>;
}
@@ -0,0 +1,68 @@
import type { FaultDashboardFilters } from '../../../shared/assets/faults';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ClipboardList, RefreshCw } from 'lucide-react';
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { fetchJson } from '../../../auth/api-client';
import { FAULT_WEEKLY_METRICS, type FaultWeeklyMetric, type FaultWeeklyResponse } from '../../../shared/assets/faults';
import { AnalysisExportButton } from '../components/analysis/AnalysisExportButton';
import { downloadFaultReport } from './report-export';
import { FlowDateRangePicker } from '../components/overview/FlowDateRangePicker';
import { getWeeklyFlowRange } from '../model';
import { FaultAnalysisModal, type FaultAnalysisSource } from './FaultAnalysisModal';
const metrics: FaultWeeklyMetric[] = ['reported', 'closed'];
const definition = '新增故障按提报时间统计,解决故障按解决时间统计;同一故障可在不同日期分别计入两个指标。';
export function FaultWeeklyReport({ refresh, scope = {}, scopeLabel = '', period }: { refresh: number; scope?: FaultDashboardFilters; scopeLabel?: string; period?: { start: string; end: string } }) {
const [range, setRange] = useState(() => period || getWeeklyFlowRange());
const scopeKey = new URLSearchParams(scope).toString();
useEffect(() => { if (period) setRange(period); }, [period?.start, period?.end]);
const [data, setData] = useState<FaultWeeklyResponse | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [revision, setRevision] = useState(0);
const [source, setSource] = useState<FaultAnalysisSource | null>(null);
const chartRef = useRef<HTMLDivElement>(null);
const close = useCallback(() => setSource(null), []);
const today = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10);
useEffect(() => {
const controller = new AbortController();
setLoading(true); setData(null); setError('');
fetchJson<FaultWeeklyResponse>(`/api/asset-faults/weekly-report?${new URLSearchParams({ start: range.start, end: range.end, ...scope })}`, { signal: controller.signal })
.then(result => { if (!controller.signal.aborted) setData(result); })
.catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); })
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [range.start, range.end, revision, refresh, scopeKey]);
const drill = (metric: FaultWeeklyMetric, day?: string) => {
if (!data || loading) return;
const params = new URLSearchParams({ start: data.start, end: data.end, metric, ...scope });
if (day) params.set('day', day);
setSource({ url: `/api/asset-faults/weekly-report-analysis?${params}`, title: `故障运维周报 · ${FAULT_WEEKLY_METRICS[metric]}`,
periodLabel: `${day || `${data.start} 至 ${data.end}`} · 按事件发生时间`, definition });
};
return <section className="fault-weekly fault-trend fault-summary" aria-label="故障运维周报" aria-busy={loading}>
<div className="fault-trend-heading"><h2><ClipboardList size={16} />故障运维周报</h2><div className="fault-report-actions"><span className="fault-weekly-unit">单位:单</span><AnalysisExportButton disabled={loading || !data || !!error} onExport={() => data ? downloadFaultReport({ kind: 'weekly', data, filterLabel: scopeLabel }, chartRef.current?.querySelector<SVGSVGElement>('svg.recharts-surface[role=application]') || undefined) : undefined} /></div></div>
<p className="fault-trend-definition">按日展示新增故障和解决故障。{period ? '统计周期沿用查询范围,可在下方单独调整。' : '默认业务周为周六至周五,可自选日期。'}</p>
<FlowDateRangePicker dateLabel="提报 / 解决时间" value={range} onChange={setRange} />
{error ? <div className="fault-message" role="alert">故障运维周报加载失败,请稍后重试。<button onClick={() => setRevision(v => v + 1)}>重试</button></div> : loading || !data ? <div className="fault-trend-loading" role="status"><RefreshCw size={16} className="animate-spin" />正在加载故障运维周报…</div> : <>
<div className="fault-trend-totals">{metrics.map(metric => <button key={metric} className={`fault-weekly-${metric}`} disabled={!data.totals[metric]} onClick={() => drill(metric)} aria-label={`周报${FAULT_WEEKLY_METRICS[metric]} ${data.totals[metric]} 单,查看分析`}><strong>{data.totals[metric].toLocaleString()}</strong><span>{FAULT_WEEKLY_METRICS[metric]} ↗</span></button>)}</div>
<div ref={chartRef} className="fault-trend-chart fault-weekly-chart" role="region" aria-label="每日新增故障与解决故障折线图,精确数量及下钻见下方每日统计表">
<div className="fault-chart-heading"><span>每日变化<small>单位:单</small></span><div className="fault-chart-legend" aria-label="图例"><span className="fault-chart-key-reported"><i />新增故障</span><span className="fault-chart-key-closed"><i />解决故障</span></div></div>
<ResponsiveContainer width="100%" height={240} minWidth={0}>
<LineChart data={data.days} margin={{ top: 20, right: 14, left: 0, bottom: 8 }} accessibilityLayer>
<CartesianGrid strokeDasharray="4 6" vertical={false} stroke="#e8edf3" />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#64748b' }} tickFormatter={v => v.slice(5)} minTickGap={32} tickMargin={12} padding={{ left: 12, right: 12 }} axisLine={false} tickLine={false} />
<YAxis width={30} tickMargin={8} tickCount={5} domain={[0, 'auto']} allowDecimals={false} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
<Tooltip cursor={{ stroke: '#cbd5e1', strokeDasharray: '4 4' }} contentStyle={{ border: '1px solid #e2e8f0', borderRadius: 12, boxShadow: '0 8px 24px rgb(15 23 42 / .08)', padding: '10px 14px', fontSize: 12 }} labelStyle={{ color: '#334155', fontWeight: 600, marginBottom: 6 }} formatter={(value, name) => [`${value} 单`, name]} />
<Line type="monotone" dataKey="reported" name="新增故障" stroke="#2563eb" strokeWidth={2.5} dot={data.days.length <= 14 ? { r: 3.5, fill: '#fff', strokeWidth: 2 } : false} activeDot={{ r: 6, stroke: '#fff', strokeWidth: 3 }} isAnimationActive={false} />
<Line type="monotone" dataKey="closed" name="解决故障" stroke="#059669" strokeWidth={2.5} dot={data.days.length <= 14 ? { r: 3.5, fill: '#059669', stroke: '#fff', strokeWidth: 1.5 } : false} activeDot={{ r: 6, stroke: '#fff', strokeWidth: 3 }} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="fault-trend-table fault-weekly-table" role="region" aria-label="故障运维每日统计表" tabIndex={0}><table><thead><tr><th>日期</th>{metrics.map(metric => <th key={metric}>{FAULT_WEEKLY_METRICS[metric]}</th>)}</tr></thead><tbody>{[...data.days].reverse().map(row => <tr key={row.date} className={row.date === today ? 'fault-weekly-today' : undefined} aria-current={row.date === today ? 'date' : undefined}><td><span className="fault-weekly-date">{row.date}{row.date === today && <span className="fault-today-label">今天</span>}</span><small>{new Date(`${row.date}T12:00:00+08:00`).toLocaleDateString('zh-CN', { weekday: 'short', timeZone: 'Asia/Shanghai' })}</small></td>{metrics.map(metric => <td key={metric}><button className={`fault-weekly-${metric}`} disabled={!row[metric]} onClick={() => drill(metric, row.date)} aria-label={`${row.date} ${FAULT_WEEKLY_METRICS[metric]} ${row[metric]} 单,查看明细`}>{row[metric].toLocaleString()}</button></td>)}</tr>)}</tbody><tfoot><tr><th>合计</th>{metrics.map(metric => <th key={metric}><button disabled={!data.totals[metric]} onClick={() => drill(metric)} aria-label={`合计${FAULT_WEEKLY_METRICS[metric]} ${data.totals[metric]} 单,查看明细`}>{data.totals[metric].toLocaleString()}</button></th>)}</tr></tfoot></table></div>
{!metrics.some(metric => data.totals[metric]) && <p className="fault-weekly-empty">所选日期范围暂无新增或解决故障。</p>}
<p className="fault-summary-note">新增故障按提报日期,解决故障按解决日期统计,不受提报日期限制。数据时间:{new Date(data.updatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}。</p>
</>}
{source && <FaultAnalysisModal key={source.url} metric="ledger" source={source} onClose={close} />}
</section>;
}
@@ -0,0 +1,50 @@
import type { AssetAnalysisDimension } from '../../../shared/assets/analytics';
import type { FaultAnalysisRecord } from '../../../shared/assets/faults';
import { DIMENSION_LABELS, getAvailableDimensions } from '../analytics-model';
export type FaultAnalysisDimension = AssetAnalysisDimension | 'handler' | 'plate' | 'faultStatus';
export type FaultAnalysisFilters = Partial<Record<FaultAnalysisDimension, string[]>>;
export const FAULT_ANALYSIS_DIMENSIONS: FaultAnalysisDimension[] = getAvailableDimensions('assets').flatMap(d => d === 'type' ? [d, 'handler', 'plate', 'faultStatus'] : [d]);
export const FAULT_ANALYSIS_LABELS = { ...DIMENSION_LABELS, handler: '运维处置人', plate: '车牌', faultStatus: '故障工单状态' };
export const FAULT_CLOSURE_LABELS: Record<string, string> = { closed: '已闭环', unclosed: '未闭环' };
export interface FaultAnalysisGroup {
key: string; values: Partial<Record<FaultAnalysisDimension, string>>;
count: number; share: number; recordIds: string[];
}
export function faultDimensionValues(row: FaultAnalysisRecord, dimension: FaultAnalysisDimension): string[] {
if (dimension === 'plate') return [row.plate?.trim() || '未关联车辆'];
if (dimension === 'faultStatus') return [row.status === '已归档' ? 'closed' : 'unclosed'];
return dimension === 'handler' ? [...new Set(row.handlers.length ? row.handlers.map(h => h.value) : ['unassigned'])] : [row.cell[dimension]];
}
export function filterFaultRecords(rows: FaultAnalysisRecord[], filters: FaultAnalysisFilters = {}): FaultAnalysisRecord[] {
return [...new Map(rows.filter(row => FAULT_ANALYSIS_DIMENSIONS.every(d => !filters[d]?.length || faultDimensionValues(row, d).some(value => filters[d]!.includes(value)))).map(row => [row.id, row])).values()];
}
/** Expand only the grouped handler dimension; totals and each group remain distinct fault IDs. */
export function analyzeFaultRecords(rows: FaultAnalysisRecord[], dimensions: FaultAnalysisDimension[], filters: FaultAnalysisFilters = {}) {
const selected = filterFaultRecords(rows, filters);
const groups = new Map<string, { values: FaultAnalysisGroup['values']; ids: Set<string> }>();
const validDimensions = [...new Set(dimensions.filter(d => FAULT_ANALYSIS_DIMENSIONS.includes(d)))];
for (const row of selected) {
let combinations: [FaultAnalysisDimension, string][][] = [[]];
for (const d of validDimensions) {
const values = faultDimensionValues(row, d).filter(value => !filters[d]?.length || filters[d]!.includes(value));
combinations = combinations.flatMap(parts => values.map(value => [...parts, [d, value] as [FaultAnalysisDimension, string]]));
}
for (const entries of combinations) {
const key = JSON.stringify(entries);
const group = groups.get(key) || { values: Object.fromEntries(entries), ids: new Set<string>() };
group.ids.add(row.id); groups.set(key, group);
}
}
return { total: selected.length, rows: [...groups].map(([key, group]): FaultAnalysisGroup => ({ key, values: group.values,
count: group.ids.size, share: selected.length ? group.ids.size / selected.length * 100 : 0, recordIds: [...group.ids] })) };
}
/** Multi-handler child rows overlap: parent subtotals must use the union, never sum children. */
export function faultParentTotals(rows: FaultAnalysisGroup[], dimensions: FaultAnalysisDimension[]): number[][] {
const totals = new Map<string, Set<string>>();
const keys = rows.map(row => dimensions.slice(0, -1).map((_, depth) => JSON.stringify(dimensions.slice(0, depth + 1).map(d => row.values[d]))));
rows.forEach((row, i) => keys[i].forEach(key => {
const ids = totals.get(key) || new Set<string>(); row.recordIds.forEach(id => ids.add(id)); totals.set(key, ids);
}));
return keys.map(row => row.map(key => totals.get(key)!.size));
}
@@ -0,0 +1,18 @@
import { createStyledWorkbook, downloadStyledWorkbook } from '../../../shared/xlsx';
import type { FaultDashboardResponse } from '../../../shared/assets/faults';
export async function downloadDashboard(data:FaultDashboardResponse,filters:string) {
const book=await createStyledWorkbook();book.creator='羚牛氢能 BI';
const tables: {name:string;headers:string[];rows:(string|number)[][]}[]=[
{name:'管理总览',headers:['指标','数量','口径'],rows:[['期间新增故障',data.totals.reported,'按提报日期'],['运维提报',data.totals.operationsReported,'本期新增故障中的运维上报'],['AI提报',data.totals.customerReported,'本期新增故障中的客户上报'],['受影响车辆',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}};
}
await downloadStyledWorkbook(book, `故障风险与服务分析_${data.start}_${data.end}.xlsx`);
}
+133
View File
@@ -0,0 +1,133 @@
/* 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)); }
}
/* Keep the report-source breakdown and comparison readable at both card widths. */
.fd-kpis>.fd-kpi { height:148px; }
.fd-kpis .fd-report-parts { margin-top:auto; }
.fd-kpis .fd-reported-kpi>p { min-height:16px; margin-top:6px; }
@media(max-width:767px) {
.fd-kpis>.fd-kpi { height:142px; }
.fd-kpis .fd-report-parts { gap:3px; }
.fd-kpis .fd-report-parts button { padding:3px 4px; }
}
@@ -0,0 +1,221 @@
.fault-page { color:#334155; min-width:0; padding:24px; }
.fault-page-header { position:relative; z-index:30; margin:-24px -24px 24px; padding:18px 24px 12px; background:rgb(255 255 255 / .95); border-bottom:1px solid #f1f5f9; text-align:center; }
.fault-page-header h1 { font-size:16px; font-weight:600; letter-spacing:.04em; color:#1f2937; }
.fault-page-header>div { display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:12px; margin-top:9px; font-size:10px; color:#94a3b8; }
.fault-page-header button { display:inline-flex; align-items:center; gap:4px; color:#64748b; cursor:pointer; padding:4px; }
.fault-summary { background:white; border:1px solid #f1f5f9; border-radius:16px; padding:20px; box-shadow:0 1px 3px rgb(15 23 42 / .02); }
.fault-summary-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:20px; }
.fault-summary-heading h2 { font-size:14px; font-weight:600; color:#1e293b; }
.fault-summary-heading>span { color:#94a3b8; font-size:11px; }
.fault-metrics { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:16px; align-items:start; }
.fault-metric { text-align:left; padding:0; border:1px solid #e2e8f0; background:#fff; border-radius:12px; transition:border-color .15s,box-shadow .15s; min-width:0; }
.fault-metric:hover:not(:disabled) { border-color:#93c5fd; box-shadow:0 4px 12px rgb(37 99 235 / .06); }
.fault-metric-main:focus-visible { outline:2px solid #2563eb; outline-offset:3px; }
.fault-metric-main:disabled { cursor:default; opacity:.6; }
.fault-metric-label { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:500; }
.fault-metric-label>svg { margin-left:auto; color:#94a3b8; }
.fault-metric-icon { display:flex; align-items:center; justify-content:center; width:34px; height:34px; border-radius:9px; background:#eff6ff; color:#2563eb; flex-shrink:0; }
.fault-metric-closed .fault-metric-icon { background:#ecfdf5; color:#059669; }
.fault-metric-open .fault-metric-icon { background:#fff7ed; color:#d97706; }
.fault-metric-ledger .fault-metric-icon { background:#f5f3ff; color:#7c3aed; }
.fault-metric-count { display:flex; align-items:baseline; gap:8px; margin:18px 0 10px; }
.fault-metric-count strong { font-size:32px; line-height:1.15; font-weight:650; color:#0f172a; font-variant-numeric:tabular-nums; }
.fault-metric-count>span { font-size:11px; color:#94a3b8; }
.fault-metric p { font-size:11px; color:#94a3b8; line-height:1.6; min-height:35px; }
.fault-metric-link { display:block; font-size:11px; color:#3b82f6; margin-top:12px; }
.fault-message { margin:12px 0; padding:14px; border:1px solid #fecaca; background:#fef2f2; border-radius:8px; color:#b91c1c; font-size:12px; }
.fault-message button { margin-left:12px; text-decoration:underline; cursor:pointer; }
.fault-parent-total,.fault-detail-table small { display:block; margin-top:5px; font-size:11px; color:#94a3b8; }
.fault-detail-table table { min-width:1100px; }
.fault-detail-table td:first-child { white-space:nowrap; }
.fault-detail-table td:first-child strong { color:#1e293b; font-weight:600; }
.fault-detail-table td:nth-child(2) { min-width:100px; max-width:180px; white-space:normal; overflow-wrap:anywhere; }
.fault-detail-table td:nth-child(3) { min-width:180px; max-width:320px; white-space:normal; overflow-wrap:anywhere; }
.fault-detail-search { display:flex; gap:6px; border:1px solid #e2e8f0; padding:8px; border-radius:6px; }
.fault-detail-search input { min-width:0; width:220px; outline:none; }
.fault-empty { text-align:center; padding:32px; color:#94a3b8; }
.fault-analysis-note { margin-top:16px; color:#94a3b8; font-size:11px; line-height:1.7; }
.fault-analysis-dialog .analysis-dimension-options { align-items:flex-start; }
.fault-more { display:flex; margin:16px auto; }
@media(max-width:1100px) { .fault-metrics { grid-template-columns:repeat(2,minmax(0,1fr)); } }
@media(max-width:767px) {
.fault-page { padding:12px; }
.fault-page-header { position:relative; z-index:30; margin:-12px -12px 16px; padding:14px 12px 10px; }
.fault-page-header h1 { font-size:15px; }
.fault-summary { padding:14px; border-radius:12px; }
.fault-summary-heading { align-items:flex-start; flex-direction:column; gap:6px; margin-bottom:14px; }
.fault-metrics { gap:10px; }
.fault-metric { padding:0; border-radius:10px; }
.fault-metric-label { font-size:12px; gap:6px; }
.fault-metric-icon { width:28px; height:28px; }
.fault-metric-label>svg { display:none; }
.fault-metric-count strong { font-size:29px; }
.fault-metric p { font-size:10px; }
.fault-detail-search { width:100%; }
.fault-detail-search input { width:100%; }
}
.fault-metric-main { display:block; width:100%; padding:18px; text-align:left; cursor:pointer; border-radius:12px; }
/* Fixed compact height keeps every metric aligned, including the open-work breakdown. */
.fault-metric { height:164px; display:flex; flex-direction:column; }
.fault-metric-main { flex:1; min-height:0; display:flex; flex-direction:column; padding:12px 14px; }
.fault-metric-label { width:100%; }
.fault-metric-icon { width:28px; height:28px; border-radius:7px; }
.fault-metric-count { margin:8px 0 5px; }
.fault-metric-count strong { font-size:28px; }
.fault-metric p { min-height:0; font-size:10px; }
.fault-metric-link { margin-top:auto; padding-top:6px; font-size:10px; }
.fault-metric-open { position:relative; }
.fault-metric-open .fault-metric-main>p,.fault-metric-open .fault-metric-link { display:none; }
.fault-open-breakdown { display:flex; align-items:center; gap:4px; margin:0 12px 12px; padding-top:8px; border-top:1px solid #f1f5f9; }
.fault-submetric { flex:1; min-width:0; display:flex; flex-direction:column; align-items:center; gap:2px; padding:4px 2px; border-radius:5px; font-size:10px; cursor:pointer; color:#64748b; }
.fault-submetric:hover { background:#eff6ff; color:#2563eb; }
.fault-submetric:focus-visible { outline:2px solid #2563eb; outline-offset:1px; }
.fault-submetric:disabled { opacity:.6; cursor:default; }
.fault-submetric strong { font-variant-numeric:tabular-nums; font-size:16px; line-height:1.2; color:#334155; }
.fault-submetric svg { display:none; }
.fault-composition-plus { color:#94a3b8; font-size:18px; font-weight:400; }
.fault-submetric-waiting { background:#fffbeb; color:#b45309; }
.fault-submetric-processing { background:#eff6ff; color:#2563eb; }
.fault-overdue-subset { position:absolute; right:14px; top:51px; display:flex; align-items:baseline; gap:4px; padding:5px 8px; border:1px solid #fee2e2; border-radius:6px; background:#fef2f2; color:#dc2626; cursor:pointer; }
.fault-overdue-subset span,.fault-overdue-subset small { font-size:10px; }
.fault-overdue-subset strong { font-size:16px; line-height:1.2; font-variant-numeric:tabular-nums; }
.fault-overdue-subset:hover { background:#fee2e2; }
.fault-overdue-subset:focus-visible { outline:2px solid #dc2626; outline-offset:2px; }
.fault-overdue-subset:disabled { opacity:.6; cursor:default; }
.fault-summary-note { margin-top:12px; font-size:10px; color:#94a3b8; line-height:1.6; }
@media(max-width:767px) {
.fault-metric { height:172px; }
.fault-metric-main { padding:12px; }
.fault-open-breakdown { margin:0 8px 10px; gap:2px; }
.fault-submetric { min-height:40px; }
.fault-overdue-subset { right:8px; top:53px; gap:3px; padding:5px; }
.fault-overdue-subset span,.fault-overdue-subset small { font-size:9px; }
}
.fault-trend { margin-top:20px; }
.fault-trend-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.fault-trend-heading h2 { display:flex; align-items:center; gap:7px; font-size:14px; font-weight:600; }
.fault-trend-heading h2 svg { color:#2563eb; }
.fault-trend-heading [role=group] { display:flex; padding:3px; border-radius:8px; background:#f1f5f9; }
.fault-trend-heading button { padding:6px 12px; border-radius:6px; font-size:11px; color:#64748b; cursor:pointer; }
.fault-trend-heading button[aria-pressed=true] { color:#2563eb; background:#fff; box-shadow:0 1px 3px #e2e8f0; }
.fault-trend-definition { margin-top:7px; font-size:11px; line-height:1.7; color:#94a3b8; }
.fault-trend-totals { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); margin:14px 0; padding:10px; border:1px solid #f1f5f9; border-radius:10px; background:#f8fafc; }
.fault-trend-totals button { display:flex; flex-direction:column; align-items:center; gap:4px; cursor:pointer; border-right:1px solid #e2e8f0; padding:4px; }
.fault-trend-totals button:last-child { border-right:0; }
.fault-trend-totals strong { font-size:21px; line-height:1.2; font-weight:650; font-variant-numeric:tabular-nums; }
.fault-trend-totals span { font-size:11px; }
.fault-trend-all { color:#2563eb; }.fault-trend-closed { color:#059669; }.fault-trend-unclosed { color:#d97706; }
.fault-trend button:disabled { cursor:default; opacity:.5; }
.fault-trend button:focus-visible { outline:2px solid #2563eb; outline-offset:2px; }
.fault-trend-chart { width:100%; min-width:0; height:240px; }
.fault-trend-loading { min-height:120px; display:flex; align-items:center; justify-content:center; gap:8px; color:#94a3b8; font-size:12px; }
.fault-trend-expand { display:flex; align-items:center; justify-content:space-between; gap:8px; width:100%; margin-top:12px; padding:12px; border:1px solid #bfdbfe; border-radius:8px; color:#2563eb; background:#eff6ff; font-size:12px; cursor:pointer; text-align:left; }
.fault-trend-table { max-height:400px; overflow:auto; margin-top:12px; border:1px solid #e2e8f0; border-radius:8px; }
.fault-trend-table table { width:100%; border-collapse:collapse; font-size:12px; white-space:nowrap; }
.fault-trend-table th { text-align:right; padding:10px 12px; position:sticky; top:0; background:#f8fafc; color:#64748b; z-index:1; }
.fault-trend-table td { padding:5px 12px; text-align:right; border-top:1px solid #f1f5f9; font-variant-numeric:tabular-nums; }
.fault-trend-table th:first-child,.fault-trend-table td:first-child { text-align:left; }
.fault-trend-table button { min-height:32px; min-width:32px; color:#2563eb; cursor:pointer; }
@media(max-width:767px) { .fault-trend { margin-top:14px; }.fault-trend-heading h2 { font-size:12px; }.fault-trend-heading button { padding:7px 10px; }.fault-trend-table th,.fault-trend-table td { padding-left:6px; padding-right:6px; font-size:10px; }.fault-trend-table button { min-height:40px; }.fault-trend-expand { font-size:11px; } }
.fault-weekly .fault-trend-totals { grid-template-columns:repeat(2,minmax(0,1fr)); }
.fault-weekly-unit { font-size:11px; color:#94a3b8; }
.fault-weekly-reported,.fault-weekly-table button.fault-weekly-reported { color:#2563eb; }
.fault-weekly-received,.fault-weekly-table button.fault-weekly-received { color:#d97706; }
.fault-weekly-closed,.fault-weekly-table button.fault-weekly-closed { color:#059669; }
.fault-weekly-table td small { display:inline-block; margin-left:8px; color:#94a3b8; font-size:10px; }
.fault-weekly-table tfoot th { position:static; border-top:1px solid #e2e8f0; background:#f8fafc; padding-top:4px; padding-bottom:4px; }
.fault-weekly-empty { margin-top:10px; font-size:12px; color:#94a3b8; text-align:center; }
@media(max-width:767px) { .fault-weekly-table td small { display:block; margin:0; }.fault-weekly .fault-trend-totals span { font-size:10px; } }
.fault-weekly-chart { height:auto; padding:16px 12px 4px; border:1px solid #edf2f7; border-radius:12px; background:#fff; }
.fault-chart-heading { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:12px; padding:0 4px 4px; font-size:12px; color:#334155; font-weight:600; }
.fault-chart-heading small { margin-left:10px; font-size:10px; color:#94a3b8; font-weight:400; }
.fault-chart-legend { display:flex; align-items:center; gap:18px; font-size:11px; color:#64748b; font-weight:400; }
.fault-chart-legend>span { display:flex; align-items:center; gap:7px; }
.fault-chart-legend i { position:relative; width:20px; height:2px; background:#2563eb; }
.fault-chart-legend i::after { content:''; position:absolute; left:7px; top:-3px; width:8px; height:8px; border:2px solid #2563eb; border-radius:50%; background:#fff; }
.fault-chart-key-closed i { background:#059669; }
.fault-chart-key-closed i::after { border-color:#059669; background:#059669; }
@media(max-width:767px) { .fault-weekly-chart { padding:14px 6px 2px; }.fault-chart-heading { gap:10px; }.fault-chart-legend { gap:12px; font-size:10px; } }
/* Compact mobile summary: preserve equal card heights and all drill-down targets. */
@media(max-width:767px) {
.fault-page-header { margin-bottom:10px; padding:10px 12px 6px; }
.fault-page-header>div { gap:8px; margin-top:3px; }
.fault-page-header button { min-height:32px; }
.fault-page>.fault-summary:first-of-type { padding:12px; }
.fault-summary-heading { flex-direction:row; flex-wrap:wrap; align-items:center; gap:4px 8px; margin-bottom:10px; }
.fault-summary-heading h2 { font-size:13px; }
.fault-summary-heading>span { font-size:10px; }
.fault-metrics { gap:8px; }
.fault-metric { height:132px; }
.fault-metric-main { padding:10px; }
.fault-metric-label { gap:6px; }
.fault-metric-icon { width:24px; height:24px; border-radius:6px; }
.fault-metric-icon svg { width:17px; height:17px; }
.fault-metric-count { gap:5px; margin:5px 0 3px; }
.fault-metric-count strong { font-size:26px; }
.fault-metric-link { padding-top:4px; }
.fault-open-breakdown { margin:0 8px 8px; padding-top:6px; gap:3px; }
.fault-submetric { flex-direction:row; justify-content:center; gap:4px; min-height:32px; padding:4px 2px; }
.fault-submetric strong { font-size:14px; }
.fault-composition-plus { font-size:14px; }
.fault-overdue-subset { top:41px; right:8px; min-height:32px; padding:4px; gap:3px; align-items:center; }
.fault-overdue-subset .fault-overdue-prefix { display:none; }
.fault-overdue-subset strong { font-size:14px; }
.fault-page>.fault-summary:first-of-type>.fault-summary-note { margin-top:8px; font-size:10px; line-height:1.5; }
}
@media(max-width:359px) {
.fault-open-breakdown { margin-right:6px; margin-left:6px; gap:2px; }
.fault-submetric { gap:2px; padding-right:0; padding-left:0; font-size:9px; white-space:nowrap; }
.fault-submetric strong { font-size:13px; }
.fault-composition-plus { font-size:12px; }
}
.fault-weekly-date { display:inline-flex; align-items:center; gap:6px; }
.fault-weekly-table tr.fault-weekly-today { background:#f5f8ff; }
.fault-weekly-today td:first-child { box-shadow:inset 2px 0 #bfd0ee; }
.fault-today-label { padding:1px 5px; border-radius:4px; background:#e8effb; color:#536d96; font-size:10px; line-height:1.6; font-weight:500; }
@media(max-width:767px) { .fault-weekly-date { gap:4px; }.fault-today-label { padding:1px 4px; font-size:9px; } }
/* Short, single-pass entrance: no bounce, list staggering, or animated blur. */
.fault-analysis-overlay { animation:fault-backdrop-enter 160ms ease-out backwards; }
.fault-analysis-dialog { --fault-enter-y:12px; --fault-enter-scale:.985; transform-origin:50% 60%; animation:fault-dialog-enter 260ms cubic-bezier(.16,1,.3,1) backwards; }
@keyframes fault-backdrop-enter { from { opacity:0; } to { opacity:1; } }
@keyframes fault-dialog-enter { from { opacity:0; transform:translateY(var(--fault-enter-y)) scale(var(--fault-enter-scale)); } to { opacity:1; transform:none; } }
@media(max-width:767px) { .fault-analysis-dialog { --fault-enter-y:8px; --fault-enter-scale:1; animation-duration:220ms; } }
@media(prefers-reduced-motion:reduce) { .fault-analysis-overlay,.fault-analysis-dialog { animation:none; } }
/* Scoped Apple-style semantic grays; keep the surrounding BI theme unchanged. */
.fault-analysis-loading { --loading-label:rgba(60,60,67,.72); --loading-fill:rgba(118,118,128,.09); --loading-line:rgba(60,60,67,.08); --loading-radius:12px; padding:8px 0 4px; min-height:340px; }
.fault-loading-status { display:flex; align-items:center; justify-content:center; gap:10px; min-height:40px; margin-bottom:20px; color:var(--loading-label); font-size:12px; letter-spacing:.01em; }
.fault-loading-spinner { width:24px; height:24px; flex-shrink:0; }
.fault-loading-spinner rect { opacity:.2; animation:fault-spinner-tick 1s linear infinite; }
@keyframes fault-spinner-tick { from { opacity:1; } to { opacity:.15; } }
.fault-loading-skeleton { animation:fault-skeleton-breathe 1.8s ease-in-out infinite; }
@keyframes fault-skeleton-breathe { 0%,100% { opacity:.65; } 50% { opacity:1; } }
.fault-loading-block { display:block; height:10px; border-radius:5px; background:var(--loading-fill); }
.fault-loading-controls { padding:16px; border:1px solid var(--loading-line); border-radius:var(--loading-radius); }
.fault-loading-title { width:84px; margin-bottom:14px; }
.fault-loading-controls>div { display:flex; flex-wrap:wrap; gap:8px; }
.fault-loading-controls>div>span { width:88px; height:28px; border-radius:7px; }
.fault-loading-controls>div>span:nth-child(2n) { width:106px; }
.fault-loading-summary { display:flex; align-items:center; gap:16px; padding:20px 0; }
.fault-loading-summary>span:first-child { width:128px; height:18px; }.fault-loading-summary>span:last-child { width:66px; }
.fault-loading-table { border:1px solid var(--loading-line); border-radius:var(--loading-radius); overflow:hidden; }
.fault-loading-table-head,.fault-loading-row { display:grid; grid-template-columns:2fr 1fr 1fr; align-items:center; gap:24px; padding:16px; }
.fault-loading-table-head { background:var(--loading-fill); }
.fault-loading-row { border-top:1px solid var(--loading-line); }
.fault-loading-table .fault-loading-block { width:60%; max-width:130px; }
.fault-loading-row:nth-child(2n)>.fault-loading-block:first-child { width:80%; }
@media(max-width:767px) { .fault-loading-status { margin-bottom:16px; }.fault-loading-controls { padding:12px; }.fault-loading-controls>div { flex-wrap:nowrap; overflow:hidden; }.fault-loading-controls>div>span { flex-shrink:0; width:76px; }.fault-loading-table-head,.fault-loading-row { padding:16px 12px; gap:16px; } }
@media(prefers-reduced-motion:reduce) { .fault-loading-spinner rect,.fault-loading-skeleton { animation:none; }.fault-loading-spinner rect:nth-child(3n) { opacity:.65; } }
@media(prefers-contrast:more) { .fault-analysis-loading { --loading-label:#334155; --loading-line:#94a3b8; --loading-fill:rgba(118,118,128,.18); } }
.fault-report-actions { display:flex; align-items:center; gap:10px; flex-wrap:wrap; justify-content:flex-end; }
.fault-report-actions .analysis-export-action { display:flex; flex-direction:column; align-items:flex-end; gap:4px; }
.fault-report-actions .analysis-export-action button { display:inline-flex; align-items:center; justify-content:center; gap:5px; min-height:32px; border:1px solid #dbe4f0; border-radius:7px; background:#fff; color:#475569; padding:6px 10px; white-space:nowrap; }
.fault-report-actions .analysis-export-action button:hover:not(:disabled) { color:#2563eb; border-color:#93c5fd; background:#eff6ff; }
.fault-report-actions .analysis-export-action small { font-size:10px; color:#dc2626; }
@media(max-width:767px) { .fault-trend-heading { flex-wrap:wrap; gap:8px; }.fault-report-actions { gap:6px; margin-left:auto; }.fault-report-actions .analysis-export-action button { min-height:40px; padding:7px 8px; font-size:10px; }.fault-report-actions .fault-weekly-unit { display:none; } }
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import ExcelJS from 'exceljs';
import { createFaultReportWorkbook, type FaultReport } from './report-export';
const now = new Date('2026-10-13T08:00:00Z');
async function saved(report: FaultReport) {
const book = await createFaultReportWorkbook(report, undefined, now);
const reopened = new ExcelJS.Workbook();
await reopened.xlsx.load(await book.xlsx.writeBuffer());
return reopened.worksheets[0];
}
test('trend export preserves monthly range, real dates, cohort counts and weighted closure rate after serialization', async () => {
const sheet = await saved({ kind: 'trend', data: {
start: '2026-09-14', end: '2026-10-13', granularity: 'month', updatedAt: now.toISOString(),
totals: { reported: 10, closed: 7, unclosed: 3 },
periods: [{ period: '2026-09', reported: 8, closed: 5, unclosed: 3 }, { period: '2026-10', reported: 2, closed: 2, unclosed: 0 }],
} });
assert.match(String(sheet.getCell('A2').value), /2026-09-14 至 2026-10-13.*按月/);
assert.equal((sheet.getCell('A10').value as Date).toISOString(), '2026-10-01T00:00:00.000Z');
assert.equal(sheet.getCell('A10').numFmt, 'yyyy-mm');
assert.equal(sheet.getCell('B11').value, 8);
assert.equal(sheet.getCell('C11').value, 5);
assert.equal(sheet.getCell('D11').value, 3);
assert.deepEqual(sheet.getCell('B12').value, { formula: 'SUM(B10:B11)', result: 10 });
assert.deepEqual(sheet.getCell('E12').value, { formula: 'IFERROR(C12/B12,0)', result: .7 });
assert.equal(sheet.autoFilter, 'A9:E11');
assert.equal(sheet.views[0].state, 'frozen');
assert.equal(sheet.getCell('A9').fill.type, 'pattern');
});
test('weekly export keeps independent solved counts, descending dates and excludes receipt metrics', async () => {
const sheet = await saved({ kind: 'weekly', data: {
start: '2026-10-12', end: '2026-10-13', updatedAt: now.toISOString(),
totals: { reported: 3, closed: 9, received: 99 },
days: [{ date: '2026-10-12', reported: 1, closed: 3, received: 90 }, { date: '2026-10-13', reported: 2, closed: 6, received: 9 }],
} });
assert.deepEqual([1, 2, 3, 4].map(col => sheet.getCell(9, col).value), ['日期', '星期', '新增故障(单)', '解决故障(单)']);
assert.equal((sheet.getCell('A10').value as Date).toISOString(), '2026-10-13T00:00:00.000Z');
assert.match(sheet.getCell('A10').numFmt, /今天/);
assert.equal(sheet.getCell('B10').value, '周二');
assert.equal(sheet.getCell('D10').value, 6);
assert.deepEqual(sheet.getCell('D12').value, { formula: 'SUM(D10:D11)', result: 9 });
assert.deepEqual(sheet.getCell('C6').value, { formula: 'SUM(D10:D11)', result: 9 });
assert.equal(sheet.columnCount, 4);
});
test('zero-data trend exports valid guarded ratios and empty reports avoid inverted SUM ranges', async () => {
for (const periods of [[], [{ period: '2026-10-13', reported: 0, closed: 0, unclosed: 0 }]]) {
const sheet = await saved({ kind: 'trend', data: {
start: '2026-10-13', end: '2026-10-13', granularity: 'day', updatedAt: now.toISOString(),
totals: { reported: 0, closed: 0, unclosed: 0 }, periods,
} });
const total = 10 + periods.length;
assert.equal(sheet.getCell(`E${total}`).formula, `IFERROR(C${total}/B${total},0)`);
assert.equal(sheet.getCell(`B${total}`).formula, periods.length ? 'SUM(B10:B10)' : '0');
assert.equal(sheet.getCell('A6').result, 0);
}
});
+137
View File
@@ -0,0 +1,137 @@
import { createStyledWorkbook, downloadStyledWorkbook } from '../../../shared/xlsx';
import type { FaultTrendResponse, FaultWeeklyResponse } from '../../../shared/assets/faults';
import type { Cell, Worksheet } from 'exceljs';
export type FaultReport = ({ kind: 'trend'; data: FaultTrendResponse } | { kind: 'weekly'; data: FaultWeeklyResponse }) & { filterLabel?: string };
interface ReportChartImage { dataUrl: string; width: number; height: number }
const palette = { navy: '1E293B', text: '334155', muted: '64748B', blue: '2563EB', green: '059669', amber: 'D97706', line: 'E2E8F0', stripe: 'F8FAFC', today: 'EFF6FF', white: 'FFFFFF' };
const fill = (color: string) => ({ type: 'pattern' as const, pattern: 'solid' as const, fgColor: { argb: `FF${color}` } });
const excelDate = (date: string) => new Date(`${date.length === 7 ? `${date}-01` : date}T00:00:00Z`);
const beijingTime = (date: string | Date) => new Date(date).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
function merged(sheet: Worksheet, row: number, from: number, to: number, value: Cell['value']) {
if (to > from) sheet.mergeCells(row, from, row, to);
const cell = sheet.getCell(row, from); cell.value = value; return cell;
}
/** Browser-independent workbook builder; export the displayed authorized snapshot without requerying. */
export async function createFaultReportWorkbook(report: FaultReport, chartImage?: ReportChartImage, now = new Date()) {
const book = await createStyledWorkbook();
book.creator = '羚牛氢能 BI'; book.created = now; book.modified = now;
const trend = report.kind === 'trend';
const title = trend ? '故障处理趋势' : '故障运维周报';
const headers = trend ? [report.data.granularity === 'month' ? '提报月份' : '提报日期', '新增故障(单)', '已闭环(单)', '未闭环(单)', '闭环率'] : ['日期', '星期', '新增故障(单)', '解决故障(单)'];
const columns = headers.length;
const sheet = book.addWorksheet(title, { properties: { defaultRowHeight: 22 }, pageSetup: { paperSize: 9, orientation: 'portrait', fitToPage: true, fitToWidth: 1, fitToHeight: 0, horizontalCentered: true, margins: { left: .3, right: .3, top: .4, bottom: .4, header: .2, footer: .2 } } });
sheet.columns = (trend ? [24, 19, 19, 19, 18] : [26, 16, 25, 25]).map(width => ({ width }));
const headerRow = 9;
const periods = trend ? [...report.data.periods].reverse() : [...report.data.days].reverse();
const start = headerRow + 1, end = headerRow + periods.length, totalRow = end + 1;
const today = new Date(now.getTime() + 8 * 3600000).toISOString().slice(0, 10);
const bottom = totalRow + 3;
for (let row = 1; row <= bottom; row++) for (let col = 1; col <= columns; col++) {
const cell = sheet.getCell(row, col);
cell.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.text}` } };
cell.alignment = { vertical: 'middle', horizontal: col === 1 ? 'left' : 'center' };
}
const titleCell = merged(sheet, 1, 1, columns, `羚牛氢能 · ${title}`);
titleCell.font = { name: 'Microsoft YaHei', size: 22, bold: true, color: { argb: 'FFFFFFFF' } };
titleCell.fill = fill(palette.navy); titleCell.alignment = { vertical: 'middle', indent: 1 }; sheet.getRow(1).height = 48;
merged(sheet, 2, 1, columns, `统计范围:${report.data.start} 至 ${report.data.end} · ${trend && report.data.granularity === 'month' ? '按月' : '按日'} · 单位:单`);
const stamp = merged(sheet, 3, 1, columns, `数据时间:${beijingTime(report.data.updatedAt)}(北京时间)`); stamp.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } };
sheet.getRow(2).height = 28; sheet.getRow(4).height = 10; sheet.getRow(7).height = 10;
if (report.filterLabel) { const filter = merged(sheet, 4, 1, columns, `筛选条件:${report.filterLabel}`); filter.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } }; filter.alignment = { wrapText: true, vertical: 'middle' }; sheet.getRow(4).height = 32; }
const sum = (column: string) => periods.length ? `SUM(${column}${start}:${column}${end})` : '0';
const cards = trend
? [{ from: 1, to: 2, label: '新增故障', col: 'B', value: report.data.totals.reported, color: palette.blue }, { from: 3, to: 4, label: '已闭环', col: 'C', value: report.data.totals.closed, color: palette.green }, { from: 5, to: 5, label: '未闭环', col: 'D', value: report.data.totals.unclosed, color: palette.amber }]
: [{ from: 1, to: 2, label: '新增故障', col: 'C', value: report.data.totals.reported, color: palette.blue }, { from: 3, to: 4, label: '解决故障', col: 'D', value: report.data.totals.closed, color: palette.green }];
for (const card of cards) {
const label = merged(sheet, 5, card.from, card.to, card.label);
const value = merged(sheet, 6, card.from, card.to, { formula: sum(card.col), result: card.value });
for (const cell of [label, value]) { cell.fill = fill(palette.stripe); cell.alignment = { horizontal: 'center', vertical: 'middle' }; }
label.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.muted}` } };
value.font = { name: 'Microsoft YaHei', size: 26, bold: true, color: { argb: `FF${card.color}` } }; value.numFmt = '#,##0';
}
sheet.getRow(5).height = 25; sheet.getRow(6).height = 40;
if (chartImage) {
const chart = book.addWorksheet('图表', { views: [{ showGridLines: false }], pageSetup: { paperSize: 9, orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 1 } });
chart.columns = headers.map(() => ({ width: 900 / columns / 7 }));
chart.properties.defaultRowHeight = 24;
const heading = merged(chart, 1, 1, columns, `羚牛氢能 · ${title}`);
heading.font = { name: 'Microsoft YaHei', size: 22, bold: true, color: { argb: 'FFFFFFFF' } }; heading.fill = fill(palette.navy); heading.alignment = { vertical: 'middle', indent: 1 }; chart.getRow(1).height = 48;
const period = merged(chart, 2, 1, columns, `${report.data.start} 至 ${report.data.end} · ${trend && report.data.granularity === 'month' ? '按月' : '按日'} · 单位:单`);
period.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.text}` } };
const key = merged(chart, 3, 1, columns, trend ? '绿色:已闭环 橙色:未闭环 柱总高为新增故障数' : '蓝色:新增故障 绿色:解决故障');
key.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } };
const id = book.addImage({ base64: chartImage.dataUrl, extension: 'png' });
const scale = Math.min(900 / chartImage.width, 360 / chartImage.height);
const width = chartImage.width * scale, height = chartImage.height * scale;
// Keep circles and labels proportional for both desktop and mobile snapshots.
chart.addImage(id, { tl: { col: (900 - width) / 2 / (900 / columns), row: 4 + (360 - height) / 64 }, ext: { width, height }, editAs: 'oneCell' });
const caption = merged(chart, 17, 1, columns, `图表为当前页面快照,精确数量见「${title}」工作表。`);
caption.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } };
chart.pageSetup.printArea = `A1:${String.fromCharCode(64 + columns)}17`;
}
headers.forEach((label, i) => { const cell = sheet.getCell(headerRow, i + 1); cell.value = label; cell.fill = fill(palette.navy); cell.font = { name: 'Microsoft YaHei', size: 11, bold: true, color: { argb: 'FFFFFFFF' } }; });
sheet.getRow(headerRow).height = 30;
if (trend) [...report.data.periods].reverse().forEach((row, i) => {
const n = start + i;
sheet.getRow(n).values = [excelDate(row.period), row.reported, row.closed, row.unclosed, { formula: `IFERROR(C${n}/B${n},0)`, result: row.reported ? row.closed / row.reported : 0 }];
sheet.getCell(n, 1).numFmt = report.data.granularity === 'month' ? 'yyyy-mm' : 'yyyy-mm-dd';
sheet.getCell(n, 5).numFmt = '0.0%;-0.0%;"—"';
});
else [...report.data.days].reverse().forEach((row, i) => {
sheet.getRow(start + i).values = [excelDate(row.date), excelDate(row.date).toLocaleDateString('zh-CN', { weekday: 'short', timeZone: 'UTC' }), row.reported, row.closed];
sheet.getCell(start + i, 1).numFmt = row.date === today ? 'yyyy-mm-dd" 今天"' : 'yyyy-mm-dd';
});
for (let row = start; row <= end; row++) {
const date = sheet.getCell(row, 1).value as Date;
const highlight = date.toISOString().slice(0, 10) === today && (!trend || report.data.granularity === 'day');
sheet.getRow(row).height = 26;
for (let col = 1; col <= columns; col++) {
const cell = sheet.getCell(row, col); cell.fill = fill(highlight ? palette.today : (row - start) % 2 ? palette.stripe : palette.white);
cell.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.text}` } };
cell.alignment = { vertical: 'middle', horizontal: col === 1 ? 'left' : 'center' };
cell.border = { bottom: { style: 'hair', color: { argb: `FF${palette.line}` } } };
if (col > (trend ? 1 : 2) && !(trend && col === 5)) {
cell.numFmt = '#,##0'; const color = trend ? [palette.blue, palette.green, palette.amber][col - 2] : col === 3 ? palette.blue : palette.green;
cell.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${color}` } };
}
}
}
sheet.getCell(totalRow, 1).value = '合计';
cards.forEach(card => { sheet.getCell(`${card.col}${totalRow}`).value = { formula: sum(card.col), result: card.value }; sheet.getCell(`${card.col}${totalRow}`).numFmt = '#,##0'; });
if (trend) { sheet.getCell(totalRow, 5).value = { formula: `IFERROR(C${totalRow}/B${totalRow},0)`, result: report.data.totals.reported ? report.data.totals.closed / report.data.totals.reported : 0 }; sheet.getCell(totalRow, 5).numFmt = '0.0%;-0.0%;"—"'; }
for (let col = 1; col <= columns; col++) { const cell = sheet.getCell(totalRow, col); cell.fill = fill('E8EFF9'); cell.font = { name: 'Microsoft YaHei', size: 11, bold: true, color: { argb: `FF${palette.navy}` } }; }
sheet.getRow(totalRow).height = 30;
const note = merged(sheet, totalRow + 2, 1, columns, trend ? '统计口径:按提报日期统计同批故障;已归档为已闭环,其余状态(含挂起)为未闭环。按月仅统计所选日期范围。' : '统计口径:新增故障按提报日期;解决故障按解决日期统计,不受提报日期限制。同一故障可在不同日期分别计入两个指标。');
note.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } }; note.alignment = { wrapText: true, vertical: 'middle' }; sheet.getRow(totalRow + 2).height = 36;
const source = merged(sheet, bottom, 1, columns, `来源:羚牛氢能 BI 当前可见统计 · 导出时间:${beijingTime(now)}(北京时间)`); source.font = { name: 'Microsoft YaHei', size: 9, color: { argb: `FF${palette.muted}` } };
sheet.autoFilter = { from: { row: headerRow, column: 1 }, to: { row: Math.max(headerRow, end), column: columns } };
sheet.views = [{ state: 'frozen', ySplit: headerRow, topLeftCell: `A${start}`, showGridLines: false }];
sheet.pageSetup.printArea = `A1:${String.fromCharCode(64 + columns)}${bottom}`;
sheet.pageSetup.printTitlesRow = `${headerRow}:${headerRow}`;
sheet.headerFooter.oddFooter = '&L羚牛氢能 · 故障管理&R第 &P 页 / 共 &N 页';
return book;
}
async function chartImage(svg: SVGSVGElement): Promise<ReportChartImage> {
const width = svg.clientWidth || Number(svg.getAttribute('width')) || 800;
const height = svg.clientHeight || Number(svg.getAttribute('height')) || 240;
const clone = svg.cloneNode(true) as SVGSVGElement;
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); clone.setAttribute('width', String(width)); clone.setAttribute('height', String(height));
clone.style.fontFamily = 'Arial, sans-serif';
const url = URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(clone)], { type: 'image/svg+xml;charset=utf-8' }));
try {
const image = new Image();
await new Promise<void>((resolve, reject) => { image.onload = () => resolve(); image.onerror = () => reject(new Error('图表生成失败')); image.src = url; });
const canvas = document.createElement('canvas'); canvas.width = Math.ceil(width * 2); canvas.height = Math.ceil(height * 2);
const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('图表生成失败');
ctx.scale(2, 2); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, width, height); ctx.drawImage(image, 0, 0, width, height);
return { dataUrl: canvas.toDataURL('image/png'), width, height };
} finally { URL.revokeObjectURL(url); }
}
export async function downloadFaultReport(report: FaultReport, svg?: SVGSVGElement) {
const png = svg ? await chartImage(svg) : undefined;
const book = await createFaultReportWorkbook(report, png);
await downloadStyledWorkbook(book, `${report.kind === 'trend' ? `故障处理趋势_${report.data.granularity === 'day' ? '按日' : '按月'}` : '故障运维周报'}_${report.data.start}_${report.data.end}.xlsx`);
}
+12
View File
@@ -0,0 +1,12 @@
import { useSyncExternalStore } from 'react';
const query = '(max-width: 767px)';
const subscribe = (notify: () => void) => {
const media = window.matchMedia(query);
media.addEventListener('change', notify);
return () => media.removeEventListener('change', notify);
};
/** Keep the two mobile asset surfaces on the same breakpoint. */
export function useMobileLayout() {
return useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);
}
+2
View File
@@ -13,6 +13,7 @@ import mileageRouter from './routes/mileage/index.js';
import schedulingRouter from './routes/scheduling/index.js';
import vehicleHeatmapRouter from './routes/vehicle-heatmap/routes.js';
import vehiclesRouter from './routes/vehicles/routes.js';
import assetFaultRouter from './routes/asset-faults/index.js';
import assetAssistantRouter from './routes/asset-assistant/index.js';
/**
@@ -32,6 +33,7 @@ export function createApp(): Hono {
app.use('/api/*', createPreviewMiddleware());
app.route('/api/vehicles', vehiclesRouter);
app.route('/api/asset-faults', assetFaultRouter);
app.route('/api/asset-assistant', assetAssistantRouter);
app.route('/api/mileage', mileageRouter);
app.route('/api/scheduling', schedulingRouter);
@@ -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},
};
}
+103
View File
@@ -0,0 +1,103 @@
import { Hono } from 'hono';
import { dashboardAnalysis, faultDashboard, filterDashboardRows, parseDashboardQuery } from './dashboard.js';
import { FAULT_OVERVIEW_METRICS, type FaultOverviewMetric } from '../../../shared/assets/faults.js';
import type { AuthUser } from '../../auth/types.js';
import { analyzeFaults, faultOverview, faultAnalysis, parseFaultQuery, visibleFaults } from './model.js';
import { faultWeeklyReport, faultWeeklyAnalysis, parseFaultWeeklyQuery } from './weekly-report.js';
import { loadFaults } from './repository.js';
import { faultTrend, faultTrendAnalysis, parseFaultTrendQuery } from './trend.js';
export function createFaultRouter(load = loadFaults) {
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) {
app.get(path, async c => {
const user = c.get('user');
if (!user) return c.json({ error: 'Unauthorized' }, 401);
let query;
try { query = parseFaultTrendQuery(c.req.query()); }
catch (error) { return c.json({ error: (error as Error).message }, 400); }
try {
const data = await load();
const rows = filterDashboardRows(visibleFaults(data.rows, user), c.req.query());
c.header('Cache-Control', 'no-store');
return c.json(path === '/trend' ? faultTrend(rows, query, data.updatedAt) : faultTrendAnalysis(rows, query, data.updatedAt));
} catch (error) {
console.error('Fault trend failed:', error instanceof Error ? error.message : 'unknown');
return c.json({ error: '故障趋势暂时不可用,请稍后重试' }, 503);
}
});
}
for (const path of ['/weekly-report', '/weekly-report-analysis'] as const) {
app.get(path, async c => {
const user = c.get('user');
if (!user) return c.json({ error: 'Unauthorized' }, 401);
let query;
try { query = parseFaultWeeklyQuery(c.req.query()); }
catch (error) { return c.json({ error: (error as Error).message }, 400); }
try {
const data = await load();
const rows = filterDashboardRows(visibleFaults(data.rows, user), c.req.query());
c.header('Cache-Control', 'no-store');
return c.json(path === '/weekly-report' ? faultWeeklyReport(rows, query, data.updatedAt) : faultWeeklyAnalysis(rows, query, data.updatedAt));
} catch (error) {
console.error('Fault weekly report failed:', error instanceof Error ? error.message : 'unknown');
return c.json({ error: '故障运维周报暂时不可用,请稍后重试' }, 503);
}
});
}
app.get('/overview', async c => {
const user = c.get('user');
if (!user) return c.json({ error: 'Unauthorized' }, 401);
try {
const data = await load();
c.header('Cache-Control', 'no-store');
return c.json(faultOverview(visibleFaults(data.rows, user), data.updatedAt));
} catch (error) {
console.error('Fault overview failed:', error instanceof Error ? error.message : 'unknown');
return c.json({ error: '故障数据暂时不可用,请稍后重试' }, 503);
}
});
app.get('/analysis', async c => {
const user = c.get('user');
if (!user) return c.json({ error: 'Unauthorized' }, 401);
const metric = c.req.query('metric') || '';
if (!Object.hasOwn(FAULT_OVERVIEW_METRICS, metric)) return c.json({ error: '无效统计指标' }, 400);
try {
const data = await load();
c.header('Cache-Control', 'no-store');
return c.json(faultAnalysis(visibleFaults(data.rows, user), metric as FaultOverviewMetric, data.updatedAt));
} catch (error) {
console.error('Fault analysis failed:', error instanceof Error ? error.message : 'unknown');
return c.json({ error: '故障数据暂时不可用,请稍后重试' }, 503);
}
});
app.get('/', async c => {
const user = c.get('user');
if (!user) return c.json({ error: 'Unauthorized' }, 401);
let query;
try { query = parseFaultQuery(c.req.query()); }
catch (error) { return c.json({ error: (error as Error).message }, 400); }
try {
const data = await load();
c.header('Cache-Control', 'no-store');
return c.json(analyzeFaults(visibleFaults(data.rows, user, query.subject), query, data.updatedAt));
} catch (error) {
console.error('Fault BI query failed:', error instanceof Error ? error.message : 'unknown');
return c.json({ error: '故障数据暂时不可用,请稍后重试' }, 503);
}
});
return app;
}
export default createFaultRouter();
+203
View File
@@ -0,0 +1,203 @@
import { createHash } from 'node:crypto';
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 { filterByPermission, maskCustomerName } from '../../auth/permissions.js';
import type { AssetAnalysisCell } from '../../../shared/assets/analytics.js';
import { deriveModelTag, deriveType, mapMacroRegion } from '../vehicles/model.js';
export type RawFault = Record<string, unknown>;
export interface ScopedFault extends FaultRecord {
subject: string; departmentName: string; managerId: string; missingVehicle: boolean;
assetCell: AssetAnalysisCell;
vehicleKey?: string;
}
export function normalizeFaultStatus(value: unknown): string {
const status = String(value ?? '').trim();
if (['', '待处理', '待处置', '未处理'].includes(status)) return '待处理';
if (['处理中', '处置中', '处理'].includes(status)) return '处理中';
if (['挂起', '已挂起'].includes(status)) return '挂起';
return status;
}
export const isOpenFault = (status: string) => ['待处理', '处理中', '挂起'].includes(status);
function array(value: unknown, preserveIds = false): unknown[] {
try {
// BIGINT IDs in legacy longtext arrays must never pass through floating point.
const parsed = typeof value === 'string' ? JSON.parse(preserveIds ? value.replace(/([\[,]\s*)(\d+)(?=\s*[,\]])/g, '$1"$2"') : value) : value;
return Array.isArray(parsed) ? parsed : [];
} catch { return []; }
}
const text = (value: unknown) => String(value ?? '').trim();
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[] {
const candidates = [
[array(row.work_ids, true), array(row.work_names)],
[[row.work_id], [row.work_name]],
[array(row.handler_ids, true), array(row.handler_names)],
[[row.handler_id], [row.handler_name]],
];
const [ids, names] = candidates.find(([ids, names]) => ids.some(positiveId) || names.some(v => text(v))) ?? [[], []];
const handlers = new Map<string, FaultOption>();
for (let i = 0; i < Math.max(ids.length, names.length); i++) {
const id = positiveId(ids[i]) ? text(ids[i]) : '';
const name = text(names[i]);
if (!id && !name) continue;
const value = id ? `id:${id}` : `name:${name}`;
if (!handlers.has(value)) handlers.set(value, { value, label: id ? directory.get(id) || name || `历史处理人(${id})` : name });
}
return handlers.size ? [...handlers.values()] : [{ value: 'unassigned', label: '未指派' }];
}
export function faultInventory(operation: unknown, vehicle: unknown, exists: boolean): string {
if (!exists || !text(operation)) return '未知';
if (!['3', '4', '可运营', '待运营', '在库'].includes(text(operation))) return '不适用';
if (text(vehicle) === '14') return '异动';
if (text(vehicle) === '4') return '待交车';
return ['3', '可运营'].includes(text(operation)) ? '可运营' : ['4', '待运营'].includes(text(operation)) ? '待运营' : '未分类';
}
export function mapFault(row: RawFault, directory: Map<string, string>): ScopedFault {
const customer = text(row.customer_name);
const city = text(row.city_name) || text(row.operation_city) || '未知';
const rawModel = text(row.model_name);
const brands: Record<string, string> = { hyundai: /帕力安|冷链|双飞翼/.test(rawModel) ? '帕力安牌' : '现代', yuejin: '跃进', feichi: '飞驰', sulong: '苏龙', higer: '海格', dongfeng: '东风', yutong: '宇通', chufeng: '楚风', tonghua: '通华', maxus: '大通', mingwei: '明威', wanfeng: '万风', shujie: '舒捷', denza: '腾势', hongyan: '红岩', 'yuanchang brand': '远程牌', others: '其他' };
const ownership = ({ '0': '自有', '1': '挂靠', '2': '外租', '3': '自有' } as Record<string, string>)[text(row.vehicle_source)] || text(row.actual_ownership);
const model = deriveModelTag(brands[text(row.brand)] || text(row.brand), rawModel, text(row.body_color), ownership, text(row.rental_company));
const type = deriveType(rawModel, text(row.model_type));
const operation = text(row.operation_status);
const operating = ['1', '2', '租赁', '自营'].includes(operation);
const inventory = ['3', '4', '可运营', '待运营', '在库'].includes(operation);
const unlinkedInventory = !customer && !!row.matched_vehicle_id && inventory;
const customerKey = customer ? createHash('sha256').update(customer).digest('hex') : unlinkedInventory ? 'inventory-unlinked' : 'unlinked';
const operationKind = ({ '1': '租赁', '2': '自营', '3': '可运营', '4': '待运营', '5': '退出运营' } as Record<string, string>)[operation] || operation || '未分类';
const inventoryLabel = faultInventory(operation, row.vehicle_status, !!row.matched_vehicle_id);
const assetCell: AssetAnalysisCell = {
type: !row.matched_vehicle_id ? '未知车型' : type === '4.5T' ? (model.includes('冷链') ? '4.5T冷链' : '4.5T普货') : ['18T', '49T'].includes(type) ? type : '其他',
model, city, region: mapMacroRegion(text(row.province_name), city),
assetStatus: operating ? 'operating' : inventory ? 'inventory' : 'other',
assetDisposition: inventory ? '在库' : operating ? (operationKind === '租赁' ? '租赁' : '自营') : '其他', operationKind,
inventoryState: ({ '异动': 'abnormal', '待交车': 'pending', '可运营': 'available', '待运营': 'awaiting', '未分类': 'unclassified' } as Record<string, AssetAnalysisCell['inventoryState']>)[inventoryLabel] || 'not-applicable',
department: model === '公务车/挂靠车' ? '公务车' : text(row.business_dept) || '未分配部门',
manager: text(row.business_manager) || '未分配', customer: customerKey, abnormalType: '不适用', abnormalAge: 'unknown', quantity: 1,
};
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) || '未关联车辆',
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,
inventory: faultInventory(row.operation_status, row.vehicle_status, !!row.matched_vehicle_id),
customer: customerKey,
customerLabel: maskCustomerName(customer) || (unlinkedInventory ? '库存车' : '未关联客户'), department: text(row.business_dept) || '未关联部门',
departmentName: text(row.business_dept), managerId: text(row.manager_id), subject: text(row.subject),
missingVehicle: !row.matched_vehicle_id, handlers: faultHandlers(row, directory),
};
}
export function visibleFaults(rows: ScopedFault[], user: AuthUser, subject?: string): ScopedFault[] {
// Fault history never inherits the public-inventory exception for current assets.
const candidates = user.permissionLevel === 'full' ? rows : rows.filter(row => !row.missingVehicle);
const scoped = user.permissionLevel === 'department' && !user.depName ? [] : filterByPermission(candidates, user);
return scoped.filter(row => !subject || row.subject === subject);
}
export function parseFaultQuery(params: Record<string, string>, now = new Date()): FaultQuery {
const today = new Date(now.getTime() + 8 * 3600000).toISOString().slice(0, 10);
const start = params.start || new Date(Date.parse(today) - 29 * 86400000).toISOString().slice(0, 10);
const end = params.end || today;
const valid = (s: string) => /^\d{4}-\d{2}-\d{2}$/.test(s) && Number.isFinite(Date.parse(s)) && new Date(s).toISOString().slice(0, 10) === s;
if (!valid(start) || !valid(end) || start > end || (Date.parse(end) - Date.parse(start)) / 86400000 > 365) throw new Error('日期范围须为有效日期,且不超过366天');
if (params.day && (!valid(params.day) || params.day < start || params.day > end)) throw new Error('明细日期超出统计范围');
const groupBy = params.groupBy || 'city';
if (!(groupBy in FAULT_DIMENSIONS) || !Object.hasOwn(FAULT_DIMENSIONS, groupBy)) throw new Error('无效统计维度');
const metric = params.metric || 'open';
if (!['reported', 'closed', 'open'].includes(metric)) throw new Error('无效统计指标');
const page = Number(params.page || 1);
if (!Number.isSafeInteger(page) || page < 1) throw new Error('无效页码');
return { start, end, groupBy: groupBy as FaultDimension, metric: metric as FaultQuery['metric'], page,
day: params.day, groupValue: params.groupValue, subject: params.subject,
filters: Object.fromEntries(Object.keys(FAULT_DIMENSIONS).filter(key => params[key]).map(key => [key, params[key]])) };
}
const dimensions = Object.keys(FAULT_DIMENSIONS) as FaultDimension[];
function values(row: ScopedFault, dimension: FaultDimension): FaultOption[] {
return dimension === 'handler' ? row.handlers : [{ value: row[dimension], label: dimension === 'customer' ? row.customerLabel : row[dimension] }];
}
export function analyzeFaults(visible: ScopedFault[], query: FaultQuery, updatedAt: string): FaultResponse {
const options = Object.fromEntries(dimensions.map(d => [d, [...new Map(visible.flatMap(row => values(row, d)).map(o => [o.value, o])).values()].sort((a, b) => a.label.localeCompare(b.label, 'zh-CN'))])) as FaultResponse['options'];
const rows = visible.filter(row => dimensions.every(d => !query.filters[d] || values(row, d).some(o => o.value === query.filters[d])));
const inRange = (time: string | null) => !!time && time.slice(0, 10) >= query.start && time.slice(0, 10) <= query.end;
const counts = (row: ScopedFault) => ({ reported: Number(inRange(row.reportedAt)), closed: Number(row.status === '已归档' && inRange(row.archivedAt)), open: Number(isOpenFault(row.status)) });
const totals = { reported: 0, closed: 0, open: 0 };
const groups = new Map<string, FaultResponse['groups'][number]>();
const daily = new Map<string, FaultResponse['daily'][number]>();
for (let ts = Date.parse(query.start); ts <= Date.parse(query.end); ts += 86400000) {
const date = new Date(ts).toISOString().slice(0, 10); daily.set(date, { date, reported: 0, closed: 0 });
}
for (const row of rows) {
const count = counts(row);
for (const key of ['reported', 'closed', 'open'] as const) totals[key] += count[key];
if (count.reported) daily.get(row.reportedAt!.slice(0, 10))!.reported++;
if (count.closed) daily.get(row.archivedAt!.slice(0, 10))!.closed++;
if (!count.reported && !count.closed && !count.open) continue;
for (const option of values(row, query.groupBy)) {
const group = groups.get(option.value) || { ...option, reported: 0, closed: 0, open: 0 };
for (const key of ['reported', 'closed', 'open'] as const) group[key] += count[key];
groups.set(option.value, group);
}
}
const details = rows.filter(row => counts(row)[query.metric] &&
(query.metric === 'open' || !query.day || (query.metric === 'reported' ? row.reportedAt : row.archivedAt)?.slice(0, 10) === query.day) &&
(!query.groupValue || values(row, query.groupBy).some(o => o.value === query.groupValue)))
.sort((a, b) => (query.metric === 'closed' ? b.archivedAt || '' : b.reportedAt || '').localeCompare(query.metric === 'closed' ? a.archivedAt || '' : a.reportedAt || '') || b.id.localeCompare(a.id));
const pageSize = 50;
const page = Math.min(query.page, Math.max(1, Math.ceil(details.length / pageSize)));
return { start: query.start, end: query.end, updatedAt, totals, daily: [...daily.values()],
groups: [...groups.values()].sort((a, b) => b.open - a.open || b.reported - a.reported || a.label.localeCompare(b.label, 'zh-CN')), options,
quality: { missingReport: rows.filter(r => !r.reportedAt).length,
invalidChronology: rows.filter(r => r.reportedAt && r.archivedAt && r.archivedAt < r.reportedAt).length,
missingVehicle: rows.filter(r => r.missingVehicle).length },
details: { rows: details.slice((page - 1) * pageSize, page * pageSize).map(({ subject, departmentName, managerId, missingVehicle, vehicleKey, assetCell, ...row }) => row), total: details.length, page, pageSize },
};
}
export function faultBusinessDate(updatedAt: string): string {
return new Date(Date.parse(updatedAt) + 8 * 3600000).toISOString().slice(0, 10);
}
export function isOverdueFault(row: FaultRecord, updatedAt: string): boolean {
// OneOS supervisor aggregate: stored deadline only, suspended clock excluded, strict < NOW().
if (!['待处理', '处理中'].includes(row.status) || !row.deadlineAt) return false;
const deadline = Date.parse(`${row.deadlineAt.replace(' ', 'T')}+08:00`);
return Number.isFinite(deadline) && deadline < Date.parse(updatedAt);
}
export function selectFaultMetric(rows: ScopedFault[], metric: import('../../../shared/assets/faults.js').FaultOverviewMetric, date: string, updatedAt: string) {
return rows.filter(row => {
switch (metric) {
case 'ledger': return true;
// Current BI definition: in progress = waiting + processing; suspended remains in the ledger.
case 'open': return ['待处理', '处理中'].includes(row.status);
case 'waiting': return row.status === '待处理';
case 'processing': return row.status === '处理中';
case 'suspended': return row.status === '挂起';
case 'overdue': return isOverdueFault(row, updatedAt);
case 'reported': return row.reportedAt?.slice(0, 10) === date;
case 'closed': return row.status === '已归档' && row.archivedAt?.slice(0, 10) === date;
}
});
}
export function faultOverview(rows: ScopedFault[], updatedAt: string): import('../../../shared/assets/faults.js').FaultOverview {
const date = faultBusinessDate(updatedAt);
return { date, updatedAt, totals: Object.fromEntries(
(Object.keys(FAULT_OVERVIEW_METRICS) as import('../../../shared/assets/faults.js').FaultOverviewMetric[])
.map(metric => [metric, selectFaultMetric(rows, metric, date, updatedAt).length]),
) as import('../../../shared/assets/faults.js').FaultOverview['totals'] };
}
export function faultAnalysis(rows: ScopedFault[], metric: import('../../../shared/assets/faults.js').FaultOverviewMetric, updatedAt: string): import('../../../shared/assets/faults.js').FaultAnalysisResponse {
const date = faultBusinessDate(updatedAt);
const selected = selectFaultMetric(rows, metric, date, updatedAt);
return { date, updatedAt, metric, customerLabels: Object.fromEntries(selected.map(row => [row.customer, row.customerLabel])),
rows: selected.map(({ subject, departmentName, managerId, missingVehicle, vehicleKey, assetCell, ...row }) => ({ ...row, cell: assetCell })) };
}
@@ -0,0 +1,69 @@
import type { RowDataPacket } from 'mysql2';
import pool from '../../db/mysql.js';
import { mapFault, type RawFault } from './model.js';
// OneOS fault ledger: latest delivery first; do not reuse the active-asset WHERE clause.
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,
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.deadline_at, '%Y-%m-%d %H:%i:%s') deadline_at,
DATE_FORMAT(receipt.received_at, '%Y-%m-%d %H:%i:%s') received_at,
CAST(vi.id AS CHAR) matched_vehicle_id, vi.operation_city,
COALESCE(city.NAME, district.NAME) city_name, vi.registered_ownership subject,
COALESCE(province.NAME, vi.province_name) province_name,
vm.brand, vm.model model_name, vm.vehicle_type model_type, vi.body_color, vi.vehicle_source, vi.actual_ownership, vi.rental_company,
vs.operation_status, vs.vehicle_status, lease.customer_name, lease.business_dept,
lease.business_manager,
CAST(COALESCE(contract.business_manager_id, vi.business_id) AS CHAR) manager_id,
CAST(f.inspection_operator_id AS CHAR) handler_id, f.inspection_operator_name handler_name,
f.inspection_operator_ids_json handler_ids, f.inspection_operator_names_json handler_names,
CAST(wo.current_executor_user_id AS CHAR) work_id, wo.current_executor_name work_name,
CAST(wo.current_executor_user_ids_json AS CHAR) work_ids,
CAST(wo.current_executor_names_json AS CHAR) work_names
FROM vehicle_fault_manage f
LEFT JOIN (
SELECT fault_id, MIN(create_time) received_at
FROM vehicle_fault_operation_log
WHERE del_flag='0' AND action_code='SAVE_PROGRESS'
AND COALESCE(TRIM(before_status), '') IN ('', '待处理', '待处置', '未处理')
AND TRIM(after_status) IN ('处理中', '处置中', '处理')
GROUP BY fault_id
) receipt ON receipt.fault_id=f.id
LEFT JOIN vehicle_info vi ON vi.id=f.vehicle_id AND vi.del_flag='0'
LEFT JOIN vehicle_model vm ON vm.id=vi.vehicle_model_id AND vm.del_flag='0'
LEFT JOIN common_district province ON province.CODE=CONCAT(LEFT(vi.operation_city,2),'0000') COLLATE utf8mb4_unicode_ci AND province.STATUS='VALID'
LEFT JOIN vehicle_status vs ON vs.id=(
SELECT s.id FROM vehicle_status s WHERE s.vehicle_id=vi.id AND s.del_flag=0
ORDER BY s.update_time DESC, s.id DESC LIMIT 1
)
LEFT JOIN vehicle_lease_order_record lease ON lease.id=(
SELECT r.id FROM vehicle_lease_order_record r WHERE r.vehicle_id=vi.id AND r.del_flag='0'
ORDER BY r.last_delivery_time DESC, r.id DESC LIMIT 1
)
LEFT JOIN vehicle_lease_contract_info contract ON contract.id=lease.contract_id AND contract.del_flag='0'
LEFT JOIN common_district district ON district.CODE=vi.operation_city COLLATE utf8mb4_unicode_ci AND district.STATUS='VALID'
LEFT JOIN common_district city ON city.CODE=(CASE
WHEN LEFT(vi.operation_city,2) IN ('11','12','31','50') THEN CONCAT(LEFT(vi.operation_city,2),'0000')
ELSE CONCAT(LEFT(vi.operation_city,4),'00') END) COLLATE utf8mb4_unicode_ci AND city.STATUS='VALID'
LEFT JOIN task_work_order wo ON wo.id=f.source_work_order_id AND wo.del_flag='0'
WHERE f.del_flag='0' AND f.is_submit IN (0,1)`;
export async function loadFaults() {
const connection = await pool.getConnection();
try {
await connection.query('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY');
const [raw] = await connection.query<RowDataPacket[]>({ sql: FAULT_SQL, timeout: 20000 });
const [users] = await connection.query<RowDataPacket[]>({ sql: `SELECT CAST(user_id AS CHAR) id, nick_name name
FROM \`ry-cloud\`.sys_user WHERE del_flag='0' AND status='0'
AND (user_type IS NULL OR user_type='sys_user')`, timeout: 10000 });
const directory = new Map<string, string>(users.map(row => [String(row.id), String(row.name || '').trim()]));
// Defensive ID de-duplication keeps imperfect lookup tables from multiplying tickets.
const rows = [...new Map(raw.map(row => [String(row.id), mapFault(row as RawFault, directory)])).values()];
return { rows, updatedAt: new Date().toISOString() };
} finally {
try { await connection.rollback(); } finally { connection.release(); }
}
}
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Hono } from 'hono';
import { mapFault, type ScopedFault } from './model.js';
import { faultTrend, faultTrendAnalysis, parseFaultTrendQuery } from './trend.js';
import { createFaultRouter } from './index.js';
import type { AuthUser } from '../../auth/types.js';
const at = '2026-10-13T08:00:00Z';
const row = (id: string, reported: string | null, status: string, archived: string | null = null): ScopedFault => mapFault({ id, reported_at: reported, handling_status: status, archived_at: archived, matched_vehicle_id: 'v1' }, new Map());
const rows = [row('a', '2026-09-30 23:59:59', '已归档', '2026-10-12 12:00:00'),
row('b', '2026-10-01 00:00:00', '处理中'), row('c', '2026-10-01 08:00:00', '挂起'),
row('d', '2026-10-03 23:59:59', '待处理'), row('before', '2026-09-29 23:59:59', '已归档', '2026-10-02 00:00:00'),
row('after', '2026-10-04 00:00:00', '已归档'), row('missing', null, '已归档')];
const query = (params: Record<string, string> = {}) => parseFaultTrendQuery({ start: '2026-09-30', end: '2026-10-03', ...params });
test('按提报日统计同批新增的当前闭环情况,跨期归档仍属于原提报批次,空日补零', () => {
const data = faultTrend(rows, query(), at);
assert.deepEqual(data.totals, { reported: 4, closed: 1, unclosed: 3 });
assert.deepEqual(data.periods.map(p => [p.period, p.reported, p.closed, p.unclosed]), [
['2026-09-30', 1, 1, 0], ['2026-10-01', 2, 0, 2], ['2026-10-02', 0, 0, 0], ['2026-10-03', 1, 0, 1],
]);
for (const period of data.periods) assert.equal(period.reported, period.closed + period.unclosed);
assert.equal(data.periods.reduce((sum, p) => sum + p.reported, 0), data.totals.reported);
});
test('按月不扩展所选首尾边界,日月总数一致;汇总与各期下钻匹配', () => {
const data = faultTrend(rows, query({ granularity: 'month' }), at);
assert.deepEqual(data.totals, faultTrend(rows, query(), at).totals);
assert.deepEqual(data.periods.map(p => [p.period, p.reported]), [['2026-09', 1], ['2026-10', 3]]);
for (const granularity of ['day', 'month']) {
for (const period of faultTrend(rows, query({ granularity }), at).periods) {
for (const [status, key] of [['all', 'reported'], ['closed', 'closed'], ['unclosed', 'unclosed']] as const) {
assert.equal(faultTrendAnalysis(rows, query({ granularity, period: period.period, status }), at).rows.length, period[key]);
}
}
}
});
test('空范围、闰日、非法周期及370天范围限制', () => {
assert.equal(faultTrend([], query(), at).periods.length, 4);
assert.deepEqual(faultTrend([], query(), at).totals, { reported: 0, closed: 0, unclosed: 0 });
assert.doesNotThrow(() => query({ start: '2024-02-29', end: '2024-02-29' }));
const invalid: Record<string, string>[] = [{ start: '2026-02-30' }, { end: '2026-01-01' }, { granularity: 'year' }, { status: '__proto__' },
{ period: '2026-09-29' }, { granularity: 'month', period: '2026-13' }, { start: '2025-01-01' }];
for (const params of invalid) assert.throws(() => query(params));
});
test('趋势和下钻都先应用人员权限,未认证及无效参数不查询数据库', async () => {
let calls = 0;
const own = { ...rows[0], managerId: 'mine' }, hidden = { ...rows[1], managerId: 'secret', code: 'PRIVATE' };
const router = createFaultRouter(async () => { calls++; return { updatedAt: at, rows: [own, hidden] }; });
assert.equal((await router.request('/trend')).status, 401);
assert.equal((await router.request('/trend-analysis')).status, 401);
const user: AuthUser = { userId: 'mine', userName: 'User', loginName: 'user', depCode: '', depName: '', permissionLevel: 'personal', roles: [] };
const app = new Hono<{ Variables: { user: AuthUser } }>();
app.use('*', async (c, next) => { c.set('user', user); await next(); }); app.route('/faults', router);
assert.equal((await app.request('/faults/trend?start=bad')).status, 400); assert.equal(calls, 0);
const params = '?start=2026-09-30&end=2026-10-03&granularity=month';
const response = await app.request(`/faults/trend${params}`);
assert.equal(response.headers.get('Cache-Control'), 'no-store');
const data = await response.json(); assert.equal(data.totals.reported, 1);
const detail = await (await app.request(`/faults/trend-analysis${params}&period=2026-09&status=closed`)).json();
assert.equal(detail.rows.length, 1); assert.ok(!JSON.stringify(detail).includes('PRIVATE'));
});
+42
View File
@@ -0,0 +1,42 @@
import type { FaultTrendQuery, FaultTrendResponse } from '../../../shared/assets/faults.js';
import { faultAnalysis, parseFaultQuery, type ScopedFault } from './model.js';
export function parseFaultTrendQuery(params: Record<string, string>): FaultTrendQuery {
const defaults = parseFaultQuery({});
const start = params.start || defaults.start, end = params.end || defaults.end;
const valid = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
if (!valid(start) || !valid(end) || start > end || Date.parse(end) - Date.parse(start) > 369 * 86400000) throw new Error('请选择有效日期范围,最多370天');
const granularity = params.granularity || 'day';
if (granularity !== 'day' && granularity !== 'month') throw new Error('无效时间粒度');
const status = params.status || 'all';
if (!['all', 'closed', 'unclosed'].includes(status)) throw new Error('无效闭环状态');
const length = granularity === 'day' ? 10 : 7;
if (params.period && (params.period.length !== length || params.period < start.slice(0, length) || params.period > end.slice(0, length)
|| !(granularity === 'day' ? valid(params.period) : valid(`${params.period}-01`)))) throw new Error('统计周期超出所选范围');
return { start, end, granularity, status: status as FaultTrendQuery['status'], period: params.period };
}
export function selectFaultCohort(rows: ScopedFault[], query: FaultTrendQuery) {
return rows.filter(row => {
const day = row.reportedAt?.slice(0, 10);
return day && day >= query.start && day <= query.end
&& (!query.period || day.slice(0, query.granularity === 'day' ? 10 : 7) === query.period)
&& (query.status === 'all' || (row.status === '已归档') === (query.status === 'closed'));
});
}
export function faultTrend(rows: ScopedFault[], query: FaultTrendQuery, updatedAt: string): FaultTrendResponse {
const periods = new Map<string, FaultTrendResponse['periods'][number]>();
for (let time = Date.parse(query.start); time <= Date.parse(query.end); time += 86400000) {
const period = new Date(time).toISOString().slice(0, query.granularity === 'day' ? 10 : 7);
if (!periods.has(period)) periods.set(period, { period, reported: 0, closed: 0, unclosed: 0 });
}
const totals = { reported: 0, closed: 0, unclosed: 0 };
for (const row of selectFaultCohort(rows, { ...query, status: 'all', period: undefined })) {
const period = periods.get(row.reportedAt!.slice(0, query.granularity === 'day' ? 10 : 7))!;
const status = row.status === '已归档' ? 'closed' : 'unclosed';
period.reported++; period[status]++; totals.reported++; totals[status]++;
}
return { start: query.start, end: query.end, granularity: query.granularity, updatedAt, totals, periods: [...periods.values()] };
}
export function faultTrendAnalysis(rows: ScopedFault[], query: FaultTrendQuery, updatedAt: string) {
return faultAnalysis(selectFaultCohort(rows, query), 'ledger', updatedAt);
}
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Hono } from 'hono';
import { mapFault, type ScopedFault } from './model.js';
import { faultWeeklyReport, faultWeeklyAnalysis, parseFaultWeeklyQuery } from './weekly-report.js';
import { createFaultRouter } from './index.js';
import type { AuthUser } from '../../auth/types.js';
const at = '2026-10-13T08:00:00Z';
const query = (params: Record<string, string> = {}) => parseFaultWeeklyQuery({ start: '2026-10-10', end: '2026-10-16', ...params });
const fault = (id: string, fields: Record<string, unknown> = {}): ScopedFault => mapFault({ id, matched_vehicle_id: 'v1', ...fields }, new Map());
const rows = [fault('old', { reported_at: '2026-09-01 08:00:00', received_at: '2026-10-10 00:00:00', archived_at: '2026-10-13 23:59:59', handling_status: '已归档' }),
fault('new', { reported_at: '2026-10-10 23:59:59', received_at: '2026-10-11 00:00:00', handling_status: '挂起' }),
fault('missing', { handling_status: '处理中' }),
fault('before', { received_at: '2026-10-09 23:59:59' }),
fault('after', { reported_at: '2026-10-17 00:00:00', received_at: '2026-10-17 00:00:00' }),
fault('not-archived', { archived_at: '2026-10-12 08:00:00', handling_status: '处理中' })];
test('周报按各自事件日期独立统计,历史提报可本周接收与闭环,缺事件时间不推断', () => {
const result = faultWeeklyReport(rows, query(), at);
assert.deepEqual(result.totals, { reported: 1, received: 2, closed: 1 });
assert.equal(result.days.length, 7);
assert.deepEqual(result.days[0], { date: '2026-10-10', reported: 1, received: 1, closed: 0 });
assert.equal(result.days[1].received, 1); assert.equal(result.days[3].closed, 1);
assert.deepEqual(result.days[6], { date: '2026-10-16', reported: 0, received: 0, closed: 0 });
for (const metric of ['reported', 'received', 'closed'] as const) {
assert.equal(result.totals[metric], result.days.reduce((n, day) => n + day[metric], 0));
assert.equal(faultWeeklyAnalysis(rows, query({ metric }), at).rows.length, result.totals[metric]);
for (const day of result.days) assert.equal(faultWeeklyAnalysis(rows, query({ metric, day: day.date }), at).rows.length, day[metric]);
}
assert.equal(faultWeeklyAnalysis(rows, query({ metric: 'received', day: '2026-10-11' }), at).rows[0].receivedAt, '2026-10-11 00:00:00');
});
test('周报时间与指标校验,自选单日和无数据日期补零', () => {
const invalid: Record<string, string>[] = [{ metric: '__proto__' }, { start: 'bad' }, { end: '2026-01-01' }, { day: '2026-10-17' }, { day: '2026-10' }];
for (const params of invalid) assert.throws(() => query(params));
const single = faultWeeklyReport([], query({ start: '2026-10-13', end: '2026-10-13' }), at);
assert.deepEqual(single.days, [{ date: '2026-10-13', reported: 0, received: 0, closed: 0 }]);
});
test('周报汇总和接收处理下钻先过滤权限,未认证与无效参数不查询', async () => {
let calls = 0;
const router = createFaultRouter(async () => { calls++; return { updatedAt: at, rows: [{ ...rows[0], managerId: 'mine' }, { ...rows[1], managerId: 'other', code: 'PRIVATE' }] }; });
for (const path of ['/weekly-report', '/weekly-report-analysis']) assert.equal((await router.request(path)).status, 401);
const user: AuthUser = { userId: 'mine', userName: 'User', loginName: 'user', depCode: '', depName: '', permissionLevel: 'personal', roles: [] };
const app = new Hono<{ Variables: { user: AuthUser } }>();
app.use('*', async (c, next) => { c.set('user', user); await next(); }); app.route('/faults', router);
assert.equal((await app.request('/faults/weekly-report?start=bad')).status, 400); assert.equal(calls, 0);
const params = '?start=2026-10-10&end=2026-10-16&metric=received';
const response = await app.request(`/faults/weekly-report${params}`);
assert.equal(response.headers.get('Cache-Control'), 'no-store');
const data = await response.json(); assert.equal(data.totals.received, 1);
const detail = await (await app.request(`/faults/weekly-report-analysis${params}`)).json();
assert.equal(detail.rows.length, 1); assert.ok(!JSON.stringify(detail).includes('PRIVATE'));
});
@@ -0,0 +1,31 @@
import { FAULT_WEEKLY_METRICS, type FaultWeeklyQuery, type FaultWeeklyResponse } from '../../../shared/assets/faults.js';
import { faultAnalysis, type ScopedFault } from './model.js';
import { parseFaultTrendQuery } from './trend.js';
export function parseFaultWeeklyQuery(params: Record<string, string>): FaultWeeklyQuery {
const range = parseFaultTrendQuery({ start: params.start, end: params.end, granularity: 'day', period: params.day });
const metric = params.metric || 'reported';
if (!Object.hasOwn(FAULT_WEEKLY_METRICS, metric)) throw new Error('无效周报指标');
return { start: range.start, end: range.end, day: range.period, metric: metric as FaultWeeklyQuery['metric'] };
}
function eventDay(row: ScopedFault, metric: FaultWeeklyQuery['metric']) {
return (metric === 'reported' ? row.reportedAt : metric === 'received' ? row.receivedAt : row.status === '已归档' ? row.archivedAt : null)?.slice(0, 10);
}
export function faultWeeklyReport(rows: ScopedFault[], query: FaultWeeklyQuery, updatedAt: string): FaultWeeklyResponse {
const days = new Map<string, FaultWeeklyResponse['days'][number]>();
for (let time = Date.parse(query.start); time <= Date.parse(query.end); time += 86400000) {
const date = new Date(time).toISOString().slice(0, 10); days.set(date, { date, reported: 0, received: 0, closed: 0 });
}
const totals = { reported: 0, received: 0, closed: 0 };
for (const row of rows) for (const metric of Object.keys(FAULT_WEEKLY_METRICS) as FaultWeeklyQuery['metric'][]) {
const day = days.get(eventDay(row, metric) || '');
if (day) { day[metric]++; totals[metric]++; }
}
return { start: query.start, end: query.end, updatedAt, totals, days: [...days.values()] };
}
export function faultWeeklyAnalysis(rows: ScopedFault[], query: FaultWeeklyQuery, updatedAt: string) {
return faultAnalysis(rows.filter(row => {
const date = eventDay(row, query.metric);
return date && date >= query.start && date <= query.end && (!query.day || date === query.day);
}), 'ledger', updatedAt);
}
+84
View File
@@ -0,0 +1,84 @@
import type { AssetAnalysisCell } from './analytics';
export const FAULT_DIMENSIONS = {
city: '运营城市', status: '故障状态', inventory: '库存状态',
customer: '客户名称', department: '业务部门', handler: '运维处置人',
} as const;
export type FaultDimension = keyof typeof FAULT_DIMENSIONS;
export type FaultMetric = 'reported' | 'closed' | 'open';
export interface FaultOption { value: string; label: string }
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 {
id: string; code: string; plate: string; description: string;
reportedAt: string | null; archivedAt: string | null; deadlineAt: string | null; receivedAt: string | null;
city: string; status: string; inventory: string;
customer: string; customerLabel: string; department: string;
handlers: FaultOption[];
level?: string; categories?: string[]; progress?: string; reportMethod?: FaultReportMethod;
}
export interface FaultQuery {
start: string; end: string; groupBy: FaultDimension;
filters: Partial<Record<FaultDimension, string>>;
metric: FaultMetric; day?: string; groupValue?: string; page: number;
subject?: string;
}
export interface FaultResponse {
start: string; end: string; updatedAt: string;
totals: FaultCounts;
daily: { date: string; reported: number; closed: number }[];
groups: (FaultOption & FaultCounts)[];
options: Record<FaultDimension, FaultOption[]>;
quality: { missingReport: number; invalidChronology: number; missingVehicle: number };
details: { rows: FaultRecord[]; total: number; page: number; pageSize: number };
}
export const FAULT_OVERVIEW_METRICS = { reported: '今日新增', closed: '本日解决', open: '当前在办', waiting: '待处理', processing: '处理中', suspended: '挂起', overdue: '逾期', ledger: '累计台账' } as const;
export type FaultOverviewMetric = keyof typeof FAULT_OVERVIEW_METRICS;
export interface FaultOverview {
date: string; updatedAt: string;
totals: Record<FaultOverviewMetric, number>;
}
export interface FaultAnalysisRecord extends FaultRecord { cell: AssetAnalysisCell }
export interface FaultAnalysisResponse {
date: string; updatedAt: string; metric: FaultOverviewMetric; rows: FaultAnalysisRecord[];
customerLabels: Record<string, string>;
}
export type FaultTrendStatus = 'all' | 'closed' | 'unclosed';
export interface FaultTrendQuery {
start: string; end: string; granularity: 'day' | 'month';
status: FaultTrendStatus; period?: string;
}
export interface FaultTrendCounts { reported: number; closed: number; unclosed: number }
export interface FaultTrendResponse {
start: string; end: string; granularity: 'day' | 'month'; updatedAt: string;
totals: FaultTrendCounts;
periods: (FaultTrendCounts & { period: string })[];
}
export const FAULT_WEEKLY_METRICS = { reported: '新增故障', received: '接收处理数量', closed: '解决故障' } as const;
export type FaultWeeklyMetric = keyof typeof FAULT_WEEKLY_METRICS;
export interface FaultWeeklyQuery { start: string; end: string; metric: FaultWeeklyMetric; day?: string }
export interface FaultWeeklyResponse {
start: string; end: string; updatedAt: string;
totals: Record<FaultWeeklyMetric, number>;
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 };
}
+18
View File
@@ -63,3 +63,21 @@ export function exportJsonSheet(
): void {
writeWorkbook([{ name: sheetName, sheet: buildJsonSheet(rows) }], fileName);
}
/** Styled reports share the workbook/download primitives while owning their sheet layout. */
export async function createStyledWorkbook() {
const { default: ExcelJS } = await import('exceljs');
return new ExcelJS.Workbook();
}
export async function downloadStyledWorkbook(book: import('exceljs').Workbook, fileName: string): Promise<void> {
const bytes = await book.xlsx.writeBuffer();
const url = URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }));
const link = document.createElement('a');
link.href = url;
link.download = ensureXlsxFilename(fileName);
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 10000);
}