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

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

Validation: TypeScript check, 502 tests, production build, desktop/mobile interactions and six-sheet Excel export passed.
This commit is contained in:
shishengliang
2026-10-16 18:18:36 +08:00
parent 589f06391f
commit 097e882886
38 changed files with 4665 additions and 8 deletions
@@ -0,0 +1,100 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowLeft, Search, Wrench, X } from 'lucide-react';
import { fetchJson } from '../../../auth/api-client';
import { FAULT_OVERVIEW_METRICS, type FaultAnalysisResponse, type FaultOverviewMetric } from '../../../shared/assets/faults';
import { getDimensionValueLabel } from '../analytics-model';
import { FAULT_CLOSURE_LABELS, FAULT_ANALYSIS_DIMENSIONS, FAULT_ANALYSIS_LABELS as DIMENSION_LABELS, analyzeFaultRecords, filterFaultRecords, faultDimensionValues, faultParentTotals, type FaultAnalysisDimension, type FaultAnalysisFilters, type FaultAnalysisGroup } from './analysis-model';
import { dimensionRowSpans, orderDimensionGroups } from '../dimension-layout';
import { FaultAnalysisLoading } from './FaultAnalysisLoading';
import { DimensionPicker } from '../components/analysis/DimensionPicker';
import { MobileAnalysisControls } from '../components/analysis/MobileAnalysisControls';
import { MobileGroupedTable } from '../components/analysis/MobileGroupedTable';
import { useDialogFocus } from '../components/useDialogFocus';
import { useMobileLayout } from '../useMobileLayout';
import '../components/analysis/asset-analysis.css';
import '../components/analysis/mobile-analysis.css';
const available = FAULT_ANALYSIS_DIMENSIONS;
// Page-lifetime memory only: closing a modal retains settings; a full reload clears them.
const analysisSettings = new Map<string, { dimensions: FaultAnalysisDimension[]; filters: FaultAnalysisFilters }>();
export interface FaultAnalysisSource { url: string; title: string; periodLabel: string; definition?: string; defaultDimensions?: FaultAnalysisDimension[]; initialDetails?: boolean }
export function FaultAnalysisModal({ metric, onClose, source }: { metric: FaultOverviewMetric; onClose: () => void; source?: FaultAnalysisSource }) {
const ref = useRef<HTMLDivElement>(null);
useDialogFocus(true, ref, onClose);
const mobile = useMobileLayout();
const [data, setData] = useState<FaultAnalysisResponse | null>(null);
const [error, setError] = useState('');
const [revision, setRevision] = useState(0);
const defaultDimensions: FaultAnalysisDimension[] = source?.defaultDimensions || ['type'];
const settingsKey = source?.url || `overview:${metric}`;
const [dimensions, setDimensions] = useState<FaultAnalysisDimension[]>(() => analysisSettings.get(settingsKey)?.dimensions || defaultDimensions);
const [filters, setFilters] = useState<FaultAnalysisFilters>(() => analysisSettings.get(settingsKey)?.filters || {});
const [merged, setMerged] = useState(true);
const [sort, setSort] = useState<'count-desc' | 'count-asc' | 'label'>('count-desc');
const [group, setGroup] = useState<FaultAnalysisGroup | null>(null);
const [search, setSearch] = useState('');
const [limit, setLimit] = useState(50);
useEffect(() => {
const controller = new AbortController();
setError(''); setData(null);
fetchJson<FaultAnalysisResponse>(source?.url || `/api/asset-faults/analysis?metric=${metric}`, { signal: controller.signal })
.then(result => { if (!controller.signal.aborted) { setData(result); if (source?.initialDetails) setGroup({key:"all", values:{}, count:result.rows.length, share:100, recordIds:result.rows.map(r=>r.id)}); } }).catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); });
return () => controller.abort();
}, [metric, revision, source?.url]);
const records = useMemo(() => data?.rows || [], [data]);
const handlerLabels = useMemo(() => new Map(records.flatMap(row => row.handlers.map(h => [h.value, h.label] as const))), [records]);
const label = (d: FaultAnalysisDimension, value: string) => d === 'plate' ? value : d === 'faultStatus' ? FAULT_CLOSURE_LABELS[value] || value : d === 'handler' ? handlerLabels.get(value) || '未指派' : d === 'customer' ? data?.customerLabels[value] || '未关联客户' : getDimensionValueLabel(d, value);
const options = available.map(key => {
const otherFilters = { ...filters }; delete otherFilters[key];
const counts = new Map<string, number>();
filterFaultRecords(records, otherFilters).forEach(row => faultDimensionValues(row, key).forEach(value => counts.set(value, (counts.get(value) || 0) + 1)));
// Keep applied selections removable even if another filter excludes them.
filters[key]?.forEach(value => { if (!counts.has(value)) counts.set(value, 0); });
return { key, label: DIMENSION_LABELS[key], options: [...counts].map(([value, count]) => ({ value, count, label: label(key, value) })).sort((a, b) => a.label.localeCompare(b.label, 'zh-CN')) };
});
const analysis = useMemo(() => analyzeFaultRecords(records, dimensions, filters), [records, dimensions, filters]);
const values = (row: FaultAnalysisGroup) => dimensions.map(d => row.values[d]!);
const ordered = orderDimensionGroups(analysis.rows, dimensions.length, values, row => row.count, sort,
(a, b, i) => label(dimensions[i], values(a)[i]).localeCompare(label(dimensions[i], values(b)[i]), 'zh-CN'));
const rows = ordered.slice(0, limit);
const spans = dimensionRowSpans(rows, dimensions.length, values);
const totals = faultParentTotals(ordered, dimensions);
const groupLabel = (row: FaultAnalysisGroup) => dimensions.map(d => label(d, row.values[d]!)).join(' · ') || '合计';
const apply = (dims: FaultAnalysisDimension[], nextFilters: FaultAnalysisFilters) => {
const saved = { dimensions: [...dims], filters: Object.fromEntries(Object.entries(nextFilters).filter(([, values]) => values?.length).map(([key, values]) => [key, [...values!]])) };
analysisSettings.set(settingsKey, saved);
setDimensions(saved.dimensions); setFilters(saved.filters); setLimit(50);
};
const reset = () => { analysisSettings.delete(settingsKey); setDimensions([...defaultDimensions]); setFilters({}); setLimit(50); };
const showDetails = (row: FaultAnalysisGroup) => { setGroup(row); setSearch(''); setLimit(50); };
const filterGroup = (row: FaultAnalysisGroup) => apply(dimensions, { ...filters, ...Object.fromEntries(dimensions.map(d => [d, [row.values[d]!]])) });
const groupIds = useMemo(() => new Set(group?.recordIds || []), [group]);
const details = group ? records.filter(row => groupIds.has(row.id) &&
`${row.code} ${row.plate} ${row.description} ${row.status} ${row.handlers.map(h => h.label).join(' ')}`.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()))
.sort((a, b) => (b.reportedAt || '').localeCompare(a.reportedAt || '') || b.id.localeCompare(a.id, undefined, { numeric: true })) : [];
return <div className="asset-analysis-overlay fault-analysis-overlay" onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
<div className="asset-analysis-dialog fault-analysis-dialog" ref={ref} role="dialog" aria-modal="true" aria-labelledby="fault-analysis-title" tabIndex={-1}>
<header className="analysis-header"><div><h2 id="fault-analysis-title"><Wrench size={18} />{source?.title || FAULT_OVERVIEW_METRICS[metric]} · {group ? '故障明细' : '多维分析'}</h2><p>{source?.periodLabel || `${data?.date || '正在读取数据'} · ${metric !== 'reported' && metric !== 'closed' ? '全部日期' : '北京时间自然日'}`} · 按故障单统计</p></div><button onClick={onClose} aria-label="关闭故障分析"><X size={20} /></button></header>
<div className="analysis-scroll">
{error ? <div className="fault-message" role="alert">{error}<button onClick={() => setRevision(v => v + 1)}>重试</button></div> : !data ? <FaultAnalysisLoading /> : group ? <>
<div className="analysis-table-tools"><button className="analysis-button" onClick={() => { setGroup(null); setLimit(50); }}><ArrowLeft size={14} />返回分组</button><span>{groupLabel(group)} · {details.length.toLocaleString()} 单</span><label className="fault-detail-search"><Search size={14} /><input aria-label="搜索故障明细" placeholder="搜索单号、车牌、描述、处置人" value={search} onChange={e => { setSearch(e.target.value); setLimit(50); }} /></label></div>
<div className="analysis-group-table is-merged fault-detail-table"><table><thead><tr>{['车牌 / 故障单号', '运维处置人', '故障描述', '状态', '提报时间', '解决时间', '运营城市', '客户 / 部门'].map(h => <th key={h}>{h}</th>)}</tr></thead><tbody>{details.slice(0, limit).map(row => <tr key={row.id}><td><strong>{row.plate || '未关联车辆'}</strong><small>{row.code || '未编号'}</small></td><td>{row.handlers.map(h => h.label).join('、') || '未指派'}</td><td>{row.description || '—'}</td><td>{row.status}</td><td>{row.reportedAt || '—'}</td><td>{row.archivedAt || '—'}</td><td>{row.city}</td><td>{row.customerLabel}<small>{row.department}</small></td></tr>)}</tbody></table></div>
{!details.length && <p className="fault-empty">没有符合条件的故障明细</p>}
{details.length > limit && <button className="analysis-button fault-more" onClick={() => setLimit(v => v + 50)}>加载更多明细(已显示 {limit} / {details.length})</button>}
</> : <>
{mobile ? <MobileAnalysisControls available={options} dimensions={dimensions} filters={filters} defaults={defaultDimensions} apply={(dims, next) => apply(dims as FaultAnalysisDimension[], next)} /> : <div className="analysis-builder"><div className="analysis-builder-heading"><h3>分析维度</h3><span>点击维度分组,展开箭头可多选筛选</span><button className="analysis-reset" onClick={reset}>重置</button></div><div className="analysis-dimension-options">{options.map(d => <DimensionPicker key={d.key} label={d.label} grouped={dimensions.includes(d.key)} order={dimensions.indexOf(d.key) + 1} options={d.options} selectedValues={filters[d.key]} onToggleGroup={() => apply(dimensions.includes(d.key) ? dimensions.filter(v => v !== d.key) : [...dimensions, d.key], filters)} onApplyFilter={selected => apply(dimensions, { ...filters, [d.key]: selected })} />)}<button aria-pressed={!dimensions.length} onClick={() => apply([], filters)}>仅汇总</button></div></div>}
<div className="analysis-result-bar"><span>当前结果<strong>{analysis.total.toLocaleString()}</strong>单</span><span>{analysis.rows.length.toLocaleString()} 个分组</span>{mobile && <button className="analysis-text-button" onClick={reset}>重置</button>}<button className="analysis-text-button" onClick={() => showDetails({ key: 'all', values: {}, count: analysis.total, share: 100, recordIds: filterFaultRecords(records, filters).map(row => row.id) })} disabled={dimensions.length > 0 || !analysis.total} hidden={dimensions.length > 0}>查看全部明细</button></div>
{dimensions.includes('handler') && <p className="fault-analysis-note">运维处置人来自故障或关联工单的当前处置分配。多人共同处置分别计入各人,总数和上级小计按故障去重,人员行不可直接相加。</p>}
<div className="analysis-table-tools">{!mobile && <h3>分组统计</h3>}<label>排序<select aria-label="分组排序" value={sort} onChange={e => { setSort(e.target.value as typeof sort); setLimit(50); }}><option value="count-desc">数量从多到少</option><option value="count-asc">数量从少到多</option><option value="label">名称顺序</option></select></label>{!mobile && dimensions.length > 1 && <label><input type="checkbox" checked={merged} onChange={e => setMerged(e.target.checked)} />合并单元格</label>}</div>
{mobile ? <MobileGroupedTable dimensions={dimensions.map(d => DIMENSION_LABELS[d])} rows={rows.map(row => ({ ...row, label: groupLabel(row), values: dimensions.map(d => label(d, row.values[d]!)) }))} unit="单" merged={merged} setMerged={setMerged} spans={spans} parentTotals={totals} onDetails={i => showDetails(rows[i])} onFilter={i => filterGroup(rows[i])} /> : <div className="analysis-group-table"><table><thead><tr>{dimensions.length ? dimensions.map(d => <th key={d}>{DIMENSION_LABELS[d]}</th>) : <th>范围</th>}<th>数量(单)</th><th>占比</th><th>操作</th></tr></thead><tbody>{rows.map((row, ri) => <tr key={row.key}>{dimensions.length ? dimensions.map((d, ci) => {
const span = merged ? spans[ri][ci] : 1;
return span ? <td key={d} rowSpan={span}>{label(d, row.values[d]!)}{merged && ci < dimensions.length - 1 && <small className="fault-parent-total">合计 {totals[ri][ci].toLocaleString()} 单</small>}</td> : null;
}) : <td>合计</td>}<td><strong>{row.count.toLocaleString()}</strong></td><td>{row.share.toFixed(1)}%</td><td>{dimensions.length > 0 && <button className="analysis-text-button" onClick={() => filterGroup(row)}>筛选此组</button>}<button className="analysis-text-button" onClick={() => showDetails(row)} aria-label={`查看明细 ${groupLabel(row)}`}>查看明细</button></td></tr>)}</tbody></table></div>}
{!rows.length && <p className="fault-empty">暂无符合条件的故障,可调整筛选条件。</p>}
{ordered.length > limit && <button className="analysis-button fault-more" onClick={() => setLimit(v => v + 50)}>加载更多分组(已显示 {limit} / {ordered.length})</button>}
<p className="fault-analysis-note">{metric === 'overdue' && '逾期仅统计待处理、处理中且处置截止时间早于本次数据时间的故障;挂起停表,无截止时间不计入。'}{source ? source.definition || '按提报日期筛选同批故障,闭环状态截至本次数据时间;已归档为已闭环,其余为未闭环。' : '维度按车辆当前信息归属;本日解决按解决时间统计。'}下钻读取最新台账,实时变动可能与首页数量存在差异。</p>
</>}
</div>
</div>
</div>;
}