3 Commits
Author SHA1 Message Date
shishengliang 5751d1af15 fix(assets): emphasize daily flow details and sort newest first
ci/woodpecker/push/woodpecker Pipeline was successful
2026-10-10 21:56:06 +08:00
shishengliang 24984fe033 fix(assets): show inventory operations managers and bump v1.1.19 2026-10-10 21:56:06 +08:00
shishengliang 7dd7e67602 fix(energy): connect Foshan external vehicle hydrogen orders
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-29 10:03:45 +08:00
49 changed files with 227 additions and 4444 deletions
-81
View File
@@ -1,81 +0,0 @@
# 故障管理
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 按导出操作动态加载。
+1 -2067
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -22,7 +22,6 @@
"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",
+1 -2
View File
@@ -12,10 +12,9 @@ function heatmapChildIds(roles?: string[]): string[] {
}
test('keeps the base asset navigation and role-gated scheduling module', () => {
assert.deepEqual(moduleIds(), ['assets', 'faults', 'mileage', 'heatmap']);
assert.deepEqual(moduleIds(), ['assets', 'mileage', 'heatmap']);
assert.deepEqual(moduleIds(['BI-SCHEDULE-OPT']), [
'assets',
'faults',
'mileage',
'heatmap',
'scheduling',
-3
View File
@@ -7,14 +7,12 @@ import {
Receipt,
Route,
Truck,
Wrench,
} from 'lucide-react';
import type { ModuleConfig } from '../components/Shell';
import { canAccessEnergy, canAccessScheduling } from '../shared/auth/roles';
import type { PathSet } from './routing';
const AssetsModule = lazy(() => import('../modules/assets/AssetsModule'));
const FaultManagement = lazy(() => import('../modules/assets/faults/FaultManagement'));
const MileageModule = lazy(() => import('../modules/mileage/MileageModule'));
const SchedulingModule = lazy(() => import('../modules/scheduling/SchedulingModule'));
const HydrogenModule = lazy(() => import('../modules/energy/HydrogenModule'));
@@ -25,7 +23,6 @@ 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 },
];
@@ -1,71 +0,0 @@
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>;
}
@@ -1,40 +0,0 @@
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>;
}
@@ -1,157 +0,0 @@
/* 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, dateLabel = '运维签章时间' }: { value: DateRange; onChange: (range: DateRange) => void; dateLabel?: string }) {
export function FlowDateRangePicker({ value, onChange }: { value: DateRange; onChange: (range: DateRange) => void }) {
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, dateLabel = '运维签章
className="mt-3 flex w-full items-center gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-left shadow-sm transition hover:border-blue-300 hover:bg-blue-50/40 focus-visible:outline-2 focus-visible:outline-blue-500">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600"><CalendarDays size={18} /></span>
<span className="min-w-0 flex-1">
<span className="block text-[10px] font-medium text-slate-400">统计日期 · {dateLabel}</span>
<span className="block text-[10px] font-medium text-slate-400">统计日期 · 运维签章时间</span>
<span className="mt-1 flex items-center gap-2 text-[13px] font-bold text-slate-700"><span>{value.start.replaceAll('-', '/')}</span><ArrowRight size={13} className="shrink-0 text-slate-300" /><span>{value.end.replaceAll('-', '/')}</span></span>
</span>
<span className="hidden rounded-lg bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-500 sm:block">{dayCount(value.start, value.end)} 天</span>
@@ -1,16 +0,0 @@
/** 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>;
}
@@ -1,100 +0,0 @@
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[] }
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); }).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>;
}
@@ -1,64 +0,0 @@
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 default function FaultManagement() {
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(setData).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">
<header className="fault-page-header"><h1>羚牛氢能-故障管理</h1><div><span>{data ? `数据更新时间:${new Date(data.updatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}` : '正在读取故障台账'}</span><button aria-label="刷新故障统计" disabled={loading} onClick={() => setRevision(v => v + 1)}><RefreshCw size={14} className={loading ? 'animate-spin' : ''} />刷新</button></div></header>
{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 className="fault-overdue-prefix">其中</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>;
}
@@ -1,70 +0,0 @@
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 }: { refresh: number }) {
const [range, setRange] = useState(initialRange);
const [granularity, setGranularity] = useState<'day' | 'month'>('day');
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 });
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]);
const drill = (status: FaultTrendStatus, period?: string) => {
if (!data || loading) return;
const params = new URLSearchParams({ start: data.start, end: data.end, granularity: data.granularity, status });
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 }, 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="#10b981" stackId="faults" maxBarSize={34} isAnimationActive={false} />
<Bar dataKey="unclosed" name="未闭环" cursor="pointer" onClick={bar => { const period = data.periods.find(row => row.period === bar.payload?.period); if (period?.unclosed) drill('unclosed', period.period); }} fill="#f59e0b" stackId="faults" maxBarSize={34} isAnimationActive={false} />
</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>;
}
@@ -1,65 +0,0 @@
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 }: { refresh: number }) {
const [range, setRange] = useState(() => getWeeklyFlowRange());
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 })}`, { 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]);
const drill = (metric: FaultWeeklyMetric, day?: string) => {
if (!data || loading) return;
const params = new URLSearchParams({ start: data.start, end: data.end, metric });
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 }, chartRef.current?.querySelector<SVGSVGElement>('svg.recharts-surface[role=application]') || undefined) : undefined} /></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">{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>;
}
@@ -1,77 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { mapFault, faultHandlers } from '../../../server/routes/asset-faults/model.js';
import type { FaultAnalysisRecord, FaultOption } from '../../../shared/assets/faults';
import { analyzeFaultRecords, filterFaultRecords, faultDimensionValues, faultParentTotals, FAULT_ANALYSIS_DIMENSIONS, FAULT_ANALYSIS_LABELS } from './analysis-model.js';
function fault(id: string, handlers: FaultOption[], manager = '客户业务经理', city = '上海市'): FaultAnalysisRecord {
const row = mapFault({ id, business_manager: manager, city_name: city }, new Map());
return { ...row, handlers, cell: row.assetCell };
}
const a = { value: 'id:1234567890123456789', label: '处置甲' };
const b = { value: 'id:2234567890123456789', label: '处置乙' };
const data = [fault('shared', [a, b, a]), fault('single', [a]), fault('none', [])];
test('运维处置人是独立维度,未分配不以业务负责人兜底', () => {
assert.ok(FAULT_ANALYSIS_DIMENSIONS.includes('handler')); assert.ok(FAULT_ANALYSIS_DIMENSIONS.includes('manager'));
assert.equal(FAULT_ANALYSIS_LABELS.handler, '运维处置人');
assert.deepEqual(faultDimensionValues(data[0], 'handler'), [a.value, b.value]);
assert.deepEqual(faultDimensionValues(data[0], 'manager'), ['客户业务经理']);
assert.deepEqual(faultDimensionValues(data[2], 'handler'), ['unassigned']);
assert.deepEqual(faultHandlers({ business_manager: '客户业务经理' }, new Map()), [{ value: 'unassigned', label: '未指派' }]);
});
test('多人各自成组,总数和组内明细按故障ID去重,父级小计取并集', () => {
const result = analyzeFaultRecords([...data, data[0]], ['city', 'handler']);
assert.equal(result.total, 3);
assert.equal(result.rows.find(row => row.values.handler === a.value)?.count, 2);
assert.equal(result.rows.find(row => row.values.handler === b.value)?.count, 1);
assert.equal(result.rows.reduce((n, row) => n + row.count, 0), 4);
assert.ok(faultParentTotals(result.rows, ['city', 'handler']).every(row => row[0] === 3));
for (const group of result.rows) assert.equal(new Set(group.recordIds).size, group.count);
});
test('多选处置人取并集,不分组时不放大;筛选甲后不出现共享故障的乙分组', () => {
assert.equal(filterFaultRecords(data, { handler: [a.value, b.value] }).length, 2);
assert.equal(analyzeFaultRecords(data, ['city'], { handler: [a.value, b.value] }).rows[0].count, 2);
const result = analyzeFaultRecords(data, ['handler'], { handler: [a.value] });
assert.equal(result.total, 2); assert.equal(result.rows.length, 1); assert.equal(result.rows[0].values.handler, a.value);
assert.equal(filterFaultRecords(data, { handler: [a.value], manager: ['其他业务经理'] }).length, 0);
assert.equal(filterFaultRecords(data, { handler: ['unassigned'] })[0].id, 'none');
});
test('同名处置人保持ID独立,组合筛选与组明细对应,空数据安全', () => {
const rows = [fault('one', [{ ...a, label: '同名' }]), fault('two', [{ ...b, label: '同名' }])];
const result = analyzeFaultRecords(rows, ['manager', 'handler']);
assert.equal(result.rows.length, 2);
for (const group of result.rows) {
const matched = filterFaultRecords(rows, { manager: [group.values.manager!], handler: [group.values.handler!] });
assert.deepEqual(matched.map(row => row.id), group.recordIds);
}
assert.deepEqual(analyzeFaultRecords([], ['handler']), { total: 0, rows: [] });
});
test('故障工单状态按归档闭环,与车辆运营状态独立;组合分组、筛选和去重口径一致', () => {
const states = ['已归档', '待处理', '处理中', '挂起', '未知状态'];
const rows = states.map((status, i) => ({ ...fault(String(i), i === 0 ? [a, b] : [a]), status }));
assert.deepEqual(rows.map(row => faultDimensionValues(row, 'faultStatus')), [['closed'], ['unclosed'], ['unclosed'], ['unclosed'], ['unclosed']]);
const result = analyzeFaultRecords(rows, ['handler', 'faultStatus']);
assert.equal(result.total, 5);
assert.equal(result.rows.find(row => row.values.handler === a.value && row.values.faultStatus === 'closed')?.count, 1);
assert.equal(result.rows.find(row => row.values.handler === a.value && row.values.faultStatus === 'unclosed')?.count, 4);
const parentTotals = faultParentTotals(result.rows, ['handler', 'faultStatus']);
result.rows.forEach((row, i) => assert.equal(parentTotals[i][0], row.values.handler === a.value ? 5 : 1));
assert.deepEqual(filterFaultRecords(rows, { faultStatus: ['closed'], handler: [a.value, b.value] }).map(row => row.id), ['0']);
assert.equal(filterFaultRecords(rows, { faultStatus: ['closed', 'unclosed'] }).length, 5);
assert.deepEqual(filterFaultRecords(rows, { faultStatus: ['unclosed'], handler: [b.value] }), []);
for (const group of result.rows) assert.deepEqual(filterFaultRecords(rows, { handler: [group.values.handler!], faultStatus: [group.values.faultStatus!] }).map(row => row.id), group.recordIds);
});
test('车牌位于第三个维度,同车多故障按单计数,支持组合筛选和缺失车牌', () => {
assert.equal(FAULT_ANALYSIS_DIMENSIONS[2], 'plate');
const rows = [{ ...fault('1', [a]), plate: '沪A12345' }, { ...fault('2', [b]), plate: '沪A12345' }, { ...fault('3', [a]), plate: '粤A12345' }, { ...fault('4', []), plate: '' }];
const grouped = analyzeFaultRecords(rows, ['plate']);
assert.equal(grouped.total, 4);
assert.equal(grouped.rows.find(row => row.values.plate === '沪A12345')?.count, 2);
assert.deepEqual(filterFaultRecords(rows, { plate: ['沪A12345'], handler: [b.value] }).map(row => row.id), ['2']);
assert.equal(filterFaultRecords(rows, { plate: ['沪A12345', '粤A12345'] }).length, 3);
assert.deepEqual(filterFaultRecords(rows, { plate: ['未关联车辆'] }).map(row => row.id), ['4']);
});
@@ -1,50 +0,0 @@
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));
}
@@ -1,221 +0,0 @@
.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; } }
@@ -1,60 +0,0 @@
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);
}
});
-140
View File
@@ -1,140 +0,0 @@
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 };
interface ReportChartImage { dataUrl: string; width: number; height: number }
const palette = { navy: '1E293B', text: '334155', muted: '64748B', blue: '2563EB', green: '059669', amber: 'D97706', line: 'E2E8F0', stripe: 'F8FAFC', today: 'EFF6FF', white: 'FFFFFF' };
const fill = (color: string) => ({ type: 'pattern' as const, pattern: 'solid' as const, fgColor: { argb: `FF${color}` } });
const excelDate = (date: string) => new Date(`${date.length === 7 ? `${date}-01` : date}T00:00:00Z`);
const beijingTime = (date: string | Date) => new Date(date).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
function merged(sheet: Worksheet, row: number, from: number, to: number, value: Cell['value']) {
if (to > from) sheet.mergeCells(row, from, row, to);
const cell = sheet.getCell(row, from); cell.value = value; return cell;
}
/** Browser-independent workbook builder; export the displayed authorized snapshot without requerying. */
export async function createFaultReportWorkbook(report: FaultReport, chartImage?: ReportChartImage, now = new Date()) {
const ExcelJS = (await import('exceljs')).default;
const book = new ExcelJS.Workbook();
book.creator = '羚牛氢能 BI'; book.created = now; book.modified = now;
const trend = report.kind === 'trend';
const title = trend ? '故障处理趋势' : '故障运维周报';
const headers = trend ? [report.data.granularity === 'month' ? '提报月份' : '提报日期', '新增故障(单)', '已闭环(单)', '未闭环(单)', '闭环率'] : ['日期', '星期', '新增故障(单)', '解决故障(单)'];
const columns = headers.length;
const sheet = book.addWorksheet(title, { properties: { defaultRowHeight: 22 }, pageSetup: { paperSize: 9, orientation: 'portrait', fitToPage: true, fitToWidth: 1, fitToHeight: 0, horizontalCentered: true, margins: { left: .3, right: .3, top: .4, bottom: .4, header: .2, footer: .2 } } });
sheet.columns = (trend ? [24, 19, 19, 19, 18] : [26, 16, 25, 25]).map(width => ({ width }));
const headerRow = 9;
const periods = trend ? [...report.data.periods].reverse() : [...report.data.days].reverse();
const start = headerRow + 1, end = headerRow + periods.length, totalRow = end + 1;
const today = new Date(now.getTime() + 8 * 3600000).toISOString().slice(0, 10);
const bottom = totalRow + 3;
for (let row = 1; row <= bottom; row++) for (let col = 1; col <= columns; col++) {
const cell = sheet.getCell(row, col);
cell.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.text}` } };
cell.alignment = { vertical: 'middle', horizontal: col === 1 ? 'left' : 'center' };
}
const titleCell = merged(sheet, 1, 1, columns, `羚牛氢能 · ${title}`);
titleCell.font = { name: 'Microsoft YaHei', size: 22, bold: true, color: { argb: 'FFFFFFFF' } };
titleCell.fill = fill(palette.navy); titleCell.alignment = { vertical: 'middle', indent: 1 }; sheet.getRow(1).height = 48;
merged(sheet, 2, 1, columns, `统计范围:${report.data.start} 至 ${report.data.end} · ${trend && report.data.granularity === 'month' ? '按月' : '按日'} · 单位:单`);
const stamp = merged(sheet, 3, 1, columns, `数据时间:${beijingTime(report.data.updatedAt)}(北京时间)`); stamp.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } };
sheet.getRow(2).height = 28; sheet.getRow(4).height = 10; sheet.getRow(7).height = 10;
const sum = (column: string) => periods.length ? `SUM(${column}${start}:${column}${end})` : '0';
const cards = trend
? [{ from: 1, to: 2, label: '新增故障', col: 'B', value: report.data.totals.reported, color: palette.blue }, { from: 3, to: 4, label: '已闭环', col: 'C', value: report.data.totals.closed, color: palette.green }, { from: 5, to: 5, label: '未闭环', col: 'D', value: report.data.totals.unclosed, color: palette.amber }]
: [{ from: 1, to: 2, label: '新增故障', col: 'C', value: report.data.totals.reported, color: palette.blue }, { from: 3, to: 4, label: '解决故障', col: 'D', value: report.data.totals.closed, color: palette.green }];
for (const card of cards) {
const label = merged(sheet, 5, card.from, card.to, card.label);
const value = merged(sheet, 6, card.from, card.to, { formula: sum(card.col), result: card.value });
for (const cell of [label, value]) { cell.fill = fill(palette.stripe); cell.alignment = { horizontal: 'center', vertical: 'middle' }; }
label.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.muted}` } };
value.font = { name: 'Microsoft YaHei', size: 26, bold: true, color: { argb: `FF${card.color}` } }; value.numFmt = '#,##0';
}
sheet.getRow(5).height = 25; sheet.getRow(6).height = 40;
if (chartImage) {
const chart = book.addWorksheet('图表', { views: [{ showGridLines: false }], pageSetup: { paperSize: 9, orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 1 } });
chart.columns = headers.map(() => ({ width: 900 / columns / 7 }));
chart.properties.defaultRowHeight = 24;
const heading = merged(chart, 1, 1, columns, `羚牛氢能 · ${title}`);
heading.font = { name: 'Microsoft YaHei', size: 22, bold: true, color: { argb: 'FFFFFFFF' } }; heading.fill = fill(palette.navy); heading.alignment = { vertical: 'middle', indent: 1 }; chart.getRow(1).height = 48;
const period = merged(chart, 2, 1, columns, `${report.data.start} 至 ${report.data.end} · ${trend && report.data.granularity === 'month' ? '按月' : '按日'} · 单位:单`);
period.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.text}` } };
const key = merged(chart, 3, 1, columns, trend ? '绿色:已闭环 橙色:未闭环 柱总高为新增故障数' : '蓝色:新增故障 绿色:解决故障');
key.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } };
const id = book.addImage({ base64: chartImage.dataUrl, extension: 'png' });
const scale = Math.min(900 / chartImage.width, 360 / chartImage.height);
const width = chartImage.width * scale, height = chartImage.height * scale;
// Keep circles and labels proportional for both desktop and mobile snapshots.
chart.addImage(id, { tl: { col: (900 - width) / 2 / (900 / columns), row: 4 + (360 - height) / 64 }, ext: { width, height }, editAs: 'oneCell' });
const caption = merged(chart, 17, 1, columns, `图表为当前页面快照,精确数量见「${title}」工作表。`);
caption.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } };
chart.pageSetup.printArea = `A1:${String.fromCharCode(64 + columns)}17`;
}
headers.forEach((label, i) => { const cell = sheet.getCell(headerRow, i + 1); cell.value = label; cell.fill = fill(palette.navy); cell.font = { name: 'Microsoft YaHei', size: 11, bold: true, color: { argb: 'FFFFFFFF' } }; });
sheet.getRow(headerRow).height = 30;
if (trend) [...report.data.periods].reverse().forEach((row, i) => {
const n = start + i;
sheet.getRow(n).values = [excelDate(row.period), row.reported, row.closed, row.unclosed, { formula: `IFERROR(C${n}/B${n},0)`, result: row.reported ? row.closed / row.reported : 0 }];
sheet.getCell(n, 1).numFmt = report.data.granularity === 'month' ? 'yyyy-mm' : 'yyyy-mm-dd';
sheet.getCell(n, 5).numFmt = '0.0%;-0.0%;"—"';
});
else [...report.data.days].reverse().forEach((row, i) => {
sheet.getRow(start + i).values = [excelDate(row.date), excelDate(row.date).toLocaleDateString('zh-CN', { weekday: 'short', timeZone: 'UTC' }), row.reported, row.closed];
sheet.getCell(start + i, 1).numFmt = row.date === today ? 'yyyy-mm-dd" 今天"' : 'yyyy-mm-dd';
});
for (let row = start; row <= end; row++) {
const date = sheet.getCell(row, 1).value as Date;
const highlight = date.toISOString().slice(0, 10) === today && (!trend || report.data.granularity === 'day');
sheet.getRow(row).height = 26;
for (let col = 1; col <= columns; col++) {
const cell = sheet.getCell(row, col); cell.fill = fill(highlight ? palette.today : (row - start) % 2 ? palette.stripe : palette.white);
cell.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.text}` } };
cell.alignment = { vertical: 'middle', horizontal: col === 1 ? 'left' : 'center' };
cell.border = { bottom: { style: 'hair', color: { argb: `FF${palette.line}` } } };
if (col > (trend ? 1 : 2) && !(trend && col === 5)) {
cell.numFmt = '#,##0'; const color = trend ? [palette.blue, palette.green, palette.amber][col - 2] : col === 3 ? palette.blue : palette.green;
cell.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${color}` } };
}
}
}
sheet.getCell(totalRow, 1).value = '合计';
cards.forEach(card => { sheet.getCell(`${card.col}${totalRow}`).value = { formula: sum(card.col), result: card.value }; sheet.getCell(`${card.col}${totalRow}`).numFmt = '#,##0'; });
if (trend) { sheet.getCell(totalRow, 5).value = { formula: `IFERROR(C${totalRow}/B${totalRow},0)`, result: report.data.totals.reported ? report.data.totals.closed / report.data.totals.reported : 0 }; sheet.getCell(totalRow, 5).numFmt = '0.0%;-0.0%;"—"'; }
for (let col = 1; col <= columns; col++) { const cell = sheet.getCell(totalRow, col); cell.fill = fill('E8EFF9'); cell.font = { name: 'Microsoft YaHei', size: 11, bold: true, color: { argb: `FF${palette.navy}` } }; }
sheet.getRow(totalRow).height = 30;
const note = merged(sheet, totalRow + 2, 1, columns, trend ? '统计口径:按提报日期统计同批故障;已归档为已闭环,其余状态(含挂起)为未闭环。按月仅统计所选日期范围。' : '统计口径:新增故障按提报日期;解决故障按解决日期统计,不受提报日期限制。同一故障可在不同日期分别计入两个指标。');
note.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } }; note.alignment = { wrapText: true, vertical: 'middle' }; sheet.getRow(totalRow + 2).height = 36;
const source = merged(sheet, bottom, 1, columns, `来源:羚牛氢能 BI 当前可见统计 · 导出时间:${beijingTime(now)}(北京时间)`); source.font = { name: 'Microsoft YaHei', size: 9, color: { argb: `FF${palette.muted}` } };
sheet.autoFilter = { from: { row: headerRow, column: 1 }, to: { row: Math.max(headerRow, end), column: columns } };
sheet.views = [{ state: 'frozen', ySplit: headerRow, topLeftCell: `A${start}`, showGridLines: false }];
sheet.pageSetup.printArea = `A1:${String.fromCharCode(64 + columns)}${bottom}`;
sheet.pageSetup.printTitlesRow = `${headerRow}:${headerRow}`;
sheet.headerFooter.oddFooter = '&L羚牛氢能 · 故障管理&R第 &P 页 / 共 &N 页';
return book;
}
async function chartImage(svg: SVGSVGElement): Promise<ReportChartImage> {
const width = svg.clientWidth || Number(svg.getAttribute('width')) || 800;
const height = svg.clientHeight || Number(svg.getAttribute('height')) || 240;
const clone = svg.cloneNode(true) as SVGSVGElement;
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); clone.setAttribute('width', String(width)); clone.setAttribute('height', String(height));
clone.style.fontFamily = 'Arial, sans-serif';
const url = URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(clone)], { type: 'image/svg+xml;charset=utf-8' }));
try {
const image = new Image();
await new Promise<void>((resolve, reject) => { image.onload = () => resolve(); image.onerror = () => reject(new Error('图表生成失败')); image.src = url; });
const canvas = document.createElement('canvas'); canvas.width = Math.ceil(width * 2); canvas.height = Math.ceil(height * 2);
const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('图表生成失败');
ctx.scale(2, 2); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, width, height); ctx.drawImage(image, 0, 0, width, height);
return { dataUrl: canvas.toDataURL('image/png'), width, height };
} finally { URL.revokeObjectURL(url); }
}
export async function downloadFaultReport(report: FaultReport, svg?: SVGSVGElement) {
const png = svg ? await chartImage(svg) : undefined;
const book = await createFaultReportWorkbook(report, png);
const bytes = await book.xlsx.writeBuffer();
const url = URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }));
const link = document.createElement('a'); link.href = url;
link.download = `${report.kind === 'trend' ? `故障处理趋势_${report.data.granularity === 'day' ? '按日' : '按月'}` : '故障运维周报'}_${report.data.start}_${report.data.end}.xlsx`;
document.body.appendChild(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), 10000);
}
-12
View File
@@ -1,12 +0,0 @@
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);
}
+7 -29
View File
@@ -28,6 +28,7 @@ export default function HydrogenDaily() {
useEffect(() => {
let cancelled = false;
setError(null);
setRows(null);
const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick };
@@ -81,34 +82,13 @@ export default function HydrogenDaily() {
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper={customer === 'external' ? '佛山南海羚牛加氢站 · 未关联车辆的订单' : '当前筛选口径'} tone="emerald" />
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
</div>
{/* 外部车辆:新系统数据还没准备好 */}
{customer === 'external' && rows !== null && totalKg === 0 && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
>
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3 relative">
<Plug size={22} className="text-blue-500" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
</div>
<div className="text-sm font-bold text-slate-700 mb-1">外部车辆 · 数据未就绪</div>
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
新系统的外部车辆加氢数据还在准备中
<br />
上线后此处将展示完整明细
</div>
</motion.div>
)}
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
{/* 时段加氢量柱图 */}
{!error && trendData.length > 0 && totalKg > 0 && (
<SurfaceCard>
<div className="flex items-center justify-between px-4 pt-4 mb-2">
<span className="text-sm font-bold text-slate-700">每日加氢量</span>
@@ -184,8 +164,7 @@ export default function HydrogenDaily() {
</SurfaceCard>
)}
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
{!(customer === 'external' && rows !== null && totalKg === 0) && (
{/* 日明细:加载失败、无订单与正常数据分别展示。 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
{/* 表头 */}
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
@@ -206,8 +185,8 @@ export default function HydrogenDaily() {
<div className="p-3"><ErrorState message={error} /></div>
) : rows === null ? (
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
) : rows.length === 0 ? (
<div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div>
) : rows.length === 0 || totalKg === 0 ? (
<div className="p-3"><EmptyState title="所选时段暂无加氢数据" description={customer === 'external' ? '佛山南海羚牛加氢站在该时段没有未关联车辆的加氢订单,请切换时间范围' : '请切换时间范围或车辆归属'} /></div>
) : rows.map(r => {
const open = expanded.has(r.date);
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
@@ -270,7 +249,6 @@ export default function HydrogenDaily() {
);
})}
</div>
)}
</div>
);
+15 -14
View File
@@ -1,4 +1,3 @@
import { hasDailyMileage, recentMileageStats } from './mileage-display';
import { useEffect, useMemo, useState } from 'react';
import { motion, AnimatePresence, useDragControls } from 'motion/react';
import { X, Truck } from 'lucide-react';
@@ -98,10 +97,13 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
// 排除"今日"列(数据未到位时易引起误读)
const historyDays = useMemo(() => days.filter(d => !isToday(d.date)), [days]);
const stats = useMemo(() => recentMileageStats(historyDays), [historyDays]);
const chartDays = useMemo(() => historyDays.map(day => ({
...day, dailyKm: hasDailyMileage(day) ? day.dailyKm : null,
})), [historyDays]);
const stats = useMemo(() => {
const totalKm = historyDays.reduce((s, d) => s + d.dailyKm, 0);
const synced = historyDays.filter(d => d.isDataSynced).length;
const avg = synced > 0 ? totalKm / synced : 0;
const max = Math.max(1, ...historyDays.map(d => d.dailyKm));
return { totalKm, synced, avg, max, totalDays: historyDays.length };
}, [historyDays]);
// 骨架天数:根据区间预估
const skeletonCount = useMemo(() => {
@@ -204,14 +206,14 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
<div className="text-[9px] font-bold text-slate-400 uppercase">区间合计</div>
<div className="text-base font-black text-slate-900 leading-tight">
{loading ? <span className="inline-block h-4 w-14 bg-slate-200 rounded animate-pulse align-middle" />
: stats.synced === 0 ? '—' : <>{Math.round(stats.totalKm).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
: <>{Math.round(stats.totalKm).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
</div>
</div>
<div className="bg-slate-50 rounded-xl p-2.5">
<div className="text-[9px] font-bold text-slate-400 uppercase">日均</div>
<div className="text-base font-black text-slate-900 leading-tight">
{loading ? <span className="inline-block h-4 w-10 bg-slate-200 rounded animate-pulse align-middle" />
: stats.synced === 0 ? '—' : <>{Math.round(stats.avg).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
: <>{Math.round(stats.avg).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
</div>
</div>
<div className="bg-slate-50 rounded-xl p-2.5">
@@ -235,7 +237,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
<SkeletonBars count={Math.min(skeletonCount, 30)} />
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<BarChart data={historyDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<XAxis
dataKey="date"
tickFormatter={(d) => formatLabel(d, range)}
@@ -247,8 +249,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
/>
<YAxis hide />
<Tooltip
filterNull={false}
formatter={(v) => [v == null ? '—' : `${Math.round(Number(v)).toLocaleString()} km`, '行驶里程']}
formatter={(v) => [`${Math.round(Number(v) || 0).toLocaleString()} km`, '行驶里程']}
labelFormatter={(d) => `日期 ${d}`}
contentStyle={{ borderRadius: 12, fontSize: 11, padding: '4px 8px' }}
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
@@ -305,15 +306,15 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${hasDailyMileage(d) ? (d.dailyKm / stats.max) * 100 : 0}%` }}
animate={{ width: `${(d.dailyKm / stats.max) * 100}%` }}
transition={{ delay: Math.min(i * 0.012, 0.4) + 0.1, duration: 0.4 }}
className={`h-full rounded-full ${d.isDataSynced ? 'bg-blue-500' : 'bg-slate-200'}`}
/>
</div>
<span className={`text-[11px] font-mono font-bold w-20 text-right ${hasDailyMileage(d) ? 'text-slate-700' : 'text-slate-400'}`}>
{hasDailyMileage(d)
<span className={`text-[11px] font-mono font-bold w-20 text-right ${d.isDataSynced ? 'text-slate-700' : 'text-amber-500/60'}`}>
{d.isDataSynced
? <>{Math.round(d.dailyKm).toLocaleString()} <span className="text-[9px] text-slate-400">km</span></>
: <span>—</span>}
: <span className="text-[9px]">未对接</span>}
</span>
</div>
</motion.div>
@@ -1,23 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { monitoringMileageValue, recentMileageStats } from './mileage-display';
import type { VehicleRecentDay } from './api';
import type { MonitoringVehicle } from './types';
const day = (dailyKm: number, isDataSynced = true): VehicleRecentDay => ({
date: '2026-10-12', dailyKm, isDataSynced, sourceProtocol: null, sourceCategory: null,
});
test('详情合计、平均值和有数据天排除缺失日,保留真实零里程', () => {
assert.deepEqual(recentMileageStats([day(100), day(900, false), day(200), day(0)]), {
totalKm: 300, synced: 3, avg: 100, max: 200, totalDays: 4,
});
assert.equal(recentMileageStats([day(0, false)]).synced, 0);
});
test('列表全区间缺失时显示横杠,即使存在累计里程', () => {
const vehicle = { dailyKm: 0, isDataSynced: false, totalKm: 64413.6 } as MonitoringVehicle;
assert.equal(monitoringMileageValue(vehicle), '—');
assert.equal(monitoringMileageValue({ ...vehicle, isDataSynced: true }), 0);
assert.equal(monitoringMileageValue({ ...vehicle, isDataSynced: true, dailyKm: 5417 }), 5417);
});
-24
View File
@@ -1,24 +0,0 @@
import type { MonitoringVehicle } from './types';
import type { VehicleRecentDay } from './api';
export function monitoringMileageValue(vehicle: MonitoringVehicle): number | string {
if (vehicle.mileageAnomaly) return '数据异常';
if (!vehicle.isDataSynced) return '—';
return Math.max(0, vehicle.dailyKm || 0);
}
export function hasDailyMileage(day: VehicleRecentDay): boolean {
return day.isDataSynced && Number.isFinite(day.dailyKm) && day.dailyKm >= 0;
}
export function recentMileageStats(days: VehicleRecentDay[]) {
const validDays = days.filter(hasDailyMileage);
const totalKm = validDays.reduce((sum, day) => sum + day.dailyKm, 0);
return {
totalKm,
synced: validDays.length,
avg: validDays.length ? totalKm / validDays.length : 0,
max: Math.max(1, ...validDays.map(day => day.dailyKm)),
totalDays: days.length,
};
}
@@ -1,4 +1,3 @@
import { monitoringMileageValue } from '../../mileage-display';
import type { Dispatch, SetStateAction } from 'react';
import { ArrowDown, ArrowUp, Minimize2, RotateCcw } from 'lucide-react';
import { motion } from 'motion/react';
@@ -284,8 +283,6 @@ export default function FullscreenMonitoring({
<tbody className="divide-y divide-slate-800/30">
{fullscreenVehicles.map((v) => {
const highMileageAlert = isHighMileageAlert(v);
const mileageValue = monitoringMileageValue(v);
const hasMileage = typeof mileageValue === 'number';
const statisticTime = vehicleStatisticTime(v, relativeNow);
const sourceDisplay = vehicleSourceDisplay(v);
return (
@@ -313,7 +310,7 @@ export default function FullscreenMonitoring({
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
<td className="px-3 py-2 text-right">
<span className={`text-xs font-mono font-bold ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-400' : 'text-blue-400') : 'text-amber-400'}`}>
<>{mileageValue.toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400/70' : 'text-slate-500'}`}>{hasMileage ? 'km' : ''}</span></>
{(v.isDataSynced || v.totalKm != null) ? <>{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400/70' : 'text-slate-500'}`}>{v.mileageAnomaly ? '' : 'km'}</span></> : <span className="text-[8px] text-amber-500/50">未对接</span>}
</span>
</td>
<td className="px-3 py-2 text-right">
@@ -1,4 +1,3 @@
import { monitoringMileageValue } from '../../mileage-display';
import type { RefObject } from 'react';
import { Truck } from 'lucide-react';
import { motion } from 'motion/react';
@@ -55,8 +54,6 @@ export default function VehicleList({
<div className="grid grid-cols-1 gap-1.5">
{vehicles.map((v) => {
const highMileageAlert = isHighMileageAlert(v);
const mileageValue = monitoringMileageValue(v);
const hasMileage = typeof mileageValue === 'number';
const statisticTime = vehicleStatisticTime(v, relativeNow);
const sourceDisplay = vehicleSourceDisplay(v);
return (
@@ -105,8 +102,8 @@ export default function VehicleList({
</div>
<div className="hidden text-right md:block">
<div className={`text-sm font-black tabular-nums ${highMileageAlert ? 'text-red-600' : 'text-blue-600'}`}>
{mileageValue.toLocaleString()}
<span className="ml-1 text-[9px] font-bold text-slate-400">{hasMileage ? 'km' : ''}</span>
{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()}
<span className="ml-1 text-[9px] font-bold text-slate-400">{v.mileageAnomaly ? '' : 'km'}</span>
</div>
</div>
<div className="hidden text-right md:block">
@@ -122,7 +119,7 @@ export default function VehicleList({
)}
<span className="text-[7px] font-black text-blue-600/50 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none" title={isRangeMode ? '区间内里程' : '当日里程'}>区</span>
<div className={`text-sm font-black leading-none ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-600' : 'text-blue-600') : 'text-amber-600'}`}>
<>{mileageValue.toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400' : 'text-slate-400'}`}>{hasMileage ? 'km' : ''}</span></>
{(v.isDataSynced || v.totalKm != null) ? <>{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400' : 'text-slate-400'}`}>{v.mileageAnomaly ? '' : 'km'}</span></> : <span className="text-[7px] text-amber-500/70">未对接</span>}
</div>
</div>
<div className="flex items-center gap-1">
+1 -1
View File
@@ -7,7 +7,7 @@ export interface MonitoringVehicle {
vin: string;
dailyKm: number;
mileageAnomaly?: string | null;
dailyMileage?: Record<string, number | null>;
dailyMileage?: Record<string, number>;
totalKm: number | null;
source: string;
sourceProtocol: MileageSourceProtocol | null;
+5 -5
View File
@@ -1,4 +1,3 @@
import { monitoringMileageValue } from './mileage-display';
import * as XLSX from 'xlsx';
import type { MonitoringVehicle } from './types';
@@ -28,7 +27,8 @@ function statusLabel(v: MonitoringVehicle): string {
function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | number {
if (kind === 'today') {
return monitoringMileageValue(v);
if (!v.isDataSynced && v.totalKm == null) return '未对接';
return Math.max(0, v.dailyKm || 0);
}
return v.totalKm != null ? v.totalKm : '未对接';
}
@@ -64,7 +64,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
v.rentStatus || '',
v.region || '',
];
const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? '—');
const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? 0);
return [
...baseRow,
...dailyCols,
@@ -135,8 +135,8 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
v.project || '',
v.rentStatus || '',
v.region || '',
...dayKeys.map(day => v.dailyMileage?.[day] ?? '—'),
mileageCell(v, 'today'),
...dayKeys.map(day => v.dailyMileage?.[day] || 0),
Math.max(0, v.dailyKm || 0),
v.totalKm != null ? v.totalKm : '',
]),
];
-2
View File
@@ -13,7 +13,6 @@ import schedulingRouter from './routes/scheduling/index.js';
import vehicleHeatmapRouter from './routes/vehicle-heatmap.js';
import vehiclesRouter from './routes/vehicles.js';
import assetAssistantRouter from './routes/asset-assistant/index.js';
import assetFaultRouter from './routes/asset-faults/index.js';
/**
* 创建 HTTP 应用,但不监听端口、不访问数据库,也不启动后台任务。
@@ -31,7 +30,6 @@ 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);
-90
View File
@@ -1,90 +0,0 @@
import { Hono } from 'hono';
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 ['/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 = visibleFaults(data.rows, user);
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 = visibleFaults(data.rows, user);
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();
@@ -1,194 +0,0 @@
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 './model.js';
import { createFaultRouter } from './index.js';
import type { AuthUser } from '../../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('./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('./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('./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);
});
-191
View File
@@ -1,191 +0,0 @@
import { createHash } from 'node:crypto';
import { FAULT_DIMENSIONS, FAULT_OVERVIEW_METRICS, type FaultDimension, type FaultOption, type FaultRecord, type FaultQuery, type FaultResponse } from '../../../shared/assets/faults.js';
import 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;
}
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));
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 {
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, 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, assetCell, ...row }) => ({ ...row, cell: assetCell })) };
}
@@ -1,67 +0,0 @@
import type { RowDataPacket } from 'mysql2';
import pool from '../../db.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
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(); }
}
}
@@ -1,61 +0,0 @@
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
@@ -1,42 +0,0 @@
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);
}
@@ -1,52 +0,0 @@
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'));
});
@@ -1,31 +0,0 @@
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);
}
+1 -1
View File
@@ -39,7 +39,7 @@ export function registerHydrogenDailyRoute(
HYDROGEN_BASE_WHERE_B,
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
dateRangeClause(`b.${HYDROGEN_LOCAL}`),
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
customerClause(customer),
].join(' AND ');
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
+26 -4
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DatabaseSync } from 'node:sqlite';
import {
customerClause,
dateRangeClause,
@@ -7,10 +8,31 @@ import {
resolveDateRange,
} from './query-model.js';
test('客户范围继续让外部车辆返回空数据', () => {
assert.equal(customerClause('external'), '1=0');
assert.equal(customerClause('lingniu'), '1=1');
assert.equal(customerClause('all'), '1=1');
test('外部车辆只取佛山羚牛站未关联车辆的订单,车牌文字和收费不影响分类', () => {
const db = new DatabaseSync(':memory:');
try {
db.exec(`CREATE TABLE orders (
id INTEGER, station_id INTEGER, vehicle_id INTEGER,
license_plate TEXT, customer_price REAL, del_flag TEXT
);
INSERT INTO orders VALUES
(1, 341, NULL, '粤A外部', 38, '0'),
(2, 341, NULL, '', 0, '0'),
(3, 341, 123, '粤A羚牛', 38, '0'),
(4, 52, NULL, '粤A其他站', 38, '0'),
(5, NULL, NULL, '', 0, '0'),
(6, NULL, 456, '粤A未关联站点', 0, '0'),
(7, 341, NULL, '粤A已删除', 38, '1');`);
const ids = (kind: 'external' | 'lingniu' | 'all') => db
.prepare(`SELECT b.id FROM orders b WHERE b.del_flag = '0' AND ${customerClause(kind)} ORDER BY b.id`)
.all().map(row => row.id);
assert.deepEqual(ids('external'), [1, 2]);
assert.deepEqual(ids('lingniu'), [3, 4, 5, 6]);
assert.deepEqual(ids('all'), [1, 2, 3, 4, 5, 6]);
assert.deepEqual([...ids('external'), ...ids('lingniu')].sort(), ids('all'));
} finally {
db.close();
}
});
test('能源日期快捷区间继续按本地自然日计算', () => {
+7 -3
View File
@@ -8,10 +8,14 @@ export interface EnergyDateRange {
const YMD_RE = /^\d{4}-\d{2}-\d{2}$/;
// 新账本目前只承载羚牛车辆订单;外部车辆仍需返回空集合而不是混用羚牛数据。
// 主数据 ID 341:佛山南海羚牛加氢站。车牌文字存在不代表已关联车辆。
const FOSHAN_LINGNIU_STATION_ID = 341;
// 日明细账本使用别名 b;两类互斥,其他订单继续保留在原羚牛范围内。
export function customerClause(customer: CustomerKind): string {
if (customer === 'external') return '1=0';
if (customer === 'lingniu') return '1=1';
const external = `(COALESCE(b.station_id, 0) = ${FOSHAN_LINGNIU_STATION_ID} AND b.vehicle_id IS NULL)`;
if (customer === 'external') return external;
if (customer === 'lingniu') return `NOT ${external}`;
return '1=1';
}
+7 -2
View File
@@ -105,6 +105,11 @@ test('四个处理器保持缓存和参数契约,加氢站点使用新主数
'/hydrogen/daily?startDate=2025-01-01&endDate=2025-01-02&customer=external&force=1',
);
assert.equal(dailyResponse.status, 200);
assert.deepEqual(await dailyResponse.json(), [
{ date: '2025-01-02', totalKg: 10, chainPct: 0, customerType: 'external',
stations: [{ name: '测试站', pricePerKg: 35, kg: 10, chainPct: 0 }] },
{ date: '2025-01-01', totalKg: 0, chainPct: 0, customerType: 'external', stations: [] },
]);
const electricApp = new Hono();
const electricDependencies = {
@@ -127,7 +132,7 @@ test('四个处理器保持缓存和参数契约,加氢站点使用新主数
{ key: 'electric/monthly?customer=external&start=2025-01-01&end=2025-01-02', force: true },
]);
// 指纹来自拆分前的 13 条 SQL,连空白符变化也会使测试失败。
// 保留查询契约指纹;日明细已更新为佛山羚牛站未关联车辆的外部订单筛选。
assert.deepEqual(
[...hydrogenCalls, ...electricCalls].map(call => sqlHash(call.sql)),
[
@@ -138,7 +143,7 @@ test('四个处理器保持缓存和参数契约,加氢站点使用新主数
'256ac8309d5a',
'dac9e86fc527',
'a71da8f05765',
'24b23780a259',
'd1620280f520',
'226fd8f950a1',
'6e4031b10926',
'4fc77463294c',
+147 -3
View File
@@ -4,8 +4,11 @@ import { dirname, join } from 'node:path';
import pool from '../../db.js';
import { fetchVehicleInfoMap } from './vehicle-info.js';
import { fetchOneOsDailyMileage, fetchOneOsMileageDates } from './oneos-api.js';
import type { OneOsProtocol } from './source-policy.js';
import { buildRangeMileage, type RangeMileageResult } from './range-model.js';
import {
sourceCategoryFromProtocol,
type MileageSourceCategory,
type OneOsProtocol,
} from './source-policy.js';
import {
buildMonitoringFilters,
buildPlateTargetNamesMap,
@@ -15,6 +18,8 @@ import {
mileageDatesBetween,
previousMileageDate,
toMileageRows,
type DailyMileageRow,
type MileageRow,
type TargetRow,
} from './cache-model.js';
import type { CachedVehicle, MonitoringCache, MonitoringFilters } from './types.js';
@@ -38,6 +43,13 @@ function shanghaiDate(): string {
}).format(new Date());
}
export interface RangeMileageResult {
vehicles: CachedVehicle[];
dailyTotals: { date: string; totalKm: number }[];
start: string;
end: string;
}
async function fetchTargetRows(): Promise<TargetRow[]> {
return pool.execute(
`SELECT t.id, t.target_name, v.plate_number
@@ -119,6 +131,23 @@ export async function queryRangeMileage(
endDate: string,
protocolPriority?: OneOsProtocol[],
): Promise<RangeMileageResult> {
if (startDate === endDate) {
const vehicles = (await queryDateMileage(startDate, protocolPriority)).map(vehicle => ({
...vehicle,
dailyMileage: { [startDate]: vehicle.dailyKm },
dailySourceProtocols: { [startDate]: vehicle.sourceProtocol },
}));
return {
vehicles,
dailyTotals: [{
date: startDate,
totalKm: Math.round(vehicles.reduce((sum, vehicle) => sum + vehicle.dailyKm, 0)),
}],
start: startDate,
end: endDate,
};
}
const days = mileageDatesBetween(startDate, endDate);
const [apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
fetchOneOsMileageDates(days, undefined, protocolPriority),
@@ -127,7 +156,122 @@ export async function queryRangeMileage(
fetchVehicleInfoMap(),
fetchTargetRows(),
]);
return buildRangeMileage(startDate, endDate, apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows, regionMap);
const dailyRows: DailyMileageRow[] = [];
for (const [date, apiRows] of apiRowsByDate) {
for (const row of apiRows) {
dailyRows.push({
plate: row.plateNumber,
vin: row.vin,
date,
daily_km: row.dailyMileageKm,
source: row.status !== 'NO_DATA' ? 'ONEOS_API' : 'NONE',
mileage_anomaly: row.status === 'DATA_ANOMALY' ? row.dataQuality || 'DATA_ANOMALY' : null,
source_protocol: row.sourceProtocol,
data_time: row.dataTime,
calculated_at: row.calculatedAt,
updated_at: row.updatedAt,
});
}
}
const perVehicleDaily = new Map<string, Record<string, number>>();
const perVehicleDailySources = new Map<string, Record<string, OneOsProtocol | null>>();
const perVehicleSourceCategories = new Map<string, Set<MileageSourceCategory>>();
const perVehicleSum = new Map<string, MileageRow>();
const dailyTotals = new Map<string, number>();
const bestDailyRows = new Map<string, DailyMileageRow>();
for (const day of days) dailyTotals.set(day, 0);
for (const row of dailyRows) {
const key = `${row.plate}\u0000${row.date}`;
const km = Math.max(0, Number(row.daily_km) || 0);
const existing = bestDailyRows.get(key);
if (!existing || km > Math.max(0, Number(existing.daily_km) || 0)) {
bestDailyRows.set(key, row);
}
}
for (const row of bestDailyRows.values()) {
const km = Math.max(0, Number(row.daily_km) || 0);
const date = row.date;
const plate = row.plate;
dailyTotals.set(date, (dailyTotals.get(date) || 0) + km);
const daily = perVehicleDaily.get(plate) || {};
daily[date] = km;
perVehicleDaily.set(plate, daily);
const dailySources = perVehicleDailySources.get(plate) || {};
dailySources[date] = row.source_protocol;
perVehicleDailySources.set(plate, dailySources);
const category = sourceCategoryFromProtocol(row.source_protocol);
if (category) {
const categories = perVehicleSourceCategories.get(plate) || new Set<MileageSourceCategory>();
categories.add(category);
perVehicleSourceCategories.set(plate, categories);
}
const existing = perVehicleSum.get(plate);
perVehicleSum.set(plate, {
plate,
vin: existing?.vin || row.vin || '',
daily_km: String((Number(existing?.daily_km) || 0) + km),
total_km: null,
mileage_anomaly: existing?.mileage_anomaly || row.mileage_anomaly || null,
source: existing?.source !== 'NONE' && existing?.source ? existing.source : (row.source || 'NONE'),
source_protocol: row.source_protocol || existing?.source_protocol || null,
data_time: row.data_time || existing?.data_time || null,
calculated_at: row.calculated_at || existing?.calculated_at || null,
updated_at: row.updated_at || existing?.updated_at || null,
});
}
const endDateMap = new Map(endDateRows.map(row => [row.plateNumber, row]));
for (const [plate, aggregate] of perVehicleSum) {
const endDateRow = endDateMap.get(plate);
if (!endDateRow) continue;
perVehicleSum.set(plate, {
...aggregate,
vin: endDateRow.vin || aggregate.vin,
total_km: endDateRow.totalMileageKm == null ? null : String(endDateRow.totalMileageKm),
source_protocol: endDateRow.sourceProtocol || aggregate.source_protocol,
data_time: endDateRow.dataTime || aggregate.data_time,
updated_at: endDateRow.updatedAt || aggregate.updated_at,
});
}
const yesterdayMap = dailyMileageMap(yesterdayRows);
const vehicles = mergeMonitoringVehicles(
Array.from(perVehicleSum.values()),
infoMap,
yesterdayMap,
buildPlateTargetNamesMap(targetRows),
regionMap,
).map(vehicle => {
const dailyMileage = perVehicleDaily.get(vehicle.plate) || {};
const dailySources = perVehicleDailySources.get(vehicle.plate) || {};
const categories = perVehicleSourceCategories.get(vehicle.plate);
const completedDailyMileage: Record<string, number> = {};
const completedDailySources: Record<string, OneOsProtocol | null> = {};
for (const day of days) completedDailyMileage[day] = dailyMileage[day] || 0;
for (const day of days) completedDailySources[day] = dailySources[day] || null;
return {
...vehicle,
dailyMileage: completedDailyMileage,
dailySourceProtocols: completedDailySources,
sourceCategory: categories && categories.size > 1
? 'MIXED' as const
: categories?.values().next().value || vehicle.sourceCategory,
};
});
return {
vehicles,
dailyTotals: days.map(date => ({ date, totalKm: Math.round(dailyTotals.get(date) || 0) })),
start: startDate,
end: endDate,
};
}
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
-6
View File
@@ -14,12 +14,6 @@ export interface OneOsDailyMileage {
sourceProtocol: OneOsProtocol | null;
}
/** Missing or unusable daily mileage must not contribute to interval statistics. */
export function validDailyMileage(row: OneOsDailyMileage | undefined): number | null {
const km = row?.dailyMileageKm;
return row?.status === 'NORMAL' && km != null && Number.isFinite(km) && km >= 0 ? km : null;
}
const MILEAGE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export function assertMileageDate(date: string): void {
@@ -1,71 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildRangeMileage } from './range-model.js';
import { validDailyMileage, type OneOsDailyMileage } from './oneos-model.js';
import type { VehicleInfoRow } from './types.js';
const info: VehicleInfoRow = {
plate: '粤A06619F', vin: 'test', customer: null, department: null, manager: null,
manager_id: null, rent_status: null, entity: null, project: null,
inventory_location: null, brand_label: null,
};
const row = (date: string, km: number | null, status: OneOsDailyMileage['status'] = 'NORMAL'): OneOsDailyMileage => ({
plateNumber: info.plate, vin: 'test', date, dailyMileageKm: km, status,
totalMileageKm: 64413.6, dataTime: null, calculatedAt: null, updatedAt: null,
sourceProtocol: 'GB32960',
});
function aggregate(rows: OneOsDailyMileage[], start = '2026-10-09', end = '2026-10-12') {
const byDate = new Map<string, OneOsDailyMileage[]>();
for (const item of rows) byDate.set(item.date, [...(byDate.get(item.date) || []), item]);
return buildRangeMileage(start, end, byDate, rows.filter(item => item.date === end), [], new Map([[info.plate, info]]), [], {});
}
test('缺失或异常的一天不覆盖其余日期合计,明细保留空值', () => {
for (const status of ['NO_DATA', 'DATA_ANOMALY', 'NORMAL'] as const) {
const result = aggregate([
row('2026-10-09', 1067), row('2026-10-10', null, status),
row('2026-10-11', 2212), row('2026-10-12', 2138),
]);
const vehicle = result.vehicles[0];
assert.equal(vehicle.dailyKm, 5417);
assert.equal(vehicle.mileageAnomaly, null);
assert.equal(vehicle.isDataSynced, true);
assert.equal(vehicle.totalKm, 64413.6);
assert.deepEqual(vehicle.dailyMileage, {
'2026-10-09': 1067, '2026-10-10': null, '2026-10-11': 2212, '2026-10-12': 2138,
});
assert.equal(result.dailyTotals.reduce((sum, day) => sum + day.totalKm, 0), 5417);
}
});
test('整段无日里程保留缺失状态及累计里程,不能伪装为有效零里程', () => {
const vehicle = aggregate([row('2026-10-12', null, 'DATA_ANOMALY')]).vehicles[0];
assert.equal(vehicle.dailyKm, 0);
assert.equal(vehicle.isDataSynced, false);
assert.equal(vehicle.mileageAnomaly, null);
assert.equal(vehicle.totalKm, 64413.6);
assert.deepEqual(Object.values(vehicle.dailyMileage!), [null, null, null, null]);
});
test('真实零里程是有效数据,缺失日期及重复无效记录不影响它', () => {
const vehicle = aggregate([row('2026-10-09', null, 'NO_DATA'), row('2026-10-09', 0)]).vehicles[0];
assert.equal(vehicle.isDataSynced, true);
assert.equal(vehicle.dailyMileage?.['2026-10-09'], 0);
assert.equal(vehicle.dailyMileage?.['2026-10-10'], null);
});
test('单日区间同样跳过缺失数据', () => {
const vehicle = aggregate([row('2026-10-12', null, 'DATA_ANOMALY')], '2026-10-12').vehicles[0];
assert.equal(vehicle.mileageAnomaly, null);
assert.equal(vehicle.isDataSynced, false);
assert.deepEqual(vehicle.dailyMileage, { '2026-10-12': null });
});
test('区间汇总和单车明细共用有效里程口径', () => {
assert.equal(validDailyMileage(undefined), null);
assert.equal(validDailyMileage(row('2026-10-12', null)), null);
assert.equal(validDailyMileage(row('2026-10-12', 123, 'DATA_ANOMALY')), null);
assert.equal(validDailyMileage(row('2026-10-12', NaN)), null);
assert.equal(validDailyMileage(row('2026-10-12', 0)), 0);
assert.equal(validDailyMileage(row('2026-10-12', 12.3)), 12.3);
});
-148
View File
@@ -1,148 +0,0 @@
import { sourceCategoryFromProtocol, type MileageSourceCategory, type OneOsProtocol } from './source-policy.js';
import { validDailyMileage, type OneOsDailyMileage } from './oneos-model.js';
import {
buildPlateTargetNamesMap, dailyMileageMap, mergeMonitoringVehicles, mileageDatesBetween,
type DailyMileageRow, type MileageRow, type TargetRow,
} from './cache-model.js';
import type { CachedVehicle, VehicleInfoRow } from './types.js';
export interface RangeMileageResult {
vehicles: CachedVehicle[];
dailyTotals: { date: string; totalKm: number }[];
start: string;
end: string;
}
export function buildRangeMileage(
startDate: string,
endDate: string,
apiRowsByDate: Map<string, OneOsDailyMileage[]>,
endDateRows: OneOsDailyMileage[],
yesterdayRows: OneOsDailyMileage[],
infoMap: Map<string, VehicleInfoRow>,
targetRows: TargetRow[],
regionMap: Record<string, string>,
): RangeMileageResult {
const days = mileageDatesBetween(startDate, endDate);
const dailyRows: DailyMileageRow[] = [];
for (const [date, apiRows] of apiRowsByDate) {
for (const row of apiRows) {
dailyRows.push({
plate: row.plateNumber,
vin: row.vin,
date,
daily_km: validDailyMileage(row),
source: row.status !== 'NO_DATA' ? 'ONEOS_API' : 'NONE',
mileage_anomaly: row.status === 'DATA_ANOMALY' ? row.dataQuality || 'DATA_ANOMALY' : null,
source_protocol: row.sourceProtocol,
data_time: row.dataTime,
calculated_at: row.calculatedAt,
updated_at: row.updatedAt,
});
}
}
const perVehicleDaily = new Map<string, Record<string, number>>();
const perVehicleDailySources = new Map<string, Record<string, OneOsProtocol | null>>();
const perVehicleSourceCategories = new Map<string, Set<MileageSourceCategory>>();
const perVehicleSum = new Map<string, MileageRow>();
const dailyTotals = new Map<string, number>();
const bestDailyRows = new Map<string, DailyMileageRow>();
for (const day of days) dailyTotals.set(day, 0);
for (const row of dailyRows) {
if (row.daily_km == null) continue;
const key = `${row.plate}\u0000${row.date}`;
const km = Math.max(0, Number(row.daily_km) || 0);
const existing = bestDailyRows.get(key);
if (!existing || km > Math.max(0, Number(existing.daily_km) || 0)) {
bestDailyRows.set(key, row);
}
}
for (const row of bestDailyRows.values()) {
const km = Math.max(0, Number(row.daily_km) || 0);
const date = row.date;
const plate = row.plate;
dailyTotals.set(date, (dailyTotals.get(date) || 0) + km);
const daily = perVehicleDaily.get(plate) || {};
daily[date] = km;
perVehicleDaily.set(plate, daily);
const dailySources = perVehicleDailySources.get(plate) || {};
dailySources[date] = row.source_protocol;
perVehicleDailySources.set(plate, dailySources);
const category = sourceCategoryFromProtocol(row.source_protocol);
if (category) {
const categories = perVehicleSourceCategories.get(plate) || new Set<MileageSourceCategory>();
categories.add(category);
perVehicleSourceCategories.set(plate, categories);
}
const existing = perVehicleSum.get(plate);
perVehicleSum.set(plate, {
plate,
vin: existing?.vin || row.vin || '',
daily_km: String((Number(existing?.daily_km) || 0) + km),
total_km: null,
mileage_anomaly: null,
source: existing?.source !== 'NONE' && existing?.source ? existing.source : (row.source || 'NONE'),
source_protocol: row.source_protocol || existing?.source_protocol || null,
data_time: row.data_time || existing?.data_time || null,
calculated_at: row.calculated_at || existing?.calculated_at || null,
updated_at: row.updated_at || existing?.updated_at || null,
});
}
const endDateMap = new Map(endDateRows.map(row => [row.plateNumber, row]));
// Preserve end-of-range odometer data even when every daily mileage is missing.
for (const [plate, endDateRow] of endDateMap) {
const aggregate = perVehicleSum.get(plate);
perVehicleSum.set(plate, {
plate,
daily_km: aggregate?.daily_km || '0',
source: aggregate?.source || 'NONE',
mileage_anomaly: null,
vin: endDateRow.vin || aggregate?.vin || '',
total_km: endDateRow.totalMileageKm == null ? null : String(endDateRow.totalMileageKm),
source_protocol: endDateRow.sourceProtocol || aggregate?.source_protocol || null,
data_time: endDateRow.dataTime || aggregate?.data_time || null,
calculated_at: endDateRow.calculatedAt || aggregate?.calculated_at || null,
updated_at: endDateRow.updatedAt || aggregate?.updated_at || null,
});
}
const yesterdayMap = dailyMileageMap(yesterdayRows);
const vehicles = mergeMonitoringVehicles(
Array.from(perVehicleSum.values()),
infoMap,
yesterdayMap,
buildPlateTargetNamesMap(targetRows),
regionMap,
).map(vehicle => {
const dailyMileage = perVehicleDaily.get(vehicle.plate) || {};
const dailySources = perVehicleDailySources.get(vehicle.plate) || {};
const categories = perVehicleSourceCategories.get(vehicle.plate);
const completedDailyMileage: Record<string, number | null> = {};
const completedDailySources: Record<string, OneOsProtocol | null> = {};
for (const day of days) completedDailyMileage[day] = dailyMileage[day] ?? null;
for (const day of days) completedDailySources[day] = dailySources[day] || null;
return {
...vehicle,
dailyMileage: completedDailyMileage,
dailySourceProtocols: completedDailySources,
sourceCategory: categories && categories.size > 1
? 'MIXED' as const
: categories?.values().next().value || vehicle.sourceCategory,
};
});
return {
vehicles,
dailyTotals: days.map(date => ({ date, totalKm: Math.round(dailyTotals.get(date) || 0) })),
start: startDate,
end: endDate,
};
}
+1 -1
View File
@@ -4,7 +4,7 @@ export interface CachedVehicle {
vin: string;
dailyKm: number;
mileageAnomaly?: string | null;
dailyMileage?: Record<string, number | null>;
dailyMileage?: Record<string, number>;
totalKm: number | null;
source: string;
sourceProtocol: 'GB32960' | 'MQTT' | 'JT808' | null;
+2 -3
View File
@@ -1,5 +1,4 @@
import { Hono } from 'hono';
import { validDailyMileage } from './oneos-model.js';
import { fetchOneOsMileageDates } from './oneos-api.js';
import {
parseMileageSourcePriority,
@@ -82,8 +81,8 @@ app.get('/:plate/recent', async (c) => {
const hit = (rowsByDate.get(key) || []).find(row => row.plateNumber === plate);
result.push({
date: key,
dailyKm: validDailyMileage(hit) ?? 0,
isDataSynced: validDailyMileage(hit) != null,
dailyKm: hit?.status === 'NORMAL' ? (hit.dailyMileageKm || 0) : 0,
isDataSynced: hit?.status === 'NORMAL',
sourceProtocol: hit?.sourceProtocol || null,
sourceCategory: sourceCategoryFromProtocol(hit?.sourceProtocol || null),
});
-65
View File
@@ -1,65 +0,0 @@
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 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[];
}
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 })[];
}
-2
View File
@@ -7,8 +7,6 @@ export default defineConfig({
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
plugins: [react(), tailwindcss()],
server: {
host: '0.0.0.0',
allowedHosts: ['qunfa.lnoneos.com'],
// Preview requests append audit logs; watching them creates a reload/request loop.
watch: { ignored: ['**/logs/**'] },
proxy: {