feat: Phase 2 — leadership 大屏 (/overview) + district normalization + drawer a11y

Phase 2 of the UX modernization. Three conflict-free workstreams.

Leadership 驾驶舱 (/overview):
- Wuhan 13-district Leaflet choropleth (public/wuhan_districts.geojson, keyed
  on name, darker=higher per 高风险高亮), legend, hover/click-zoom
- 全部/门诊/住院 Segmented toggle drives choropleth + Top-5 district bar
- literal "数据截至2023-12" as-of badge (D3 honesty); raw spinner → LoadingState
- decompose OverviewDashboard 501→273; 6 components + 2 helpers under components/overview/

District normalization (backend data boundary):
- case_loader.normalize_district + load_cases_by_district_daily collapse the
  26 dirty labels (武昌/武昌区…) → 13 canonical; analysis/grid/insights repointed
  (fixes a grid-merge row-drop bug as a bonus); in-memory, schema unchanged

Shell a11y (code-review carryover):
- drawer is now a proper modal: ESC, body scroll-lock, focus-in + focus-trap
  cycle + focus-restore, role=dialog/aria-modal/aria-label, hamburger aria-expanded
- SideNav expanded state lifted to AppShell so rail+drawer stay in sync
- RouteErrorBoundary around <Outlet/> keeps shell chrome on page/chunk failure

Gates: tsc 0 · vitest 64 · e2e 19/19 (17 user-flows + 2 overview) · build ok
· backend pytest 6 new + 48 regression green

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 20:24:26 +08:00
parent 3db3b12480
commit 9a94156acc
22 changed files with 1378 additions and 358 deletions

View File

@@ -25,12 +25,42 @@ _cache: dict[str, Optional[pd.DataFrame | datetime]] = {
"outpatient": None, "outpatient": None,
"inpatient": None, "inpatient": None,
"combined": None, "combined": None,
"cases_by_district_daily": None,
"loaded_at": None, "loaded_at": None,
} }
# Guards the lazy build so concurrent callers don't duplicate the load/concat. # Guards the lazy build so concurrent callers don't duplicate the load/concat.
_load_lock = threading.RLock() _load_lock = threading.RLock()
# Canonical Wuhan administrative districts (13), matching the `name` field in
# Datas/武汉市.geojson. All district roll-ups must collapse to exactly these.
CANONICAL_DISTRICTS = [
'江岸区', '江汉区', '硚口区', '汉阳区', '武昌区', '青山区', '洪山区',
'东西湖区', '汉南区', '蔡甸区', '江夏区', '黄陂区', '新洲区',
]
# Bare (suffix-less) base name -> canonical 区-suffixed name.
_DISTRICT_BASE_TO_CANONICAL = {d[:-1]: d for d in CANONICAL_DISTRICTS}
_DISTRICT_SUFFIXES = ('', '', '')
def normalize_district(name: str) -> str:
"""Map a district label to its canonical 区-suffixed form.
The case parquet carries both bare ("武昌") and suffixed ("武昌区") spellings
of the same district, which double-counts in any roll-up. This collapses
them: known bare names map to their canonical form; already-suffixed names
pass through unchanged; anything else gets a "" appended.
"""
if name is None:
return name
name = str(name).strip()
if name in _DISTRICT_BASE_TO_CANONICAL:
return _DISTRICT_BASE_TO_CANONICAL[name]
if name.endswith(_DISTRICT_SUFFIXES):
return name
return f"{name}"
# Wuhan district mapping # Wuhan district mapping
WUHAN_DISTRICTS = { WUHAN_DISTRICTS = {
'江岸区': ['江岸'], '江岸区': ['江岸'],
@@ -170,3 +200,40 @@ def get_inpatient_data() -> pd.DataFrame:
"""Return the cached inpatient dataframe""" """Return the cached inpatient dataframe"""
load_data() load_data()
return _cache["inpatient"] # type: ignore[return-value] return _cache["inpatient"] # type: ignore[return-value]
def load_cases_by_district_daily() -> pd.DataFrame:
"""Load processed/cases_by_district_daily.parquet with districts normalized.
The on-disk parquet carries both bare and 区-suffixed spellings of each
district (26 labels = 13 districts × 2 spellings), so any groupby on the
raw `district` column double-counts. This is the single data-access
boundary: it normalizes labels to the canonical 13 and re-aggregates
(sum of outpatient_count / inpatient_count / total_cases per
normalized district + date), so every downstream consumer
(analysis / grid / insights) sees clean, deduped 13-district data.
Returns a copy with columns [date, district, outpatient_count,
inpatient_count, total_cases]. Raises FileNotFoundError if the parquet
is missing (callers handle this as they did before).
"""
path = PROCESSED_DIR / "cases_by_district_daily.parquet"
cached = _cache.get("cases_by_district_daily")
if cached is not None:
return cast(pd.DataFrame, cached).copy()
with _load_lock:
cached = _cache.get("cases_by_district_daily")
if cached is not None:
return cast(pd.DataFrame, cached).copy()
df = pd.read_parquet(path)
df["district"] = df["district"].map(normalize_district)
agg = (
df.groupby(["date", "district"], as_index=False)[
["outpatient_count", "inpatient_count", "total_cases"]
]
.sum()
)
_cache["cases_by_district_daily"] = agg # type: ignore[assignment]
return agg.copy()

View File

@@ -12,6 +12,7 @@ import pandas as pd
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT, WUHAN_BOUNDS, LAT_STEP, LON_STEP from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT, WUHAN_BOUNDS, LAT_STEP, LON_STEP
from data.case_loader import load_cases_by_district_daily
from utils.date_helpers import get_latest_date from utils.date_helpers import get_latest_date
from utils.geojson import parse_geojson_file, load_districts from utils.geojson import parse_geojson_file, load_districts
from utils.geo import point_in_polygon from utils.geo import point_in_polygon
@@ -179,18 +180,16 @@ def _district_avg_aqi() -> dict:
def _district_total_cases() -> dict: def _district_total_cases() -> dict:
"""Real total recorded cases per district from cases_by_district_daily. """Real total recorded cases per district from cases_by_district_daily.
District labels in the case file are inconsistent ("武昌" vs "武昌区"), District labels are normalized to the canonical 13 区-suffixed names at the
so names are normalized by stripping the "" suffix and summed, then data-access boundary (data.case_loader), so this is a plain per-district
keyed by the canonical mapping name (with ""). Returns {district: cases}. sum. Returns {district: cases}.
""" """
path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet" try:
if not path.exists(): df = load_cases_by_district_daily()
except FileNotFoundError:
return {} return {}
df = pd.read_parquet(path, columns=["district", "total_cases"]) by_district = df.groupby("district")["total_cases"].sum()
df = df.copy() return {str(d): int(v) for d, v in by_district.items()}
df["base"] = df["district"].str.replace("", "", regex=False)
by_base = df.groupby("base")["total_cases"].sum()
return {f"{base}": int(v) for base, v in by_base.items()}
@lru_cache(maxsize=8) @lru_cache(maxsize=8)

View File

@@ -21,6 +21,7 @@ from models import (
MultiDayPredictionRequest, MultiDayPredictionRequest,
MultiDayPredictionResponse, MultiDayPredictionResponse,
) )
from data.case_loader import load_cases_by_district_daily
router = APIRouter(prefix="/api", tags=["grid"]) router = APIRouter(prefix="/api", tags=["grid"])
@@ -43,14 +44,13 @@ def _compute_historical_aggregation(
) -> HistoricalAggregationResponse: ) -> HistoricalAggregationResponse:
"""Run the full pandas aggregation pipeline (called in thread pool).""" """Run the full pandas aggregation pipeline (called in thread pool)."""
try: try:
cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet") cases_df = load_cases_by_district_daily()
except FileNotFoundError: except FileNotFoundError:
return HistoricalAggregationResponse( return HistoricalAggregationResponse(
aggregations=[], total_records=0, aggregations=[], total_records=0,
date_range=(start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")), date_range=(start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")),
timestamp=datetime.now().isoformat(), timestamp=datetime.now().isoformat(),
) )
cases_df = cases_df.copy()
cases_df['date'] = pd.to_datetime(cases_df['date']) cases_df['date'] = pd.to_datetime(cases_df['date'])
filtered_cases = cases_df[ filtered_cases = cases_df[
@@ -196,7 +196,7 @@ def _grids_geojson_body(date: str, district: Optional[str], risk_level: Optional
if district: if district:
merged = merged[merged['district_name'].str.contains(district.replace('', ''), na=False, regex=False)] merged = merged[merged['district_name'].str.contains(district.replace('', ''), na=False, regex=False)]
cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet").copy() cases_df = load_cases_by_district_daily()
cases_df['date'] = pd.to_datetime(cases_df['date']).dt.strftime('%Y-%m-%d') cases_df['date'] = pd.to_datetime(cases_df['date']).dt.strftime('%Y-%m-%d')
cases_df = cases_df[cases_df['date'] == date] cases_df = cases_df[cases_df['date'] == date]
@@ -364,7 +364,7 @@ def _compute_grid_history(grid_id: str, days: int) -> dict:
district = grid_info.iloc[0]['district_name'] district = grid_info.iloc[0]['district_name']
cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet") cases_df = load_cases_by_district_daily()
cases_df['date'] = pd.to_datetime(cases_df['date']) cases_df['date'] = pd.to_datetime(cases_df['date'])
end_date = datetime.now() end_date = datetime.now()

View File

@@ -11,6 +11,7 @@ from pydantic import BaseModel, Field
from typing import Dict, List, Literal from typing import Dict, List, Literal
from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT
from data.case_loader import load_cases_by_district_daily
from models import ( from models import (
InsightsResponse, InsightsResponse,
InsightTrend, InsightTrend,
@@ -491,22 +492,22 @@ async def get_insights_cards():
cases_path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet" cases_path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
if cases_path.exists(): if cases_path.exists():
cases_df = _cached_parquet(str(cases_path)) # Districts already normalized to the canonical 13 区-suffixed names.
cases_df = load_cases_by_district_daily()
cases_df["date"] = pd.to_datetime(cases_df["date"])
latest_case_date = cases_df["date"].max() latest_case_date = cases_df["date"].max()
latest_cases = cases_df[cases_df["date"] == latest_case_date].copy() latest_cases = cases_df[cases_df["date"] == latest_case_date]
latest_cases["base_district"] = latest_cases["district"].str.replace("", "") district_daily = latest_cases.groupby("district")["total_cases"].sum().sort_values(ascending=False)
district_daily = latest_cases.groupby("base_district")["total_cases"].sum().sort_values(ascending=False)
total_daily = int(district_daily.sum()) total_daily = int(district_daily.sum())
top_name = district_daily.index[0] top_name = district_daily.index[0]
top_val = int(district_daily.iloc[0]) top_val = int(district_daily.iloc[0])
num_districts = len(district_daily) num_districts = len(district_daily)
week_ago = latest_case_date - pd.Timedelta(days=6) week_ago = latest_case_date - pd.Timedelta(days=6)
week_cases = cases_df[cases_df["date"] >= week_ago].copy() week_cases = cases_df[cases_df["date"] >= week_ago]
week_cases["base_district"] = week_cases["district"].str.replace("", "")
daily_totals = week_cases.groupby("date")["total_cases"].sum() daily_totals = week_cases.groupby("date")["total_cases"].sum()
avg_daily = int(daily_totals.mean()) avg_daily = int(daily_totals.mean())
week_district = week_cases.groupby("base_district")["total_cases"].sum().sort_values(ascending=False) week_district = week_cases.groupby("district")["total_cases"].sum().sort_values(ascending=False)
week_top_val = int(week_district.iloc[0]) week_top_val = int(week_district.iloc[0])
date_str = latest_case_date.strftime("%m月%d") date_str = latest_case_date.strftime("%m月%d")
@@ -515,8 +516,8 @@ async def get_insights_cards():
title=f"日病例统计 ({date_str})", title=f"日病例统计 ({date_str})",
description=( description=(
f"最近统计日({date_str})全市{num_districts}个区共记录{total_daily}例儿童呼吸道疾病病例," f"最近统计日({date_str})全市{num_districts}个区共记录{total_daily}例儿童呼吸道疾病病例,"
f"{top_name}{top_val}例为当日最高。近7日日均{avg_daily}例," f"{top_name}{top_val}例为当日最高。近7日日均{avg_daily}例,"
f"{week_district.index[0]}累计{week_top_val}例居首。" f"{week_district.index[0]}累计{week_top_val}例居首。"
), ),
type="warning", type="warning",
metric="日病例", metric="日病例",

View File

@@ -0,0 +1,82 @@
"""Tests for district label normalization at the case-loader boundary.
The processed/cases_by_district_daily.parquet carries both bare ("武昌") and
区-suffixed ("武昌区") spellings of each district (26 labels = 13 districts × 2
spellings), which double-counts in any roll-up. data.case_loader normalizes
these to the canonical 13 区-suffixed names and re-aggregates. These tests pin
that behavior.
"""
import sys
from pathlib import Path
import pandas as pd
import pytest
# Ensure the backend package root is importable at collection time (mirrors the
# sys.path handling other modules rely on once the app is imported).
BACKEND_ROOT = Path(__file__).parent.parent
if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT))
from data.case_loader import ( # noqa: E402
CANONICAL_DISTRICTS,
normalize_district,
load_cases_by_district_daily,
)
PROJECT_ROOT = Path(__file__).parent.parent.parent
RAW_PARQUET = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
def test_normalize_district_known_bare_forms():
"""Every known bare form maps to its canonical 区-suffixed name."""
cases = {
"武昌": "武昌区", "汉阳": "汉阳区", "江岸": "江岸区", "硚口": "硚口区",
"青山": "青山区", "洪山": "洪山区", "东西湖": "东西湖区", "汉南": "汉南区",
"蔡甸": "蔡甸区", "江夏": "江夏区", "黄陂": "黄陂区", "新洲": "新洲区",
"江汉": "江汉区",
}
for bare, canonical in cases.items():
assert normalize_district(bare) == canonical
def test_normalize_district_already_suffixed_passes_through():
for d in CANONICAL_DISTRICTS:
assert normalize_district(d) == d
def test_canonical_set_is_exactly_thirteen():
assert len(CANONICAL_DISTRICTS) == 13
assert len(set(CANONICAL_DISTRICTS)) == 13
@pytest.mark.skipif(not RAW_PARQUET.exists(), reason="case parquet not present")
def test_loader_collapses_to_thirteen_canonical_districts():
df = load_cases_by_district_daily()
districts = set(df["district"].unique())
# (a) exactly 13 unique districts, all canonical
assert len(districts) == 13, f"expected 13 districts, got {len(districts)}: {sorted(districts)}"
assert districts == set(CANONICAL_DISTRICTS)
# (b) no bare / unsuffixed duplicates remain
for name in districts:
assert name.endswith(("", "", "")), f"unsuffixed district leaked: {name}"
@pytest.mark.skipif(not RAW_PARQUET.exists(), reason="case parquet not present")
def test_loader_preserves_totals_no_rows_dropped_or_double_counted():
"""Sum integrity: normalized total == raw parquet total."""
raw = pd.read_parquet(RAW_PARQUET)
normalized = load_cases_by_district_daily()
assert int(normalized["total_cases"].sum()) == int(raw["total_cases"].sum())
assert int(normalized["outpatient_count"].sum()) == int(raw["outpatient_count"].sum())
assert int(normalized["inpatient_count"].sum()) == int(raw["inpatient_count"].sum())
@pytest.mark.skipif(not RAW_PARQUET.exists(), reason="case parquet not present")
def test_raw_parquet_actually_has_dirty_labels():
"""Sanity: the raw file really has the 26-label problem we are fixing."""
raw = pd.read_parquet(RAW_PARQUET)
assert raw["district"].nunique() > 13

View File

@@ -0,0 +1,119 @@
/**
* 综合概览大屏 (/overview) 验收测试。
*
* 与 user-flows.spec.ts 一致:用 addInitScript 注入 cbpoa_token 绕过登录门,
* page.route 拦截 /api/** 使套件 hermetic无需 :8000。/wuhan_districts.geojson
* 走真实静态资源dev server 提供),由 Leaflet 取用。
*/
import { test, expect, Page } from '@playwright/test';
import { TESTIDS } from '../src/utils/testids';
/** 13 区里造两条数据,断言 choropleth 能着色、toggle 能切换。 */
function districtPayload() {
return {
districts: [
{ district: '武昌区', outpatient: 120, inpatient: 30, total: 150, outpatient_ratio: 0.8, inpatient_ratio: 0.2 },
{ district: '江岸', outpatient: 60, inpatient: 10, total: 70, outpatient_ratio: 0.86, inpatient_ratio: 0.14 },
],
total: 220,
};
}
async function seedAuthAndMockApi(page: Page) {
await page.addInitScript(() => {
localStorage.setItem('cbpoa_token', 'e2e-test-token');
});
await page.route('/api/**', (route) => {
const url = route.request().url();
const json = (body: unknown) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
if (url.includes('/alerts')) {
return json({ alerts: [], total: 0 });
}
if (url.includes('/cases/stats')) {
return json({
total_outpatient: 1000,
total_inpatient: 200,
date_range: { start: '2023-01-01', end: '2023-12-01' },
top_districts: [],
top_diagnoses: [
{ diagnosis: '上呼吸道感染', outpatient: 300, inpatient: 40 },
{ diagnosis: '肺炎', outpatient: 120, inpatient: 80 },
],
});
}
if (url.includes('/cases/trend')) {
return json({
trend: [
{ date: '2023-11-01', outpatient: 10, inpatient: 2, total: 12 },
{ date: '2023-11-02', outpatient: 14, inpatient: 3, total: 17 },
],
summary: {
total_outpatient: 24,
total_inpatient: 5,
period_count: 2,
avg_daily_outpatient: 12,
avg_daily_inpatient: 2.5,
},
});
}
if (url.includes('/cases/districts')) {
return json(districtPayload());
}
if (url.includes('/risk/stats')) {
return json({ high_risk_count: 7, total_grids: 100, avg_risk: 0.4 });
}
if (url.includes('/environment/pollutants')) {
return json({ data: [{ date: '2023-11-01', AQI: 80 }, { date: '2023-11-02', AQI: 95 }] });
}
return json({ data: [], items: [], total: 0 });
});
}
test.describe('Overview 大屏', () => {
test.use({ viewport: { width: 1280, height: 900 } });
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('renders kpi-row, choropleth, as-of badge and metric toggle', async ({ page }) => {
await page.goto('/overview');
await expect(page.locator(`[data-testid="${TESTIDS.pageOverview}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.kpiRow}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.choroplethWrapper}"]`)).toBeVisible();
// Literal honesty badge — exact text.
const badge = page.locator(`[data-testid="${TESTIDS.asofBadge}"]`);
await expect(badge).toBeVisible();
await expect(badge).toHaveText('数据截至2023-12');
});
test('门诊/住院 toggle switches active segment without error', async ({ page }) => {
await page.goto('/overview');
await expect(page.locator(`[data-testid="${TESTIDS.choroplethWrapper}"]`)).toBeVisible();
const outBtn = page.locator(`[data-testid="${TESTIDS.outinpatientToggle}-outpatient"]`);
const inBtn = page.locator(`[data-testid="${TESTIDS.outinpatientToggle}-inpatient"]`);
const allBtn = page.locator(`[data-testid="${TESTIDS.outinpatientToggle}-all"]`);
// Default: 全部 active (primary background).
await expect(allBtn).toHaveClass(/bg-primary/);
await outBtn.click();
await expect(outBtn).toHaveClass(/bg-primary/);
await expect(allBtn).not.toHaveClass(/bg-primary/);
await inBtn.click();
await expect(inBtn).toHaveClass(/bg-primary/);
await expect(outBtn).not.toHaveClass(/bg-primary/);
// Wrapper still mounted after toggling — no render crash.
await expect(page.locator(`[data-testid="${TESTIDS.choroplethWrapper}"]`)).toBeVisible();
});
});

Binary file not shown.

View File

@@ -1,7 +1,8 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, useEffect, useRef } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { TopNav } from '@/components/TopNav'; import { TopNav } from '@/components/TopNav';
import { SideNav } from '@/components/SideNav'; import { SideNav } from '@/components/SideNav';
import { RouteErrorBoundary } from '@/components/RouteErrorBoundary';
import { useRiskStore } from '@/stores'; import { useRiskStore } from '@/stores';
import { TESTIDS } from '@/utils/testids'; import { TESTIDS } from '@/utils/testids';
@@ -9,17 +10,84 @@ interface AppShellProps {
onLogout?: () => void; onLogout?: () => void;
} }
// 收集容器内当前可聚焦的元素,供初始聚焦与焦点循环陷阱使用。
function getFocusable(container: HTMLElement): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
).filter((el) => el.offsetParent !== null || el === document.activeElement);
}
// 仅负责布局骨架(顶栏 / 侧栏 / 内容区),不涉及路由匹配与鉴权。 // 仅负责布局骨架(顶栏 / 侧栏 / 内容区),不涉及路由匹配与鉴权。
export function AppShell({ onLogout }: AppShellProps) { export function AppShell({ onLogout }: AppShellProps) {
const alerts = useRiskStore((s) => s.alerts); const alerts = useRiskStore((s) => s.alerts);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
// 提升手风琴展开态:导轨与抽屉两份 SideNav 共享,保持同步。
const [expandedNav, setExpandedNav] = useState<string | null>('monitoring');
const drawerRef = useRef<HTMLElement>(null);
const openDrawer = useCallback(() => setDrawerOpen(true), []); const openDrawer = useCallback(() => setDrawerOpen(true), []);
const closeDrawer = useCallback(() => setDrawerOpen(false), []); const closeDrawer = useCallback(() => setDrawerOpen(false), []);
// 抽屉作为模态ESC 关闭、锁定 body 滚动、焦点移入并在关闭后归还给汉堡。
useEffect(() => {
if (!drawerOpen) return;
const opener = document.activeElement as HTMLElement | null;
// 锁定 body 滚动,关闭时还原原值。
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
// 焦点移入抽屉(优先第一个可聚焦元素,否则聚焦抽屉容器本身)。
const drawer = drawerRef.current;
const focusables = drawer ? getFocusable(drawer) : [];
(focusables[0] ?? drawer)?.focus();
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
closeDrawer();
return;
}
// 焦点循环陷阱Tab 在抽屉内首尾元素之间循环。
if (e.key === 'Tab' && drawer) {
const items = getFocusable(drawer);
if (items.length === 0) {
e.preventDefault();
drawer.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (e.shiftKey && (active === first || active === drawer)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
document.body.style.overflow = prevOverflow;
// 关闭后把焦点还给打开抽屉的元素(汉堡按钮),回退到按 testid 查询。
const restoreTarget =
opener ??
document.querySelector<HTMLElement>(`[data-testid="${TESTIDS.hamburger}"]`);
restoreTarget?.focus();
};
}, [drawerOpen, closeDrawer]);
return ( return (
<div data-testid={TESTIDS.appShell} className="h-screen bg-bg-page flex flex-col overflow-hidden"> <div data-testid={TESTIDS.appShell} className="h-screen bg-bg-page flex flex-col overflow-hidden">
<TopNav onLogout={onLogout} onToggleMenu={openDrawer} /> <TopNav onLogout={onLogout} onToggleMenu={openDrawer} isMenuOpen={drawerOpen} />
<div className="flex flex-1 min-h-0"> <div className="flex flex-1 min-h-0">
{/* lg 及以上:持久侧栏导轨 */} {/* lg 及以上:持久侧栏导轨 */}
@@ -27,7 +95,11 @@ export function AppShell({ onLogout }: AppShellProps) {
data-testid={TESTIDS.sidebarRail} data-testid={TESTIDS.sidebarRail}
className="hidden lg:block w-[200px] shrink-0 bg-bg-card border-r border-border" className="hidden lg:block w-[200px] shrink-0 bg-bg-card border-r border-border"
> >
<SideNav alertCount={alerts.length} /> <SideNav
alertCount={alerts.length}
expanded={expandedNav}
onExpandedChange={setExpandedNav}
/>
</aside> </aside>
{/* lg 以下:离屏抽屉 + 遮罩 */} {/* lg 以下:离屏抽屉 + 遮罩 */}
@@ -39,16 +111,29 @@ export function AppShell({ onLogout }: AppShellProps) {
/> />
)} )}
<aside <aside
ref={drawerRef}
id="app-drawer"
role="dialog"
aria-modal="true"
aria-label="导航菜单"
tabIndex={-1}
data-testid={TESTIDS.appDrawer} data-testid={TESTIDS.appDrawer}
className={`fixed top-0 left-0 bottom-0 z-50 w-[260px] max-w-[80vw] bg-bg-card border-r border-border shadow-xl transition-transform duration-200 lg:hidden ${ className={`fixed top-0 left-0 bottom-0 z-50 w-[260px] max-w-[80vw] bg-bg-card border-r border-border shadow-xl transition-transform duration-200 lg:hidden ${
drawerOpen ? 'translate-x-0' : '-translate-x-full' drawerOpen ? 'translate-x-0' : '-translate-x-full'
}`} }`}
> >
<SideNav alertCount={alerts.length} onNavigate={closeDrawer} /> <SideNav
alertCount={alerts.length}
onNavigate={closeDrawer}
expanded={expandedNav}
onExpandedChange={setExpandedNav}
/>
</aside> </aside>
<main className="flex-1 min-w-0 overflow-auto p-5"> <main className="flex-1 min-w-0 overflow-auto p-5">
<Outlet /> <RouteErrorBoundary>
<Outlet />
</RouteErrorBoundary>
</main> </main>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,45 @@
import { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
}
// 路由级错误边界:单个页面(含懒加载 chunk崩溃时只降级内容区
// 保留外层骨架(顶栏 + 侧栏),避免整页白屏。
export class RouteErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
private reset = () => {
this.setState({ hasError: false });
};
render() {
if (this.state.hasError) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="text-danger text-base mb-2"></div>
<button
onClick={this.reset}
className="mt-2 px-4 py-2 bg-primary text-white rounded text-sm"
>
</button>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -6,6 +6,10 @@ interface SideNavProps {
alertCount?: number; alertCount?: number;
// 抽屉模式下点击导航项后关闭抽屉(持久侧栏可不传)。 // 抽屉模式下点击导航项后关闭抽屉(持久侧栏可不传)。
onNavigate?: () => void; onNavigate?: () => void;
// 受控的展开手风琴分组:由 AppShell 提供时,导轨与抽屉两份实例保持同步。
// 不传则回退到内部 state向后兼容独立使用。
expanded?: string | null;
onExpandedChange?: (moduleId: string | null) => void;
} }
interface NavItem { interface NavItem {
@@ -56,14 +60,28 @@ const modules: { id: string; label: string; icon: React.ReactNode; items: NavIte
}, },
]; ];
export function SideNav({ alertCount = 0, onNavigate }: SideNavProps) { export function SideNav({
alertCount = 0,
onNavigate,
expanded: expandedProp,
onExpandedChange,
}: SideNavProps) {
const location = useLocation(); const location = useLocation();
// 当前路径命中的模块默认展开。 // 当前路径命中的模块默认展开。
const moduleForPath = (pathname: string) => const moduleForPath = (pathname: string) =>
modules.find((m) => m.items.some((item) => pathname.startsWith(item.to)))?.id ?? 'monitoring'; modules.find((m) => m.items.some((item) => pathname.startsWith(item.to)))?.id ?? 'monitoring';
const [expanded, setExpanded] = useState<string | null>(() => moduleForPath(location.pathname)); // 受控/非受控双模式:父级传入 expanded 时由父级管理,否则回退内部 state。
const [internalExpanded, setInternalExpanded] = useState<string | null>(() =>
moduleForPath(location.pathname)
);
const isControlled = expandedProp !== undefined;
const expanded = isControlled ? expandedProp : internalExpanded;
const setExpanded = (next: string | null) => {
if (isControlled) onExpandedChange?.(next);
else setInternalExpanded(next);
};
const isActiveModule = (moduleId: string) => { const isActiveModule = (moduleId: string) => {
const module = modules.find((m) => m.id === moduleId); const module = modules.find((m) => m.id === moduleId);

View File

@@ -5,6 +5,8 @@ interface TopNavProps {
onLogout?: () => void; onLogout?: () => void;
// 移动端汉堡按钮:切换侧栏抽屉。 // 移动端汉堡按钮:切换侧栏抽屉。
onToggleMenu?: () => void; onToggleMenu?: () => void;
// 抽屉是否展开(用于汉堡按钮的 aria-expanded
isMenuOpen?: boolean;
} }
function Clock() { function Clock() {
@@ -16,7 +18,7 @@ function Clock() {
return <span>{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>; return <span>{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>;
} }
export function TopNav({ onLogout, onToggleMenu }: TopNavProps) { export function TopNav({ onLogout, onToggleMenu, isMenuOpen = false }: TopNavProps) {
return ( return (
<nav className="h-[52px] bg-bg-card border-b border-border flex items-center px-5 z-50"> <nav className="h-[52px] bg-bg-card border-b border-border flex items-center px-5 z-50">
{onToggleMenu && ( {onToggleMenu && (
@@ -24,6 +26,8 @@ export function TopNav({ onLogout, onToggleMenu }: TopNavProps) {
type="button" type="button"
onClick={onToggleMenu} onClick={onToggleMenu}
aria-label="打开菜单" aria-label="打开菜单"
aria-expanded={isMenuOpen}
aria-controls="app-drawer"
data-testid={TESTIDS.hamburger} data-testid={TESTIDS.hamburger}
className="lg:hidden mr-3 -ml-1 w-9 h-9 flex items-center justify-center rounded-md text-text-secondary hover:bg-bg-hover transition-colors" className="lg:hidden mr-3 -ml-1 w-9 h-9 flex items-center justify-center rounded-md text-text-secondary hover:bg-bg-hover transition-colors"
> >

View File

@@ -0,0 +1,67 @@
import { memo } from 'react';
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { EmptyState } from '@/components/ui';
import { CHART_COLORS } from './chartColors';
export interface AlertSlice {
name: string;
value: number;
color: string;
}
interface AlertSeverityDonutProps {
data: AlertSlice[];
}
const tooltipStyle = {
backgroundColor: '#FFFFFF',
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
borderRadius: '8px',
fontSize: '12px',
};
function AlertSeverityDonutComponent({ data }: AlertSeverityDonutProps) {
const hasData = data.some((d) => d.value > 0);
return (
<div className="card p-4">
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
</div>
{hasData ? (
<div className="flex items-center justify-center">
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={4}
dataKey="value"
nameKey="name"
>
{data.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: number, name: string) => [value, name]}
/>
<Legend
wrapperStyle={{ fontSize: '12px' }}
formatter={(value: string) => <span className="text-text-primary">{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
</div>
) : (
<EmptyState title="暂无预警数据" />
)}
</div>
);
}
export const AlertSeverityDonut = memo(AlertSeverityDonutComponent);

View File

@@ -0,0 +1,100 @@
import { memo } from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { EmptyState } from '@/components/ui';
import { CHART_COLORS } from './chartColors';
export interface MergedTrendItem {
date: string;
cases: number;
aqi: number;
}
interface CaseAqiTrendProps {
data: MergedTrendItem[];
}
function formatDateLabel(dateStr: string): string {
const d = new Date(dateStr);
return `${d.getMonth() + 1}/${d.getDate()}`;
}
const tooltipStyle = {
backgroundColor: '#FFFFFF',
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
borderRadius: '8px',
fontSize: '12px',
};
function CaseAqiTrendComponent({ data }: CaseAqiTrendProps) {
return (
<div className="card p-4">
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
30AQI趋势
</div>
{data.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} />
<XAxis
dataKey="date"
tickFormatter={formatDateLabel}
tick={{ fontSize: 10, fill: CHART_COLORS.axis }}
interval="preserveStartEnd"
axisLine={{ stroke: CHART_COLORS.grid }}
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 10, fill: CHART_COLORS.axis }}
axisLine={{ stroke: CHART_COLORS.grid }}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 10, fill: CHART_COLORS.aqi }}
axisLine={{ stroke: CHART_COLORS.grid }}
/>
<Tooltip
contentStyle={tooltipStyle}
labelStyle={{ color: CHART_COLORS.tooltipText, fontWeight: 600 }}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Line
yAxisId="left"
type="monotone"
dataKey="cases"
name="病例数"
stroke={CHART_COLORS.cases}
strokeWidth={2}
dot={false}
activeDot={{ r: 3 }}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="aqi"
name="AQI"
stroke={CHART_COLORS.aqi}
strokeWidth={2}
strokeDasharray="5 5"
dot={false}
activeDot={{ r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<EmptyState title="暂无数据" />
)}
</div>
);
}
export const CaseAqiTrend = memo(CaseAqiTrendComponent);

View File

@@ -0,0 +1,168 @@
import { memo, useEffect, useMemo, useRef } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { CHART_COLORS } from './chartColors';
interface DistrictChoroplethProps {
/** 区名(规范,带「区」) → 当前 metric 标量值 的查表。 */
metricLookup: Record<string, number>;
/** 当前指标的中文标签,用于 tooltip如「门诊病例」。 */
metricLabel: string;
}
const WUHAN_CENTER: [number, number] = [30.59, 114.3];
/** 把值映射到 7 档顺序色阶;高值 → 深色。 */
function colorForValue(value: number, max: number): string {
const scale = CHART_COLORS.choropleth;
if (max <= 0 || value <= 0) return CHART_COLORS.choroplethEmpty;
const ratio = value / max;
const idx = Math.min(scale.length - 1, Math.floor(ratio * scale.length));
return scale[idx];
}
interface WuhanFeatureProps {
name: string;
}
// @types/geojson 随 @types/leaflet 一并提供 GeoJSON 全局命名空间。
type WuhanFeatureCollection = GeoJSON.FeatureCollection;
function DistrictChoroplethComponent({ metricLookup, metricLabel }: DistrictChoroplethProps) {
const mapDivRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const geoLayerRef = useRef<L.GeoJSON | null>(null);
const geoDataRef = useRef<WuhanFeatureCollection | null>(null);
const maxValue = useMemo(() => {
const vals = Object.values(metricLookup);
return vals.length ? Math.max(...vals) : 0;
}, [metricLookup]);
// 创建地图 + 加载 geojson 一次。
useEffect(() => {
if (!mapDivRef.current || mapRef.current) return;
const map = L.map(mapDivRef.current, {
center: WUHAN_CENTER,
zoom: 9,
zoomControl: true,
attributionControl: false,
scrollWheelZoom: false,
});
mapRef.current = map;
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
maxZoom: 18,
}).addTo(map);
let cancelled = false;
fetch('/wuhan_districts.geojson')
.then((r) => r.json())
.then((data: WuhanFeatureCollection) => {
if (cancelled || !mapRef.current) return;
geoDataRef.current = data;
renderLayer();
try {
const tmp = L.geoJSON(data);
map.fitBounds(tmp.getBounds(), { padding: [12, 12] });
} catch {
/* keep default center if bounds fail */
}
})
.catch(() => {
/* network/mock failure — wrapper still renders for tests */
});
return () => {
cancelled = true;
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
}
geoLayerRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 当 metric 变化时重绘填色。
useEffect(() => {
renderLayer();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [metricLookup, maxValue, metricLabel]);
function renderLayer() {
const map = mapRef.current;
const data = geoDataRef.current;
if (!map || !data) return;
if (geoLayerRef.current) {
geoLayerRef.current.remove();
geoLayerRef.current = null;
}
geoLayerRef.current = L.geoJSON(data, {
style: (feature) => {
const name = (feature?.properties as WuhanFeatureProps | undefined)?.name ?? '';
const value = metricLookup[name] ?? 0;
return {
fillColor: colorForValue(value, maxValue),
fillOpacity: 0.78,
color: CHART_COLORS.choroplethStroke,
weight: 1.2,
};
},
onEachFeature: (feature, layer) => {
const name = (feature.properties as WuhanFeatureProps).name ?? '未知';
const value = metricLookup[name] ?? 0;
layer.bindTooltip(
`<div style="font-size:12px"><b>${name}</b><br/>${metricLabel}${value.toLocaleString()}</div>`,
{ sticky: true }
);
layer.on({
mouseover: (e) => {
(e.target as L.Path).setStyle({ weight: 2.4, color: CHART_COLORS.cases });
},
mouseout: (e) => {
(e.target as L.Path).setStyle({ weight: 1.2, color: CHART_COLORS.choroplethStroke });
},
click: (e) => {
map.fitBounds((e.target as L.GeoJSON).getBounds(), { padding: [40, 40] });
},
});
},
}).addTo(map);
}
// 图例的 5 档分界值。
const legendStops = useMemo(() => {
const scale = CHART_COLORS.choropleth;
return scale.map((color, i) => ({
color,
label: maxValue > 0 ? Math.round((maxValue * (i + 1)) / scale.length).toLocaleString() : '0',
}));
}, [maxValue]);
return (
<div data-testid="choropleth-wrapper" className="relative">
<div ref={mapDivRef} className="w-full rounded-lg overflow-hidden" style={{ height: 420 }} />
<div className="absolute bottom-3 right-3 z-[1000] bg-bg-card/95 px-3 py-2 rounded-lg border border-border shadow-sm">
<div className="text-[11px] font-semibold text-text-secondary mb-1.5">{metricLabel}</div>
<div className="flex items-center gap-0">
{legendStops.map((s) => (
<div key={s.color} className="flex flex-col items-center">
<div className="w-7 h-3" style={{ backgroundColor: s.color }} />
</div>
))}
</div>
<div className="flex justify-between mt-1 text-[10px] text-text-muted">
<span></span>
<span></span>
</div>
</div>
</div>
);
}
export const DistrictChoropleth = memo(DistrictChoroplethComponent);

View File

@@ -0,0 +1,84 @@
import { memo } from 'react';
import {
Activity,
AlertTriangle,
Droplets,
Building2,
TrendingUp,
TrendingDown,
Users,
} from 'lucide-react';
import { StatCard } from '@/components/StatCard';
import { TESTIDS } from '@/utils/testids';
import { CHART_COLORS } from './chartColors';
export interface KpiData {
totalCases: number;
todayCases: number;
changeRatio: number | null;
activeAlerts: number;
highRiskGrids: number;
avgAQI: number;
}
interface KpiRowProps {
kpi: KpiData | null;
}
function changeTrendOf(ratio: number | null | undefined) {
if (ratio == null) return undefined;
if (ratio > 0) return { direction: 'up' as const, value: `${ratio.toFixed(1)}%` };
if (ratio < 0) return { direction: 'down' as const, value: `${Math.abs(ratio).toFixed(1)}%` };
return { direction: 'stable' as const, value: '0%' };
}
function KpiRowComponent({ kpi }: KpiRowProps) {
const changeTrend = changeTrendOf(kpi?.changeRatio);
return (
<div
data-testid={TESTIDS.kpiRow}
className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3"
>
<StatCard
icon={<Users className="w-4 h-4 text-primary" />}
label="累计病例总数"
value={kpi?.totalCases?.toLocaleString() ?? '--'}
/>
<StatCard
icon={<Activity className="w-4 h-4 text-success" />}
label="今日病例"
value={kpi?.todayCases?.toLocaleString() ?? '--'}
/>
<StatCard
icon={
(changeTrend?.direction === 'up' && <TrendingUp className="w-4 h-4 text-danger" />) ||
(changeTrend?.direction === 'down' && (
<TrendingDown className="w-4 h-4 text-success" />
)) || <Activity className="w-4 h-4 text-text-muted" />
}
label="7日变化率"
value={changeTrend ? changeTrend.value : '--'}
trend={changeTrend}
/>
<StatCard
icon={<AlertTriangle className="w-4 h-4 text-warning" />}
label="活跃预警数"
value={kpi?.activeAlerts?.toLocaleString() ?? '--'}
color={kpi && kpi.activeAlerts > 0 ? CHART_COLORS.alertP1 : undefined}
/>
<StatCard
icon={<Building2 className="w-4 h-4 text-danger" />}
label="高风险网格"
value={kpi?.highRiskGrids?.toLocaleString() ?? '--'}
/>
<StatCard
icon={<Droplets className="w-4 h-4 text-primary-light" />}
label="平均AQI"
value={kpi?.avgAQI?.toLocaleString() ?? '--'}
/>
</div>
);
}
export const KpiRow = memo(KpiRowComponent);

View File

@@ -0,0 +1,64 @@
import { memo } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { EmptyState } from '@/components/ui';
import type { DiagnosisBreakdown } from '@/types';
import { CHART_COLORS } from './chartColors';
interface TopDiagnosesBarProps {
diagnoses: DiagnosisBreakdown[];
}
const tooltipStyle = {
backgroundColor: '#FFFFFF',
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
borderRadius: '8px',
fontSize: '12px',
};
function TopDiagnosesBarComponent({ diagnoses }: TopDiagnosesBarProps) {
return (
<div className="card p-4">
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
Top 5
</div>
{diagnoses.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart
data={[...diagnoses].reverse()}
layout="vertical"
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: CHART_COLORS.axis }} />
<YAxis
type="category"
dataKey="diagnosis"
tick={{ fontSize: 11, fill: CHART_COLORS.axisLabel }}
width={100}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: number, name: string) => [value.toLocaleString(), name]}
/>
<Bar dataKey="outpatient" stackId="a" fill={CHART_COLORS.outpatient} name="门诊" barSize={16} />
<Bar dataKey="inpatient" stackId="a" fill={CHART_COLORS.inpatient} name="住院" barSize={16} />
</BarChart>
</ResponsiveContainer>
) : (
<EmptyState title="暂无数据" />
)}
</div>
);
}
export const TopDiagnosesBar = memo(TopDiagnosesBarComponent);

View File

@@ -0,0 +1,91 @@
import { memo, useMemo } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { EmptyState } from '@/components/ui';
import { CHART_COLORS } from './chartColors';
import { metricValue, type DistrictMetric, type MetricKey } from './districtNormalize';
interface TopDistrictsBarProps {
/** 已归一并聚合到 13 区的指标数据。 */
districts: DistrictMetric[];
metric: MetricKey;
metricLabel: string;
}
const tooltipStyle = {
backgroundColor: '#FFFFFF',
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
borderRadius: '8px',
fontSize: '12px',
};
function TopDistrictsBarComponent({ districts, metric, metricLabel }: TopDistrictsBarProps) {
// 按当前 metric 排序取 Top5横向条形图需 reverse 使最大值在顶部。
const top5 = useMemo(() => {
return [...districts]
.sort((a, b) => metricValue(b, metric) - metricValue(a, metric))
.slice(0, 5)
.map((d) => ({
district: d.district,
outpatient: d.outpatient,
inpatient: d.inpatient,
value: metricValue(d, metric),
}))
.reverse();
}, [districts, metric]);
const hasData = top5.some((d) => d.value > 0);
const showStack = metric === 'all';
return (
<div className="card p-4">
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
Top 5 {metricLabel}
</div>
{hasData ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={top5} layout="vertical" margin={{ top: 0, right: 10, left: 30, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: CHART_COLORS.axis }} />
<YAxis
type="category"
dataKey="district"
tick={{ fontSize: 11, fill: CHART_COLORS.axisLabel }}
width={64}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: number, name: string) => [value.toLocaleString(), name]}
/>
{showStack ? (
<>
<Bar dataKey="outpatient" stackId="a" fill={CHART_COLORS.outpatient} name="门诊" barSize={20} />
<Bar dataKey="inpatient" stackId="a" fill={CHART_COLORS.inpatient} name="住院" barSize={20} />
</>
) : (
<Bar
dataKey="value"
fill={metric === 'inpatient' ? CHART_COLORS.inpatient : CHART_COLORS.outpatient}
name={metricLabel}
barSize={20}
/>
)}
</BarChart>
</ResponsiveContainer>
) : (
<EmptyState title="暂无数据" />
)}
</div>
);
}
export const TopDistrictsBar = memo(TopDistrictsBarComponent);

View File

@@ -0,0 +1,22 @@
/**
* 概览大屏图表与地图使用的字面色值集中处。
* Recharts / Leaflet 需要原始 hex无法用 Tailwind class故在此集中定义
* 避免页面里散落 magic hex。
*/
export const CHART_COLORS = {
outpatient: '#2563EB', // 门诊primary
inpatient: '#DC2626', // 住院danger
cases: '#2563EB',
aqi: '#D97706', // warning
grid: '#E2E8F0', // border
axis: '#64748B', // text-secondary
axisLabel: '#374151',
tooltipBorder: '#E2E8F0',
tooltipText: '#1E293B',
alertP1: '#DC2626',
alertP2: '#D97706',
// choropleth 顺序色阶(浅 → 深),高值高亮
choropleth: ['#DBEAFE', '#BFDBFE', '#93C5FD', '#60A5FA', '#3B82F6', '#2563EB', '#1D4ED8'],
choroplethEmpty: '#F1F5F9', // 无数据区填充
choroplethStroke: '#FFFFFF',
} as const;

View File

@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest';
import {
WUHAN_DISTRICTS,
normalizeDistrictName,
joinDistrictCases,
buildMetricLookup,
} from './districtNormalize';
import type { DistrictCaseData } from '@/types';
function mk(district: string, outpatient: number, inpatient: number): DistrictCaseData {
return {
district,
outpatient,
inpatient,
total: outpatient + inpatient,
outpatient_ratio: 0,
inpatient_ratio: 0,
};
}
describe('normalizeDistrictName', () => {
it('maps every bare form to its canonical 区-name', () => {
for (const canonical of WUHAN_DISTRICTS) {
const bare = canonical.replace(/区$/, '');
expect(normalizeDistrictName(bare)).toBe(canonical);
}
});
it('passes canonical names through unchanged', () => {
for (const canonical of WUHAN_DISTRICTS) {
expect(normalizeDistrictName(canonical)).toBe(canonical);
}
});
it('trims whitespace and returns null for unknown/empty', () => {
expect(normalizeDistrictName(' 武昌 ')).toBe('武昌区');
expect(normalizeDistrictName('')).toBeNull();
expect(normalizeDistrictName(null)).toBeNull();
expect(normalizeDistrictName('火星区')).toBeNull();
});
});
describe('joinDistrictCases', () => {
it('always yields exactly the 13 canonical districts in canonical order', () => {
const joined = joinDistrictCases([mk('武昌', 5, 1)]);
expect(joined).toHaveLength(13);
expect(joined.map((d) => d.district)).toEqual([...WUHAN_DISTRICTS]);
});
it('collapses 武昌 + 武昌区 into ONE summed district (no double-count)', () => {
const cases = [mk('武昌', 10, 2), mk('武昌区', 4, 3)];
const joined = joinDistrictCases(cases);
const wuchang = joined.find((d) => d.district === '武昌区')!;
expect(wuchang.outpatient).toBe(14);
expect(wuchang.inpatient).toBe(5);
expect(wuchang.total).toBe(19);
// exactly 13 entries — the duplicate did not create a 14th row
expect(joined).toHaveLength(13);
});
it('preserves sum integrity: sum(joined) == sum(input) for the 13 known districts', () => {
const cases: DistrictCaseData[] = [
mk('武昌', 10, 2),
mk('武昌区', 4, 3),
mk('江岸', 7, 1),
mk('江岸区', 2, 0),
mk('洪山区', 9, 4),
mk('黄陂', 3, 1),
];
const inputOut = cases.reduce((s, c) => s + c.outpatient, 0);
const inputIn = cases.reduce((s, c) => s + c.inpatient, 0);
const inputTotal = cases.reduce((s, c) => s + c.total, 0);
const joined = joinDistrictCases(cases);
const joinedOut = joined.reduce((s, d) => s + d.outpatient, 0);
const joinedIn = joined.reduce((s, d) => s + d.inpatient, 0);
const joinedTotal = joined.reduce((s, d) => s + d.total, 0);
expect(joinedOut).toBe(inputOut);
expect(joinedIn).toBe(inputIn);
expect(joinedTotal).toBe(inputTotal);
});
it('ignores records outside the 13 districts (no leakage into the sum)', () => {
const cases = [mk('武昌区', 5, 0), mk('火星区', 99, 99)];
const joined = joinDistrictCases(cases);
expect(joined.reduce((s, d) => s + d.total, 0)).toBe(5);
expect(joined).toHaveLength(13);
});
it('fills unseen districts with zeros', () => {
const joined = joinDistrictCases([mk('武昌区', 5, 1)]);
const jiangan = joined.find((d) => d.district === '江岸区')!;
expect(jiangan.total).toBe(0);
});
});
describe('buildMetricLookup', () => {
const joined = joinDistrictCases([mk('武昌', 10, 2), mk('江岸区', 3, 4)]);
it('keys by canonical name for the selected metric', () => {
expect(buildMetricLookup(joined, 'all')['武昌区']).toBe(12);
expect(buildMetricLookup(joined, 'outpatient')['武昌区']).toBe(10);
expect(buildMetricLookup(joined, 'inpatient')['武昌区']).toBe(2);
expect(buildMetricLookup(joined, 'all')['江岸区']).toBe(7);
});
it('produces a lookup covering all 13 districts', () => {
expect(Object.keys(buildMetricLookup(joined, 'all'))).toHaveLength(13);
});
});

View File

@@ -0,0 +1,112 @@
/**
* 区县名称归一化与 case 数据聚合。
*
* 武汉市 geojson 的 `name` 属性是带「区」后缀的规范名(武昌区、江岸区…)。
* 后端 case 数据可能返回裸名(武昌)或带后缀名(武昌区),甚至两者并存。
* 这里把所有形式归一到 13 个规范名,并把同一区的门诊/住院/总数求和,
* 保证 join 后恰好 13 个区、无重复计数、求和守恒。
*/
import type { DistrictCaseData } from '@/types';
/** geojson 中武汉市的 13 个区(带「区」后缀),即规范名集合。 */
export const WUHAN_DISTRICTS = [
'江岸区',
'江汉区',
'硚口区',
'汉阳区',
'武昌区',
'青山区',
'洪山区',
'东西湖区',
'汉南区',
'蔡甸区',
'江夏区',
'黄陂区',
'新洲区',
] as const;
export type CanonicalDistrict = (typeof WUHAN_DISTRICTS)[number];
/** 规范名去掉「区」后缀的裸名 → 规范名 的映射,用于把裸名补全。 */
const BARE_TO_CANONICAL: Record<string, CanonicalDistrict> = WUHAN_DISTRICTS.reduce(
(acc, name) => {
acc[name.replace(/区$/, '')] = name;
return acc;
},
{} as Record<string, CanonicalDistrict>
);
/**
* 把任意形式的区名归一到规范名(带「区」后缀)。
* - 已是规范名 → 原样返回
* - 裸名(武昌)→ 补「区」(武昌区)
* - 不在 13 区内 → 返回 null调用方应忽略避免污染 join
*/
export function normalizeDistrictName(raw: string | null | undefined): CanonicalDistrict | null {
if (!raw) return null;
const trimmed = raw.trim();
if (!trimmed) return null;
// 已带后缀且在规范集合内
if ((WUHAN_DISTRICTS as readonly string[]).includes(trimmed)) {
return trimmed as CanonicalDistrict;
}
// 裸名补全
const bare = trimmed.replace(/区$/, '');
return BARE_TO_CANONICAL[bare] ?? null;
}
/** join 后每个区的指标值(按当前 metric 取出的标量)。 */
export interface DistrictMetric {
district: CanonicalDistrict;
outpatient: number;
inpatient: number;
total: number;
}
export type MetricKey = 'all' | 'outpatient' | 'inpatient';
/** 取出某条聚合记录在当前 metric 下用于着色/排序的标量值。 */
export function metricValue(d: DistrictMetric, metric: MetricKey): number {
if (metric === 'outpatient') return d.outpatient;
if (metric === 'inpatient') return d.inpatient;
return d.total;
}
/**
* 把 case 数组按区名归一后聚合到 13 个规范区。
* 同名区(武昌 + 武昌区)会被折叠并对各字段求和,绝不重复计数。
* 返回固定 13 项(未出现的区补 0顺序与 WUHAN_DISTRICTS 一致,
* 便于与 geojson 稳定 join。
*/
export function joinDistrictCases(cases: readonly DistrictCaseData[]): DistrictMetric[] {
const acc = new Map<CanonicalDistrict, DistrictMetric>();
for (const name of WUHAN_DISTRICTS) {
acc.set(name, { district: name, outpatient: 0, inpatient: 0, total: 0 });
}
for (const c of cases) {
const canonical = normalizeDistrictName(c.district);
if (!canonical) continue; // 非 13 区的记录忽略
const entry = acc.get(canonical)!;
entry.outpatient += c.outpatient || 0;
entry.inpatient += c.inpatient || 0;
entry.total += c.total || 0;
}
return WUHAN_DISTRICTS.map((name) => acc.get(name)!);
}
/**
* 构建 区名(规范) → 指标标量 的查表,供 geojson 着色按 name 直接索引。
*/
export function buildMetricLookup(
joined: readonly DistrictMetric[],
metric: MetricKey
): Record<string, number> {
const lookup: Record<string, number> = {};
for (const d of joined) {
lookup[d.district] = metricValue(d, metric);
}
return lookup;
}

View File

@@ -1,31 +1,9 @@
import { useEffect, useState, useMemo } from 'react'; import { useEffect, useState, useMemo } from 'react';
import { import { Activity } from 'lucide-react';
LineChart,
Line,
BarChart,
Bar,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import {
Activity,
AlertTriangle,
Droplets,
Building2,
TrendingUp,
TrendingDown,
Users,
} from 'lucide-react';
import { caseApi, riskApi, alertApi, envApi } from '@/services/api'; import { caseApi, riskApi, alertApi, envApi } from '@/services/api';
import { StatCard } from '@/components/StatCard';
import { ErrorBanner } from '@/components/ErrorBanner'; import { ErrorBanner } from '@/components/ErrorBanner';
import { LoadingState, Segmented } from '@/components/ui';
import { TESTIDS } from '@/utils/testids';
import type { import type {
CaseTrendPoint, CaseTrendPoint,
DistrictCaseData, DistrictCaseData,
@@ -33,27 +11,30 @@ import type {
DiagnosisBreakdown, DiagnosisBreakdown,
Alert, Alert,
} from '@/types'; } from '@/types';
import { KpiRow, type KpiData } from '@/components/overview/KpiRow';
import { CaseAqiTrend, type MergedTrendItem } from '@/components/overview/CaseAqiTrend';
import { DistrictChoropleth } from '@/components/overview/DistrictChoropleth';
import { TopDistrictsBar } from '@/components/overview/TopDistrictsBar';
import { TopDiagnosesBar } from '@/components/overview/TopDiagnosesBar';
import { AlertSeverityDonut, type AlertSlice } from '@/components/overview/AlertSeverityDonut';
import { CHART_COLORS } from '@/components/overview/chartColors';
import {
joinDistrictCases,
buildMetricLookup,
type MetricKey,
} from '@/components/overview/districtNormalize';
// --- Types for fetched data --- const METRIC_OPTIONS: { value: MetricKey; label: string }[] = [
interface KpiData { { value: 'all', label: '全部' },
totalCases: number; { value: 'outpatient', label: '门诊' },
todayCases: number; { value: 'inpatient', label: '住院' },
changeRatio: number | null; ];
activeAlerts: number;
highRiskGrids: number;
avgAQI: number;
}
interface MergedTrendItem { const METRIC_LABEL: Record<MetricKey, string> = {
date: string; all: '病例',
cases: number; outpatient: '门诊',
aqi: number; inpatient: '住院',
} };
function formatDateLabel(dateStr: string): string {
const d = new Date(dateStr);
return `${d.getMonth() + 1}/${d.getDate()}`;
}
function computeChangeRatio(trend: CaseTrendPoint[]): number | null { function computeChangeRatio(trend: CaseTrendPoint[]): number | null {
if (trend.length < 8) return null; if (trend.length < 8) return null;
@@ -66,12 +47,15 @@ function computeChangeRatio(trend: CaseTrendPoint[]): number | null {
export function OverviewDashboard() { export function OverviewDashboard() {
const [kpi, setKpi] = useState<KpiData | null>(null); const [kpi, setKpi] = useState<KpiData | null>(null);
const [mergedTrend, setMergedTrend] = useState<MergedTrendItem[]>([]); const [mergedTrend, setMergedTrend] = useState<MergedTrendItem[]>([]);
const [topDistricts, setTopDistricts] = useState<DistrictCaseData[]>([]); const [districts, setDistricts] = useState<DistrictCaseData[]>([]);
const [topDiagnoses, setTopDiagnoses] = useState<DiagnosisBreakdown[]>([]); const [topDiagnoses, setTopDiagnoses] = useState<DiagnosisBreakdown[]>([]);
const [alertPie, setAlertPie] = useState<{ name: string; value: number; color: string }[]>([]); const [alertPie, setAlertPie] = useState<AlertSlice[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [errors, setErrors] = useState<string[]>([]); const [errors, setErrors] = useState<string[]>([]);
// 门诊/住院/全部 — 同时驱动 choropleth 与 Top5 区县条形图。
const [metric, setMetric] = useState<MetricKey>('all');
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -88,7 +72,7 @@ export function OverviewDashboard() {
start30.setDate(start30.getDate() - 30); start30.setDate(start30.getDate() - 30);
const start30Str = start30.toISOString().split('T')[0]; const start30Str = start30.toISOString().split('T')[0];
// KPI sources — Promise.allSettled to survive individual failures // KPI sources — Promise.allSettled to survive individual failures.
const [statsR, trend14R, alertsR, riskStatsR, pollutantsR] = await Promise.allSettled([ const [statsR, trend14R, alertsR, riskStatsR, pollutantsR] = await Promise.allSettled([
caseApi.getStats(), caseApi.getStats(),
caseApi.getTrend({ start_date: start14Str, end_date: endStr, group_by: 'day' }), caseApi.getTrend({ start_date: start14Str, end_date: endStr, group_by: 'day' }),
@@ -97,7 +81,7 @@ export function OverviewDashboard() {
envApi.getPollutants(7), envApi.getPollutants(7),
]); ]);
// Trend sources // Trend + district sources.
const [trend30R, districtsR, diagStatsR] = await Promise.allSettled([ const [trend30R, districtsR, diagStatsR] = await Promise.allSettled([
caseApi.getTrend({ start_date: start30Str, end_date: endStr, group_by: 'day' }), caseApi.getTrend({ start_date: start30Str, end_date: endStr, group_by: 'day' }),
caseApi.getDistricts(), caseApi.getDistricts(),
@@ -108,7 +92,7 @@ export function OverviewDashboard() {
const newErrors: string[] = []; const newErrors: string[] = [];
// --- Build KPI --- // --- KPI ---
let totalCases = 0; let totalCases = 0;
if (statsR.status === 'fulfilled') { if (statsR.status === 'fulfilled') {
const s = statsR.value; const s = statsR.value;
@@ -121,9 +105,7 @@ export function OverviewDashboard() {
let changeRatio: number | null = null; let changeRatio: number | null = null;
if (trend14R.status === 'fulfilled') { if (trend14R.status === 'fulfilled') {
const trend = trend14R.value.trend || []; const trend = trend14R.value.trend || [];
if (trend.length > 0) { if (trend.length > 0) todayCases = trend[trend.length - 1].total;
todayCases = trend[trend.length - 1].total;
}
changeRatio = computeChangeRatio(trend); changeRatio = computeChangeRatio(trend);
} else { } else {
newErrors.push('今日病例数据加载失败'); newErrors.push('今日病例数据加载失败');
@@ -158,33 +140,24 @@ export function OverviewDashboard() {
} }
setKpi({ totalCases, todayCases, changeRatio, activeAlerts, highRiskGrids, avgAQI }); setKpi({ totalCases, todayCases, changeRatio, activeAlerts, highRiskGrids, avgAQI });
setErrors(newErrors);
// --- Merge case trend + AQI --- // --- Merge case trend + AQI ---
if (trend30R.status === 'fulfilled') { if (trend30R.status === 'fulfilled') {
const trend30 = trend30R.value.trend || []; const trend30 = trend30R.value.trend || [];
const aqiMap: Record<string, number> = {}; const aqiMap: Record<string, number> = {};
if (pollutantsR.status === 'fulfilled') { for (const p of pollutantData) aqiMap[p.date] = p.AQI || 0;
for (const p of pollutantData) { setMergedTrend(
aqiMap[p.date] = p.AQI || 0; trend30.map((t) => ({ date: t.date, cases: t.total, aqi: aqiMap[t.date] || 0 }))
} );
}
// Only use data from the last 30 days for display
const merged: MergedTrendItem[] = trend30.map((t) => ({
date: t.date,
cases: t.total,
aqi: aqiMap[t.date] || 0,
}));
setMergedTrend(merged);
} else if (!newErrors.includes('今日病例数据加载失败')) { } else if (!newErrors.includes('今日病例数据加载失败')) {
newErrors.push('趋势数据加载失败'); newErrors.push('趋势数据加载失败');
} }
// --- Top 5 Districts --- // --- Districts (feeds choropleth + Top5 via normalize/join) ---
if (districtsR.status === 'fulfilled') { if (districtsR.status === 'fulfilled') {
const districts = districtsR.value.districts || []; setDistricts(districtsR.value.districts || []);
const sorted = [...districts].sort((a, b) => b.total - a.total); } else {
setTopDistricts(sorted.slice(0, 5)); newErrors.push('区县数据加载失败');
} }
// --- Top 5 Diagnoses --- // --- Top 5 Diagnoses ---
@@ -204,43 +177,33 @@ export function OverviewDashboard() {
const p1 = alertList.filter((a) => a.priority === 'P1').length; const p1 = alertList.filter((a) => a.priority === 'P1').length;
const p2 = alertList.filter((a) => a.priority === 'P2').length; const p2 = alertList.filter((a) => a.priority === 'P2').length;
setAlertPie([ setAlertPie([
{ name: 'P1 紧急', value: p1, color: '#EF4444' }, { name: 'P1 紧急', value: p1, color: CHART_COLORS.alertP1 },
{ name: 'P2 关注', value: p2, color: '#F59E0B' }, { name: 'P2 关注', value: p2, color: CHART_COLORS.alertP2 },
]); ]);
setErrors(newErrors);
setIsLoading(false); setIsLoading(false);
}; };
fetchAll(); fetchAll();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, []); }, []);
const changeTrend = useMemo(() => { // 归一并聚合到 13 区一次,供 choropleth 与 Top5 共享。
if (kpi?.changeRatio == null) return undefined; const joinedDistricts = useMemo(() => joinDistrictCases(districts), [districts]);
if (kpi.changeRatio > 0) { const metricLookup = useMemo(
return { direction: 'up' as const, value: `${kpi.changeRatio.toFixed(1)}%` }; () => buildMetricLookup(joinedDistricts, metric),
} [joinedDistricts, metric]
if (kpi.changeRatio < 0) { );
return { direction: 'down' as const, value: `${Math.abs(kpi.changeRatio).toFixed(1)}%` };
}
return { direction: 'stable' as const, value: '0%' };
}, [kpi?.changeRatio]);
// --- Loading state ---
if (isLoading) { if (isLoading) {
return ( return <LoadingState label="加载概览数据…" testid={TESTIDS.pageLoading} />;
<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>
);
} }
return ( return (
<div data-testid="page-overview" className="flex flex-col h-full overflow-auto"> <div data-testid={TESTIDS.pageOverview} className="flex flex-col h-full overflow-auto">
{/* Error banner */}
{errors.length > 0 && ( {errors.length > 0 && (
<div className="px-6 pt-4"> <div className="px-6 pt-4">
<ErrorBanner <ErrorBanner
@@ -252,249 +215,58 @@ export function OverviewDashboard() {
)} )}
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
{/* Page header */} {/* Page header + honesty badge + metric toggle */}
<div> <div className="flex flex-wrap items-start justify-between gap-3">
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2"> <div>
<Activity className="w-5 h-5 text-primary" /> <h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
<Activity className="w-5 h-5 text-primary" />
</h1>
<p className="text-[12px] text-gray-500"></p> <span
</div> data-testid={TESTIDS.asofBadge}
className="ml-1 inline-flex items-center rounded-full bg-bg-hover px-2 py-0.5 text-[11px] font-medium text-text-secondary border border-border"
{/* Section 1: KPI Row */} >
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3"> 2023-12
<StatCard </span>
icon={<Users className="w-4 h-4 text-blue-600" />} </h1>
label="累计病例总数" <p className="text-[12px] text-text-secondary"></p>
value={kpi?.totalCases?.toLocaleString() ?? '--'}
/>
<StatCard
icon={<Activity className="w-4 h-4 text-green-600" />}
label="今日病例"
value={kpi?.todayCases?.toLocaleString() ?? '--'}
/>
<StatCard
icon={
(changeTrend?.direction === 'up' && <TrendingUp className="w-4 h-4 text-red-500" />) ||
(changeTrend?.direction === 'down' && <TrendingDown className="w-4 h-4 text-green-500" />) || (
<Activity className="w-4 h-4 text-gray-400" />
)
}
label="7日变化率"
value={changeTrend ? changeTrend.value : '--'}
trend={changeTrend}
/>
<StatCard
icon={<AlertTriangle className="w-4 h-4 text-orange-500" />}
label="活跃预警数"
value={kpi?.activeAlerts?.toLocaleString() ?? '--'}
color={kpi && kpi.activeAlerts > 0 ? '#EF4444' : undefined}
/>
<StatCard
icon={<Building2 className="w-4 h-4 text-red-500" />}
label="高风险网格"
value={kpi?.highRiskGrids?.toLocaleString() ?? '--'}
/>
<StatCard
icon={<Droplets className="w-4 h-4 text-cyan-500" />}
label="平均AQI"
value={kpi?.avgAQI?.toLocaleString() ?? '--'}
/>
</div>
{/* Section 2: Case + AQI Mini Trend */}
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
30AQI趋势
</div> </div>
{mergedTrend.length > 0 ? ( <Segmented
<ResponsiveContainer width="100%" height={200}> options={METRIC_OPTIONS}
<LineChart data={mergedTrend} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}> value={metric}
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" /> onChange={setMetric}
<XAxis testid={TESTIDS.outinpatientToggle}
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> </div>
{/* Section 3 + 4: Top Districts + Top Diagnoses side by side */} {/* KPI Row */}
<KpiRow kpi={kpi} />
{/* Headline: Wuhan 13-district choropleth */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-3">
13{METRIC_LABEL[metric]}
</div>
<DistrictChoropleth
metricLookup={metricLookup}
metricLabel={`${METRIC_LABEL[metric]}`}
/>
</div>
{/* Case + AQI trend */}
<CaseAqiTrend data={mergedTrend} />
{/* Top districts (metric-driven) + Top diagnoses */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Section 3: Top 5 Districts */} <TopDistrictsBar
<div className="card p-4"> districts={joinedDistricts}
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4"> metric={metric}
Top 5 metricLabel={METRIC_LABEL[metric]}
</div> />
{topDistricts.length > 0 ? ( <TopDiagnosesBar diagnoses={topDiagnoses} />
<ResponsiveContainer width="100%" height={220}>
<BarChart
data={[...topDistricts].reverse()}
layout="vertical"
margin={{ top: 0, right: 10, left: 30, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
<YAxis
type="category"
dataKey="district"
tick={{ fontSize: 11, fill: '#374151' }}
width={60}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number) => [value.toLocaleString(), '病例数']}
/>
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={20} />
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={20} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
{/* Section 4: Top 5 Diagnoses */}
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
Top 5
</div>
{topDiagnoses.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<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(), '病例数']}
/>
<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>
</div> </div>
{/* Section 5: Alert Severity Donut */} {/* Alert severity donut */}
<div className="card p-4"> <AlertSeverityDonut data={alertPie} />
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
</div>
{alertPie[0].value > 0 || alertPie[1].value > 0 ? (
<div className="flex items-center justify-center">
<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, idx) => (
<Cell key={idx} fill={entry.color} />
))}
</Pie>
<Tooltip
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-gray-700">{value}</span>
)}
/>
</PieChart>
</ResponsiveContainer>
</div>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -34,6 +34,12 @@ export const TESTIDS = {
pageDemographics: 'page-demographics', pageDemographics: 'page-demographics',
pageDisease: 'page-disease', pageDisease: 'page-disease',
pageEnvironment: 'page-environment', pageEnvironment: 'page-environment',
// 综合概览 大屏
kpiRow: 'kpi-row',
choroplethWrapper: 'choropleth-wrapper',
asofBadge: 'asof-badge',
outinpatientToggle: 'outinpatient-toggle',
} as const; } as const;
export type TestId = (typeof TESTIDS)[keyof typeof TESTIDS]; export type TestId = (typeof TESTIDS)[keyof typeof TESTIDS];