Files
CA/frontend/src/pages/OverviewDashboard.tsx

274 lines
9.5 KiB
TypeScript
Raw Normal View History

import { useEffect, useState, useMemo } from 'react';
import { Activity } from 'lucide-react';
import { caseApi, riskApi, alertApi, envApi } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import { LoadingState, Segmented } from '@/components/ui';
import { TESTIDS } from '@/utils/testids';
import type {
CaseTrendPoint,
DistrictCaseData,
PollutantPoint,
DiagnosisBreakdown,
Alert,
} from '@/types';
import { KpiRow, type KpiData } from '@/components/overview/KpiRow';
import { CaseAqiTrend, type MergedTrendItem } from '@/components/overview/CaseAqiTrend';
import { DistrictChoropleth } from '@/components/overview/DistrictChoropleth';
import { TopDistrictsBar } from '@/components/overview/TopDistrictsBar';
import { TopDiagnosesBar } from '@/components/overview/TopDiagnosesBar';
import { AlertSeverityDonut, type AlertSlice } from '@/components/overview/AlertSeverityDonut';
import { CHART_COLORS } from '@/components/overview/chartColors';
import {
joinDistrictCases,
buildMetricLookup,
type MetricKey,
} from '@/components/overview/districtNormalize';
const METRIC_OPTIONS: { value: MetricKey; label: string }[] = [
{ value: 'all', label: '全部' },
{ value: 'outpatient', label: '门诊' },
{ value: 'inpatient', label: '住院' },
];
const METRIC_LABEL: Record<MetricKey, string> = {
all: '病例',
outpatient: '门诊',
inpatient: '住院',
};
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<KpiData | null>(null);
const [mergedTrend, setMergedTrend] = useState<MergedTrendItem[]>([]);
const [districts, setDistricts] = useState<DistrictCaseData[]>([]);
const [topDiagnoses, setTopDiagnoses] = useState<DiagnosisBreakdown[]>([]);
const [alertPie, setAlertPie] = useState<AlertSlice[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errors, setErrors] = useState<string[]>([]);
// 门诊/住院/全部 — 同时驱动 choropleth 与 Top5 区县条形图。
const [metric, setMetric] = useState<MetricKey>('all');
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 + district 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[] = [];
// --- 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 });
// --- Merge case trend + AQI ---
if (trend30R.status === 'fulfilled') {
const trend30 = trend30R.value.trend || [];
const aqiMap: Record<string, number> = {};
for (const p of pollutantData) aqiMap[p.date] = p.AQI || 0;
setMergedTrend(
trend30.map((t) => ({ date: t.date, cases: t.total, aqi: aqiMap[t.date] || 0 }))
);
} else if (!newErrors.includes('今日病例数据加载失败')) {
newErrors.push('趋势数据加载失败');
}
// --- Districts (feeds choropleth + Top5 via normalize/join) ---
if (districtsR.status === 'fulfilled') {
setDistricts(districtsR.value.districts || []);
} else {
newErrors.push('区县数据加载失败');
}
// --- 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: CHART_COLORS.alertP1 },
{ name: 'P2 关注', value: p2, color: CHART_COLORS.alertP2 },
]);
setErrors(newErrors);
setIsLoading(false);
};
fetchAll();
return () => {
cancelled = true;
};
}, []);
// 归一并聚合到 13 区一次,供 choropleth 与 Top5 共享。
const joinedDistricts = useMemo(() => joinDistrictCases(districts), [districts]);
const metricLookup = useMemo(
() => buildMetricLookup(joinedDistricts, metric),
[joinedDistricts, metric]
);
if (isLoading) {
return <LoadingState label="加载概览数据…" testid={TESTIDS.pageLoading} />;
}
return (
<div data-testid={TESTIDS.pageOverview} className="flex flex-col h-full overflow-auto">
{errors.length > 0 && (
<div className="px-6 pt-4">
<ErrorBanner
error={errors.join('')}
onRetry={() => window.location.reload()}
onDismiss={() => setErrors([])}
/>
</div>
)}
<div className="p-6 space-y-6">
{/* Page header + honesty badge + metric toggle */}
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
<Activity className="w-5 h-5 text-primary" />
<span
data-testid={TESTIDS.asofBadge}
className="ml-1 inline-flex items-center rounded-full bg-bg-hover px-2 py-0.5 text-[11px] font-medium text-text-secondary border border-border"
>
2023-12
</span>
</h1>
<p className="text-[12px] text-text-secondary"></p>
</div>
<Segmented
options={METRIC_OPTIONS}
value={metric}
onChange={setMetric}
testid={TESTIDS.outinpatientToggle}
/>
</div>
{/* KPI Row */}
<KpiRow kpi={kpi} />
{/* Headline: Wuhan 13-district choropleth */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-3">
13{METRIC_LABEL[metric]}
</div>
<DistrictChoropleth
metricLookup={metricLookup}
metricLabel={`${METRIC_LABEL[metric]}`}
/>
</div>
{/* Case + AQI trend */}
<CaseAqiTrend data={mergedTrend} />
{/* Top districts (metric-driven) + Top diagnoses */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<TopDistrictsBar
districts={joinedDistricts}
metric={metric}
metricLabel={METRIC_LABEL[metric]}
/>
<TopDiagnosesBar diagnoses={topDiagnoses} />
</div>
{/* Alert severity donut */}
<AlertSeverityDonut data={alertPie} />
</div>
</div>
);
}