feat/ux-modernization #1

Merged
akiba merged 6 commits from feat/ux-modernization into main 2026-06-21 09:05:53 -05:00
26 changed files with 2349 additions and 1215 deletions
Showing only changes of commit 33f0f497d3 - Show all commits

232
frontend/e2e/perf.spec.ts Normal file
View File

@@ -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);
});
});

View File

@@ -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);
});
}
});

View File

@@ -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'] },
},
],

View File

@@ -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 (
<div className="fixed bottom-5 left-1/2 -translate-x-1/2 bg-bg-card rounded-lg border border-border-light shadow-lg z-50 px-5 py-4 min-w-[320px]">
<div className="flex items-center justify-between mb-3">
<span className="text-[14px] font-semibold text-text-primary"> (100m)</span>
<button onClick={onClose} className="text-text-muted hover:text-text-primary text-[18px] leading-none">&times;</button>
</div>
<div className="space-y-2 text-[12px]">
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="font-mono text-text-primary">{cellInfo.grid_id}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="font-mono text-text-primary">{cellInfo.lat.toFixed(4)}, {cellInfo.lon.toFixed(4)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className={`font-bold ${cellInfo.risk >= 0.8 ? 'text-danger' : cellInfo.risk >= 0.6 ? 'text-warning' : cellInfo.risk >= 0.4 ? 'text-primary' : 'text-success'}`}>
{(cellInfo.risk * 100).toFixed(1)}%
</span>
</div>
<div className="flex gap-3 pt-1">
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
<div className="text-[10px] text-text-muted">1</div>
<div className="font-bold text-[13px]">{(cellInfo.risk_1d * 100).toFixed(0)}%</div>
</div>
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
<div className="text-[10px] text-text-muted">3</div>
<div className="font-bold text-[13px]">{(cellInfo.risk_3d * 100).toFixed(0)}%</div>
</div>
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
<div className="text-[10px] text-text-muted">7</div>
<div className="font-bold text-[13px]">{(cellInfo.risk_7d * 100).toFixed(0)}%</div>
</div>
</div>
{cellInfo.nearestAlertId && (
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="text-text-primary">{(cellInfo.nearestAlertDist * 111).toFixed(1)} km</span>
</div>
)}
{!cellInfo.nearestAlertId && (
<div className="text-[11px] text-text-muted mt-1 pt-2 border-t border-border">
</div>
)}
</div>
</div>
);
});
interface AlertDetailModalProps {
alert: ExtendedAlert;
onClose: () => void;
}
// Alert detail modal
export const AlertDetailModal = React.memo(function AlertDetailModal({ alert, onClose }: AlertDetailModalProps) {
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center" onClick={onClose}>
<div className="bg-bg-card rounded-lg p-6 max-w-md w-full mx-4" onClick={e => e.stopPropagation()}>
<h3 className="font-display text-[16px] font-semibold mb-3"></h3>
<div className="space-y-2 text-[13px]">
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className={`font-bold ${alert.priority === 'P1' ? 'text-danger' : 'text-warning'}`}>
{alert.priority}
</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="font-bold">{Math.round(alert.risk_value * 100)}%</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span>{HORIZON_LABELS[alert.forecast_horizon]}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span>{alert.region}</span>
</div>
<div className="pt-2 border-t border-border">
<div className="text-text-muted mb-1"></div>
<div className="text-[12px]">{alert.reason}</div>
</div>
</div>
<button
onClick={onClose}
className="mt-4 w-full px-4 py-2 bg-primary text-white rounded hover:bg-primary/80 transition-colors text-[13px]"
>
</button>
</div>
</div>
);
});

View File

@@ -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 (
<div className="card p-3 mb-4">
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-1">
{(['all', 1, 3, 7] as const).map((horizon) => (
<button
key={horizon}
onClick={() => onHorizonChange(horizon)}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
selectedHorizon === horizon
? 'bg-primary text-white'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
{horizon === 'all' ? '全部' : HORIZON_LABELS[horizon]}
</button>
))}
</div>
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-1">
{(['all', 'P1', 'P2'] as const).map((priority) => (
<button
key={priority}
onClick={() => onPriorityChange(priority)}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
selectedPriority === priority
? priority === 'P1'
? 'bg-danger text-white'
: priority === 'P2'
? 'bg-warning text-white'
: 'bg-primary text-white'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
{priority === 'all' ? '全部' : priority}
</button>
))}
</div>
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={1}
step={0.05}
value={riskRange[0]}
onChange={(e) => 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"
/>
<span className="text-[12px] text-text-muted">-</span>
<input
type="number"
min={0}
max={1}
step={0.05}
value={riskRange[1]}
onChange={(e) => 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"
/>
</div>
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-1">
<button
onClick={onToggleMap}
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
showMap
? 'bg-primary/10 text-primary border border-primary/30'
: 'bg-bg-page text-text-muted border border-border'
}`}
>
</button>
{/* 预警标记切换:聚类(医生)视角隐藏整块——个体病例点不可开启(隐私不变量)。 */}
{!isCluster && (
<button
onClick={onToggleAlertMarkers}
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
showAlertMarkers
? 'bg-primary/10 text-primary border border-primary/30'
: 'bg-bg-page text-text-muted border border-border'
}`}
>
</button>
)}
{/* 网格切换官员视角隐藏整块100m 网格对其无意义/太超前)。 */}
{!isOfficial && (
<div data-testid={TESTIDS.gridLayerWrapper}>
<button
onClick={onToggleGrid}
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
showGrid
? 'bg-primary/10 text-primary border border-primary/30'
: 'bg-bg-page text-text-muted border border-border'
}`}
>
</button>
</div>
)}
{/* 聚类(医生)视角:病种过滤是其核心工具,挂载于此。 */}
{isCluster && <DiseaseFilter />}
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-1">
<button
onClick={() => onSortByChange('risk')}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
sortBy === 'risk'
? 'bg-bg-card text-primary border border-primary'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
</button>
<button
onClick={() => onSortByChange('time')}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
sortBy === 'time'
? 'bg-bg-card text-primary border border-primary'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
</button>
</div>
</div>
</div>
</div>
);
});

View File

@@ -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 */}
<div className="flex items-center justify-between mb-4 flex-wrap gap-x-4 gap-y-2">
<div className="min-w-0">
<h1 className="font-display text-[18px] font-semibold mb-1"></h1>
<p className="text-[12px] text-text-muted truncate">
100m网格风险预测 · · -
</p>
</div>
<div className="flex items-center gap-3 text-[11px] shrink-0 flex-wrap">
<span className="text-text-muted"> <span className="font-semibold text-text-primary">{total}</span> </span>
<span className="px-2 py-1 bg-danger/10 border border-danger/20 rounded text-danger font-semibold">P1: {p1}</span>
<span className="px-2 py-1 bg-warning/10 border border-warning/20 rounded text-warning font-semibold">P2: {p2}</span>
</div>
</div>
{/* Tab strip — in-page, no router */}
<div className="flex gap-1 mb-4 border-b border-border">
{([
{ key: 'list', label: '预警列表' },
{ key: 'stats', label: '风险统计' },
] as const).map((tab) => (
<button
key={tab.key}
onClick={() => onTabChange(tab.key)}
className={`px-4 py-2 text-[13px] font-medium -mb-px border-b-2 transition-colors ${
activeTab === tab.key
? 'border-primary text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'
}`}
>
{tab.label}
</button>
))}
</div>
</>
);
});

View File

@@ -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 (
<div className="grid grid-cols-4 gap-3 mb-4">
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"> (0.8)</div>
<div className="text-xl font-bold text-danger">{riskStats.high}</div>
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-danger rounded-full" style={{ width: `${total > 0 ? (riskStats.high / total) * 100 : 0}%` }} />
</div>
</div>
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"> (0.6-0.8)</div>
<div className="text-xl font-bold text-warning">{riskStats.mediumHigh}</div>
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-warning rounded-full" style={{ width: `${total > 0 ? (riskStats.mediumHigh / total) * 100 : 0}%` }} />
</div>
</div>
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"> (0.4-0.6)</div>
<div className="text-xl font-bold text-primary">{riskStats.medium}</div>
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full" style={{ width: `${total > 0 ? (riskStats.medium / total) * 100 : 0}%` }} />
</div>
</div>
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"></div>
<div className="text-xl font-bold text-text-primary">{(riskStats.avgRisk * 100).toFixed(1)}%</div>
<div className="mt-1.5 text-[10px] text-text-muted">
: {riskStats.topDistricts.slice(0, 2).map(([d, n]) => `${d}(${n})`).join(', ')}
</div>
</div>
</div>
);
});
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 (
<div
className={`card overflow-hidden transition-colors cursor-pointer ${
isSelected ? 'border-primary ring-1 ring-primary' : 'hover:border-primary'
}`}
onClick={handleClick}
>
<div className={`px-4 py-3 border-b ${isP1 ? 'bg-danger/5 border-danger/20' : 'bg-warning/5 border-warning/20'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${isP1 ? 'bg-danger' : 'bg-warning'}`} />
<span className={`text-[11px] font-bold ${isP1 ? 'text-danger' : 'text-warning'}`}>
{alert.priority}
</span>
<span className="text-[10px] text-text-muted">
{HORIZON_LABELS[alert.forecast_horizon] || '未知'}
</span>
</div>
<span className={`text-[18px] font-bold ${isP1 ? 'text-danger' : 'text-warning'}`}>
{riskPercent}%
</span>
</div>
</div>
<div className="p-4">
<div className="mb-3">
<div className="text-[13px] font-semibold mb-1">
{alert.region} - {alert.street}
</div>
<div className="text-[11px] text-text-muted">
{alert.grid_id}
</div>
</div>
<div className={`text-[12px] px-3 py-2 rounded mb-3 ${
isP1 ? 'bg-danger/10 text-danger' : 'bg-warning/10 text-warning'
}`}>
{alert.reason}
</div>
<div className="flex items-center justify-between text-[11px] text-text-muted">
<span>{alert.forecast_time}</span>
<span>{alert.timestamp}</span>
</div>
</div>
</div>
);
});
interface AlertsListProps {
filteredAlerts: ExtendedAlert[];
selectedAlert: string | null;
onCardClick: (id: string) => void;
}
export const AlertsList = React.memo(function AlertsList({ filteredAlerts, selectedAlert, onCardClick }: AlertsListProps) {
return (
<div className="space-y-3 max-h-[calc(100vh-280px)] overflow-y-auto">
{filteredAlerts.slice(0, 50).map((alert) => (
<AlertCard
key={alert.alert_id}
alert={alert}
isSelected={selectedAlert === alert.alert_id}
alertId={alert.alert_id}
onCardClick={onCardClick}
/>
))}
{filteredAlerts.length > 50 && (
<div className="text-center text-text-muted text-[12px] py-2">
{filteredAlerts.length - 50}
</div>
)}
</div>
);
});

View File

@@ -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 (
<>
<AlertsToolbar
forecastDay={props.forecastDay}
onForecastDayChange={props.onForecastDayChange}
isFullscreen={isFullscreen}
onToggleFullscreen={props.onToggleFullscreen}
onExportCsv={props.onExportCsv}
onExportJson={props.onExportJson}
/>
<AlertsFilterBar
selectedHorizon={props.selectedHorizon}
onHorizonChange={props.onHorizonChange}
selectedPriority={props.selectedPriority}
onPriorityChange={props.onPriorityChange}
riskRange={props.riskRange}
onRiskRangeChange={props.onRiskRangeChange}
showMap={showMap}
onToggleMap={props.onToggleMap}
showAlertMarkers={props.showAlertMarkers}
onToggleAlertMarkers={props.onToggleAlertMarkers}
showGrid={props.showGrid}
onToggleGrid={props.onToggleGrid}
sortBy={props.sortBy}
onSortByChange={props.onSortByChange}
isCluster={isCluster}
isOfficial={props.isOfficial}
/>
<RiskDistributionSummary riskStats={riskStats} total={filteredAlerts.length} />
{isLoading ? (
<div className="card p-8">
<LoadingState />
</div>
) : filteredAlerts.length === 0 && !isCluster ? (
// 聚类(医生)视角即使没有个体预警,也要展示聚合密度栅格——故不走空状态分支。
<div className="card p-8 text-center">
<svg className="w-12 h-12 mx-auto mb-3 text-text-muted opacity-50" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
</svg>
<div className="text-text-muted text-[13px]"></div>
</div>
) : (
<div className={`grid gap-4 ${isFullscreen ? 'grid-cols-1' : 'grid-cols-[1fr_400px]'}`}>
{showMap && (
<AlertsMapPanel
selectedGridId={props.selectedGridId}
onGridClick={props.onGridClick}
onCellInfo={props.onCellInfo}
forecastDay={props.forecastDay}
effectiveShowAlertMarkers={props.effectiveShowAlertMarkers}
showGrid={props.showGrid}
filteredAlerts={filteredAlerts}
isFullscreen={isFullscreen}
isCluster={isCluster}
isOfficial={props.isOfficial}
/>
)}
{!isFullscreen && (
<AlertsList
filteredAlerts={filteredAlerts}
selectedAlert={props.selectedAlert}
onCardClick={props.onCardClick}
/>
)}
</div>
)}
</>
);
});

View File

@@ -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 (
<div data-testid={isCluster ? TESTIDS.clusterView : undefined}>
<AlertMap
selectedGridId={selectedGridId}
onGridClick={onGridClick}
onCellInfo={onCellInfo}
forecastDay={forecastDay}
showAlertMarkers={effectiveShowAlertMarkers}
showGrid={isOfficial ? false : showGrid}
filteredAlerts={filteredAlerts}
isFullscreen={isFullscreen}
/>
{/*
隐私不变量的「数据级」可断言点:每渲染一个个体病例点标记,就在此输出一个
data-testid="patient-point" 的隐藏标记。Leaflet 的 CircleMarker 是 canvas/SVG
内部对象、不带 testid无法被 e2e 直接计数;这里把「实际会显示的个体点集合」
镜像成 DOM使测试可断言医生/聚类视角下 patient-point 计数恒为 0
而无需窥探 Leaflet 内部。effectiveShowAlertMarkers 在 cluster 模式恒为 false
故该集合为空。
*/}
{effectiveShowAlertMarkers &&
filteredAlerts.map((a) => (
<span
key={a.alert_id}
data-testid={TESTIDS.patientPoint}
className="hidden"
aria-hidden
/>
))}
</div>
);
});

View File

@@ -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 (
<div className="space-y-4">
{/* Risk distribution as StatCards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="高风险 (≥0.8)" value={riskStats.high} color="#ef4444" />
<StatCard label="中高风险 (0.6-0.8)" value={riskStats.mediumHigh} color="#f59e0b" />
<StatCard label="中风险 (0.4-0.6)" value={riskStats.medium} color="#3b82f6" />
<StatCard label="平均风险" value={`${(riskStats.avgRisk * 100).toFixed(1)}%`} />
</div>
{/* Risk trend chart (real data from /api/analysis/trend) */}
{trendLoading ? (
<div className="card p-8"><LoadingState /></div>
) : trendError ? (
<div className="card p-8 text-center text-danger text-[13px]">{trendError}</div>
) : trendData.length === 0 ? (
<div className="card p-8 text-center text-text-muted text-[13px]"></div>
) : (
<StatisticalCharts
data={trendData}
showCases={false}
showRisk
height={280}
/>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Top high-risk districts bar */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
Top 5
</div>
{riskStats.topDistricts.length > 0 ? (
<div className="space-y-3">
{riskStats.topDistricts.map(([district, count]) => (
<div key={district}>
<div className="flex items-center justify-between text-[12px] mb-1">
<span className="text-text-primary font-medium">{district}</span>
<span className="text-text-muted">{count} </span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-danger rounded-full"
style={{ width: `${topDistrictMax > 0 ? (count / topDistrictMax) * 100 : 0}%` }}
/>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-text-muted text-[13px]"></div>
)}
</div>
{/* Alert severity donut (P1/P2) */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
</div>
{riskStats.p1 > 0 || riskStats.p2 > 0 ? (
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={alertPie}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={4}
dataKey="value"
nameKey="name"
>
{alertPie.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<RechartsTooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number, name: string) => [value, name]}
/>
<Legend
wrapperStyle={{ fontSize: '12px' }}
formatter={(value: string) => <span className="text-text-secondary">{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-text-muted text-[13px]"></div>
)}
</div>
</div>
</div>
);
});

View File

@@ -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 (
<div className="card p-3 mb-3">
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-0.5 bg-bg-page p-0.5 rounded">
{([1, 3, 7] as const).map((day) => (
<button
key={day}
onClick={() => onForecastDayChange(day)}
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
forecastDay === day
? 'bg-bg-card text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{day}
</button>
))}
</div>
</div>
<div className="w-px h-6 bg-border" />
<button
onClick={onToggleFullscreen}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
isFullscreen
? 'bg-bg-card text-primary border border-primary'
: 'bg-bg-page text-text-secondary border border-border'
}`}
>
{isFullscreen ? '退出全屏' : '全屏'}
</button>
<div className="w-px h-6 bg-border" />
<button
onClick={onExportCsv}
className="px-3 py-1.5 text-[12px] font-medium rounded bg-bg-page text-text-secondary border border-border hover:border-primary transition-colors"
>
CSV
</button>
<button
onClick={onExportJson}
className="px-3 py-1.5 text-[12px] font-medium rounded bg-bg-page text-text-secondary border border-border hover:border-primary transition-colors"
>
JSON
</button>
</div>
</div>
);
});

View File

@@ -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<number, string> = {
1: '1 天后',
3: '3 天后',
7: '7 天后',
};
export interface RiskStats {
p1: number;
p2: number;
high: number;
mediumHigh: number;
medium: number;
avgRisk: number;
topDistricts: [string, number][];
}

View File

@@ -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<TrendPoint[]>([]);
const [trendLoading, setTrendLoading] = useState(false);
const [trendError, setTrendError] = useState<string | null>(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<string, number> = {};
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,
};
}

View File

@@ -0,0 +1,145 @@
import { memo } from 'react';
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { ErrorBanner } from '@/components/ErrorBanner';
import { CalendarHeatmap } from '@/components/CalendarHeatmap';
import type { TopDiagnosis } from './types';
function formatDateLabel(dateStr: string): string {
const d = new Date(dateStr);
return `${d.getMonth() + 1}/${d.getDate()}`;
}
interface CaseStatsTabProps {
loading: boolean;
loaded: boolean;
error: string | null;
currentDate: string;
topDiagnoses: TopDiagnosis[];
caseTrend: Array<{ date: string; cases: number; aqi: number }>;
heatmapData: Array<{ date: string; value: number }>;
heatmapYear: number | null;
onRetry: () => void;
onDismissError: () => void;
}
// 病例统计 tab —— 诊断分布 / 病例与AQI趋势 / 日历热力图。纯展示,数据由父级按需加载。
export const CaseStatsTab = memo(function CaseStatsTab({
loading,
loaded,
error,
currentDate,
topDiagnoses,
caseTrend,
heatmapData,
heatmapYear,
onRetry,
onDismissError,
}: CaseStatsTabProps) {
if (loading && !loaded) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="space-y-6">
{error && (
<ErrorBanner
error={error}
onRetry={onRetry}
onDismiss={onDismissError}
/>
)}
{/* Top 5 诊断分布 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top 5 </h3>
{topDiagnoses.length > 0 ? (
<ResponsiveContainer width="100%" height={240}>
<BarChart
data={[...topDiagnoses].reverse()}
layout="vertical"
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
<YAxis
type="category"
dataKey="diagnosis"
tick={{ fontSize: 11, fill: '#374151' }}
width={100}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{ backgroundColor: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '8px', fontSize: '12px' }}
formatter={(value: number) => [value.toLocaleString(), '病例数']}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={16} />
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={16} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
{/* 近30日病例与AQI趋势 (driven off Monitoring timeline currentDate) */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-1">AQI趋势</h3>
<p className="text-xs text-gray-500 mb-4"> {currentDate} 30</p>
{caseTrend.length > 0 ? (
<ResponsiveContainer width="100%" height={260}>
<LineChart data={caseTrend} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
dataKey="date"
tickFormatter={formatDateLabel}
tick={{ fontSize: 10, fill: '#64748B' }}
interval="preserveStartEnd"
axisLine={{ stroke: '#E2E8F0' }}
/>
<YAxis yAxisId="left" tick={{ fontSize: 10, fill: '#64748B' }} axisLine={{ stroke: '#E2E8F0' }} />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10, fill: '#F59E0B' }} axisLine={{ stroke: '#E2E8F0' }} />
<Tooltip
contentStyle={{ backgroundColor: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '8px', fontSize: '12px' }}
labelStyle={{ color: '#1E293B', fontWeight: 600 }}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Line yAxisId="left" type="monotone" dataKey="cases" name="病例数" stroke="#3B82F6" strokeWidth={2} dot={false} activeDot={{ r: 3 }} />
<Line yAxisId="right" type="monotone" dataKey="aqi" name="AQI" stroke="#F59E0B" strokeWidth={2} strokeDasharray="5 5" dot={false} activeDot={{ r: 3 }} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
{/* 日历热力图 (year derived from data) */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{heatmapYear ? `${heatmapYear}` : ''}
</h3>
{heatmapYear && heatmapData.length > 0 ? (
<CalendarHeatmap data={heatmapData} year={heatmapYear} />
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
</div>
);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<number, string> = {
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 (
<div data-testid="page-alerts" className={isFullscreen ? 'fixed inset-0 z-40 bg-bg-page pt-[52px] p-5' : 'p-5'}>
<div data-testid={TESTIDS.pageAlerts} className={isFullscreen ? 'fixed inset-0 z-40 bg-bg-page pt-[52px] p-5' : 'p-5'}>
{error && (
<ErrorBanner
error={error}
@@ -269,596 +234,68 @@ export function AlertsDashboard() {
/>
)}
{/* Header */}
<div className="flex items-center justify-between mb-4 flex-wrap gap-x-4 gap-y-2">
<div className="min-w-0">
<h1 className="font-display text-[18px] font-semibold mb-1"></h1>
<p className="text-[12px] text-text-muted truncate">
100m网格风险预测 · · -
</p>
</div>
<div className="flex items-center gap-3 text-[11px] shrink-0 flex-wrap">
<span className="text-text-muted"> <span className="font-semibold text-text-primary">{filteredAlerts.length}</span> </span>
<span className="px-2 py-1 bg-danger/10 border border-danger/20 rounded text-danger font-semibold">P1: {riskStats.p1}</span>
<span className="px-2 py-1 bg-warning/10 border border-warning/20 rounded text-warning font-semibold">P2: {riskStats.p2}</span>
</div>
</div>
{/* Tab strip — in-page, no router */}
<div className="flex gap-1 mb-4 border-b border-border">
{([
{ key: 'list', label: '预警列表' },
{ key: 'stats', label: '风险统计' },
] as const).map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-[13px] font-medium -mb-px border-b-2 transition-colors ${
activeTab === tab.key
? 'border-primary text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'
}`}
>
{tab.label}
</button>
))}
</div>
<AlertsHeader
total={filteredAlerts.length}
p1={riskStats.p1}
p2={riskStats.p2}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
{activeTab === 'list' && (
<>
{/* Toolbar Row 1: Forecast + Fullscreen + Export */}
<div className="card p-3 mb-3">
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-0.5 bg-bg-page p-0.5 rounded">
{([1, 3, 7] as const).map((day) => (
<button
key={day}
onClick={() => setForecastDay(day)}
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
forecastDay === day
? 'bg-bg-card text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{day}
</button>
))}
</div>
</div>
<div className="w-px h-6 bg-border" />
<button
onClick={() => setIsFullscreen(!isFullscreen)}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
isFullscreen
? 'bg-bg-card text-primary border border-primary'
: 'bg-bg-page text-text-secondary border border-border'
}`}
>
{isFullscreen ? '退出全屏' : '全屏'}
</button>
<div className="w-px h-6 bg-border" />
<button
onClick={exportToCsv}
className="px-3 py-1.5 text-[12px] font-medium rounded bg-bg-page text-text-secondary border border-border hover:border-primary transition-colors"
>
CSV
</button>
<button
onClick={exportToJson}
className="px-3 py-1.5 text-[12px] font-medium rounded bg-bg-page text-text-secondary border border-border hover:border-primary transition-colors"
>
JSON
</button>
</div>
</div>
{/* Toolbar Row 2: Filters */}
<div className="card p-3 mb-4">
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-1">
{(['all', 1, 3, 7] as const).map((horizon) => (
<button
key={horizon}
onClick={() => setSelectedHorizon(horizon)}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
selectedHorizon === horizon
? 'bg-primary text-white'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
{horizon === 'all' ? '全部' : HORIZON_LABELS[horizon]}
</button>
))}
</div>
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-1">
{(['all', 'P1', 'P2'] as const).map((priority) => (
<button
key={priority}
onClick={() => setSelectedPriority(priority)}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
selectedPriority === priority
? priority === 'P1'
? 'bg-danger text-white'
: priority === 'P2'
? 'bg-warning text-white'
: 'bg-primary text-white'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
{priority === 'all' ? '全部' : priority}
</button>
))}
</div>
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={1}
step={0.05}
value={riskRange[0]}
onChange={(e) => 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"
/>
<span className="text-[12px] text-text-muted">-</span>
<input
type="number"
min={0}
max={1}
step={0.05}
value={riskRange[1]}
onChange={(e) => 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"
/>
</div>
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-1">
<button
onClick={() => setShowMap(!showMap)}
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
showMap
? 'bg-primary/10 text-primary border border-primary/30'
: 'bg-bg-page text-text-muted border border-border'
}`}
>
</button>
{/* 预警标记切换:聚类(医生)视角隐藏整块——个体病例点不可开启(隐私不变量)。 */}
{!isCluster && (
<button
onClick={() => setShowAlertMarkers(!showAlertMarkers)}
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
showAlertMarkers
? 'bg-primary/10 text-primary border border-primary/30'
: 'bg-bg-page text-text-muted border border-border'
}`}
>
</button>
)}
{/* 网格切换官员视角隐藏整块100m 网格对其无意义/太超前)。 */}
{!isOfficial && (
<div data-testid={TESTIDS.gridLayerWrapper}>
<button
onClick={() => setShowGrid(!showGrid)}
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
showGrid
? 'bg-primary/10 text-primary border border-primary/30'
: 'bg-bg-page text-text-muted border border-border'
}`}
>
</button>
</div>
)}
{/* 聚类(医生)视角:病种过滤是其核心工具,挂载于此。 */}
{isCluster && <DiseaseFilter />}
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-1">
<button
onClick={() => setSortBy('risk')}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
sortBy === 'risk'
? 'bg-bg-card text-primary border border-primary'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
</button>
<button
onClick={() => setSortBy('time')}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
sortBy === 'time'
? 'bg-bg-card text-primary border border-primary'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
</button>
</div>
</div>
</div>
</div>
{/* Risk distribution summary */}
<div className="grid grid-cols-4 gap-3 mb-4">
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"> (0.8)</div>
<div className="text-xl font-bold text-danger">{riskStats.high}</div>
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-danger rounded-full" style={{ width: `${filteredAlerts.length > 0 ? (riskStats.high / filteredAlerts.length) * 100 : 0}%` }} />
</div>
</div>
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"> (0.6-0.8)</div>
<div className="text-xl font-bold text-warning">{riskStats.mediumHigh}</div>
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-warning rounded-full" style={{ width: `${filteredAlerts.length > 0 ? (riskStats.mediumHigh / filteredAlerts.length) * 100 : 0}%` }} />
</div>
</div>
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"> (0.4-0.6)</div>
<div className="text-xl font-bold text-primary">{riskStats.medium}</div>
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full" style={{ width: `${filteredAlerts.length > 0 ? (riskStats.medium / filteredAlerts.length) * 100 : 0}%` }} />
</div>
</div>
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"></div>
<div className="text-xl font-bold text-text-primary">{(riskStats.avgRisk * 100).toFixed(1)}%</div>
<div className="mt-1.5 text-[10px] text-text-muted">
: {riskStats.topDistricts.slice(0, 2).map(([d, n]) => `${d}(${n})`).join(', ')}
</div>
</div>
</div>
{isLoading ? (
<div className="card p-8">
<LoadingState />
</div>
) : filteredAlerts.length === 0 && !isCluster ? (
// 聚类(医生)视角即使没有个体预警,也要展示聚合密度栅格——故不走空状态分支。
<div className="card p-8 text-center">
<svg className="w-12 h-12 mx-auto mb-3 text-text-muted opacity-50" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
</svg>
<div className="text-text-muted text-[13px]"></div>
</div>
) : (
<div className={`grid gap-4 ${isFullscreen ? 'grid-cols-1' : 'grid-cols-[1fr_400px]'}`}>
{showMap && (
<div data-testid={isCluster ? TESTIDS.clusterView : undefined}>
<AlertMap
selectedGridId={selectedGridId}
onGridClick={handleGridClick}
onCellInfo={handleCellInfo}
forecastDay={forecastDay}
showAlertMarkers={effectiveShowAlertMarkers}
showGrid={isOfficial ? false : showGrid}
filteredAlerts={filteredAlerts}
isFullscreen={isFullscreen}
/>
{/*
隐私不变量的「数据级」可断言点:每渲染一个个体病例点标记,就在此输出一个
data-testid="patient-point" 的隐藏标记。Leaflet 的 CircleMarker 是 canvas/SVG
内部对象、不带 testid无法被 e2e 直接计数;这里把「实际会显示的个体点集合」
镜像成 DOM使测试可断言医生/聚类视角下 patient-point 计数恒为 0
而无需窥探 Leaflet 内部。effectiveShowAlertMarkers 在 cluster 模式恒为 false
故该集合为空。
*/}
{effectiveShowAlertMarkers &&
filteredAlerts.map((a) => (
<span
key={a.alert_id}
data-testid={TESTIDS.patientPoint}
className="hidden"
aria-hidden
/>
))}
</div>
)}
{!isFullscreen && (
<div className="space-y-3 max-h-[calc(100vh-280px)] overflow-y-auto">
{filteredAlerts.slice(0, 50).map((alert) => (
<AlertCard
key={alert.alert_id}
alert={alert}
isSelected={selectedAlert === alert.alert_id}
alertId={alert.alert_id}
onCardClick={handleAlertCardClick}
/>
))}
{filteredAlerts.length > 50 && (
<div className="text-center text-text-muted text-[12px] py-2">
{filteredAlerts.length - 50}
</div>
)}
</div>
)}
</div>
)}
</>
<AlertsListTab
forecastDay={forecastDay}
onForecastDayChange={setForecastDay}
isFullscreen={isFullscreen}
onToggleFullscreen={() => 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' && (
<div className="space-y-4">
{/* Risk distribution as StatCards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="高风险 (≥0.8)" value={riskStats.high} color="#ef4444" />
<StatCard label="中高风险 (0.6-0.8)" value={riskStats.mediumHigh} color="#f59e0b" />
<StatCard label="中风险 (0.4-0.6)" value={riskStats.medium} color="#3b82f6" />
<StatCard label="平均风险" value={`${(riskStats.avgRisk * 100).toFixed(1)}%`} />
</div>
{/* Risk trend chart (real data from /api/analysis/trend) */}
{trendLoading ? (
<div className="card p-8"><LoadingState /></div>
) : trendError ? (
<div className="card p-8 text-center text-danger text-[13px]">{trendError}</div>
) : trendData.length === 0 ? (
<div className="card p-8 text-center text-text-muted text-[13px]"></div>
) : (
<StatisticalCharts
data={trendData}
showCases={false}
showRisk
height={280}
/>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Top high-risk districts bar */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
Top 5
</div>
{riskStats.topDistricts.length > 0 ? (
<div className="space-y-3">
{riskStats.topDistricts.map(([district, count]) => (
<div key={district}>
<div className="flex items-center justify-between text-[12px] mb-1">
<span className="text-text-primary font-medium">{district}</span>
<span className="text-text-muted">{count} </span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-danger rounded-full"
style={{ width: `${topDistrictMax > 0 ? (count / topDistrictMax) * 100 : 0}%` }}
/>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-text-muted text-[13px]"></div>
)}
</div>
{/* Alert severity donut (P1/P2) */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
</div>
{riskStats.p1 > 0 || riskStats.p2 > 0 ? (
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={alertPie}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={4}
dataKey="value"
nameKey="name"
>
{alertPie.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<RechartsTooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number, name: string) => [value, name]}
/>
<Legend
wrapperStyle={{ fontSize: '12px' }}
formatter={(value: string) => <span className="text-text-secondary">{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-text-muted text-[13px]"></div>
)}
</div>
</div>
</div>
<AlertsRiskPanel
riskStats={riskStats}
trendData={trendData}
trendLoading={trendLoading}
trendError={trendError}
/>
)}
{/* Cell info panel - shown when clicking grid cell without alert */}
{cellInfo && !selectedAlertData && (
<div className="fixed bottom-5 left-1/2 -translate-x-1/2 bg-bg-card rounded-lg border border-border-light shadow-lg z-50 px-5 py-4 min-w-[320px]">
<div className="flex items-center justify-between mb-3">
<span className="text-[14px] font-semibold text-text-primary"> (100m)</span>
<button onClick={clearCellInfo} className="text-text-muted hover:text-text-primary text-[18px] leading-none">&times;</button>
</div>
<div className="space-y-2 text-[12px]">
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="font-mono text-text-primary">{cellInfo.grid_id}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="font-mono text-text-primary">{cellInfo.lat.toFixed(4)}, {cellInfo.lon.toFixed(4)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className={`font-bold ${cellInfo.risk >= 0.8 ? 'text-danger' : cellInfo.risk >= 0.6 ? 'text-warning' : cellInfo.risk >= 0.4 ? 'text-primary' : 'text-success'}`}>
{(cellInfo.risk * 100).toFixed(1)}%
</span>
</div>
<div className="flex gap-3 pt-1">
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
<div className="text-[10px] text-text-muted">1</div>
<div className="font-bold text-[13px]">{(cellInfo.risk_1d * 100).toFixed(0)}%</div>
</div>
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
<div className="text-[10px] text-text-muted">3</div>
<div className="font-bold text-[13px]">{(cellInfo.risk_3d * 100).toFixed(0)}%</div>
</div>
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
<div className="text-[10px] text-text-muted">7</div>
<div className="font-bold text-[13px]">{(cellInfo.risk_7d * 100).toFixed(0)}%</div>
</div>
</div>
{cellInfo.nearestAlertId && (
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="text-text-primary">{(cellInfo.nearestAlertDist * 111).toFixed(1)} km</span>
</div>
)}
{!cellInfo.nearestAlertId && (
<div className="text-[11px] text-text-muted mt-1 pt-2 border-t border-border">
</div>
)}
</div>
</div>
<CellInfoPanel cellInfo={cellInfo} onClose={clearCellInfo} />
)}
{/* Alert detail modal */}
{selectedAlertData && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center" onClick={clearSelectedAlert}>
<div className="bg-bg-card rounded-lg p-6 max-w-md w-full mx-4" onClick={e => e.stopPropagation()}>
<h3 className="font-display text-[16px] font-semibold mb-3"></h3>
<div className="space-y-2 text-[13px]">
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className={`font-bold ${selectedAlertData.priority === 'P1' ? 'text-danger' : 'text-warning'}`}>
{selectedAlertData.priority}
</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="font-bold">{Math.round(selectedAlertData.risk_value * 100)}%</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span>{HORIZON_LABELS[selectedAlertData.forecast_horizon]}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span>{selectedAlertData.region}</span>
</div>
<div className="pt-2 border-t border-border">
<div className="text-text-muted mb-1"></div>
<div className="text-[12px]">{selectedAlertData.reason}</div>
</div>
</div>
<button
onClick={clearSelectedAlert}
className="mt-4 w-full px-4 py-2 bg-primary text-white rounded hover:bg-primary/80 transition-colors text-[13px]"
>
</button>
</div>
</div>
<AlertDetailModal alert={selectedAlertData} onClose={clearSelectedAlert} />
)}
</div>
);
}
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 (
<div
className={`card overflow-hidden transition-colors cursor-pointer ${
isSelected ? 'border-primary ring-1 ring-primary' : 'hover:border-primary'
}`}
onClick={handleClick}
>
<div className={`px-4 py-3 border-b ${isP1 ? 'bg-danger/5 border-danger/20' : 'bg-warning/5 border-warning/20'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${isP1 ? 'bg-danger' : 'bg-warning'}`} />
<span className={`text-[11px] font-bold ${isP1 ? 'text-danger' : 'text-warning'}`}>
{alert.priority}
</span>
<span className="text-[10px] text-text-muted">
{HORIZON_LABELS[alert.forecast_horizon] || '未知'}
</span>
</div>
<span className={`text-[18px] font-bold ${isP1 ? 'text-danger' : 'text-warning'}`}>
{riskPercent}%
</span>
</div>
</div>
<div className="p-4">
<div className="mb-3">
<div className="text-[13px] font-semibold mb-1">
{alert.region} - {alert.street}
</div>
<div className="text-[11px] text-text-muted">
{alert.grid_id}
</div>
</div>
<div className={`text-[12px] px-3 py-2 rounded mb-3 ${
isP1 ? 'bg-danger/10 text-danger' : 'bg-warning/10 text-warning'
}`}>
{alert.reason}
</div>
<div className="flex items-center justify-between text-[11px] text-text-muted">
<span>{alert.forecast_time}</span>
<span>{alert.timestamp}</span>
</div>
</div>
</div>
);
});

View File

@@ -154,8 +154,9 @@ function SeasonalityHeatmap({ data }: { data: DiseaseSeasonalityPoint[] }) {
</div>
)}
<div className="overflow-x-auto">
<div
className="grid gap-px bg-gray-200 border border-gray-200 rounded overflow-hidden"
className="grid gap-px bg-gray-200 border border-gray-200 rounded overflow-hidden min-w-[480px]"
style={{
gridTemplateColumns: `minmax(90px, auto) repeat(${uniqueMonths > 0 ? uniqueMonths : 12}, 1fr)`,
}}
@@ -198,6 +199,7 @@ function SeasonalityHeatmap({ data }: { data: DiseaseSeasonalityPoint[] }) {
</Fragment>
))}
</div>
</div>
</div>
);
}

View File

@@ -128,7 +128,7 @@ export function DistrictComparison() {
};
return (
<div data-testid="page-district">
<div data-testid="page-district" className="overflow-x-hidden">
{error && (
<ErrorBanner
error={error}
@@ -245,7 +245,7 @@ export function DistrictComparison() {
</ResponsiveContainer>
</div>
<div className="grid grid-cols-4 gap-4">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{sortedData.map((district, index) => (
<div key={district.district} className="card p-4">
<div className="flex items-center justify-between mb-3">

View File

@@ -187,7 +187,7 @@ export function Insights() {
: [];
return (
<div data-testid="page-insights">
<div data-testid="page-insights" className="overflow-x-hidden">
{error && (
<ErrorBanner
error={error}
@@ -212,7 +212,7 @@ export function Insights() {
)}
{insights && (
<div className="grid grid-cols-4 gap-4 mb-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-4">
{stats.map((stat) => (
<div key={stat.label} className="card p-4">
<div className="flex items-center gap-2 mb-2">
@@ -232,7 +232,7 @@ export function Insights() {
)}
{insights && (
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{(insights.cards || []).map((card) => {
const config = TYPE_CONFIG[card.type];
const Icon = config.icon;

View File

@@ -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<Array<{ date: string; cases: number; aqi?: number }>>([]);
// In-page tab strip (local state, no router — mirrors the existing activePage pattern)
const [activeTab, setActiveTab] = useState<MonitoringTab>('overview');
// --- 病例统计 tab state (fetched on demand) ---
const [topDiagnoses, setTopDiagnoses] = useState<TopDiagnosis[]>([]);
const [caseTrend, setCaseTrend] = useState<Array<{ date: string; cases: number; aqi: number }>>([]);
const [heatmapData, setHeatmapData] = useState<Array<{ date: string; value: number }>>([]);
const [heatmapYear, setHeatmapYear] = useState<number | null>(null);
const [casesTabLoaded, setCasesTabLoaded] = useState(false);
const [casesTabLoading, setCasesTabLoading] = useState(false);
const [casesTabError, setCasesTabError] = useState<string | null>(null);
// --- 区域统计 tab state (fetched on demand) ---
const [districtMetrics, setDistrictMetrics] = useState<DistrictCaseData[]>([]);
const [districtSortKey, setDistrictSortKey] = useState<string>('total');
const [districtTabLoaded, setDistrictTabLoaded] = useState(false);
const [districtTabLoading, setDistrictTabLoading] = useState(false);
const [districtTabError, setDistrictTabError] = useState<string | null>(null);
const {
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<ReturnType<typeof setTimeout> | null>(null);
// Load chart data for 90-day window ending at the given reference date
const loadChartData = useCallback((refDate: string, district?: string) => {
const end = new Date(refDate);
const start = new Date(refDate);
start.setDate(start.getDate() - 90);
const startStr = start.toISOString().split('T')[0];
const endStr = end.toISOString().split('T')[0];
if (selectedDiagnoses.length > 0 && selectedDiagnoses.length <= 3) {
caseApi.getTrend({
start_date: startStr,
end_date: endStr,
group_by: 'day',
diagnosis: selectedDiagnoses.join(','),
}).then((data) => {
const trend = data.trend || [];
setChartData(
trend.map((t: { date: string; total: number }) => ({ date: t.date, cases: t.total }))
);
}).catch((e) => { console.error('Failed to load chart data:', e); });
} else {
gridApi.getHistoricalAggregated(startStr, endStr, 'daily', district)
.then((data) => {
const rows = data.aggregations || [];
const dailyCases: Record<string, number> = {};
rows.forEach((item: { date: string; total_cases: number }) => {
dailyCases[item.date] = (dailyCases[item.date] || 0) + item.total_cases;
});
setChartData(
Object.entries(dailyCases)
.map(([date, cases]) => ({ date, cases }))
.sort((a, b) => a.date.localeCompare(b.date))
);
}).catch((e) => { console.error('Failed to load chart data:', e); });
}
// Fetch districtCases with date filter (single day = currentDate)
const diagnosisParam = selectedDiagnoses.length > 0 ? selectedDiagnoses.join(',') : undefined;
fetchDistrictCases(diagnosisParam, undefined, refDate);
}, [fetchDistrictCases, selectedDiagnoses]);
// 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<string, number> = {};
if (pollutantsR.status === 'fulfilled') {
for (const p of pollutantsR.value.data || []) {
aqiMap[p.date] = p.AQI || 0;
}
}
if (trendR.status === 'fulfilled') {
const trend = trendR.value.trend || [];
setCaseTrend(
trend.map((t) => ({ date: t.date, cases: t.total, aqi: aqiMap[t.date] || 0 }))
);
} else {
errs.push('趋势数据加载失败');
}
// Calendar heatmap: daily cases for the data's actual year (derived from trend data)
if (yearTrendR.status === 'fulfilled') {
const yearTrend = yearTrendR.value.trend || [];
if (yearTrend.length > 0) {
const derivedYear = new Date(yearTrend[0].date).getFullYear();
setHeatmapYear(derivedYear);
setHeatmapData(yearTrend.map((t) => ({ date: t.date, value: t.total })));
} else {
setHeatmapYear(end.getFullYear());
setHeatmapData([]);
}
} else {
errs.push('日历热力图加载失败');
}
setCasesTabError(errs.length > 0 ? errs.join('') : null);
setCasesTabLoading(false);
setCasesTabLoaded(true);
}, []);
// --- On-demand loader: 区域统计 tab ---
const loadDistrictTab = useCallback(async () => {
setDistrictTabLoading(true);
setDistrictTabError(null);
try {
const res = await caseApi.getDistricts();
setDistrictMetrics(res.districts || []);
setDistrictTabError(null);
} catch {
setDistrictTabError('区域统计加载失败');
} finally {
setDistrictTabLoading(false);
setDistrictTabLoaded(true);
}
}, []);
// Fetch tab data the first time a tab is opened (avoids loading everything upfront)
useEffect(() => {
if (activeTab === 'cases' && !casesTabLoaded && !casesTabLoading) {
loadCasesTab(currentDate);
}
if (activeTab === 'districts' && !districtTabLoaded && !districtTabLoading) {
loadDistrictTab();
}
}, [activeTab, casesTabLoaded, casesTabLoading, districtTabLoaded, districtTabLoading, currentDate, loadCasesTab, loadDistrictTab]);
// When the timeline date moves, refresh an already-opened 病例统计 tab so its
// trend window tracks the Monitoring timeline rather than going stale.
useEffect(() => {
if (activeTab === 'cases' && casesTabLoaded) {
loadCasesTab(currentDate);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentDate]);
// 区域统计 table: sortable district rows + heatmap columns
const districtTableRows = useMemo(() => {
const sorted = [...districtMetrics].sort((a, b) => {
switch (districtSortKey) {
case 'outpatient': return b.outpatient - a.outpatient;
case 'inpatient': return b.inpatient - a.inpatient;
case 'inpatient_ratio': return (b.inpatient_ratio ?? 0) - (a.inpatient_ratio ?? 0);
default: return b.total - a.total;
}
});
return sorted.map((d) => d.district);
}, [districtMetrics, districtSortKey]);
const districtTableData = useMemo(() => {
const map: Record<string, Record<string, number>> = {};
for (const d of districtMetrics) {
map[d.district] = {
total: d.total,
outpatient: d.outpatient,
inpatient: d.inpatient,
inpatient_ratio: Math.round((d.inpatient_ratio ?? 0) * 1000) / 10,
};
}
return map;
}, [districtMetrics]);
// 数据层:图表窗口 + 病例统计/区域统计两个按需 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 */}
<div className="bg-white border-b border-gray-200 px-6 py-4 shrink-0">
<div className="flex items-start justify-between flex-wrap gap-x-4 gap-y-3">
<div className="flex-1 min-w-0 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard
icon={<Calendar className="w-4 h-4 text-blue-600" />}
label="当日病例"
value={stats.todayCases !== null ? stats.todayCases.toLocaleString() : '--'}
/>
<StatCard
icon={<Activity className="w-4 h-4 text-indigo-600" />}
label="7日均值"
value={stats.avg7d.toLocaleString()}
sparkline={sparkline7d.length >= 2 ? { data: sparkline7d, color: '#6366F1' } : undefined}
/>
<StatCard
icon={
stats.trend === 'up' ? <TrendingUp className="w-4 h-4 text-red-500" /> :
stats.trend === 'down' ? <TrendingDown className="w-4 h-4 text-green-500" /> :
<Activity className="w-4 h-4 text-gray-400" />
}
label="趋势"
value={stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳'}
trend={{
direction: stats.trend === 'up' ? 'up' : stats.trend === 'down' ? 'down' : 'stable',
value: stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳',
}}
/>
<StatCard
icon={<Zap className="w-4 h-4 text-amber-500" />}
label="峰值日"
value={`${stats.maxDay.cases.toLocaleString()} (${stats.maxDay.date.slice(5)})`}
/>
<StatCard
icon={<BarChart3 className="w-4 h-4 text-purple-500" />}
label="标准差"
value={stats.stdDev.toLocaleString()}
/>
<StatCard
icon={<Stethoscope className="w-4 h-4 text-orange-500" />}
label="门诊 / 住院"
value={`${stats.totalOutpatient.toLocaleString()} / ${stats.totalInpatient.toLocaleString()}`}
/>
</div>
<MonitoringStatsBar stats={data.stats} sparkline7d={data.sparkline7d} />
{/* Disease filter */}
<div className="flex items-center gap-2 shrink-0">
{stats.noData && (
{data.stats.noData && (
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded"></span>
)}
<DiseaseFilter onFilterChange={() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
loadChartData(currentDate, selectedDistrict || undefined);
}, 300);
}} />
<DiseaseFilter onFilterChange={data.debouncedLoadChart} />
<AdminBreadcrumb />
</div>
</div>
@@ -515,195 +179,47 @@ export function MonitoringDashboard({
<div className="flex-1 overflow-auto p-6 pb-24">
{/* 概览 tab — unchanged Monitoring content */}
{activeTab === 'overview' && (
isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : (
<div className="space-y-6">
{/* Case Location Map */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<CaseLocationMap height="400px" district={selectedDistrict} street={selectedStreet} date={currentDate} />
</div>
{/* Statistical Charts */}
<StatisticalCharts
data={chartData}
height={350}
showCases={true}
showAQI={true}
/>
{/* District breakdown — 区域 roll-upURL 粒度真相来源) */}
<div data-testid={TESTIDS.districtRollup} className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900"></h3>
<Segmented<Granularity>
testid={TESTIDS.granularityControl}
size="sm"
options={[
{ value: 'city', label: '全市' },
{ value: 'district', label: '区域' },
{ value: 'street', label: '街道' },
]}
value={granularity}
onChange={handleGranularityChange}
/>
</div>
<div className="space-y-2">
<DistrictBreakdown
districtCases={districtCases}
selectedDistrict={selectedDistrict}
onDistrictSelect={handleDistrictSelect}
/>
</div>
<div className="flex items-center gap-4 mt-3 pt-2 border-t border-gray-100">
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<span className="w-3 h-3 bg-orange-400 rounded-sm" />
</div>
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<span className="w-3 h-3 bg-red-400 rounded-sm" />
</div>
</div>
</div>
</div>
)
<OverviewTab
isLoading={isLoading}
chartData={data.chartData}
districtCases={data.districtCases}
selectedDistrict={selectedDistrict}
selectedStreet={selectedStreet}
currentDate={currentDate}
granularity={granularity}
onGranularityChange={handleGranularityChange}
onDistrictSelect={handleDistrictSelect}
/>
)}
{/* 病例统计 tab */}
{activeTab === 'cases' && (
casesTabLoading && !casesTabLoaded ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : (
<div className="space-y-6">
{casesTabError && (
<ErrorBanner
error={casesTabError}
onRetry={() => loadCasesTab(currentDate)}
onDismiss={() => setCasesTabError(null)}
/>
)}
{/* Top 5 诊断分布 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top 5 </h3>
{topDiagnoses.length > 0 ? (
<ResponsiveContainer width="100%" height={240}>
<BarChart
data={[...topDiagnoses].reverse()}
layout="vertical"
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
<YAxis
type="category"
dataKey="diagnosis"
tick={{ fontSize: 11, fill: '#374151' }}
width={100}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{ backgroundColor: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '8px', fontSize: '12px' }}
formatter={(value: number) => [value.toLocaleString(), '病例数']}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={16} />
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={16} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
{/* 近30日病例与AQI趋势 (driven off Monitoring timeline currentDate) */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-1">AQI趋势</h3>
<p className="text-xs text-gray-500 mb-4"> {currentDate} 30</p>
{caseTrend.length > 0 ? (
<ResponsiveContainer width="100%" height={260}>
<LineChart data={caseTrend} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
dataKey="date"
tickFormatter={formatDateLabel}
tick={{ fontSize: 10, fill: '#64748B' }}
interval="preserveStartEnd"
axisLine={{ stroke: '#E2E8F0' }}
/>
<YAxis yAxisId="left" tick={{ fontSize: 10, fill: '#64748B' }} axisLine={{ stroke: '#E2E8F0' }} />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10, fill: '#F59E0B' }} axisLine={{ stroke: '#E2E8F0' }} />
<Tooltip
contentStyle={{ backgroundColor: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '8px', fontSize: '12px' }}
labelStyle={{ color: '#1E293B', fontWeight: 600 }}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Line yAxisId="left" type="monotone" dataKey="cases" name="病例数" stroke="#3B82F6" strokeWidth={2} dot={false} activeDot={{ r: 3 }} />
<Line yAxisId="right" type="monotone" dataKey="aqi" name="AQI" stroke="#F59E0B" strokeWidth={2} strokeDasharray="5 5" dot={false} activeDot={{ r: 3 }} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
{/* 日历热力图 (year derived from data) */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{heatmapYear ? `${heatmapYear}` : ''}
</h3>
{heatmapYear && heatmapData.length > 0 ? (
<CalendarHeatmap data={heatmapData} year={heatmapYear} />
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
</div>
)
<CaseStatsTab
loading={data.casesTabLoading}
loaded={data.casesTabLoaded}
error={data.casesTabError}
currentDate={currentDate}
topDiagnoses={data.topDiagnoses}
caseTrend={data.caseTrend}
heatmapData={data.heatmapData}
heatmapYear={data.heatmapYear}
onRetry={() => data.loadCasesTab(currentDate)}
onDismissError={() => data.setCasesTabError(null)}
/>
)}
{/* 区域统计 tab */}
{activeTab === 'districts' && (
districtTabLoading && !districtTabLoaded ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : (
<div className="space-y-6">
{districtTabError && (
<ErrorBanner
error={districtTabError}
onRetry={() => loadDistrictTab()}
onDismiss={() => setDistrictTabError(null)}
/>
)}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-1"></h3>
<p className="text-xs text-gray-500 mb-4"></p>
{districtTableRows.length > 0 ? (
<MetricHeatmapTable
rows={districtTableRows}
columns={[
{ key: 'total', label: '病例' },
{ key: 'outpatient', label: '门诊' },
{ key: 'inpatient', label: '住院' },
{ key: 'inpatient_ratio', label: '住院占比%' },
]}
data={districtTableData}
onSort={(col) => setDistrictSortKey(col)}
/>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
</div>
)
<DistrictStatsTab
loading={data.districtTabLoading}
loaded={data.districtTabLoaded}
error={data.districtTabError}
rows={data.districtTableRows}
data={data.districtTableData}
onRetry={() => data.loadDistrictTab()}
onDismissError={() => data.setDistrictTabError(null)}
onSort={(col) => data.setDistrictSortKey(col)}
/>
)}
</div>
@@ -721,55 +237,3 @@ export function MonitoringDashboard({
</div>
);
}
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 (
<div
key={d.district}
className={`flex items-center gap-3 p-2 rounded cursor-pointer transition-colors ${
selectedDistrict === d.district ? 'bg-blue-50' : 'hover:bg-gray-50'
}`}
onClick={() => handleDistrictClick(d.district)}
>
<div className="w-16 text-sm text-gray-700 text-right shrink-0">{d.district}</div>
<div className="flex-1 h-6 bg-gray-100 rounded overflow-hidden flex">
<div
className="bg-orange-400 h-full transition-all"
style={{ width: `${barWidth * outPct / 100}%` }}
title={`门诊: ${d.outpatient.toLocaleString()}`}
/>
<div
className="bg-red-400 h-full transition-all"
style={{ width: `${barWidth * inPct / 100}%` }}
title={`住院: ${d.inpatient.toLocaleString()}`}
/>
</div>
<div className="w-20 text-right text-sm font-medium text-gray-900 shrink-0">
{d.total.toLocaleString()}
</div>
</div>
);
})}
</>
);
});

View File

@@ -103,6 +103,7 @@ function ReportList({ onSelect }: { onSelect: (id: string) => void }) {
</div>
) : (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
@@ -135,6 +136,7 @@ function ReportList({ onSelect }: { onSelect: (id: string) => void }) {
))}
</tbody>
</table>
</div>
</div>
)}
</div>
@@ -171,7 +173,7 @@ function ReportDetail({ reportId, onBack }: { reportId: string; onBack: () => vo
<ChevronLeft className="w-4 h-4" />
</button>
<div className="flex items-center justify-between mb-4">
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<div>
<h2 className="text-lg font-semibold text-gray-900">{metadata.title}</h2>
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500">
@@ -208,7 +210,7 @@ function ReportDetail({ reportId, onBack }: { reportId: string; onBack: () => vo
</div>
{/* Summary cards */}
<div className="grid grid-cols-5 gap-3 mb-6">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 mb-6">
{[
{ 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
</div>
{/* Sections and charts in 2-column layout */}
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
{sections.map((section, idx) => (
<div key={idx} className="bg-white rounded-lg border border-gray-200 p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-2">{section.title}</h3>
@@ -330,7 +332,7 @@ export function ReportsCenter() {
}, [fetchReportsList]);
return (
<div data-testid="page-reports">
<div data-testid="page-reports" className="overflow-x-hidden">
{error && view === 'list' && (
<ErrorBanner error={error} onRetry={() => { clearError(); fetchReportsList(); }} onDismiss={clearError} />
)}

View File

@@ -149,7 +149,7 @@ export function TrendAnalysis() {
};
return (
<div data-testid="page-trend">
<div data-testid="page-trend" className="overflow-x-hidden">
{error && (
<ErrorBanner
error={error}
@@ -311,7 +311,7 @@ export function TrendAnalysis() {
)}
{latestData && (
<div className="grid grid-cols-4 gap-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{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);