feat/ux-modernization #1
232
frontend/e2e/perf.spec.ts
Normal file
232
frontend/e2e/perf.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
135
frontend/e2e/responsive.spec.ts
Normal file
135
frontend/e2e/responsive.spec.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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'] },
|
||||
},
|
||||
],
|
||||
|
||||
108
frontend/src/components/alerts/AlertDetailModal.tsx
Normal file
108
frontend/src/components/alerts/AlertDetailModal.tsx
Normal 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">×</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>
|
||||
);
|
||||
});
|
||||
194
frontend/src/components/alerts/AlertsFilterBar.tsx
Normal file
194
frontend/src/components/alerts/AlertsFilterBar.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
57
frontend/src/components/alerts/AlertsHeader.tsx
Normal file
57
frontend/src/components/alerts/AlertsHeader.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
138
frontend/src/components/alerts/AlertsList.tsx
Normal file
138
frontend/src/components/alerts/AlertsList.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
129
frontend/src/components/alerts/AlertsListTab.tsx
Normal file
129
frontend/src/components/alerts/AlertsListTab.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
64
frontend/src/components/alerts/AlertsMapPanel.tsx
Normal file
64
frontend/src/components/alerts/AlertsMapPanel.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
130
frontend/src/components/alerts/AlertsRiskPanel.tsx
Normal file
130
frontend/src/components/alerts/AlertsRiskPanel.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
73
frontend/src/components/alerts/AlertsToolbar.tsx
Normal file
73
frontend/src/components/alerts/AlertsToolbar.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
32
frontend/src/components/alerts/types.ts
Normal file
32
frontend/src/components/alerts/types.ts
Normal 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][];
|
||||
}
|
||||
163
frontend/src/components/alerts/useAlertsData.ts
Normal file
163
frontend/src/components/alerts/useAlertsData.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
145
frontend/src/components/monitoring/CaseStatsTab.tsx
Normal file
145
frontend/src/components/monitoring/CaseStatsTab.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
66
frontend/src/components/monitoring/DistrictStatsTab.tsx
Normal file
66
frontend/src/components/monitoring/DistrictStatsTab.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
56
frontend/src/components/monitoring/MonitoringStatsBar.tsx
Normal file
56
frontend/src/components/monitoring/MonitoringStatsBar.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
142
frontend/src/components/monitoring/OverviewTab.tsx
Normal file
142
frontend/src/components/monitoring/OverviewTab.tsx
Normal 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-up(URL 粒度真相来源) */}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
38
frontend/src/components/monitoring/types.ts
Normal file
38
frontend/src/components/monitoring/types.ts
Normal 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;
|
||||
}
|
||||
317
frontend/src/components/monitoring/useMonitoringData.ts
Normal file
317
frontend/src/components/monitoring/useMonitoringData.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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">×</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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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-up(URL 粒度真相来源) */}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user