From 33f0f497d3426fa6fa61840e73c3a5f8b088bb66 Mon Sep 17 00:00:00 2001 From: Akiba So Date: Sun, 21 Jun 2026 21:00:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=204=20=E2=80=94=20responsive=20an?= =?UTF-8?q?alysis=20pages=20+=20perf=20harness=20+=20god-component=20split?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/e2e/perf.spec.ts | 232 ++++++ frontend/e2e/responsive.spec.ts | 135 ++++ frontend/playwright.config.ts | 10 + .../components/alerts/AlertDetailModal.tsx | 108 +++ .../src/components/alerts/AlertsFilterBar.tsx | 194 +++++ .../src/components/alerts/AlertsHeader.tsx | 57 ++ frontend/src/components/alerts/AlertsList.tsx | 138 ++++ .../src/components/alerts/AlertsListTab.tsx | 129 ++++ .../src/components/alerts/AlertsMapPanel.tsx | 64 ++ .../src/components/alerts/AlertsRiskPanel.tsx | 130 ++++ .../src/components/alerts/AlertsToolbar.tsx | 73 ++ frontend/src/components/alerts/types.ts | 32 + .../src/components/alerts/useAlertsData.ts | 163 +++++ .../components/monitoring/CaseStatsTab.tsx | 145 ++++ .../monitoring/DistrictStatsTab.tsx | 66 ++ .../monitoring/MonitoringStatsBar.tsx | 56 ++ .../src/components/monitoring/OverviewTab.tsx | 142 ++++ frontend/src/components/monitoring/types.ts | 38 + .../monitoring/useMonitoringData.ts | 317 ++++++++ frontend/src/pages/AlertsDashboard.tsx | 675 ++---------------- frontend/src/pages/DiseaseAnalysis.tsx | 4 +- frontend/src/pages/DistrictComparison.tsx | 4 +- frontend/src/pages/Insights.tsx | 6 +- frontend/src/pages/MonitoringDashboard.tsx | 632 ++-------------- frontend/src/pages/ReportsCenter.tsx | 10 +- frontend/src/pages/TrendAnalysis.tsx | 4 +- 26 files changed, 2349 insertions(+), 1215 deletions(-) create mode 100644 frontend/e2e/perf.spec.ts create mode 100644 frontend/e2e/responsive.spec.ts create mode 100644 frontend/src/components/alerts/AlertDetailModal.tsx create mode 100644 frontend/src/components/alerts/AlertsFilterBar.tsx create mode 100644 frontend/src/components/alerts/AlertsHeader.tsx create mode 100644 frontend/src/components/alerts/AlertsList.tsx create mode 100644 frontend/src/components/alerts/AlertsListTab.tsx create mode 100644 frontend/src/components/alerts/AlertsMapPanel.tsx create mode 100644 frontend/src/components/alerts/AlertsRiskPanel.tsx create mode 100644 frontend/src/components/alerts/AlertsToolbar.tsx create mode 100644 frontend/src/components/alerts/types.ts create mode 100644 frontend/src/components/alerts/useAlertsData.ts create mode 100644 frontend/src/components/monitoring/CaseStatsTab.tsx create mode 100644 frontend/src/components/monitoring/DistrictStatsTab.tsx create mode 100644 frontend/src/components/monitoring/MonitoringStatsBar.tsx create mode 100644 frontend/src/components/monitoring/OverviewTab.tsx create mode 100644 frontend/src/components/monitoring/types.ts create mode 100644 frontend/src/components/monitoring/useMonitoringData.ts 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) => ( + + ))} +
+
+ +
+ +
+ 风险值: +
+ onRiskRangeChange([parseFloat(e.target.value) || 0, riskRange[1]])} + className="w-16 px-2 py-1.5 text-[12px] border border-border rounded bg-bg-page text-text-primary focus:outline-none focus:border-primary" + /> + - + onRiskRangeChange([riskRange[0], parseFloat(e.target.value) || 1])} + className="w-16 px-2 py-1.5 text-[12px] border border-border rounded bg-bg-page text-text-primary focus:outline-none focus:border-primary" + /> +
+
+ +
+ +
+ + {/* 预警标记切换:聚类(医生)视角隐藏整块——个体病例点不可开启(隐私不变量)。 */} + {!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]} + /> + {value}} + /> + + + ) : ( +
暂无预警数据
+ )} +
+
+
+ ); +}); 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) => ( - - ))} -
-
- -
- -
- 风险值: -
- setRiskRange([parseFloat(e.target.value) || 0, riskRange[1]])} - className="w-16 px-2 py-1.5 text-[12px] border border-border rounded bg-bg-page text-text-primary focus:outline-none focus:border-primary" - /> - - - setRiskRange([riskRange[0], parseFloat(e.target.value) || 1])} - className="w-16 px-2 py-1.5 text-[12px] border border-border rounded bg-bg-page text-text-primary focus:outline-none focus:border-primary" - /> -
-
- -
- -
- - {/* 预警标记切换:聚类(医生)视角隐藏整块——个体病例点不可开启(隐私不变量)。 */} - {!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]} - /> - {value}} - /> - - - ) : ( -
暂无预警数据
- )} -
-
-
+ )} {/* 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);