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(); 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(null); useDialogFocus(true, ref, onClose); const mobile = useMobileLayout(); const [data, setData] = useState(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(() => analysisSettings.get(settingsKey)?.dimensions || defaultDimensions); const [filters, setFilters] = useState(() => analysisSettings.get(settingsKey)?.filters || {}); const [merged, setMerged] = useState(true); const [sort, setSort] = useState<'count-desc' | 'count-asc' | 'label'>('count-desc'); const [group, setGroup] = useState(null); const [search, setSearch] = useState(''); const [limit, setLimit] = useState(50); useEffect(() => { const controller = new AbortController(); setError(''); setData(null); fetchJson(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 === 'level' ? value || '未标注' : d === 'customer' ? data?.customerLabels[value] || '未关联客户' : getDimensionValueLabel(d, value); const options = available.map(key => { const otherFilters = { ...filters }; delete otherFilters[key]; const counts = new Map(); 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
{ if (e.target === e.currentTarget) onClose(); }}>

{source?.title || FAULT_OVERVIEW_METRICS[metric]} · {group ? '故障明细' : '多维分析'}

{source?.periodLabel || `${data?.date || '正在读取数据'} · ${metric !== 'reported' && metric !== 'closed' ? '全部日期' : '北京时间自然日'}`} · 按故障单统计

{error ?
{error}
: !data ? : group ? <>
{groupLabel(group)} · {details.length.toLocaleString()} 单
{['车牌 / 故障单号', '故障程度', '运维处置人', '故障描述', '状态', '提报时间', '解决时间', '运营城市', '客户 / 部门'].map(h => )}{details.slice(0, limit).map(row => )}
{h}
{row.plate || '未关联车辆'}{row.code || '未编号'}{row.level || '未标注'}{row.handlers.map(h => h.label).join('、') || '未指派'}{row.description || '—'}{row.status}{row.reportedAt || '—'}{row.archivedAt || '—'}{row.city}{row.customerLabel}{row.department}
{!details.length &&

没有符合条件的故障明细

} {details.length > limit && } : <> {mobile ? apply(dims as FaultAnalysisDimension[], next)} /> :

分析维度

点击维度分组,展开箭头可多选筛选
{options.map(d => apply(dimensions.includes(d.key) ? dimensions.filter(v => v !== d.key) : [...dimensions, d.key], filters)} onApplyFilter={selected => apply(dimensions, { ...filters, [d.key]: selected })} />)}
}
当前结果{analysis.total.toLocaleString()}单{analysis.rows.length.toLocaleString()} 个分组{mobile && }
{dimensions.includes('handler') &&

运维处置人来自故障或关联工单的当前处置分配。多人共同处置分别计入各人,总数和上级小计按故障去重,人员行不可直接相加。

}
{!mobile &&

分组统计

}{!mobile && dimensions.length > 1 && }
{mobile ? 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])} /> :
{dimensions.length ? dimensions.map(d => ) : }{rows.map((row, ri) => {dimensions.length ? dimensions.map((d, ci) => { const span = merged ? spans[ri][ci] : 1; return span ? : null; }) : })}
{DIMENSION_LABELS[d]}范围数量(单)占比操作
{label(d, row.values[d]!)}{merged && ci < dimensions.length - 1 && 合计 {totals[ri][ci].toLocaleString()} 单}合计{row.count.toLocaleString()}{row.share.toFixed(1)}%{dimensions.length > 0 && }
} {!rows.length &&

暂无符合条件的故障,可调整筛选条件。

} {ordered.length > limit && }

{metric === 'overdue' && '逾期仅统计待处理、处理中且处置截止时间早于本次数据时间的故障;挂起停表,无截止时间不计入。'}{source ? source.definition || '按提报日期筛选同批故障,闭环状态截至本次数据时间;已归档为已闭环,其余为未闭环。' : '维度按车辆当前信息归属;本日解决按解决时间统计。'}下钻读取最新台账,实时变动可能与首页数量存在差异。

}
; }