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>
274 lines
9.5 KiB
TypeScript
274 lines
9.5 KiB
TypeScript
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>
|
||
);
|
||
}
|