Files
ln-bi/src/modules/assets/faults/FaultTrendCard.tsx
T
2026-10-16 12:38:52 +08:00

74 lines
8.7 KiB
TypeScript

import type { FaultDashboardFilters } from '../../../shared/assets/faults';
import { useCallback, useEffect, useRef, useState } from 'react';
import { CalendarDays, ChevronDown, RefreshCw } from 'lucide-react';
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { fetchJson } from '../../../auth/api-client';
import type { FaultTrendResponse, FaultTrendStatus } from '../../../shared/assets/faults';
import { AnalysisExportButton } from '../components/analysis/AnalysisExportButton';
import { downloadFaultReport } from './report-export';
import { FlowDateRangePicker } from '../components/overview/FlowDateRangePicker';
import { FaultAnalysisModal, type FaultAnalysisSource } from './FaultAnalysisModal';
function initialRange() {
const end = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10);
return { start: new Date(Date.parse(end) - 29 * 86400000).toISOString().slice(0, 10), end };
}
const statusLabels = { all: '新增故障', closed: '其中已闭环', unclosed: '其中未闭环' };
const countKeys = { all: 'reported', closed: 'closed', unclosed: 'unclosed' } as const;
export function FaultTrendCard({ refresh, scope = {}, scopeLabel = '', period }: { refresh: number; scope?: FaultDashboardFilters; scopeLabel?: string; period?: { start: string; end: string } }) {
const [range, setRange] = useState(() => period || initialRange());
const [granularity, setGranularity] = useState<'day' | 'month'>('day');
const scopeKey = new URLSearchParams(scope).toString();
useEffect(() => { if (period) setRange(period); }, [period?.start, period?.end]);
const [data, setData] = useState<FaultTrendResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [revision, setRevision] = useState(0);
const [expanded, setExpanded] = useState(false);
const [source, setSource] = useState<FaultAnalysisSource | null>(null);
const chartRef = useRef<HTMLDivElement>(null);
const close = useCallback(() => setSource(null), []);
useEffect(() => {
const controller = new AbortController();
setLoading(true); setError(''); setData(null);
const params = new URLSearchParams({ ...range, granularity, ...scope });
fetchJson<FaultTrendResponse>(`/api/asset-faults/trend?${params}`, { signal: controller.signal })
.then(result => { if (!controller.signal.aborted) setData(result); })
.catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); })
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [range.start, range.end, granularity, revision, refresh, scopeKey]);
const drill = (status: FaultTrendStatus, period?: string) => {
if (!data || loading) return;
const params = new URLSearchParams({ start: data.start, end: data.end, granularity: data.granularity, status, ...scope });
if (period) params.set('period', period);
setSource({ url: `/api/asset-faults/trend-analysis?${params}`, title: `故障处理趋势 · ${statusLabels[status]}`, defaultDimensions: ['handler', 'faultStatus'],
periodLabel: `${data.start} 至 ${data.end}${period ? ` · ${period}` : ''} · 按提报日期` });
};
return <section className="fault-trend fault-summary" aria-label="故障处理趋势" aria-busy={loading}>
<div className="fault-trend-heading"><h2><CalendarDays size={16} />故障处理趋势</h2><div className="fault-report-actions"><AnalysisExportButton disabled={loading || !data || !!error} onExport={() => data ? downloadFaultReport({ kind: 'trend', data, filterLabel: scopeLabel }, chartRef.current?.querySelector<SVGSVGElement>('svg.recharts-surface[role=application]') || undefined) : undefined} /><div role="group" aria-label="趋势时间粒度">{(['day', 'month'] as const).map(value => <button key={value} aria-pressed={granularity === value} onClick={() => setGranularity(value)}>{value === 'day' ? '按日' : '按月'}</button>)}</div></div></div>
<p className="fault-trend-definition">按提报日期统计新增故障,展示这批故障截至当前的闭环情况。新增 = 已闭环 + 未闭环。点击柱内色块,可按运维处置人、故障工单状态查看对应日期的故障。</p>
<FlowDateRangePicker dateLabel="故障提报时间" value={range} onChange={setRange} />
{error ? <div className="fault-message" role="alert">故障趋势加载失败,请稍后重试。<button onClick={() => setRevision(v => v + 1)}>重试</button></div> : loading || !data ? <div className="fault-trend-loading" role="status"><RefreshCw size={16} className="animate-spin" />正在加载故障趋势…</div> : <>
<div className="fault-trend-totals">{(['all', 'closed', 'unclosed'] as const).map(status => <button key={status} className={`fault-trend-${status}`} disabled={!data.totals[countKeys[status]]} onClick={() => drill(status)} aria-label={`${statusLabels[status]} ${data.totals[countKeys[status]]} 单,查看趋势分析`}><strong>{data.totals[countKeys[status]].toLocaleString()}</strong><span>{statusLabels[status]} ↗</span></button>)}</div>
{data.totals.reported ? <div ref={chartRef} className="fault-trend-chart" role="region" aria-label={`故障${granularity === 'day' ? '日' : '月'}趋势堆叠柱图,新增${data.totals.reported}单,已闭环${data.totals.closed}单,未闭环${data.totals.unclosed}单;精确数量可展开统计表查看`}>
<ResponsiveContainer width="100%" height={240} minWidth={0}>
<BarChart data={data.periods} margin={{ top: 12, right: 8, left: -18, bottom: 0 }} accessibilityLayer>
<CartesianGrid strokeDasharray="4 6" vertical={false} stroke="#e8edf3" />
<XAxis dataKey="period" tick={{ fontSize: 10, fill: '#64748b' }} tickFormatter={v => data.granularity === 'day' ? v.slice(5) : v} minTickGap={24} axisLine={false} tickLine={false} />
<YAxis allowDecimals={false} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
<Tooltip cursor={{ fill: '#f1f5f9', opacity: 0.7 }} contentStyle={{ border: '1px solid #e2e8f0', borderRadius: 12, boxShadow: '0 8px 24px rgb(15 23 42 / .08)', fontSize: 12 }} labelFormatter={(_, payload) => { const point = payload?.[0]?.payload; return point ? `${point.period} · 新增 ${point.reported} 单` : ''; }} formatter={(value, name) => [`${value} 单`, name]} />
<Legend iconType="circle" iconSize={8} wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="closed" name="已闭环" cursor="pointer" onClick={bar => { const period = data.periods.find(row => row.period === bar.payload?.period); if (period?.closed) drill('closed', period.period); }} fill="#2475ff" stackId="faults" maxBarSize={34} isAnimationActive={false} />
<Bar dataKey="unclosed" name="未闭环" cursor="pointer" onClick={bar => { const period = data.periods.find(row => row.period === bar.payload?.period); if (period?.unclosed) drill('unclosed', period.period); }} fill="#ff981a" stackId="faults" maxBarSize={34} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div> : <p className="fault-empty">所选时间范围暂无新增故障</p>}
<button className="fault-trend-expand" aria-expanded={expanded} aria-controls="fault-trend-periods" onClick={() => setExpanded(v => !v)}><span>{expanded ? '收起' : '展开'}{data.granularity === 'day' ? '每日' : '每月'}统计 · 点击数量查看故障闭环明细</span><ChevronDown size={16} style={{ transform: expanded ? 'rotate(180deg)' : undefined }} /></button>
{expanded && <div id="fault-trend-periods" className="fault-trend-table"><table><thead><tr><th>{data.granularity === 'day' ? '提报日期' : '提报月份'}</th><th>新增</th><th>已闭环</th><th>未闭环</th><th>闭环率</th></tr></thead><tbody>{[...data.periods].reverse().map(row => <tr key={row.period}><td>{row.period}</td>{(['all', 'closed', 'unclosed'] as const).map(status => <td key={status}><button disabled={!row[countKeys[status]]} onClick={() => drill(status, row.period)} aria-label={`${row.period} ${statusLabels[status]} ${row[countKeys[status]]} 单,查看明细`}>{row[countKeys[status]].toLocaleString()}</button></td>)}<td>{row.reported ? `${(row.closed / row.reported * 100).toFixed(1)}%` : '—'}</td></tr>)}</tbody></table></div>}
<p className="fault-summary-note">已归档视为已闭环,其余状态(含挂起)为未闭环;无提报时间不纳入趋势。按月仍仅统计所选日期范围,不扩展至整月。状态截至 {new Date(data.updatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}。</p>
</>}
{source && <FaultAnalysisModal key={source.url} metric="ledger" source={source} onClose={close} />}
</section>;
}