feat: Phase 4 — responsive analysis pages + perf harness + god-component splits

Final phase of the UX modernization. Four conflict-free lanes.

Responsive (D4 — desktop+mobile 并重):
- 7 analysis pages made usable at 375px: grid-cols-4/5 → grid-cols-2 sm:*
  responsive variants; raw tables wrapped in overflow-x-auto; page overflow guards
- new e2e/responsive.spec.ts loops all 7 analysis routes at 375px asserting no
  horizontal scroll

Perf harness:
- playwright.config.ts gains an isolated `perf` project (testMatch /perf/), default
  chromium project excludes it (testIgnore)
- new e2e/perf.spec.ts: CDP Network.emulateNetworkConditions (Fast 3G) +
  PerformanceObserver LCP on /overview kpi-row + route-transition timing; numbers
  reported as a relative regression signal (dev-server, not a prod SLA), not gated

God-component splits (pure refactors, behavior-preserving):
- MonitoringDashboard 686 → 239 lines: extracted components/monitoring/* (StatsBar,
  OverviewTab, CaseStatsTab, DistrictStatsTab) + useMonitoringData hook; URL-granularity
  source-of-truth + drilldown reconcile kept in the orchestrator (no desync regression)
- AlertsDashboard 816 → 301 lines: extracted components/alerts/* (Toolbar, List,
  RiskPanel, MapPanel, DetailModal, …); role/privacy/grid-hide logic kept in the
  orchestrator — doctor-view privacy invariant (zero patient-point) still holds

Gates: tsc 0 · vitest 75 · functional e2e 37/37 (incl doctor-view privacy +
granularity + responsive) · build ok · perf project runs + reports

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 21:00:15 +08:00
parent 8ad7e086bb
commit 33f0f497d3
26 changed files with 2349 additions and 1215 deletions

View File

@@ -0,0 +1,145 @@
import { memo } from 'react';
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { ErrorBanner } from '@/components/ErrorBanner';
import { CalendarHeatmap } from '@/components/CalendarHeatmap';
import type { TopDiagnosis } from './types';
function formatDateLabel(dateStr: string): string {
const d = new Date(dateStr);
return `${d.getMonth() + 1}/${d.getDate()}`;
}
interface CaseStatsTabProps {
loading: boolean;
loaded: boolean;
error: string | null;
currentDate: string;
topDiagnoses: TopDiagnosis[];
caseTrend: Array<{ date: string; cases: number; aqi: number }>;
heatmapData: Array<{ date: string; value: number }>;
heatmapYear: number | null;
onRetry: () => void;
onDismissError: () => void;
}
// 病例统计 tab —— 诊断分布 / 病例与AQI趋势 / 日历热力图。纯展示,数据由父级按需加载。
export const CaseStatsTab = memo(function CaseStatsTab({
loading,
loaded,
error,
currentDate,
topDiagnoses,
caseTrend,
heatmapData,
heatmapYear,
onRetry,
onDismissError,
}: CaseStatsTabProps) {
if (loading && !loaded) {
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>
</div>
);
}
return (
<div className="space-y-6">
{error && (
<ErrorBanner
error={error}
onRetry={onRetry}
onDismiss={onDismissError}
/>
)}
{/* Top 5 诊断分布 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top 5 </h3>
{topDiagnoses.length > 0 ? (
<ResponsiveContainer width="100%" height={240}>
<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(), '病例数']}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<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>
{/* 近30日病例与AQI趋势 (driven off Monitoring timeline currentDate) */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-1">AQI趋势</h3>
<p className="text-xs text-gray-500 mb-4"> {currentDate} 30</p>
{caseTrend.length > 0 ? (
<ResponsiveContainer width="100%" height={260}>
<LineChart data={caseTrend} 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>
)}
</div>
{/* 日历热力图 (year derived from data) */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{heatmapYear ? `${heatmapYear}` : ''}
</h3>
{heatmapYear && heatmapData.length > 0 ? (
<CalendarHeatmap data={heatmapData} year={heatmapYear} />
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,66 @@
import { memo } from 'react';
import { ErrorBanner } from '@/components/ErrorBanner';
import { MetricHeatmapTable } from '@/components/MetricHeatmapTable';
interface DistrictStatsTabProps {
loading: boolean;
loaded: boolean;
error: string | null;
rows: string[];
data: Record<string, Record<string, number>>;
onRetry: () => void;
onDismissError: () => void;
onSort: (col: string) => void;
}
// 区域统计 tab —— 区域指标热力表。纯展示,排序键由父级持有。
export const DistrictStatsTab = memo(function DistrictStatsTab({
loading,
loaded,
error,
rows,
data,
onRetry,
onDismissError,
onSort,
}: DistrictStatsTabProps) {
if (loading && !loaded) {
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>
</div>
);
}
return (
<div className="space-y-6">
{error && (
<ErrorBanner
error={error}
onRetry={onRetry}
onDismiss={onDismissError}
/>
)}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-1"></h3>
<p className="text-xs text-gray-500 mb-4"></p>
{rows.length > 0 ? (
<MetricHeatmapTable
rows={rows}
columns={[
{ key: 'total', label: '病例' },
{ key: 'outpatient', label: '门诊' },
{ key: 'inpatient', label: '住院' },
{ key: 'inpatient_ratio', label: '住院占比%' },
]}
data={data}
onSort={onSort}
/>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,56 @@
import { memo } from 'react';
import { Calendar, Activity, TrendingUp, TrendingDown, Stethoscope, Zap, BarChart3 } from 'lucide-react';
import { StatCard } from '@/components/StatCard';
import type { MonitoringStats } from './types';
interface MonitoringStatsBarProps {
stats: MonitoringStats;
sparkline7d: number[];
}
// 监测页顶部统计条 —— 纯展示已自适应grid-cols-2 sm:grid-cols-3 lg:grid-cols-6
export const MonitoringStatsBar = memo(function MonitoringStatsBar({ stats, sparkline7d }: MonitoringStatsBarProps) {
return (
<div className="flex-1 min-w-0 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard
icon={<Calendar className="w-4 h-4 text-blue-600" />}
label="当日病例"
value={stats.todayCases !== null ? stats.todayCases.toLocaleString() : '--'}
/>
<StatCard
icon={<Activity className="w-4 h-4 text-indigo-600" />}
label="7日均值"
value={stats.avg7d.toLocaleString()}
sparkline={sparkline7d.length >= 2 ? { data: sparkline7d, color: '#6366F1' } : undefined}
/>
<StatCard
icon={
stats.trend === 'up' ? <TrendingUp className="w-4 h-4 text-red-500" /> :
stats.trend === 'down' ? <TrendingDown className="w-4 h-4 text-green-500" /> :
<Activity className="w-4 h-4 text-gray-400" />
}
label="趋势"
value={stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳'}
trend={{
direction: stats.trend === 'up' ? 'up' : stats.trend === 'down' ? 'down' : 'stable',
value: stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳',
}}
/>
<StatCard
icon={<Zap className="w-4 h-4 text-amber-500" />}
label="峰值日"
value={`${stats.maxDay.cases.toLocaleString()} (${stats.maxDay.date.slice(5)})`}
/>
<StatCard
icon={<BarChart3 className="w-4 h-4 text-purple-500" />}
label="标准差"
value={stats.stdDev.toLocaleString()}
/>
<StatCard
icon={<Stethoscope className="w-4 h-4 text-orange-500" />}
label="门诊 / 住院"
value={`${stats.totalOutpatient.toLocaleString()} / ${stats.totalInpatient.toLocaleString()}`}
/>
</div>
);
});

View File

@@ -0,0 +1,142 @@
import { memo, useMemo, useCallback } from 'react';
import { StatisticalCharts } from '@/components/StatisticalCharts';
import { CaseLocationMap } from '@/components/CaseLocationMap';
import { Segmented } from '@/components/ui';
import { TESTIDS } from '@/utils/testids';
import type { Granularity, DistrictCaseRow } from './types';
interface OverviewTabProps {
isLoading: boolean;
chartData: Array<{ date: string; cases: number; aqi?: number }>;
districtCases: DistrictCaseRow[];
selectedDistrict: string | null;
selectedStreet: string | null;
currentDate: string;
granularity: Granularity;
onGranularityChange: (g: Granularity) => void;
onDistrictSelect: (district: string) => void;
}
// 概览 tab —— 病例分布地图 + 统计图表 + 区县 roll-up粒度真相来源在父级 URL
export const OverviewTab = memo(function OverviewTab({
isLoading,
chartData,
districtCases,
selectedDistrict,
selectedStreet,
currentDate,
granularity,
onGranularityChange,
onDistrictSelect,
}: OverviewTabProps) {
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>
</div>
);
}
return (
<div className="space-y-6">
{/* Case Location Map */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<CaseLocationMap height="400px" district={selectedDistrict} street={selectedStreet} date={currentDate} />
</div>
{/* Statistical Charts */}
<StatisticalCharts
data={chartData}
height={350}
showCases={true}
showAQI={true}
/>
{/* District breakdown — 区域 roll-upURL 粒度真相来源) */}
<div data-testid={TESTIDS.districtRollup} className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900"></h3>
<Segmented<Granularity>
testid={TESTIDS.granularityControl}
size="sm"
options={[
{ value: 'city', label: '全市' },
{ value: 'district', label: '区域' },
{ value: 'street', label: '街道' },
]}
value={granularity}
onChange={onGranularityChange}
/>
</div>
<div className="space-y-2">
<DistrictBreakdown
districtCases={districtCases}
selectedDistrict={selectedDistrict}
onDistrictSelect={onDistrictSelect}
/>
</div>
<div className="flex items-center gap-4 mt-3 pt-2 border-t border-gray-100">
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<span className="w-3 h-3 bg-orange-400 rounded-sm" />
</div>
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<span className="w-3 h-3 bg-red-400 rounded-sm" />
</div>
</div>
</div>
</div>
);
});
interface DistrictBreakdownProps {
districtCases: DistrictCaseRow[];
selectedDistrict: string | null;
// 点击区域条目时上抛——由父组件驱动 URL粒度真相来源不在此处 mutate store。
onDistrictSelect: (district: string) => void;
}
const DistrictBreakdown = memo(function DistrictBreakdown({ districtCases, selectedDistrict, onDistrictSelect }: DistrictBreakdownProps) {
const sortedCases = useMemo(() => [...districtCases].sort((a, b) => b.total - a.total), [districtCases]);
const maxTotal = useMemo(() => sortedCases.length > 0 ? sortedCases[0].total : 1, [sortedCases]);
const handleDistrictClick = useCallback((district: string) => {
onDistrictSelect(district);
}, [onDistrictSelect]);
return (
<>
{sortedCases.map((d) => {
const outPct = d.total > 0 ? (d.outpatient / d.total) * 100 : 0;
const inPct = d.total > 0 ? (d.inpatient / d.total) * 100 : 0;
const barWidth = (d.total / maxTotal) * 100;
return (
<div
key={d.district}
className={`flex items-center gap-3 p-2 rounded cursor-pointer transition-colors ${
selectedDistrict === d.district ? 'bg-blue-50' : 'hover:bg-gray-50'
}`}
onClick={() => handleDistrictClick(d.district)}
>
<div className="w-16 text-sm text-gray-700 text-right shrink-0">{d.district}</div>
<div className="flex-1 h-6 bg-gray-100 rounded overflow-hidden flex">
<div
className="bg-orange-400 h-full transition-all"
style={{ width: `${barWidth * outPct / 100}%` }}
title={`门诊: ${d.outpatient.toLocaleString()}`}
/>
<div
className="bg-red-400 h-full transition-all"
style={{ width: `${barWidth * inPct / 100}%` }}
title={`住院: ${d.inpatient.toLocaleString()}`}
/>
</div>
<div className="w-20 text-right text-sm font-medium text-gray-900 shrink-0">
{d.total.toLocaleString()}
</div>
</div>
);
})}
</>
);
});

View File

@@ -0,0 +1,38 @@
// 监测页内部共享类型。Granularity 的真相来源仍是 URL由 MonitoringDashboard 拥有;
// 此处只暴露类型与子组件复用的 props 形状。
export type Granularity = 'city' | 'district' | 'street';
export const GRANULARITY_VALUES: readonly Granularity[] = ['city', 'district', 'street'] as const;
export function parseGranularity(raw: string | null): Granularity {
return GRANULARITY_VALUES.includes(raw as Granularity) ? (raw as Granularity) : 'city';
}
// 概览 tab 区县条目所需的最小字段(来自 monitoringStore 的 districtCases
export interface DistrictCaseRow {
district: string;
total: number;
outpatient: number;
inpatient: number;
}
export interface MonitoringStats {
totalCases: number;
avgCases: number;
maxDay: { date: string; cases: number };
minDay: { date: string; cases: number };
stdDev: number;
trend: 'up' | 'down' | 'stable';
totalOutpatient: number;
totalInpatient: number;
avg7d: number;
todayCases: number | null;
noData: boolean;
}
export interface TopDiagnosis {
diagnosis: string;
outpatient: number;
inpatient: number;
total: number;
}

View File

@@ -0,0 +1,317 @@
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { useMonitoringStore } from '@/stores';
import { useDiseaseStore } from '@/stores/diseaseStore';
import { gridApi, caseApi, envApi } from '@/services/api';
import type { DistrictCaseData } from '@/types';
import type { MonitoringStats, TopDiagnosis } from './types';
type MonitoringTab = 'overview' | 'cases' | 'districts';
interface UseMonitoringDataArgs {
activeTab: MonitoringTab;
currentDate: string;
selectedDistrict: string | null;
}
// 监测页数据层:图表 90 天窗口、病例统计/区域统计两个按需 tab 的加载与派生。
// 不触碰 URL/drilldown粒度真相来源仍由 MonitoringDashboard 持有),只消费 currentDate 与
// selectedDistrict 作为入参,避免把 store-mutation 逻辑下沉到子组件。
export function useMonitoringData({ activeTab, currentDate, selectedDistrict }: UseMonitoringDataArgs) {
const [chartData, setChartData] = useState<Array<{ date: string; cases: number; aqi?: number }>>([]);
// --- 病例统计 tab state (fetched on demand) ---
const [topDiagnoses, setTopDiagnoses] = useState<TopDiagnosis[]>([]);
const [caseTrend, setCaseTrend] = useState<Array<{ date: string; cases: number; aqi: number }>>([]);
const [heatmapData, setHeatmapData] = useState<Array<{ date: string; value: number }>>([]);
const [heatmapYear, setHeatmapYear] = useState<number | null>(null);
const [casesTabLoaded, setCasesTabLoaded] = useState(false);
const [casesTabLoading, setCasesTabLoading] = useState(false);
const [casesTabError, setCasesTabError] = useState<string | null>(null);
// --- 区域统计 tab state (fetched on demand) ---
const [districtMetrics, setDistrictMetrics] = useState<DistrictCaseData[]>([]);
const [districtSortKey, setDistrictSortKey] = useState<string>('total');
const [districtTabLoaded, setDistrictTabLoaded] = useState(false);
const [districtTabLoading, setDistrictTabLoading] = useState(false);
const [districtTabError, setDistrictTabError] = useState<string | null>(null);
const districtCases = useMonitoringStore((s) => s.districtCases);
const fetchDistrictCases = useMonitoringStore((s) => s.fetchDistrictCases);
const { selectedDiagnoses } = useDiseaseStore();
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Load chart data for 90-day window ending at the given reference date
const loadChartData = useCallback((refDate: string, district?: string) => {
const end = new Date(refDate);
const start = new Date(refDate);
start.setDate(start.getDate() - 90);
const startStr = start.toISOString().split('T')[0];
const endStr = end.toISOString().split('T')[0];
if (selectedDiagnoses.length > 0 && selectedDiagnoses.length <= 3) {
caseApi.getTrend({
start_date: startStr,
end_date: endStr,
group_by: 'day',
diagnosis: selectedDiagnoses.join(','),
}).then((data) => {
const trend = data.trend || [];
setChartData(
trend.map((t: { date: string; total: number }) => ({ date: t.date, cases: t.total }))
);
}).catch((e) => { console.error('Failed to load chart data:', e); });
} else {
gridApi.getHistoricalAggregated(startStr, endStr, 'daily', district)
.then((data) => {
const rows = data.aggregations || [];
const dailyCases: Record<string, number> = {};
rows.forEach((item: { date: string; total_cases: number }) => {
dailyCases[item.date] = (dailyCases[item.date] || 0) + item.total_cases;
});
setChartData(
Object.entries(dailyCases)
.map(([date, cases]) => ({ date, cases }))
.sort((a, b) => a.date.localeCompare(b.date))
);
}).catch((e) => { console.error('Failed to load chart data:', e); });
}
// Fetch districtCases with date filter (single day = currentDate)
const diagnosisParam = selectedDiagnoses.length > 0 ? selectedDiagnoses.join(',') : undefined;
fetchDistrictCases(diagnosisParam, undefined, refDate);
}, [fetchDistrictCases, selectedDiagnoses]);
// 提供给外部(手动刷新 / 病种过滤)触发的去抖加载。
const debouncedLoadChart = useCallback(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
loadChartData(currentDate, selectedDistrict || undefined);
}, 300);
}, [loadChartData, currentDate, selectedDistrict]);
// Re-fetch when currentDate, district, or diagnoses change
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
loadChartData(currentDate, selectedDistrict || undefined);
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [currentDate, selectedDistrict, loadChartData]);
// Enhanced stats: window stats + current-date snapshot
const stats = useMemo<MonitoringStats>(() => {
const noData = chartData.length === 0;
const totalCases = noData ? 0 : chartData.reduce((sum, d) => sum + d.cases, 0);
const avgCases = noData ? 0 : Math.round(totalCases / chartData.length);
let maxDay = { date: '--', cases: 0 };
let minDay = { date: '--', cases: 0 };
let stdDev = 0;
let trend: 'up' | 'down' | 'stable' = 'stable';
if (!noData) {
maxDay = chartData.reduce((max, d) => d.cases > max.cases ? d : max, chartData[0]);
minDay = chartData.reduce((min, d) => d.cases < min.cases ? d : min, chartData[0]);
const variance = chartData.reduce((sum, d) => sum + (d.cases - avgCases) ** 2, 0) / chartData.length;
stdDev = Math.round(Math.sqrt(variance));
const halfIdx = Math.floor(chartData.length / 2);
const firstHalf = chartData.slice(0, halfIdx);
const secondHalf = chartData.slice(halfIdx);
const firstAvg = firstHalf.reduce((s, d) => s + d.cases, 0) / firstHalf.length;
const secondAvg = secondHalf.reduce((s, d) => s + d.cases, 0) / secondHalf.length;
trend = secondAvg > firstAvg * 1.1 ? 'up' : secondAvg < firstAvg * 0.9 ? 'down' : 'stable';
}
// 7-day moving average (last 7 days of the window)
const last7 = chartData.slice(-7);
const avg7d = last7.length > 0 ? Math.round(last7.reduce((s, d) => s + d.cases, 0) / last7.length) : 0;
// Current date snapshot: find the data point matching currentDate
const todaySnapshot = chartData.find((d) => d.date === currentDate);
const todayCases = todaySnapshot?.cases ?? null;
// Case type breakdown from districtCases
const totalOutpatient = districtCases.reduce((s, d) => s + d.outpatient, 0);
const totalInpatient = districtCases.reduce((s, d) => s + d.inpatient, 0);
return {
totalCases, avgCases, maxDay, minDay,
stdDev, trend, totalOutpatient, totalInpatient,
avg7d, todayCases, noData,
};
}, [chartData, districtCases, currentDate]);
// 7-day sparkline for the StatCard bar (last 7 days of the loaded window)
const sparkline7d = useMemo(() => chartData.slice(-7).map((d) => d.cases), [chartData]);
// --- On-demand loader: 病例统计 tab ---
// Drives the trend off the Monitoring timeline (30-day window ending at currentDate),
// NOT a fixed now-30d window. Year for the heatmap is derived from the data.
const loadCasesTab = useCallback(async (refDate: string) => {
setCasesTabLoading(true);
setCasesTabError(null);
const end = new Date(refDate);
const start = new Date(refDate);
start.setDate(start.getDate() - 30);
const startStr = start.toISOString().split('T')[0];
const endStr = end.toISOString().split('T')[0];
const yearStart = `${end.getFullYear()}-01-01`;
const yearEnd = `${end.getFullYear()}-12-31`;
const [statsR, trendR, pollutantsR, yearTrendR] = await Promise.allSettled([
caseApi.getStats(),
caseApi.getTrend({ start_date: startStr, end_date: endStr, group_by: 'day' }),
envApi.getPollutants(30),
caseApi.getTrend({ start_date: yearStart, end_date: yearEnd, group_by: 'day' }),
]);
const errs: string[] = [];
if (statsR.status === 'fulfilled') {
const topDiag = statsR.value.top_diagnoses || [];
setTopDiagnoses(
topDiag.slice(0, 5).map((d) => ({
diagnosis: d.diagnosis,
outpatient: d.outpatient,
inpatient: d.inpatient,
total: d.outpatient + d.inpatient,
}))
);
} else {
errs.push('诊断分布加载失败');
}
const aqiMap: Record<string, number> = {};
if (pollutantsR.status === 'fulfilled') {
for (const p of pollutantsR.value.data || []) {
aqiMap[p.date] = p.AQI || 0;
}
}
if (trendR.status === 'fulfilled') {
const trend = trendR.value.trend || [];
setCaseTrend(
trend.map((t) => ({ date: t.date, cases: t.total, aqi: aqiMap[t.date] || 0 }))
);
} else {
errs.push('趋势数据加载失败');
}
// Calendar heatmap: daily cases for the data's actual year (derived from trend data)
if (yearTrendR.status === 'fulfilled') {
const yearTrend = yearTrendR.value.trend || [];
if (yearTrend.length > 0) {
const derivedYear = new Date(yearTrend[0].date).getFullYear();
setHeatmapYear(derivedYear);
setHeatmapData(yearTrend.map((t) => ({ date: t.date, value: t.total })));
} else {
setHeatmapYear(end.getFullYear());
setHeatmapData([]);
}
} else {
errs.push('日历热力图加载失败');
}
setCasesTabError(errs.length > 0 ? errs.join('') : null);
setCasesTabLoading(false);
setCasesTabLoaded(true);
}, []);
// --- On-demand loader: 区域统计 tab ---
const loadDistrictTab = useCallback(async () => {
setDistrictTabLoading(true);
setDistrictTabError(null);
try {
const res = await caseApi.getDistricts();
setDistrictMetrics(res.districts || []);
setDistrictTabError(null);
} catch {
setDistrictTabError('区域统计加载失败');
} finally {
setDistrictTabLoading(false);
setDistrictTabLoaded(true);
}
}, []);
// Fetch tab data the first time a tab is opened (avoids loading everything upfront)
useEffect(() => {
if (activeTab === 'cases' && !casesTabLoaded && !casesTabLoading) {
loadCasesTab(currentDate);
}
if (activeTab === 'districts' && !districtTabLoaded && !districtTabLoading) {
loadDistrictTab();
}
}, [activeTab, casesTabLoaded, casesTabLoading, districtTabLoaded, districtTabLoading, currentDate, loadCasesTab, loadDistrictTab]);
// When the timeline date moves, refresh an already-opened 病例统计 tab so its
// trend window tracks the Monitoring timeline rather than going stale.
useEffect(() => {
if (activeTab === 'cases' && casesTabLoaded) {
loadCasesTab(currentDate);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentDate]);
// 区域统计 table: sortable district rows + heatmap columns
const districtTableRows = useMemo(() => {
const sorted = [...districtMetrics].sort((a, b) => {
switch (districtSortKey) {
case 'outpatient': return b.outpatient - a.outpatient;
case 'inpatient': return b.inpatient - a.inpatient;
case 'inpatient_ratio': return (b.inpatient_ratio ?? 0) - (a.inpatient_ratio ?? 0);
default: return b.total - a.total;
}
});
return sorted.map((d) => d.district);
}, [districtMetrics, districtSortKey]);
const districtTableData = useMemo(() => {
const map: Record<string, Record<string, number>> = {};
for (const d of districtMetrics) {
map[d.district] = {
total: d.total,
outpatient: d.outpatient,
inpatient: d.inpatient,
inpatient_ratio: Math.round((d.inpatient_ratio ?? 0) * 1000) / 10,
};
}
return map;
}, [districtMetrics]);
return {
// 概览
chartData,
stats,
sparkline7d,
districtCases,
// 病例统计
topDiagnoses,
caseTrend,
heatmapData,
heatmapYear,
casesTabLoaded,
casesTabLoading,
casesTabError,
setCasesTabError,
loadCasesTab,
// 区域统计
districtTableRows,
districtTableData,
districtTabLoaded,
districtTabLoading,
districtTabError,
setDistrictTabError,
setDistrictSortKey,
loadDistrictTab,
// 图表手动加载(错误重试 / 病种过滤)
loadChartData,
debouncedLoadChart,
};
}