69 lines
8.5 KiB
TypeScript
69 lines
8.5 KiB
TypeScript
import type { FaultDashboardFilters } from '../../../shared/assets/faults';
|
|
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, scope = {}, scopeLabel = '', period }: { refresh: number; scope?: FaultDashboardFilters; scopeLabel?: string; period?: { start: string; end: string } }) {
|
|
const [range, setRange] = useState(() => period || getWeeklyFlowRange());
|
|
const scopeKey = new URLSearchParams(scope).toString();
|
|
useEffect(() => { if (period) setRange(period); }, [period?.start, period?.end]);
|
|
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, ...scope })}`, { 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, scopeKey]);
|
|
const drill = (metric: FaultWeeklyMetric, day?: string) => {
|
|
if (!data || loading) return;
|
|
const params = new URLSearchParams({ start: data.start, end: data.end, metric, ...scope });
|
|
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, filterLabel: scopeLabel }, chartRef.current?.querySelector<SVGSVGElement>('svg.recharts-surface[role=application]') || undefined) : undefined} /></div></div>
|
|
<p className="fault-trend-definition">按日展示新增故障和解决故障。{period ? '统计周期沿用查询范围,可在下方单独调整。' : '默认业务周为周六至周五,可自选日期。'}</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>;
|
|
}
|