101 lines
14 KiB
TypeScript
101 lines
14 KiB
TypeScript
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 === '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<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.level || '未标注'}</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>;
|
||
}
|