import { useEffect, useState, useMemo } from 'react'; import { LineChart, Line, BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, } from 'recharts'; import { Activity, AlertTriangle, Droplets, Building2, TrendingUp, TrendingDown, Users, } from 'lucide-react'; import { caseApi, riskApi, alertApi, envApi } from '@/services/api'; import { StatCard } from '@/components/StatCard'; import { ErrorBanner } from '@/components/ErrorBanner'; import type { CaseTrendPoint, DistrictCaseData, PollutantPoint, DiagnosisBreakdown, Alert, } from '@/types'; // --- Types for fetched data --- interface KpiData { totalCases: number; todayCases: number; changeRatio: number | null; activeAlerts: number; highRiskGrids: number; avgAQI: number; } interface MergedTrendItem { date: string; cases: number; aqi: number; } function formatDateLabel(dateStr: string): string { const d = new Date(dateStr); return `${d.getMonth() + 1}/${d.getDate()}`; } function computeChangeRatio(trend: CaseTrendPoint[]): number | null { if (trend.length < 8) return null; const recent7 = trend.slice(-7).reduce((s, p) => s + p.total, 0); const prior7 = trend.slice(-14, -7).reduce((s, p) => s + p.total, 0); if (prior7 === 0) return null; return ((recent7 - prior7) / prior7) * 100; } export function OverviewDashboard() { const [kpi, setKpi] = useState(null); const [mergedTrend, setMergedTrend] = useState([]); const [topDistricts, setTopDistricts] = useState([]); const [topDiagnoses, setTopDiagnoses] = useState([]); const [alertPie, setAlertPie] = useState<{ name: string; value: number; color: string }[]>([]); const [isLoading, setIsLoading] = useState(true); const [errors, setErrors] = useState([]); useEffect(() => { let cancelled = false; const fetchAll = async () => { setIsLoading(true); setErrors([]); const now = new Date(); const endStr = now.toISOString().split('T')[0]; const start14 = new Date(now); start14.setDate(start14.getDate() - 14); const start14Str = start14.toISOString().split('T')[0]; const start30 = new Date(now); start30.setDate(start30.getDate() - 30); const start30Str = start30.toISOString().split('T')[0]; // KPI sources — Promise.allSettled to survive individual failures const [statsR, trend14R, alertsR, riskStatsR, pollutantsR] = await Promise.allSettled([ caseApi.getStats(), caseApi.getTrend({ start_date: start14Str, end_date: endStr, group_by: 'day' }), alertApi.getAlerts(), riskApi.getStats(), envApi.getPollutants(7), ]); // Trend sources const [trend30R, districtsR, diagStatsR] = await Promise.allSettled([ caseApi.getTrend({ start_date: start30Str, end_date: endStr, group_by: 'day' }), caseApi.getDistricts(), caseApi.getStats(), // reuse for top_diagnoses ]); if (cancelled) return; const newErrors: string[] = []; // --- Build KPI --- let totalCases = 0; if (statsR.status === 'fulfilled') { const s = statsR.value; totalCases = (s.total_outpatient || 0) + (s.total_inpatient || 0); } else { newErrors.push('累计病例数据加载失败'); } let todayCases = 0; let changeRatio: number | null = null; if (trend14R.status === 'fulfilled') { const trend = trend14R.value.trend || []; if (trend.length > 0) { todayCases = trend[trend.length - 1].total; } changeRatio = computeChangeRatio(trend); } else { newErrors.push('今日病例数据加载失败'); } let activeAlerts = 0; let alertList: Alert[] = []; if (alertsR.status === 'fulfilled') { alertList = alertsR.value.alerts || []; activeAlerts = alertList.length; } else { newErrors.push('预警数据加载失败'); } let highRiskGrids = 0; if (riskStatsR.status === 'fulfilled') { highRiskGrids = riskStatsR.value.high_risk_count || 0; } else { newErrors.push('风险网格数据加载失败'); } let avgAQI = 0; let pollutantData: PollutantPoint[] = []; if (pollutantsR.status === 'fulfilled') { pollutantData = pollutantsR.value.data || []; if (pollutantData.length > 0) { const sumAQI = pollutantData.reduce((s, p) => s + (p.AQI || 0), 0); avgAQI = Math.round(sumAQI / pollutantData.length); } } else { newErrors.push('AQI数据加载失败'); } setKpi({ totalCases, todayCases, changeRatio, activeAlerts, highRiskGrids, avgAQI }); setErrors(newErrors); // --- Merge case trend + AQI --- if (trend30R.status === 'fulfilled') { const trend30 = trend30R.value.trend || []; const aqiMap: Record = {}; if (pollutantsR.status === 'fulfilled') { for (const p of pollutantData) { aqiMap[p.date] = p.AQI || 0; } } // Only use data from the last 30 days for display const merged: MergedTrendItem[] = trend30.map((t) => ({ date: t.date, cases: t.total, aqi: aqiMap[t.date] || 0, })); setMergedTrend(merged); } else if (!newErrors.includes('今日病例数据加载失败')) { newErrors.push('趋势数据加载失败'); } // --- Top 5 Districts --- if (districtsR.status === 'fulfilled') { const districts = districtsR.value.districts || []; const sorted = [...districts].sort((a, b) => b.total - a.total); setTopDistricts(sorted.slice(0, 5)); } // --- Top 5 Diagnoses --- if (diagStatsR.status === 'fulfilled') { const topDiag = diagStatsR.value.top_diagnoses || []; setTopDiagnoses( topDiag.slice(0, 5).map((d) => ({ diagnosis: d.diagnosis, outpatient: d.outpatient, inpatient: d.inpatient, total: d.outpatient + d.inpatient, })) ); } // --- Alert severity donut --- const p1 = alertList.filter((a) => a.priority === 'P1').length; const p2 = alertList.filter((a) => a.priority === 'P2').length; setAlertPie([ { name: 'P1 紧急', value: p1, color: '#EF4444' }, { name: 'P2 关注', value: p2, color: '#F59E0B' }, ]); setIsLoading(false); }; fetchAll(); return () => { cancelled = true; }; }, []); const changeTrend = useMemo(() => { if (kpi?.changeRatio == null) return undefined; if (kpi.changeRatio > 0) { return { direction: 'up' as const, value: `${kpi.changeRatio.toFixed(1)}%` }; } if (kpi.changeRatio < 0) { return { direction: 'down' as const, value: `${Math.abs(kpi.changeRatio).toFixed(1)}%` }; } return { direction: 'stable' as const, value: '0%' }; }, [kpi?.changeRatio]); // --- Loading state --- if (isLoading) { return (
); } return (
{/* Error banner */} {errors.length > 0 && (
window.location.reload()} onDismiss={() => setErrors([])} />
)}
{/* Page header */}

综合概览

病例、环境与预警关键指标总览

{/* Section 1: KPI Row */}
} label="累计病例总数" value={kpi?.totalCases?.toLocaleString() ?? '--'} /> } label="今日病例" value={kpi?.todayCases?.toLocaleString() ?? '--'} /> ) || (changeTrend?.direction === 'down' && ) || ( ) } label="7日变化率" value={changeTrend ? changeTrend.value : '--'} trend={changeTrend} /> } label="活跃预警数" value={kpi?.activeAlerts?.toLocaleString() ?? '--'} color={kpi && kpi.activeAlerts > 0 ? '#EF4444' : undefined} /> } label="高风险网格" value={kpi?.highRiskGrids?.toLocaleString() ?? '--'} /> } label="平均AQI" value={kpi?.avgAQI?.toLocaleString() ?? '--'} />
{/* Section 2: Case + AQI Mini Trend */}
近30日病例与AQI趋势
{mergedTrend.length > 0 ? ( ) : (
暂无数据
)}
{/* Section 3 + 4: Top Districts + Top Diagnoses side by side */}
{/* Section 3: Top 5 Districts */}
Top 5 区县病例分布
{topDistricts.length > 0 ? ( [value.toLocaleString(), '病例数']} /> ) : (
暂无数据
)}
{/* Section 4: Top 5 Diagnoses */}
Top 5 诊断分布
{topDiagnoses.length > 0 ? ( [value.toLocaleString(), '病例数']} /> ) : (
暂无数据
)}
{/* Section 5: Alert Severity Donut */}
预警严重度分布
{alertPie[0].value > 0 || alertPie[1].value > 0 ? (
{alertPie.map((entry, idx) => ( ))} [value, name]} /> ( {value} )} />
) : (
暂无预警数据
)}
); }