diff --git a/frontend/e2e/perf.spec.ts b/frontend/e2e/perf.spec.ts
new file mode 100644
index 0000000..d35f1c8
--- /dev/null
+++ b/frontend/e2e/perf.spec.ts
@@ -0,0 +1,232 @@
+/**
+ * Performance-measurement harness for the leadership 大屏 (/overview).
+ *
+ * Runs ONLY in the dedicated `perf` Playwright project (see playwright.config.ts
+ * testMatch) so emulated network throttling never pollutes the functional suite.
+ *
+ * What it measures:
+ * 1. LCP (Largest Contentful Paint) of /overview under emulated Fast 3G.
+ * 2. Client-side route-transition time from /overview → /monitoring.
+ *
+ * Throttling model: /api/** is mocked to resolve INSTANTLY (see seedAuthAndMockApi),
+ * so the backend contributes ~0ms. That is deliberate — it isolates the realistic
+ * SPA cost on a slow link: the *static asset graph* (app JS/CSS bundle + the
+ * /wuhan_districts.geojson choropleth payload, which is served by the real dev
+ * server, not mocked). Fast 3G therefore shapes exactly the bytes a cold-cache
+ * leadership client must pull before first paint, which is what LCP should reflect.
+ *
+ * Assertion policy (per the UX-modernization plan): LCP and route-transition
+ * targets (2500ms LCP / 800ms transition) are REPORTED, not hard CI gates — a
+ * miss under throttle on a loaded CI box must not fail the build. We therefore
+ * record each number against its target as a test annotation + console line and
+ * let the test PASS regardless of the target. (Note: `expect.soft` would still
+ * mark the test failed at teardown, so it's the wrong tool for a report-only
+ * target — annotations are.) Hard assertions guard ONLY that the measurement
+ * machinery worked: LCP was observed (> 0) and the nav actually landed.
+ *
+ * Caveat on absolute values: this runs against the Vite DEV server (unbundled,
+ * unminified ESM with per-module requests). Dev LCP under Fast 3G is therefore
+ * far higher than a production build would be — these numbers are a relative
+ * regression signal for this harness, not a production SLA.
+ */
+import { test, expect, Page } from '@playwright/test';
+import { TESTIDS } from '../src/utils/testids';
+
+// Reported (soft) targets — see file header.
+const LCP_TARGET_MS = 2500;
+const ROUTE_TRANSITION_TARGET_MS = 800;
+
+// Emulated "Fast 3G" network conditions (Chrome DevTools preset).
+const FAST_3G = {
+ offline: false,
+ downloadThroughput: (1.6 * 1024 * 1024) / 8, // 1.6 Mbps
+ uploadThroughput: (750 * 1024) / 8, // 750 Kbps
+ latency: 150, // ms RTT
+};
+
+/**
+ * Seed auth + mock /api/** so the page renders hermetically. Mirrors the helper
+ * in user-flows.spec.ts, with one deliberate difference: /wuhan_districts.geojson
+ * is a real static asset and is NOT under /api, so page.route('/api/**') already
+ * lets it pass through to the dev server (the realistic, throttled payload).
+ */
+async function seedAuthAndMockApi(page: Page) {
+ await page.addInitScript(() => {
+ localStorage.setItem('cbpoa_token', 'e2e-test-token');
+ });
+
+ // Mock backend responses instantly so Fast-3G shapes only the static asset
+ // graph (JS/CSS + geojson), not API latency.
+ await page.route('/api/**', (route) => {
+ const url = route.request().url();
+
+ if (url.includes('/alerts')) {
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ alerts: [], total: 0 }),
+ });
+ return;
+ }
+
+ if (url.includes('/history/aggregated')) {
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ aggregations: [], total_records: 0 }),
+ });
+ return;
+ }
+
+ if (url.includes('/grids')) {
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
+ });
+ return;
+ }
+
+ if (url.includes('/cases/demographics')) {
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ age_distribution: [],
+ gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
+ age_diagnosis_matrix: [],
+ }),
+ });
+ return;
+ }
+
+ if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
+ return;
+ }
+
+ if (url.includes('/cases/districts') || url.includes('/districts')) {
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
+ return;
+ }
+
+ if (url.includes('/cases/trend') || url.includes('/cases')) {
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ data: [], total: 0 }),
+ });
+ return;
+ }
+
+ // Default fallback — safe empty shape.
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ data: [], items: [], total: 0 }),
+ });
+ });
+}
+
+test.describe('Performance — /overview under emulated Fast 3G', () => {
+ test('LCP and route-transition are measured and reported', async ({ page }, testInfo) => {
+ // Fast-3G throttling makes the cold asset-graph download slow; the default 30s
+ // test budget can be eaten by the initial /overview load alone. Give the whole
+ // measurement flow generous headroom — this bounds the harness, not the metrics.
+ test.setTimeout(120_000);
+ await seedAuthAndMockApi(page);
+
+ // Install the LCP observer BEFORE any navigation so it captures the very
+ // first paint. buffered:true also replays entries emitted before observe().
+ await page.addInitScript(() => {
+ (window as unknown as { __lcp: number }).__lcp = 0;
+ new PerformanceObserver((list) => {
+ const entries = list.getEntries();
+ (window as unknown as { __lcp: number }).__lcp = entries[entries.length - 1].startTime;
+ }).observe({ type: 'largest-contentful-paint', buffered: true });
+ });
+
+ // Apply Fast 3G throttling via CDP before navigating.
+ const client = await page.context().newCDPSession(page);
+ await client.send('Network.enable');
+ await client.send('Network.emulateNetworkConditions', FAST_3G);
+
+ // --- LCP measurement -----------------------------------------------------
+ // Generous wait: under Fast 3G the throttled JS bundle download dominates, so
+ // first meaningful paint legitimately exceeds the 5s default expect timeout.
+ // The LCP NUMBER we read is the real measured value — this timeout only bounds
+ // how long we'll wait for the asset graph to arrive before failing the harness.
+ await page.goto('/overview');
+ await expect(page.locator(`[data-testid="${TESTIDS.kpiRow}"]`)).toBeVisible({ timeout: 30_000 });
+
+ // LCP finalizes on the last contentful paint; give the observer a beat to flush
+ // the entry for the kpi-row we just saw before reading it.
+ await page.waitForTimeout(200);
+ const lcp = await page.evaluate(() => (window as unknown as { __lcp: number }).__lcp);
+
+ // --- Route-transition measurement ---------------------------------------
+ // Expand the 监测 module if its NavLink is collapsed, then click it.
+ const railSel = `[data-testid="${TESTIDS.sidebarRail}"]`;
+ const navMonitoring = page.locator(`${railSel} [data-testid="${TESTIDS.navMonitoring}"]`);
+ if (!(await navMonitoring.isVisible())) {
+ await page.locator(`${railSel} button`).filter({ hasText: '监测' }).first().click();
+ }
+ await expect(navMonitoring).toBeVisible();
+
+ const t0 = await page.evaluate(() => performance.now());
+ await navMonitoring.click();
+ await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible({
+ timeout: 30_000,
+ });
+ const t1 = await page.evaluate(() => performance.now());
+ const routeTransitionMs = t1 - t0;
+
+ // --- Report --------------------------------------------------------------
+ // eslint-disable-next-line no-console
+ console.log(`[perf] /overview LCP (Fast 3G): ${lcp.toFixed(0)} ms (target < ${LCP_TARGET_MS})`);
+ // eslint-disable-next-line no-console
+ console.log(
+ `[perf] /overview → /monitoring route transition: ${routeTransitionMs.toFixed(0)} ms (target < ${ROUTE_TRANSITION_TARGET_MS})`
+ );
+ await testInfo.attach('perf-metrics', {
+ contentType: 'application/json',
+ body: JSON.stringify(
+ {
+ lcpMs: Math.round(lcp),
+ lcpTargetMs: LCP_TARGET_MS,
+ routeTransitionMs: Math.round(routeTransitionMs),
+ routeTransitionTargetMs: ROUTE_TRANSITION_TARGET_MS,
+ network: 'Fast 3G (emulated via CDP)',
+ },
+ null,
+ 2
+ ),
+ });
+
+ // --- Reported targets (NOT gates) ---------------------------------------
+ // Record each metric vs. its target as a passing/over annotation. A miss is
+ // visible in the report and console but does NOT fail the test.
+ const lcpVerdict = lcp < LCP_TARGET_MS ? 'within' : 'over';
+ const routeVerdict = routeTransitionMs < ROUTE_TRANSITION_TARGET_MS ? 'within' : 'over';
+ testInfo.annotations.push({
+ type: 'perf-lcp',
+ description: `${Math.round(lcp)}ms (target ${LCP_TARGET_MS}ms — ${lcpVerdict})`,
+ });
+ testInfo.annotations.push({
+ type: 'perf-route-transition',
+ description: `${Math.round(routeTransitionMs)}ms (target ${ROUTE_TRANSITION_TARGET_MS}ms — ${routeVerdict})`,
+ });
+ if (lcpVerdict === 'over' || routeVerdict === 'over') {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `[perf] target exceeded (LCP ${lcpVerdict}, route ${routeVerdict}) — reported, not gated (dev-server throttled run).`
+ );
+ }
+
+ // --- Hard assertions (gates) --------------------------------------------
+ // Only the measurement machinery is gated: the observer fired and the nav
+ // landed (page-monitoring visibility is already hard-asserted above).
+ expect(lcp, 'LCP observer should have recorded a paint').toBeGreaterThan(0);
+ expect(routeTransitionMs, 'route transition should elapse measurable time').toBeGreaterThan(0);
+ });
+});
diff --git a/frontend/e2e/responsive.spec.ts b/frontend/e2e/responsive.spec.ts
new file mode 100644
index 0000000..a73ccb4
--- /dev/null
+++ b/frontend/e2e/responsive.spec.ts
@@ -0,0 +1,135 @@
+/**
+ * Phase-4 responsive acceptance: every analysis page must be usable at 375px
+ * (the narrowest mobile viewport in D4) with NO horizontal scroll.
+ *
+ * Auth + backend mocking mirror e2e/user-flows.spec.ts (seedAuthAndMockApi):
+ * seed localStorage['cbpoa_token'] so the login gate is skipped, then mock all
+ * /api/** calls so the suite runs hermetically without a live :8000 backend.
+ */
+import { test, expect, Page } from '@playwright/test';
+import { TESTIDS } from '../src/utils/testids';
+
+// Each analysis route paired with its page-* mount testid.
+const ANALYSIS_PAGES: Array<{ route: string; testid: string }> = [
+ { route: '/analysis/trend', testid: TESTIDS.pageTrend },
+ { route: '/analysis/district', testid: TESTIDS.pageDistrict },
+ { route: '/analysis/insights', testid: TESTIDS.pageInsights },
+ { route: '/analysis/reports', testid: TESTIDS.pageReports },
+ { route: '/analysis/demographics', testid: TESTIDS.pageDemographics },
+ { route: '/analysis/disease', testid: TESTIDS.pageDisease },
+ { route: '/analysis/environment', testid: TESTIDS.pageEnvironment },
+];
+
+/** Seed auth token and mock all /api/** calls before each page load. */
+async function seedAuthAndMockApi(page: Page) {
+ await page.addInitScript(() => {
+ localStorage.setItem('cbpoa_token', 'e2e-test-token');
+ });
+
+ // Mock backend responses so the suite is hermetic — no live :8000 required.
+ // Each response must match the TypeScript interface shape; returning {} causes
+ // pages to throw when accessing expected array properties.
+ await page.route('/api/**', (route) => {
+ const url = route.request().url();
+
+ if (url.includes('/alerts')) {
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ alerts: [], total: 0 }),
+ });
+ return;
+ }
+
+ if (url.includes('/history/aggregated')) {
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ aggregations: [], total_records: 0 }),
+ });
+ return;
+ }
+
+ if (url.includes('/grids')) {
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
+ });
+ return;
+ }
+
+ if (url.includes('/cases/demographics')) {
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ age_distribution: [],
+ gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
+ age_diagnosis_matrix: [],
+ }),
+ });
+ return;
+ }
+
+ if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([]),
+ });
+ return;
+ }
+
+ if (url.includes('/cases/districts') || url.includes('/districts')) {
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([]),
+ });
+ return;
+ }
+
+ if (url.includes('/cases/trend') || url.includes('/cases')) {
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ data: [], total: 0 }),
+ });
+ return;
+ }
+
+ // Default fallback — return a safe empty object.
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ data: [], items: [], total: 0 }),
+ });
+ });
+}
+
+test.describe('Responsive — analysis pages @375px', () => {
+ test.use({ viewport: { width: 375, height: 812 } });
+
+ test.beforeEach(async ({ page }) => {
+ await seedAuthAndMockApi(page);
+ });
+
+ for (const { route, testid } of ANALYSIS_PAGES) {
+ test(`${route} mounts and has no horizontal scroll at 375px`, async ({ page }) => {
+ await page.goto(route);
+
+ // Page must mount.
+ await expect(page.locator(`[data-testid="${testid}"]`)).toBeVisible();
+
+ // No horizontal overflow: scrollWidth must not exceed clientWidth (+1px slack
+ // for sub-pixel rounding).
+ const noHorizontalScroll = await page.evaluate(
+ () =>
+ document.documentElement.scrollWidth <=
+ document.documentElement.clientWidth + 1
+ );
+ expect(noHorizontalScroll, `${route} overflows horizontally at 375px`).toBe(true);
+ });
+ }
+});
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts
index 759e23a..2de4010 100644
--- a/frontend/playwright.config.ts
+++ b/frontend/playwright.config.ts
@@ -14,6 +14,16 @@ export default defineConfig({
projects: [
{
name: 'chromium',
+ // Functional suite. Exclude the throttled perf spec so emulated Fast-3G
+ // latency never bleeds into (or slows) the normal acceptance run.
+ testIgnore: /perf\.spec\.ts/,
+ use: { ...devices['Desktop Chrome'] },
+ },
+ {
+ // Dedicated perf project — only perf.spec.ts runs here, under CDP network
+ // throttling. Kept separate so functional and perf measurements don't mix.
+ name: 'perf',
+ testMatch: /perf\.spec\.ts/,
use: { ...devices['Desktop Chrome'] },
},
],
diff --git a/frontend/src/components/alerts/AlertDetailModal.tsx b/frontend/src/components/alerts/AlertDetailModal.tsx
new file mode 100644
index 0000000..9b736ae
--- /dev/null
+++ b/frontend/src/components/alerts/AlertDetailModal.tsx
@@ -0,0 +1,108 @@
+import React from 'react';
+import type { CellInfo } from '@/components/AlertMap';
+import { HORIZON_LABELS } from './types';
+import type { ExtendedAlert } from './types';
+
+interface CellInfoPanelProps {
+ cellInfo: CellInfo;
+ onClose: () => void;
+}
+
+// Cell info panel - shown when clicking grid cell without alert
+export const CellInfoPanel = React.memo(function CellInfoPanel({ cellInfo, onClose }: CellInfoPanelProps) {
+ return (
+
+
+ 网格详情 (100m)
+
+
+
+
+ 网格
+ {cellInfo.grid_id}
+
+
+ 坐标
+ {cellInfo.lat.toFixed(4)}, {cellInfo.lon.toFixed(4)}
+
+
+ 当前风险
+ = 0.8 ? 'text-danger' : cellInfo.risk >= 0.6 ? 'text-warning' : cellInfo.risk >= 0.4 ? 'text-primary' : 'text-success'}`}>
+ {(cellInfo.risk * 100).toFixed(1)}%
+
+
+
+
+
1天
+
{(cellInfo.risk_1d * 100).toFixed(0)}%
+
+
+
3天
+
{(cellInfo.risk_3d * 100).toFixed(0)}%
+
+
+
7天
+
{(cellInfo.risk_7d * 100).toFixed(0)}%
+
+
+ {cellInfo.nearestAlertId && (
+
+ 最近预警距离
+ {(cellInfo.nearestAlertDist * 111).toFixed(1)} km
+
+ )}
+ {!cellInfo.nearestAlertId && (
+
+ 该区域无预警
+
+ )}
+
+
+ );
+});
+
+interface AlertDetailModalProps {
+ alert: ExtendedAlert;
+ onClose: () => void;
+}
+
+// Alert detail modal
+export const AlertDetailModal = React.memo(function AlertDetailModal({ alert, onClose }: AlertDetailModalProps) {
+ return (
+
+
e.stopPropagation()}>
+
预警详情
+
+
+ 优先级
+
+ {alert.priority}
+
+
+
+ 风险值
+ {Math.round(alert.risk_value * 100)}%
+
+
+ 预测时效
+ {HORIZON_LABELS[alert.forecast_horizon]}
+
+
+ 位置
+ {alert.region}
+
+
+
预警原因
+
{alert.reason}
+
+
+
+
+
+ );
+});
diff --git a/frontend/src/components/alerts/AlertsFilterBar.tsx b/frontend/src/components/alerts/AlertsFilterBar.tsx
new file mode 100644
index 0000000..0bfe700
--- /dev/null
+++ b/frontend/src/components/alerts/AlertsFilterBar.tsx
@@ -0,0 +1,194 @@
+import React from 'react';
+import { TESTIDS } from '@/utils/testids';
+import { DiseaseFilter } from '@/components/DiseaseFilter';
+import { HORIZON_LABELS } from './types';
+
+interface AlertsFilterBarProps {
+ selectedHorizon: number | 'all';
+ onHorizonChange: (horizon: number | 'all') => void;
+ selectedPriority: 'all' | 'P1' | 'P2';
+ onPriorityChange: (priority: 'all' | 'P1' | 'P2') => void;
+ riskRange: [number, number];
+ onRiskRangeChange: (range: [number, number]) => void;
+ showMap: boolean;
+ onToggleMap: () => void;
+ showAlertMarkers: boolean;
+ onToggleAlertMarkers: () => void;
+ showGrid: boolean;
+ onToggleGrid: () => void;
+ sortBy: 'risk' | 'time';
+ onSortByChange: (sortBy: 'risk' | 'time') => void;
+ // 视角驱动的两条不变量(结果由 orchestrator 计算后下传):
+ isCluster: boolean; // 聚类(医生)视角:隐藏「预警标记」切换 + 挂载病种过滤
+ isOfficial: boolean; // 官员视角:隐藏网格切换
+}
+
+// Toolbar Row 2: Filters (时效/优先级/风险值/图层切换/排序).
+export const AlertsFilterBar = React.memo(function AlertsFilterBar({
+ selectedHorizon,
+ onHorizonChange,
+ selectedPriority,
+ onPriorityChange,
+ riskRange,
+ onRiskRangeChange,
+ showMap,
+ onToggleMap,
+ showAlertMarkers,
+ onToggleAlertMarkers,
+ showGrid,
+ onToggleGrid,
+ sortBy,
+ onSortByChange,
+ isCluster,
+ isOfficial,
+}: AlertsFilterBarProps) {
+ return (
+
+
+
+
预测时效:
+
+ {(['all', 1, 3, 7] as const).map((horizon) => (
+
+ ))}
+
+
+
+
+
+
+
优先级:
+
+ {(['all', 'P1', 'P2'] as const).map((priority) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ {/* 预警标记切换:聚类(医生)视角隐藏整块——个体病例点不可开启(隐私不变量)。 */}
+ {!isCluster && (
+
+ )}
+ {/* 网格切换:官员视角隐藏整块(100m 网格对其无意义/太超前)。 */}
+ {!isOfficial && (
+
+
+
+ )}
+ {/* 聚类(医生)视角:病种过滤是其核心工具,挂载于此。 */}
+ {isCluster &&
}
+
+
+
+
+
+
排序:
+
+
+
+
+
+
+
+ );
+});
diff --git a/frontend/src/components/alerts/AlertsHeader.tsx b/frontend/src/components/alerts/AlertsHeader.tsx
new file mode 100644
index 0000000..aefa76b
--- /dev/null
+++ b/frontend/src/components/alerts/AlertsHeader.tsx
@@ -0,0 +1,57 @@
+import React from 'react';
+
+interface AlertsHeaderProps {
+ total: number;
+ p1: number;
+ p2: number;
+ activeTab: 'list' | 'stats';
+ onTabChange: (tab: 'list' | 'stats') => void;
+}
+
+// 页头(标题 + 计数)+ 页内 tab 切换条(不走 router)。
+export const AlertsHeader = React.memo(function AlertsHeader({
+ total,
+ p1,
+ p2,
+ activeTab,
+ onTabChange,
+}: AlertsHeaderProps) {
+ return (
+ <>
+ {/* Header */}
+
+
+
风险预警
+
+ 100m网格风险预测 · 多时间尺度预警 · 病例-气象关联分析
+
+
+
+ 共 {total} 条预警
+ P1: {p1}
+ P2: {p2}
+
+
+
+ {/* Tab strip — in-page, no router */}
+
+ {([
+ { key: 'list', label: '预警列表' },
+ { key: 'stats', label: '风险统计' },
+ ] as const).map((tab) => (
+
+ ))}
+
+ >
+ );
+});
diff --git a/frontend/src/components/alerts/AlertsList.tsx b/frontend/src/components/alerts/AlertsList.tsx
new file mode 100644
index 0000000..2ec5998
--- /dev/null
+++ b/frontend/src/components/alerts/AlertsList.tsx
@@ -0,0 +1,138 @@
+import React, { useCallback } from 'react';
+import { HORIZON_LABELS } from './types';
+import type { ExtendedAlert, RiskStats } from './types';
+
+interface RiskDistributionSummaryProps {
+ riskStats: RiskStats;
+ total: number;
+}
+
+// 预警列表 tab 顶部的风险分布概要(4 卡)。
+export const RiskDistributionSummary = React.memo(function RiskDistributionSummary({
+ riskStats,
+ total,
+}: RiskDistributionSummaryProps) {
+ return (
+
+
+
高风险 (≥0.8)
+
{riskStats.high}
+
+
0 ? (riskStats.high / total) * 100 : 0}%` }} />
+
+
+
+
中高风险 (0.6-0.8)
+
{riskStats.mediumHigh}
+
+
0 ? (riskStats.mediumHigh / total) * 100 : 0}%` }} />
+
+
+
+
中风险 (0.4-0.6)
+
{riskStats.medium}
+
+
0 ? (riskStats.medium / total) * 100 : 0}%` }} />
+
+
+
+
平均风险
+
{(riskStats.avgRisk * 100).toFixed(1)}%
+
+ 高风险区域: {riskStats.topDistricts.slice(0, 2).map(([d, n]) => `${d}(${n})`).join(', ')}
+
+
+
+ );
+});
+
+interface AlertCardProps {
+ alert: ExtendedAlert;
+ isSelected?: boolean;
+ alertId: string;
+ onCardClick: (id: string) => void;
+}
+
+const AlertCard = React.memo(function AlertCard({ alert, isSelected, alertId, onCardClick }: AlertCardProps) {
+ const isP1 = alert.priority === 'P1';
+ const riskPercent = Math.round(alert.risk_value * 100);
+
+ const handleClick = useCallback(() => {
+ onCardClick(alertId);
+ }, [alertId, onCardClick]);
+
+ return (
+
+
+
+
+
+
+ {alert.priority}
+
+
+ {HORIZON_LABELS[alert.forecast_horizon] || '未知'}
+
+
+
+ {riskPercent}%
+
+
+
+
+
+
+
+ {alert.region} - {alert.street}
+
+
+ 网格:{alert.grid_id}
+
+
+
+
+ {alert.reason}
+
+
+
+ 预测时间:{alert.forecast_time}
+ 生成:{alert.timestamp}
+
+
+
+ );
+});
+
+interface AlertsListProps {
+ filteredAlerts: ExtendedAlert[];
+ selectedAlert: string | null;
+ onCardClick: (id: string) => void;
+}
+
+export const AlertsList = React.memo(function AlertsList({ filteredAlerts, selectedAlert, onCardClick }: AlertsListProps) {
+ return (
+
+ {filteredAlerts.slice(0, 50).map((alert) => (
+
+ ))}
+ {filteredAlerts.length > 50 && (
+
+ 还有 {filteredAlerts.length - 50} 条预警未显示
+
+ )}
+
+ );
+});
diff --git a/frontend/src/components/alerts/AlertsListTab.tsx b/frontend/src/components/alerts/AlertsListTab.tsx
new file mode 100644
index 0000000..ce502e5
--- /dev/null
+++ b/frontend/src/components/alerts/AlertsListTab.tsx
@@ -0,0 +1,129 @@
+import React from 'react';
+import { LoadingState } from '@/components/ui';
+import type { CellInfo } from '@/components/AlertMap';
+import { AlertsToolbar } from './AlertsToolbar';
+import { AlertsFilterBar } from './AlertsFilterBar';
+import { AlertsMapPanel } from './AlertsMapPanel';
+import { AlertsList, RiskDistributionSummary } from './AlertsList';
+import type { ExtendedAlert, RiskStats } from './types';
+
+interface AlertsListTabProps {
+ // toolbar
+ forecastDay: 1 | 3 | 7;
+ onForecastDayChange: (day: 1 | 3 | 7) => void;
+ isFullscreen: boolean;
+ onToggleFullscreen: () => void;
+ onExportCsv: () => void;
+ onExportJson: () => void;
+ // filter bar
+ selectedHorizon: number | 'all';
+ onHorizonChange: (horizon: number | 'all') => void;
+ selectedPriority: 'all' | 'P1' | 'P2';
+ onPriorityChange: (priority: 'all' | 'P1' | 'P2') => void;
+ riskRange: [number, number];
+ onRiskRangeChange: (range: [number, number]) => void;
+ showMap: boolean;
+ onToggleMap: () => void;
+ showAlertMarkers: boolean;
+ onToggleAlertMarkers: () => void;
+ showGrid: boolean;
+ onToggleGrid: () => void;
+ sortBy: 'risk' | 'time';
+ onSortByChange: (sortBy: 'risk' | 'time') => void;
+ // data
+ riskStats: RiskStats;
+ filteredAlerts: ExtendedAlert[];
+ isLoading: boolean;
+ selectedGridId: string | null;
+ selectedAlert: string | null;
+ onGridClick: (gridId: string) => void;
+ onCellInfo: (info: CellInfo) => void;
+ onCardClick: (id: string) => void;
+ // privacy/role results (computed by orchestrator)
+ effectiveShowAlertMarkers: boolean;
+ isCluster: boolean;
+ isOfficial: boolean;
+}
+
+export const AlertsListTab = React.memo(function AlertsListTab(props: AlertsListTabProps) {
+ const {
+ filteredAlerts,
+ isLoading,
+ isCluster,
+ isFullscreen,
+ showMap,
+ riskStats,
+ } = props;
+
+ return (
+ <>
+
+
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : filteredAlerts.length === 0 && !isCluster ? (
+ // 聚类(医生)视角即使没有个体预警,也要展示聚合密度栅格——故不走空状态分支。
+
+ ) : (
+
+ {showMap && (
+
+ )}
+ {!isFullscreen && (
+
+ )}
+
+ )}
+ >
+ );
+});
diff --git a/frontend/src/components/alerts/AlertsMapPanel.tsx b/frontend/src/components/alerts/AlertsMapPanel.tsx
new file mode 100644
index 0000000..8fff684
--- /dev/null
+++ b/frontend/src/components/alerts/AlertsMapPanel.tsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import { TESTIDS } from '@/utils/testids';
+import { AlertMap } from '@/components/AlertMap';
+import type { CellInfo } from '@/components/AlertMap';
+import type { ExtendedAlert } from './types';
+
+interface AlertsMapPanelProps {
+ selectedGridId: string | null;
+ onGridClick: (gridId: string) => void;
+ onCellInfo: (info: CellInfo) => void;
+ forecastDay: 1 | 3 | 7;
+ // effectiveShowAlertMarkers:唯一真值,cluster 模式恒为 false(隐私不变量),由 orchestrator 计算。
+ effectiveShowAlertMarkers: boolean;
+ showGrid: boolean;
+ filteredAlerts: ExtendedAlert[];
+ isFullscreen: boolean;
+ isCluster: boolean;
+ isOfficial: boolean;
+}
+
+export const AlertsMapPanel = React.memo(function AlertsMapPanel({
+ selectedGridId,
+ onGridClick,
+ onCellInfo,
+ forecastDay,
+ effectiveShowAlertMarkers,
+ showGrid,
+ filteredAlerts,
+ isFullscreen,
+ isCluster,
+ isOfficial,
+}: AlertsMapPanelProps) {
+ return (
+
+
+ {/*
+ 隐私不变量的「数据级」可断言点:每渲染一个个体病例点标记,就在此输出一个
+ data-testid="patient-point" 的隐藏标记。Leaflet 的 CircleMarker 是 canvas/SVG
+ 内部对象、不带 testid,无法被 e2e 直接计数;这里把「实际会显示的个体点集合」
+ 镜像成 DOM,使测试可断言医生/聚类视角下 patient-point 计数恒为 0,
+ 而无需窥探 Leaflet 内部。effectiveShowAlertMarkers 在 cluster 模式恒为 false,
+ 故该集合为空。
+ */}
+ {effectiveShowAlertMarkers &&
+ filteredAlerts.map((a) => (
+
+ ))}
+
+ );
+});
diff --git a/frontend/src/components/alerts/AlertsRiskPanel.tsx b/frontend/src/components/alerts/AlertsRiskPanel.tsx
new file mode 100644
index 0000000..99d2128
--- /dev/null
+++ b/frontend/src/components/alerts/AlertsRiskPanel.tsx
@@ -0,0 +1,130 @@
+import React, { useMemo } from 'react';
+import { LoadingState } from '@/components/ui';
+import { StatCard } from '@/components/StatCard';
+import { StatisticalCharts } from '@/components/StatisticalCharts';
+import { PieChart, Pie, Cell, Tooltip as RechartsTooltip, Legend, ResponsiveContainer } from 'recharts';
+import type { RiskStats } from './types';
+
+interface AlertsRiskPanelProps {
+ riskStats: RiskStats;
+ trendData: Array<{ date: string; cases: number; risk: number }>;
+ trendLoading: boolean;
+ trendError: string | null;
+}
+
+export const AlertsRiskPanel = React.memo(function AlertsRiskPanel({
+ riskStats,
+ trendData,
+ trendLoading,
+ trendError,
+}: AlertsRiskPanelProps) {
+ // Severity donut data (P1/P2)
+ const alertPie = useMemo(() => ([
+ { name: 'P1 (紧急)', value: riskStats.p1, color: '#ef4444' },
+ { name: 'P2 (关注)', value: riskStats.p2, color: '#f59e0b' },
+ ]), [riskStats.p1, riskStats.p2]);
+
+ const topDistrictMax = useMemo(
+ () => riskStats.topDistricts.reduce((m, [, n]) => Math.max(m, n), 0),
+ [riskStats.topDistricts],
+ );
+
+ return (
+
+ {/* Risk distribution as StatCards */}
+
+
+
+
+
+
+
+ {/* Risk trend chart (real data from /api/analysis/trend) */}
+ {trendLoading ? (
+
+ ) : trendError ? (
+
{trendError}
+ ) : trendData.length === 0 ? (
+
暂无风险趋势数据
+ ) : (
+
+ )}
+
+
+ {/* Top high-risk districts bar */}
+
+
+ 高风险区域 Top 5
+
+ {riskStats.topDistricts.length > 0 ? (
+
+ {riskStats.topDistricts.map(([district, count]) => (
+
+
+ {district}
+ {count} 条
+
+
+
0 ? (count / topDistrictMax) * 100 : 0}%` }}
+ />
+
+
+ ))}
+
+ ) : (
+
暂无区域数据
+ )}
+
+
+ {/* Alert severity donut (P1/P2) */}
+
+
+ 预警严重度分布
+
+ {riskStats.p1 > 0 || riskStats.p2 > 0 ? (
+
+
+
+ {alertPie.map((entry) => (
+ |
+ ))}
+
+ [value, name]}
+ />
+
+
+ ) : (
+
暂无预警数据
+ )}
+
+
+
+ );
+});
diff --git a/frontend/src/components/alerts/AlertsToolbar.tsx b/frontend/src/components/alerts/AlertsToolbar.tsx
new file mode 100644
index 0000000..3a4b214
--- /dev/null
+++ b/frontend/src/components/alerts/AlertsToolbar.tsx
@@ -0,0 +1,73 @@
+import React from 'react';
+
+interface AlertsToolbarProps {
+ forecastDay: 1 | 3 | 7;
+ onForecastDayChange: (day: 1 | 3 | 7) => void;
+ isFullscreen: boolean;
+ onToggleFullscreen: () => void;
+ onExportCsv: () => void;
+ onExportJson: () => void;
+}
+
+// Toolbar Row 1: 网格预测时效 + 全屏 + 导出.
+export const AlertsToolbar = React.memo(function AlertsToolbar({
+ forecastDay,
+ onForecastDayChange,
+ isFullscreen,
+ onToggleFullscreen,
+ onExportCsv,
+ onExportJson,
+}: AlertsToolbarProps) {
+ return (
+
+
+
+
网格预测:
+
+ {([1, 3, 7] as const).map((day) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+});
diff --git a/frontend/src/components/alerts/types.ts b/frontend/src/components/alerts/types.ts
new file mode 100644
index 0000000..6ac271d
--- /dev/null
+++ b/frontend/src/components/alerts/types.ts
@@ -0,0 +1,32 @@
+// Shared types for the alerts dashboard subcomponents.
+export interface ExtendedAlert {
+ alert_id: string;
+ grid_id: string;
+ region: string;
+ street: string;
+ latitude: number;
+ longitude: number;
+ risk_value: number;
+ risk_level: 'high' | 'medium_high' | 'medium' | 'medium_low' | 'low';
+ priority: 'P1' | 'P2';
+ forecast_horizon: number;
+ forecast_time: string;
+ reason: string;
+ timestamp: string;
+}
+
+export const HORIZON_LABELS: Record
= {
+ 1: '1 天后',
+ 3: '3 天后',
+ 7: '7 天后',
+};
+
+export interface RiskStats {
+ p1: number;
+ p2: number;
+ high: number;
+ mediumHigh: number;
+ medium: number;
+ avgRisk: number;
+ topDistricts: [string, number][];
+}
diff --git a/frontend/src/components/alerts/useAlertsData.ts b/frontend/src/components/alerts/useAlertsData.ts
new file mode 100644
index 0000000..47a5842
--- /dev/null
+++ b/frontend/src/components/alerts/useAlertsData.ts
@@ -0,0 +1,163 @@
+import { useState, useMemo, useEffect, useCallback } from 'react';
+import { useRiskStore } from '@/stores';
+import { analysisApi } from '@/services/api';
+import type { ExtendedAlert, RiskStats } from './types';
+
+interface UseAlertsDataParams {
+ selectedHorizon: number | 'all';
+ selectedPriority: 'all' | 'P1' | 'P2';
+ sortBy: 'risk' | 'time';
+ debouncedRiskRange: [number, number];
+ activeTab: 'list' | 'stats';
+}
+
+interface TrendPoint { date: string; cases: number; risk: number }
+
+// 预警仪表盘的数据层:派生 extendedAlerts/filteredAlerts/riskStats、按需拉取风险趋势、
+// 以及 CSV/JSON 导出辅助。角色/隐私计算保留在 orchestrator,不在此处。
+export function useAlertsData({
+ selectedHorizon,
+ selectedPriority,
+ sortBy,
+ debouncedRiskRange,
+ activeTab,
+}: UseAlertsDataParams) {
+ const alerts = useRiskStore((s) => s.alerts);
+
+ // Risk-trend data for the 风险统计 tab, fetched on demand
+ const [trendData, setTrendData] = useState([]);
+ const [trendLoading, setTrendLoading] = useState(false);
+ const [trendError, setTrendError] = useState(null);
+ const [trendLoaded, setTrendLoaded] = useState(false);
+
+ // Fetch real risk-trend data when the 风险统计 tab is first opened
+ useEffect(() => {
+ if (activeTab !== 'stats' || trendLoaded) return;
+ let cancelled = false;
+ setTrendLoading(true);
+ setTrendError(null);
+ analysisApi
+ .getTrend(14)
+ .then((res: { dates?: string[]; values?: number[] }) => {
+ if (cancelled) return;
+ const dates = res?.dates ?? [];
+ const values = res?.values ?? [];
+ setTrendData(dates.map((date, i) => ({ date, cases: 0, risk: values[i] ?? 0 })));
+ setTrendLoaded(true);
+ })
+ .catch((err: unknown) => {
+ if (cancelled) return;
+ setTrendError(err instanceof Error ? err.message : '加载风险趋势失败');
+ })
+ .finally(() => {
+ if (!cancelled) setTrendLoading(false);
+ });
+ return () => { cancelled = true; };
+ }, [activeTab, trendLoaded]);
+
+ const extendedAlerts: ExtendedAlert[] = useMemo(() => {
+ const now = Date.now();
+ return (alerts || []).map((alert) => {
+ const forecastDate = new Date(alert.forecast_time);
+ const diffDays = Math.ceil((forecastDate.getTime() - now) / (1000 * 60 * 60 * 24));
+ const horizon = diffDays <= 1 ? 1 : diffDays <= 3 ? 3 : 7;
+
+ return {
+ ...alert,
+ latitude: alert.latitude || 0,
+ longitude: alert.longitude || 0,
+ forecast_horizon: horizon,
+ };
+ });
+ }, [alerts]);
+
+ const filteredAlerts = useMemo(() => {
+ return extendedAlerts
+ .filter((alert) => {
+ const horizonMatch = selectedHorizon === 'all' || alert.forecast_horizon === selectedHorizon;
+ const priorityMatch = selectedPriority === 'all' || alert.priority === selectedPriority;
+ const riskMatch = alert.risk_value >= debouncedRiskRange[0] && alert.risk_value <= debouncedRiskRange[1];
+ return horizonMatch && priorityMatch && riskMatch;
+ })
+ .sort((a, b) => {
+ if (sortBy === 'risk') {
+ return b.risk_value - a.risk_value;
+ }
+ return new Date(b.forecast_time).getTime() - new Date(a.forecast_time).getTime();
+ });
+ }, [extendedAlerts, selectedHorizon, selectedPriority, sortBy, debouncedRiskRange]);
+
+ // Risk distribution stats (includes p1/p2 counts) — single pass over each array
+ const riskStats: RiskStats = useMemo(() => {
+ // p1/p2 reflect the full (unfiltered) alert set
+ let p1 = 0;
+ let p2 = 0;
+ for (const a of extendedAlerts) {
+ if (a.priority === 'P1') p1++;
+ else if (a.priority === 'P2') p2++;
+ }
+
+ // Single pass over filteredAlerts: counters + sum + district map
+ let high = 0;
+ let mediumHigh = 0;
+ let medium = 0;
+ let sum = 0;
+ const byDistrict: Record = {};
+ for (const a of filteredAlerts) {
+ const v = a.risk_value;
+ if (v >= 0.8) high++;
+ else if (v >= 0.6) mediumHigh++;
+ else if (v >= 0.4) medium++;
+ sum += v;
+ const d = a.region || '未知';
+ byDistrict[d] = (byDistrict[d] || 0) + 1;
+ }
+ const avgRisk = filteredAlerts.length > 0 ? sum / filteredAlerts.length : 0;
+
+ const topDistricts = Object.entries(byDistrict)
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 5);
+
+ return { p1, p2, high, mediumHigh, medium, avgRisk, topDistricts };
+ }, [extendedAlerts, filteredAlerts]);
+
+ // Export utilities
+ const exportToCsv = useCallback(() => {
+ const headers = ['alert_id', 'grid_id', 'region', 'street', 'latitude', 'longitude', 'risk_value', 'priority', 'forecast_horizon', 'reason', 'timestamp'];
+ const rows = filteredAlerts.map(a => [
+ a.alert_id, a.grid_id, a.region, a.street,
+ a.latitude, a.longitude, a.risk_value, a.priority,
+ a.forecast_horizon, `"${a.reason}"`, a.timestamp,
+ ]);
+ const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
+ const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `alerts_${new Date().toISOString().split('T')[0]}.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+ }, [filteredAlerts]);
+
+ const exportToJson = useCallback(() => {
+ const json = JSON.stringify(filteredAlerts, null, 2);
+ const blob = new Blob([json], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `alerts_${new Date().toISOString().split('T')[0]}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ }, [filteredAlerts]);
+
+ return {
+ extendedAlerts,
+ filteredAlerts,
+ riskStats,
+ trendData,
+ trendLoading,
+ trendError,
+ exportToCsv,
+ exportToJson,
+ };
+}
diff --git a/frontend/src/components/monitoring/CaseStatsTab.tsx b/frontend/src/components/monitoring/CaseStatsTab.tsx
new file mode 100644
index 0000000..9cdb9ce
--- /dev/null
+++ b/frontend/src/components/monitoring/CaseStatsTab.tsx
@@ -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 (
+
+ );
+ }
+
+ return (
+
+ {error && (
+
+ )}
+
+ {/* Top 5 诊断分布 */}
+
+
Top 5 诊断分布
+ {topDiagnoses.length > 0 ? (
+
+
+
+
+
+ [value.toLocaleString(), '病例数']}
+ />
+
+
+
+
+
+ ) : (
+
暂无数据
+ )}
+
+
+ {/* 近30日病例与AQI趋势 (driven off Monitoring timeline currentDate) */}
+
+
病例与AQI趋势
+
截至 {currentDate} 的近30日窗口
+ {caseTrend.length > 0 ? (
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
暂无数据
+ )}
+
+
+ {/* 日历热力图 (year derived from data) */}
+
+
+ {heatmapYear ? `${heatmapYear}年 ` : ''}每日病例日历
+
+ {heatmapYear && heatmapData.length > 0 ? (
+
+ ) : (
+
暂无数据
+ )}
+
+
+ );
+});
diff --git a/frontend/src/components/monitoring/DistrictStatsTab.tsx b/frontend/src/components/monitoring/DistrictStatsTab.tsx
new file mode 100644
index 0000000..ed64d29
--- /dev/null
+++ b/frontend/src/components/monitoring/DistrictStatsTab.tsx
@@ -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>;
+ 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 (
+
+ );
+ }
+
+ return (
+
+ {error && (
+
+ )}
+
+
+
区域指标热力表
+
点击列标题排序
+ {rows.length > 0 ? (
+
+ ) : (
+
暂无数据
+ )}
+
+
+ );
+});
diff --git a/frontend/src/components/monitoring/MonitoringStatsBar.tsx b/frontend/src/components/monitoring/MonitoringStatsBar.tsx
new file mode 100644
index 0000000..26f36be
--- /dev/null
+++ b/frontend/src/components/monitoring/MonitoringStatsBar.tsx
@@ -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 (
+
+
}
+ label="当日病例"
+ value={stats.todayCases !== null ? stats.todayCases.toLocaleString() : '--'}
+ />
+
}
+ label="7日均值"
+ value={stats.avg7d.toLocaleString()}
+ sparkline={sparkline7d.length >= 2 ? { data: sparkline7d, color: '#6366F1' } : undefined}
+ />
+
:
+ stats.trend === 'down' ?
:
+
+ }
+ 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' ? '下降' : '平稳',
+ }}
+ />
+
}
+ label="峰值日"
+ value={`${stats.maxDay.cases.toLocaleString()} (${stats.maxDay.date.slice(5)})`}
+ />
+
}
+ label="标准差"
+ value={stats.stdDev.toLocaleString()}
+ />
+
}
+ label="门诊 / 住院"
+ value={`${stats.totalOutpatient.toLocaleString()} / ${stats.totalInpatient.toLocaleString()}`}
+ />
+
+ );
+});
diff --git a/frontend/src/components/monitoring/OverviewTab.tsx b/frontend/src/components/monitoring/OverviewTab.tsx
new file mode 100644
index 0000000..8af736b
--- /dev/null
+++ b/frontend/src/components/monitoring/OverviewTab.tsx
@@ -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 (
+
+ );
+ }
+
+ return (
+
+ {/* Case Location Map */}
+
+
病例分布地图
+
+
+
+ {/* Statistical Charts */}
+
+
+ {/* District breakdown — 区域 roll-up(URL 粒度真相来源) */}
+
+
+
区县病例分布
+
+ testid={TESTIDS.granularityControl}
+ size="sm"
+ options={[
+ { value: 'city', label: '全市' },
+ { value: 'district', label: '区域' },
+ { value: 'street', label: '街道' },
+ ]}
+ value={granularity}
+ onChange={onGranularityChange}
+ />
+
+
+
+
+
+
+
+ );
+});
+
+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 (
+ handleDistrictClick(d.district)}
+ >
+
{d.district}
+
+
+ {d.total.toLocaleString()}
+
+
+ );
+ })}
+ >
+ );
+});
diff --git a/frontend/src/components/monitoring/types.ts b/frontend/src/components/monitoring/types.ts
new file mode 100644
index 0000000..0a128da
--- /dev/null
+++ b/frontend/src/components/monitoring/types.ts
@@ -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;
+}
diff --git a/frontend/src/components/monitoring/useMonitoringData.ts b/frontend/src/components/monitoring/useMonitoringData.ts
new file mode 100644
index 0000000..cb74c01
--- /dev/null
+++ b/frontend/src/components/monitoring/useMonitoringData.ts
@@ -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>([]);
+
+ // --- 病例统计 tab state (fetched on demand) ---
+ const [topDiagnoses, setTopDiagnoses] = useState([]);
+ const [caseTrend, setCaseTrend] = useState>([]);
+ const [heatmapData, setHeatmapData] = useState>([]);
+ const [heatmapYear, setHeatmapYear] = useState(null);
+ const [casesTabLoaded, setCasesTabLoaded] = useState(false);
+ const [casesTabLoading, setCasesTabLoading] = useState(false);
+ const [casesTabError, setCasesTabError] = useState(null);
+
+ // --- 区域统计 tab state (fetched on demand) ---
+ const [districtMetrics, setDistrictMetrics] = useState([]);
+ const [districtSortKey, setDistrictSortKey] = useState('total');
+ const [districtTabLoaded, setDistrictTabLoaded] = useState(false);
+ const [districtTabLoading, setDistrictTabLoading] = useState(false);
+ const [districtTabError, setDistrictTabError] = useState(null);
+
+ const districtCases = useMonitoringStore((s) => s.districtCases);
+ const fetchDistrictCases = useMonitoringStore((s) => s.fetchDistrictCases);
+ const { selectedDiagnoses } = useDiseaseStore();
+
+ const debounceRef = useRef | 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 = {};
+ 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(() => {
+ 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 = {};
+ 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> = {};
+ 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,
+ };
+}
diff --git a/frontend/src/pages/AlertsDashboard.tsx b/frontend/src/pages/AlertsDashboard.tsx
index 7317af0..0553df0 100644
--- a/frontend/src/pages/AlertsDashboard.tsx
+++ b/frontend/src/pages/AlertsDashboard.tsx
@@ -1,39 +1,15 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
-import { LoadingState } from '@/components/ui';
-import React from 'react';
import { useRiskStore, useSessionStore } from '@/stores';
import { TESTIDS } from '@/utils/testids';
-import { AlertMap } from '@/components/AlertMap';
-import { DiseaseFilter } from '@/components/DiseaseFilter';
import type { CellInfo } from '@/components/AlertMap';
import { ErrorBanner } from '@/components/ErrorBanner';
-import { StatCard } from '@/components/StatCard';
-import { StatisticalCharts } from '@/components/StatisticalCharts';
import { analysisApi } from '@/services/api';
-import { PieChart, Pie, Cell, Tooltip as RechartsTooltip, Legend, ResponsiveContainer } from 'recharts';
-
-interface ExtendedAlert {
- alert_id: string;
- grid_id: string;
- region: string;
- street: string;
- latitude: number;
- longitude: number;
- risk_value: number;
- risk_level: 'high' | 'medium_high' | 'medium' | 'medium_low' | 'low';
- priority: 'P1' | 'P2';
- forecast_horizon: number;
- forecast_time: string;
- reason: string;
- timestamp: string;
-}
-
-const HORIZON_LABELS: Record = {
- 1: '1 天后',
- 3: '3 天后',
- 7: '7 天后',
-};
+import { AlertsHeader } from '@/components/alerts/AlertsHeader';
+import { AlertsListTab } from '@/components/alerts/AlertsListTab';
+import { AlertsRiskPanel } from '@/components/alerts/AlertsRiskPanel';
+import { AlertDetailModal, CellInfoPanel } from '@/components/alerts/AlertDetailModal';
+import type { ExtendedAlert, RiskStats } from '@/components/alerts/types';
export function AlertsDashboard() {
// 视角驱动的两条不变量(D2:纯前端视图预设,非访问控制):
@@ -149,7 +125,7 @@ export function AlertsDashboard() {
}, [extendedAlerts, selectedHorizon, selectedPriority, sortBy, debouncedRiskRange]);
// Risk distribution stats (includes p1/p2 counts) — single pass over each array
- const riskStats = useMemo(() => {
+ const riskStats: RiskStats = useMemo(() => {
// p1/p2 reflect the full (unfiltered) alert set
let p1 = 0;
let p2 = 0;
@@ -186,17 +162,6 @@ export function AlertsDashboard() {
return filteredAlerts.find(a => a.alert_id === selectedAlert);
}, [filteredAlerts, selectedAlert]);
- // Severity donut data (P1/P2) for the 风险统计 tab
- const alertPie = useMemo(() => ([
- { name: 'P1 (紧急)', value: riskStats.p1, color: '#ef4444' },
- { name: 'P2 (关注)', value: riskStats.p2, color: '#f59e0b' },
- ]), [riskStats.p1, riskStats.p2]);
-
- const topDistrictMax = useMemo(
- () => riskStats.topDistricts.reduce((m, [, n]) => Math.max(m, n), 0),
- [riskStats.topDistricts],
- );
-
const selectedGridId = useMemo(() => {
if (!selectedAlert) return null;
const alert = filteredAlerts.find(a => a.alert_id === selectedAlert);
@@ -239,7 +204,7 @@ export function AlertsDashboard() {
a.forecast_horizon, `"${a.reason}"`, a.timestamp,
]);
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
- const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
+ const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -260,7 +225,7 @@ export function AlertsDashboard() {
}, [filteredAlerts]);
return (
-
+
{error && (
)}
- {/* Header */}
-
-
-
风险预警
-
- 100m网格风险预测 · 多时间尺度预警 · 病例-气象关联分析
-
-
-
- 共 {filteredAlerts.length} 条预警
- P1: {riskStats.p1}
- P2: {riskStats.p2}
-
-
-
- {/* Tab strip — in-page, no router */}
-
- {([
- { key: 'list', label: '预警列表' },
- { key: 'stats', label: '风险统计' },
- ] as const).map((tab) => (
-
- ))}
-
+
{activeTab === 'list' && (
- <>
- {/* Toolbar Row 1: Forecast + Fullscreen + Export */}
-
-
-
-
网格预测:
-
- {([1, 3, 7] as const).map((day) => (
-
- ))}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Toolbar Row 2: Filters */}
-
-
-
-
预测时效:
-
- {(['all', 1, 3, 7] as const).map((horizon) => (
-
- ))}
-
-
-
-
-
-
-
优先级:
-
- {(['all', 'P1', 'P2'] as const).map((priority) => (
-
- ))}
-
-
-
-
-
-
-
-
-
-
-
- {/* 预警标记切换:聚类(医生)视角隐藏整块——个体病例点不可开启(隐私不变量)。 */}
- {!isCluster && (
-
- )}
- {/* 网格切换:官员视角隐藏整块(100m 网格对其无意义/太超前)。 */}
- {!isOfficial && (
-
-
-
- )}
- {/* 聚类(医生)视角:病种过滤是其核心工具,挂载于此。 */}
- {isCluster &&
}
-
-
-
-
-
-
排序:
-
-
-
-
-
-
-
-
- {/* Risk distribution summary */}
-
-
-
高风险 (≥0.8)
-
{riskStats.high}
-
-
0 ? (riskStats.high / filteredAlerts.length) * 100 : 0}%` }} />
-
-
-
-
中高风险 (0.6-0.8)
-
{riskStats.mediumHigh}
-
-
0 ? (riskStats.mediumHigh / filteredAlerts.length) * 100 : 0}%` }} />
-
-
-
-
中风险 (0.4-0.6)
-
{riskStats.medium}
-
-
0 ? (riskStats.medium / filteredAlerts.length) * 100 : 0}%` }} />
-
-
-
-
平均风险
-
{(riskStats.avgRisk * 100).toFixed(1)}%
-
- 高风险区域: {riskStats.topDistricts.slice(0, 2).map(([d, n]) => `${d}(${n})`).join(', ')}
-
-
-
-
- {isLoading ? (
-
-
-
- ) : filteredAlerts.length === 0 && !isCluster ? (
- // 聚类(医生)视角即使没有个体预警,也要展示聚合密度栅格——故不走空状态分支。
-
- ) : (
-
- {showMap && (
-
-
- {/*
- 隐私不变量的「数据级」可断言点:每渲染一个个体病例点标记,就在此输出一个
- data-testid="patient-point" 的隐藏标记。Leaflet 的 CircleMarker 是 canvas/SVG
- 内部对象、不带 testid,无法被 e2e 直接计数;这里把「实际会显示的个体点集合」
- 镜像成 DOM,使测试可断言医生/聚类视角下 patient-point 计数恒为 0,
- 而无需窥探 Leaflet 内部。effectiveShowAlertMarkers 在 cluster 模式恒为 false,
- 故该集合为空。
- */}
- {effectiveShowAlertMarkers &&
- filteredAlerts.map((a) => (
-
- ))}
-
- )}
- {!isFullscreen && (
-
- {filteredAlerts.slice(0, 50).map((alert) => (
-
- ))}
- {filteredAlerts.length > 50 && (
-
- 还有 {filteredAlerts.length - 50} 条预警未显示
-
- )}
-
- )}
-
- )}
- >
+
setIsFullscreen(!isFullscreen)}
+ onExportCsv={exportToCsv}
+ onExportJson={exportToJson}
+ selectedHorizon={selectedHorizon}
+ onHorizonChange={setSelectedHorizon}
+ selectedPriority={selectedPriority}
+ onPriorityChange={setSelectedPriority}
+ riskRange={riskRange}
+ onRiskRangeChange={setRiskRange}
+ showMap={showMap}
+ onToggleMap={() => setShowMap(!showMap)}
+ showAlertMarkers={showAlertMarkers}
+ onToggleAlertMarkers={() => setShowAlertMarkers(!showAlertMarkers)}
+ showGrid={showGrid}
+ onToggleGrid={() => setShowGrid(!showGrid)}
+ sortBy={sortBy}
+ onSortByChange={setSortBy}
+ riskStats={riskStats}
+ filteredAlerts={filteredAlerts}
+ isLoading={isLoading}
+ selectedGridId={selectedGridId}
+ selectedAlert={selectedAlert}
+ onGridClick={handleGridClick}
+ onCellInfo={handleCellInfo}
+ onCardClick={handleAlertCardClick}
+ effectiveShowAlertMarkers={effectiveShowAlertMarkers}
+ isCluster={isCluster}
+ isOfficial={isOfficial}
+ />
)}
{activeTab === 'stats' && (
-
- {/* Risk distribution as StatCards */}
-
-
-
-
-
-
-
- {/* Risk trend chart (real data from /api/analysis/trend) */}
- {trendLoading ? (
-
- ) : trendError ? (
-
{trendError}
- ) : trendData.length === 0 ? (
-
暂无风险趋势数据
- ) : (
-
- )}
-
-
- {/* Top high-risk districts bar */}
-
-
- 高风险区域 Top 5
-
- {riskStats.topDistricts.length > 0 ? (
-
- {riskStats.topDistricts.map(([district, count]) => (
-
-
- {district}
- {count} 条
-
-
-
0 ? (count / topDistrictMax) * 100 : 0}%` }}
- />
-
-
- ))}
-
- ) : (
-
暂无区域数据
- )}
-
-
- {/* Alert severity donut (P1/P2) */}
-
-
- 预警严重度分布
-
- {riskStats.p1 > 0 || riskStats.p2 > 0 ? (
-
-
-
- {alertPie.map((entry) => (
- |
- ))}
-
- [value, name]}
- />
-
-
- ) : (
-
暂无预警数据
- )}
-
-
-
+
)}
{/* Cell info panel - shown when clicking grid cell without alert */}
{cellInfo && !selectedAlertData && (
-
-
- 网格详情 (100m)
-
-
-
-
- 网格
- {cellInfo.grid_id}
-
-
- 坐标
- {cellInfo.lat.toFixed(4)}, {cellInfo.lon.toFixed(4)}
-
-
- 当前风险
- = 0.8 ? 'text-danger' : cellInfo.risk >= 0.6 ? 'text-warning' : cellInfo.risk >= 0.4 ? 'text-primary' : 'text-success'}`}>
- {(cellInfo.risk * 100).toFixed(1)}%
-
-
-
-
-
1天
-
{(cellInfo.risk_1d * 100).toFixed(0)}%
-
-
-
3天
-
{(cellInfo.risk_3d * 100).toFixed(0)}%
-
-
-
7天
-
{(cellInfo.risk_7d * 100).toFixed(0)}%
-
-
- {cellInfo.nearestAlertId && (
-
- 最近预警距离
- {(cellInfo.nearestAlertDist * 111).toFixed(1)} km
-
- )}
- {!cellInfo.nearestAlertId && (
-
- 该区域无预警
-
- )}
-
-
+
)}
{/* Alert detail modal */}
{selectedAlertData && (
-
-
e.stopPropagation()}>
-
预警详情
-
-
- 优先级
-
- {selectedAlertData.priority}
-
-
-
- 风险值
- {Math.round(selectedAlertData.risk_value * 100)}%
-
-
- 预测时效
- {HORIZON_LABELS[selectedAlertData.forecast_horizon]}
-
-
- 位置
- {selectedAlertData.region}
-
-
-
预警原因
-
{selectedAlertData.reason}
-
-
-
-
-
+
)}
);
}
-
-interface AlertCardProps {
- alert: ExtendedAlert;
- isSelected?: boolean;
- alertId: string;
- onCardClick: (id: string) => void;
-}
-
-const AlertCard = React.memo(function AlertCard({ alert, isSelected, alertId, onCardClick }: AlertCardProps) {
- const isP1 = alert.priority === 'P1';
- const riskPercent = Math.round(alert.risk_value * 100);
-
- const handleClick = useCallback(() => {
- onCardClick(alertId);
- }, [alertId, onCardClick]);
-
- return (
-
-
-
-
-
-
- {alert.priority}
-
-
- {HORIZON_LABELS[alert.forecast_horizon] || '未知'}
-
-
-
- {riskPercent}%
-
-
-
-
-
-
-
- {alert.region} - {alert.street}
-
-
- 网格:{alert.grid_id}
-
-
-
-
- {alert.reason}
-
-
-
- 预测时间:{alert.forecast_time}
- 生成:{alert.timestamp}
-
-
-
- );
-});
diff --git a/frontend/src/pages/DiseaseAnalysis.tsx b/frontend/src/pages/DiseaseAnalysis.tsx
index cc097c9..2d81f5e 100644
--- a/frontend/src/pages/DiseaseAnalysis.tsx
+++ b/frontend/src/pages/DiseaseAnalysis.tsx
@@ -154,8 +154,9 @@ function SeasonalityHeatmap({ data }: { data: DiseaseSeasonalityPoint[] }) {
当前数据仅覆盖单月,完整季节性分析需要全年数据
)}
+
0 ? uniqueMonths : 12}, 1fr)`,
}}
@@ -198,6 +199,7 @@ function SeasonalityHeatmap({ data }: { data: DiseaseSeasonalityPoint[] }) {
))}
+
);
}
diff --git a/frontend/src/pages/DistrictComparison.tsx b/frontend/src/pages/DistrictComparison.tsx
index a357c60..69b2606 100644
--- a/frontend/src/pages/DistrictComparison.tsx
+++ b/frontend/src/pages/DistrictComparison.tsx
@@ -128,7 +128,7 @@ export function DistrictComparison() {
};
return (
-
+
{error && (
-
+
{sortedData.map((district, index) => (
diff --git a/frontend/src/pages/Insights.tsx b/frontend/src/pages/Insights.tsx
index 3abdcdf..7bddc90 100644
--- a/frontend/src/pages/Insights.tsx
+++ b/frontend/src/pages/Insights.tsx
@@ -187,7 +187,7 @@ export function Insights() {
: [];
return (
-
+
{error && (
+
{stats.map((stat) => (
@@ -232,7 +232,7 @@ export function Insights() {
)}
{insights && (
-
+
{(insights.cards || []).map((card) => {
const config = TYPE_CONFIG[card.type];
const Icon = config.icon;
diff --git a/frontend/src/pages/MonitoringDashboard.tsx b/frontend/src/pages/MonitoringDashboard.tsx
index 662c78b..9c655d0 100644
--- a/frontend/src/pages/MonitoringDashboard.tsx
+++ b/frontend/src/pages/MonitoringDashboard.tsx
@@ -1,57 +1,21 @@
-import { useEffect, useState, useMemo, useRef, useCallback, memo } from 'react';
+import { useEffect, useState, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
-import {
- LineChart,
- Line,
- BarChart,
- Bar,
- XAxis,
- YAxis,
- CartesianGrid,
- Tooltip,
- Legend,
- ResponsiveContainer,
-} from 'recharts';
-import { Calendar, Activity, TrendingUp, TrendingDown, Stethoscope, Zap, BarChart3 } from 'lucide-react';
import { useTimelineStore, useMonitoringStore } from '@/stores';
-import { useDiseaseStore } from '@/stores/diseaseStore';
import { useDrilldownStore } from '@/stores/drilldownStore';
-import { gridApi, caseApi, envApi } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import { TimelinePlayer } from '@/components/TimelinePlayer';
-import { StatisticalCharts } from '@/components/StatisticalCharts';
-import { CaseLocationMap } from '@/components/CaseLocationMap';
import { DiseaseFilter } from '@/components/DiseaseFilter';
import { AdminBreadcrumb } from '@/components/AdminBreadcrumb';
-import { StatCard } from '@/components/StatCard';
-import { CalendarHeatmap } from '@/components/CalendarHeatmap';
-import { MetricHeatmapTable } from '@/components/MetricHeatmapTable';
-import { Segmented } from '@/components/ui';
-import { TESTIDS } from '@/utils/testids';
-import type { DistrictCaseData } from '@/types';
+import { MonitoringStatsBar } from '@/components/monitoring/MonitoringStatsBar';
+import { OverviewTab } from '@/components/monitoring/OverviewTab';
+import { CaseStatsTab } from '@/components/monitoring/CaseStatsTab';
+import { DistrictStatsTab } from '@/components/monitoring/DistrictStatsTab';
+import { useMonitoringData } from '@/components/monitoring/useMonitoringData';
+import { parseGranularity } from '@/components/monitoring/types';
+import type { Granularity } from '@/components/monitoring/types';
type MonitoringTab = 'overview' | 'cases' | 'districts';
-// URL 粒度参数取值 —— 监测页的「真相来源」(source of truth)。
-// drilldownStore 由 URL 派生,不再自行持有真相。
-type Granularity = 'city' | 'district' | 'street';
-const GRANULARITY_VALUES: readonly Granularity[] = ['city', 'district', 'street'] as const;
-function parseGranularity(raw: string | null): Granularity {
- return GRANULARITY_VALUES.includes(raw as Granularity) ? (raw as Granularity) : 'city';
-}
-
-interface TopDiagnosis {
- diagnosis: string;
- outpatient: number;
- inpatient: number;
- total: number;
-}
-
-function formatDateLabel(dateStr: string): string {
- const d = new Date(dateStr);
- return `${d.getMonth() + 1}/${d.getDate()}`;
-}
-
interface MonitoringDashboardProps {
defaultStartDate?: string;
defaultEndDate?: string;
@@ -61,27 +25,9 @@ export function MonitoringDashboard({
defaultStartDate = '2022-12-01',
defaultEndDate = '2024-12-30',
}: MonitoringDashboardProps) {
- const [chartData, setChartData] = useState
>([]);
-
// In-page tab strip (local state, no router — mirrors the existing activePage pattern)
const [activeTab, setActiveTab] = useState('overview');
- // --- 病例统计 tab state (fetched on demand) ---
- const [topDiagnoses, setTopDiagnoses] = useState([]);
- const [caseTrend, setCaseTrend] = useState>([]);
- const [heatmapData, setHeatmapData] = useState>([]);
- const [heatmapYear, setHeatmapYear] = useState(null);
- const [casesTabLoaded, setCasesTabLoaded] = useState(false);
- const [casesTabLoading, setCasesTabLoading] = useState(false);
- const [casesTabError, setCasesTabError] = useState(null);
-
- // --- 区域统计 tab state (fetched on demand) ---
- const [districtMetrics, setDistrictMetrics] = useState([]);
- const [districtSortKey, setDistrictSortKey] = useState('total');
- const [districtTabLoaded, setDistrictTabLoaded] = useState(false);
- const [districtTabLoading, setDistrictTabLoading] = useState(false);
- const [districtTabError, setDistrictTabError] = useState(null);
-
const {
currentDate,
isPlaying,
@@ -92,16 +38,13 @@ export function MonitoringDashboard({
setDateRange,
} = useTimelineStore();
- const districtCases = useMonitoringStore((s) => s.districtCases);
const error = useMonitoringStore((s) => s.error);
const clearError = useMonitoringStore((s) => s.clearError);
- const fetchDistrictCases = useMonitoringStore((s) => s.fetchDistrictCases);
const isLoading = useMonitoringStore((s) => s.isLoading);
const { selectedDistrict, selectedStreet } = useDrilldownStore();
const drillDown = useDrilldownStore((s) => s.drillDown);
const resetDrillDown = useDrilldownStore((s) => s.resetDrillDown);
- const { selectedDiagnoses } = useDiseaseStore();
// --- URL 是粒度的真相来源;drilldownStore 由 URL 派生 ---
const [searchParams, setSearchParams] = useSearchParams();
@@ -169,243 +112,9 @@ export function MonitoringDashboard({
setCurrentDate(defaultEndDate);
}, [defaultStartDate, defaultEndDate, setDateRange, setCurrentDate]);
- const debounceRef = useRef | 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 = {};
- 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]);
-
- // 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(() => {
- 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 = {};
- 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> = {};
- 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]);
+ // 数据层:图表窗口 + 病例统计/区域统计两个按需 tab 的加载与派生。
+ // 不持有 URL/drilldown 真相来源,只消费 currentDate 与 selectedDistrict。
+ const data = useMonitoringData({ activeTab, currentDate, selectedDistrict });
const handleDateChange = useCallback((date: string) => {
setCurrentDate(date);
@@ -423,7 +132,7 @@ export function MonitoringDashboard({
error={error}
onRetry={() => {
clearError();
- loadChartData(currentDate, selectedDistrict || undefined);
+ data.loadChartData(currentDate, selectedDistrict || undefined);
}}
onDismiss={clearError}
/>
@@ -432,59 +141,14 @@ export function MonitoringDashboard({
{/* Top stats bar — standardized with StatCard */}
-
-
}
- label="当日病例"
- value={stats.todayCases !== null ? stats.todayCases.toLocaleString() : '--'}
- />
-
}
- label="7日均值"
- value={stats.avg7d.toLocaleString()}
- sparkline={sparkline7d.length >= 2 ? { data: sparkline7d, color: '#6366F1' } : undefined}
- />
-
:
- stats.trend === 'down' ?
:
-
- }
- 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' ? '下降' : '平稳',
- }}
- />
-
}
- label="峰值日"
- value={`${stats.maxDay.cases.toLocaleString()} (${stats.maxDay.date.slice(5)})`}
- />
-
}
- label="标准差"
- value={stats.stdDev.toLocaleString()}
- />
-
}
- label="门诊 / 住院"
- value={`${stats.totalOutpatient.toLocaleString()} / ${stats.totalInpatient.toLocaleString()}`}
- />
-
+
{/* Disease filter */}
- {stats.noData && (
+ {data.stats.noData && (
该时段暂无数据
)}
-
{
- if (debounceRef.current) clearTimeout(debounceRef.current);
- debounceRef.current = setTimeout(() => {
- loadChartData(currentDate, selectedDistrict || undefined);
- }, 300);
- }} />
+
@@ -515,195 +179,47 @@ export function MonitoringDashboard({
{/* 概览 tab — unchanged Monitoring content */}
{activeTab === 'overview' && (
- isLoading ? (
-
- ) : (
-
- {/* Case Location Map */}
-
-
病例分布地图
-
-
-
- {/* Statistical Charts */}
-
-
- {/* District breakdown — 区域 roll-up(URL 粒度真相来源) */}
-
-
-
区县病例分布
-
- testid={TESTIDS.granularityControl}
- size="sm"
- options={[
- { value: 'city', label: '全市' },
- { value: 'district', label: '区域' },
- { value: 'street', label: '街道' },
- ]}
- value={granularity}
- onChange={handleGranularityChange}
- />
-
-
-
-
-
-
-
- )
+
)}
{/* 病例统计 tab */}
{activeTab === 'cases' && (
- casesTabLoading && !casesTabLoaded ? (
-
- ) : (
-
- {casesTabError && (
-
loadCasesTab(currentDate)}
- onDismiss={() => setCasesTabError(null)}
- />
- )}
-
- {/* Top 5 诊断分布 */}
-
-
Top 5 诊断分布
- {topDiagnoses.length > 0 ? (
-
-
-
-
-
- [value.toLocaleString(), '病例数']}
- />
-
-
-
-
-
- ) : (
-
暂无数据
- )}
-
-
- {/* 近30日病例与AQI趋势 (driven off Monitoring timeline currentDate) */}
-
-
病例与AQI趋势
-
截至 {currentDate} 的近30日窗口
- {caseTrend.length > 0 ? (
-
-
-
-
-
-
-
-
-
-
-
-
- ) : (
-
暂无数据
- )}
-
-
- {/* 日历热力图 (year derived from data) */}
-
-
- {heatmapYear ? `${heatmapYear}年 ` : ''}每日病例日历
-
- {heatmapYear && heatmapData.length > 0 ? (
-
- ) : (
-
暂无数据
- )}
-
-
- )
+
data.loadCasesTab(currentDate)}
+ onDismissError={() => data.setCasesTabError(null)}
+ />
)}
{/* 区域统计 tab */}
{activeTab === 'districts' && (
- districtTabLoading && !districtTabLoaded ? (
-
- ) : (
-
- {districtTabError && (
-
loadDistrictTab()}
- onDismiss={() => setDistrictTabError(null)}
- />
- )}
-
-
-
区域指标热力表
-
点击列标题排序
- {districtTableRows.length > 0 ? (
-
setDistrictSortKey(col)}
- />
- ) : (
- 暂无数据
- )}
-
-
- )
+ data.loadDistrictTab()}
+ onDismissError={() => data.setDistrictTabError(null)}
+ onSort={(col) => data.setDistrictSortKey(col)}
+ />
)}
@@ -721,55 +237,3 @@ export function MonitoringDashboard({
);
}
-
-interface DistrictBreakdownProps {
- districtCases: Array<{ district: string; total: number; outpatient: number; inpatient: number }>;
- 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 (
- handleDistrictClick(d.district)}
- >
-
{d.district}
-
-
- {d.total.toLocaleString()}
-
-
- );
- })}
- >
- );
-});
diff --git a/frontend/src/pages/ReportsCenter.tsx b/frontend/src/pages/ReportsCenter.tsx
index 4e7ced1..a0d27c7 100644
--- a/frontend/src/pages/ReportsCenter.tsx
+++ b/frontend/src/pages/ReportsCenter.tsx
@@ -103,6 +103,7 @@ function ReportList({ onSelect }: { onSelect: (id: string) => void }) {
) : (
+
@@ -135,6 +136,7 @@ function ReportList({ onSelect }: { onSelect: (id: string) => void }) {
))}
+
)}
@@ -171,7 +173,7 @@ function ReportDetail({ reportId, onBack }: { reportId: string; onBack: () => vo
返回列表
-
+
{metadata.title}
@@ -208,7 +210,7 @@ function ReportDetail({ reportId, onBack }: { reportId: string; onBack: () => vo
{/* Summary cards */}
-
+
{[
{ label: '总病例数', value: summary.total_cases.toLocaleString(), icon: Activity, color: 'text-blue-600', bg: 'bg-blue-50' },
{ label: '平均风险', value: (summary.avg_risk * 100).toFixed(1) + '%', icon: AlertTriangle, color: 'text-orange-600', bg: 'bg-orange-50' },
@@ -234,7 +236,7 @@ function ReportDetail({ reportId, onBack }: { reportId: string; onBack: () => vo
{/* Sections and charts in 2-column layout */}
-
+
{sections.map((section, idx) => (
{section.title}
@@ -330,7 +332,7 @@ export function ReportsCenter() {
}, [fetchReportsList]);
return (
-
+
{error && view === 'list' && (
{ clearError(); fetchReportsList(); }} onDismiss={clearError} />
)}
diff --git a/frontend/src/pages/TrendAnalysis.tsx b/frontend/src/pages/TrendAnalysis.tsx
index 2272165..c301dab 100644
--- a/frontend/src/pages/TrendAnalysis.tsx
+++ b/frontend/src/pages/TrendAnalysis.tsx
@@ -149,7 +149,7 @@ export function TrendAnalysis() {
};
return (
-
+
{error && (
+
{POLLUTANT_OPTIONS.filter((p) => selectedPollutants.includes(p.key)).slice(0, 4).map((p) => {
const value = latestData[p.key as keyof typeof latestData] as number;
const change = getChange(p.key);