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>
This commit is contained in:
2026-06-21 20:24:26 +08:00
parent 3db3b12480
commit 9a94156acc
22 changed files with 1378 additions and 358 deletions

View File

@@ -1,31 +1,9 @@
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 { Activity } from 'lucide-react';
import { caseApi, riskApi, alertApi, envApi } from '@/services/api';
import { StatCard } from '@/components/StatCard';
import { ErrorBanner } from '@/components/ErrorBanner';
import { LoadingState, Segmented } from '@/components/ui';
import { TESTIDS } from '@/utils/testids';
import type {
CaseTrendPoint,
DistrictCaseData,
@@ -33,27 +11,30 @@ import type {
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';
// --- Types for fetched data ---
interface KpiData {
totalCases: number;
todayCases: number;
changeRatio: number | null;
activeAlerts: number;
highRiskGrids: number;
avgAQI: number;
}
const METRIC_OPTIONS: { value: MetricKey; label: string }[] = [
{ value: 'all', label: '全部' },
{ value: 'outpatient', label: '门诊' },
{ value: 'inpatient', label: '住院' },
];
interface MergedTrendItem {
date: string;
cases: number;
aqi: number;
}
function formatDateLabel(dateStr: string): string {
const d = new Date(dateStr);
return `${d.getMonth() + 1}/${d.getDate()}`;
}
const METRIC_LABEL: Record<MetricKey, string> = {
all: '病例',
outpatient: '门诊',
inpatient: '住院',
};
function computeChangeRatio(trend: CaseTrendPoint[]): number | null {
if (trend.length < 8) return null;
@@ -66,12 +47,15 @@ function computeChangeRatio(trend: CaseTrendPoint[]): number | null {
export function OverviewDashboard() {
const [kpi, setKpi] = useState<KpiData | null>(null);
const [mergedTrend, setMergedTrend] = useState<MergedTrendItem[]>([]);
const [topDistricts, setTopDistricts] = useState<DistrictCaseData[]>([]);
const [districts, setDistricts] = useState<DistrictCaseData[]>([]);
const [topDiagnoses, setTopDiagnoses] = useState<DiagnosisBreakdown[]>([]);
const [alertPie, setAlertPie] = useState<{ name: string; value: number; color: string }[]>([]);
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;
@@ -88,7 +72,7 @@ export function OverviewDashboard() {
start30.setDate(start30.getDate() - 30);
const start30Str = start30.toISOString().split('T')[0];
// KPI sources — Promise.allSettled to survive individual failures
// 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' }),
@@ -97,7 +81,7 @@ export function OverviewDashboard() {
envApi.getPollutants(7),
]);
// Trend sources
// Trend + district sources.
const [trend30R, districtsR, diagStatsR] = await Promise.allSettled([
caseApi.getTrend({ start_date: start30Str, end_date: endStr, group_by: 'day' }),
caseApi.getDistricts(),
@@ -108,7 +92,7 @@ export function OverviewDashboard() {
const newErrors: string[] = [];
// --- Build KPI ---
// --- KPI ---
let totalCases = 0;
if (statsR.status === 'fulfilled') {
const s = statsR.value;
@@ -121,9 +105,7 @@ export function OverviewDashboard() {
let changeRatio: number | null = null;
if (trend14R.status === 'fulfilled') {
const trend = trend14R.value.trend || [];
if (trend.length > 0) {
todayCases = trend[trend.length - 1].total;
}
if (trend.length > 0) todayCases = trend[trend.length - 1].total;
changeRatio = computeChangeRatio(trend);
} else {
newErrors.push('今日病例数据加载失败');
@@ -158,33 +140,24 @@ export function OverviewDashboard() {
}
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<string, number> = {};
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);
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('趋势数据加载失败');
}
// --- Top 5 Districts ---
// --- Districts (feeds choropleth + Top5 via normalize/join) ---
if (districtsR.status === 'fulfilled') {
const districts = districtsR.value.districts || [];
const sorted = [...districts].sort((a, b) => b.total - a.total);
setTopDistricts(sorted.slice(0, 5));
setDistricts(districtsR.value.districts || []);
} else {
newErrors.push('区县数据加载失败');
}
// --- Top 5 Diagnoses ---
@@ -204,43 +177,33 @@ export function OverviewDashboard() {
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' },
{ name: 'P1 紧急', value: p1, color: CHART_COLORS.alertP1 },
{ name: 'P2 关注', value: p2, color: CHART_COLORS.alertP2 },
]);
setErrors(newErrors);
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]);
// 归一并聚合到 13 区一次,供 choropleth 与 Top5 共享。
const joinedDistricts = useMemo(() => joinDistrictCases(districts), [districts]);
const metricLookup = useMemo(
() => buildMetricLookup(joinedDistricts, metric),
[joinedDistricts, metric]
);
// --- Loading state ---
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
);
return <LoadingState label="加载概览数据…" testid={TESTIDS.pageLoading} />;
}
return (
<div data-testid="page-overview" className="flex flex-col h-full overflow-auto">
{/* Error banner */}
<div data-testid={TESTIDS.pageOverview} className="flex flex-col h-full overflow-auto">
{errors.length > 0 && (
<div className="px-6 pt-4">
<ErrorBanner
@@ -252,249 +215,58 @@ export function OverviewDashboard() {
)}
<div className="p-6 space-y-6">
{/* Page header */}
<div>
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
<Activity className="w-5 h-5 text-primary" />
</h1>
<p className="text-[12px] text-gray-500"></p>
</div>
{/* Section 1: KPI Row */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard
icon={<Users className="w-4 h-4 text-blue-600" />}
label="累计病例总数"
value={kpi?.totalCases?.toLocaleString() ?? '--'}
/>
<StatCard
icon={<Activity className="w-4 h-4 text-green-600" />}
label="今日病例"
value={kpi?.todayCases?.toLocaleString() ?? '--'}
/>
<StatCard
icon={
(changeTrend?.direction === 'up' && <TrendingUp className="w-4 h-4 text-red-500" />) ||
(changeTrend?.direction === 'down' && <TrendingDown className="w-4 h-4 text-green-500" />) || (
<Activity className="w-4 h-4 text-gray-400" />
)
}
label="7日变化率"
value={changeTrend ? changeTrend.value : '--'}
trend={changeTrend}
/>
<StatCard
icon={<AlertTriangle className="w-4 h-4 text-orange-500" />}
label="活跃预警数"
value={kpi?.activeAlerts?.toLocaleString() ?? '--'}
color={kpi && kpi.activeAlerts > 0 ? '#EF4444' : undefined}
/>
<StatCard
icon={<Building2 className="w-4 h-4 text-red-500" />}
label="高风险网格"
value={kpi?.highRiskGrids?.toLocaleString() ?? '--'}
/>
<StatCard
icon={<Droplets className="w-4 h-4 text-cyan-500" />}
label="平均AQI"
value={kpi?.avgAQI?.toLocaleString() ?? '--'}
/>
</div>
{/* Section 2: Case + AQI Mini Trend */}
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
30AQI趋势
{/* 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>
{mergedTrend.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={mergedTrend} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
dataKey="date"
tickFormatter={formatDateLabel}
tick={{ fontSize: 10, fill: '#64748B' }}
interval="preserveStartEnd"
axisLine={{ stroke: '#E2E8F0' }}
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 10, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 10, fill: '#F59E0B' }}
axisLine={{ stroke: '#E2E8F0' }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
labelStyle={{ color: '#1E293B', fontWeight: 600 }}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Line
yAxisId="left"
type="monotone"
dataKey="cases"
name="病例数"
stroke="#3B82F6"
strokeWidth={2}
dot={false}
activeDot={{ r: 3 }}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="aqi"
name="AQI"
stroke="#F59E0B"
strokeWidth={2}
strokeDasharray="5 5"
dot={false}
activeDot={{ r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
<Segmented
options={METRIC_OPTIONS}
value={metric}
onChange={setMetric}
testid={TESTIDS.outinpatientToggle}
/>
</div>
{/* Section 3 + 4: Top Districts + Top Diagnoses side by side */}
{/* 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">
{/* Section 3: Top 5 Districts */}
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
Top 5
</div>
{topDistricts.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart
data={[...topDistricts].reverse()}
layout="vertical"
margin={{ top: 0, right: 10, left: 30, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
<YAxis
type="category"
dataKey="district"
tick={{ fontSize: 11, fill: '#374151' }}
width={60}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number) => [value.toLocaleString(), '病例数']}
/>
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={20} />
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={20} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
{/* Section 4: Top 5 Diagnoses */}
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
Top 5
</div>
{topDiagnoses.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart
data={[...topDiagnoses].reverse()}
layout="vertical"
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
<YAxis
type="category"
dataKey="diagnosis"
tick={{ fontSize: 11, fill: '#374151' }}
width={100}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number) => [value.toLocaleString(), '病例数']}
/>
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={16} />
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={16} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
<TopDistrictsBar
districts={joinedDistricts}
metric={metric}
metricLabel={METRIC_LABEL[metric]}
/>
<TopDiagnosesBar diagnoses={topDiagnoses} />
</div>
{/* Section 5: Alert Severity Donut */}
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
</div>
{alertPie[0].value > 0 || alertPie[1].value > 0 ? (
<div className="flex items-center justify-center">
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={alertPie}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={4}
dataKey="value"
nameKey="name"
>
{alertPie.map((entry, idx) => (
<Cell key={idx} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number, name: string) => [value, name]}
/>
<Legend
wrapperStyle={{ fontSize: '12px' }}
formatter={(value: string) => (
<span className="text-gray-700">{value}</span>
)}
/>
</PieChart>
</ResponsiveContainer>
</div>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
{/* Alert severity donut */}
<AlertSeverityDonut data={alertPie} />
</div>
</div>
);