Files
CA/frontend/src/pages/OverviewDashboard.tsx
Akiba So 9a94156acc feat: Phase 2 — leadership 大屏 (/overview) + district normalization + drawer a11y
Phase 2 of the UX modernization. Three conflict-free workstreams.

Leadership 驾驶舱 (/overview):
- Wuhan 13-district Leaflet choropleth (public/wuhan_districts.geojson, keyed
  on name, darker=higher per 高风险高亮), legend, hover/click-zoom
- 全部/门诊/住院 Segmented toggle drives choropleth + Top-5 district bar
- literal "数据截至2023-12" as-of badge (D3 honesty); raw spinner → LoadingState
- decompose OverviewDashboard 501→273; 6 components + 2 helpers under components/overview/

District normalization (backend data boundary):
- case_loader.normalize_district + load_cases_by_district_daily collapse the
  26 dirty labels (武昌/武昌区…) → 13 canonical; analysis/grid/insights repointed
  (fixes a grid-merge row-drop bug as a bonus); in-memory, schema unchanged

Shell a11y (code-review carryover):
- drawer is now a proper modal: ESC, body scroll-lock, focus-in + focus-trap
  cycle + focus-restore, role=dialog/aria-modal/aria-label, hamburger aria-expanded
- SideNav expanded state lifted to AppShell so rail+drawer stay in sync
- RouteErrorBoundary around <Outlet/> keeps shell chrome on page/chunk failure

Gates: tsc 0 · vitest 64 · e2e 19/19 (17 user-flows + 2 overview) · build ok
· backend pytest 6 new + 48 regression green

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:24:26 +08:00

274 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}