feat/ux-modernization #1

Merged
akiba merged 6 commits from feat/ux-modernization into main 2026-06-21 09:05:53 -05:00
22 changed files with 1378 additions and 358 deletions
Showing only changes of commit 9a94156acc - Show all commits

View File

@@ -25,12 +25,42 @@ _cache: dict[str, Optional[pd.DataFrame | datetime]] = {
"outpatient": None,
"inpatient": None,
"combined": None,
"cases_by_district_daily": None,
"loaded_at": None,
}
# Guards the lazy build so concurrent callers don't duplicate the load/concat.
_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_DISTRICTS = {
'江岸区': ['江岸'],
@@ -170,3 +200,40 @@ def get_inpatient_data() -> pd.DataFrame:
"""Return the cached inpatient dataframe"""
load_data()
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 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.geojson import parse_geojson_file, load_districts
from utils.geo import point_in_polygon
@@ -179,18 +180,16 @@ def _district_avg_aqi() -> dict:
def _district_total_cases() -> dict:
"""Real total recorded cases per district from cases_by_district_daily.
District labels in the case file are inconsistent ("武昌" vs "武昌区"),
so names are normalized by stripping the "" suffix and summed, then
keyed by the canonical mapping name (with ""). Returns {district: cases}.
District labels are normalized to the canonical 13 区-suffixed names at the
data-access boundary (data.case_loader), so this is a plain per-district
sum. Returns {district: cases}.
"""
path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
if not path.exists():
try:
df = load_cases_by_district_daily()
except FileNotFoundError:
return {}
df = pd.read_parquet(path, columns=["district", "total_cases"])
df = df.copy()
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()}
by_district = df.groupby("district")["total_cases"].sum()
return {str(d): int(v) for d, v in by_district.items()}
@lru_cache(maxsize=8)

View File

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

View File

@@ -11,6 +11,7 @@ from pydantic import BaseModel, Field
from typing import Dict, List, Literal
from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT
from data.case_loader import load_cases_by_district_daily
from models import (
InsightsResponse,
InsightTrend,
@@ -491,22 +492,22 @@ async def get_insights_cards():
cases_path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
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_cases = cases_df[cases_df["date"] == latest_case_date].copy()
latest_cases["base_district"] = latest_cases["district"].str.replace("", "")
district_daily = latest_cases.groupby("base_district")["total_cases"].sum().sort_values(ascending=False)
latest_cases = cases_df[cases_df["date"] == latest_case_date]
district_daily = latest_cases.groupby("district")["total_cases"].sum().sort_values(ascending=False)
total_daily = int(district_daily.sum())
top_name = district_daily.index[0]
top_val = int(district_daily.iloc[0])
num_districts = len(district_daily)
week_ago = latest_case_date - pd.Timedelta(days=6)
week_cases = cases_df[cases_df["date"] >= week_ago].copy()
week_cases["base_district"] = week_cases["district"].str.replace("", "")
week_cases = cases_df[cases_df["date"] >= week_ago]
daily_totals = week_cases.groupby("date")["total_cases"].sum()
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])
date_str = latest_case_date.strftime("%m月%d")
@@ -515,8 +516,8 @@ async def get_insights_cards():
title=f"日病例统计 ({date_str})",
description=(
f"最近统计日({date_str})全市{num_districts}个区共记录{total_daily}例儿童呼吸道疾病病例,"
f"{top_name}{top_val}例为当日最高。近7日日均{avg_daily}例,"
f"{week_district.index[0]}累计{week_top_val}例居首。"
f"{top_name}{top_val}例为当日最高。近7日日均{avg_daily}例,"
f"{week_district.index[0]}累计{week_top_val}例居首。"
),
type="warning",
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 { TopNav } from '@/components/TopNav';
import { SideNav } from '@/components/SideNav';
import { RouteErrorBoundary } from '@/components/RouteErrorBoundary';
import { useRiskStore } from '@/stores';
import { TESTIDS } from '@/utils/testids';
@@ -9,17 +10,84 @@ interface AppShellProps {
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) {
const alerts = useRiskStore((s) => s.alerts);
const [drawerOpen, setDrawerOpen] = useState(false);
// 提升手风琴展开态:导轨与抽屉两份 SideNav 共享,保持同步。
const [expandedNav, setExpandedNav] = useState<string | null>('monitoring');
const drawerRef = useRef<HTMLElement>(null);
const openDrawer = useCallback(() => setDrawerOpen(true), []);
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 (
<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">
{/* lg 及以上:持久侧栏导轨 */}
@@ -27,7 +95,11 @@ export function AppShell({ onLogout }: AppShellProps) {
data-testid={TESTIDS.sidebarRail}
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>
{/* lg 以下:离屏抽屉 + 遮罩 */}
@@ -39,16 +111,29 @@ export function AppShell({ onLogout }: AppShellProps) {
/>
)}
<aside
ref={drawerRef}
id="app-drawer"
role="dialog"
aria-modal="true"
aria-label="导航菜单"
tabIndex={-1}
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 ${
drawerOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<SideNav alertCount={alerts.length} onNavigate={closeDrawer} />
<SideNav
alertCount={alerts.length}
onNavigate={closeDrawer}
expanded={expandedNav}
onExpandedChange={setExpandedNav}
/>
</aside>
<main className="flex-1 min-w-0 overflow-auto p-5">
<Outlet />
<RouteErrorBoundary>
<Outlet />
</RouteErrorBoundary>
</main>
</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;
// 抽屉模式下点击导航项后关闭抽屉(持久侧栏可不传)。
onNavigate?: () => void;
// 受控的展开手风琴分组:由 AppShell 提供时,导轨与抽屉两份实例保持同步。
// 不传则回退到内部 state向后兼容独立使用。
expanded?: string | null;
onExpandedChange?: (moduleId: string | null) => void;
}
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 moduleForPath = (pathname: string) =>
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 module = modules.find((m) => m.id === moduleId);

View File

@@ -5,6 +5,8 @@ interface TopNavProps {
onLogout?: () => void;
// 移动端汉堡按钮:切换侧栏抽屉。
onToggleMenu?: () => void;
// 抽屉是否展开(用于汉堡按钮的 aria-expanded
isMenuOpen?: boolean;
}
function Clock() {
@@ -16,7 +18,7 @@ function Clock() {
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 (
<nav className="h-[52px] bg-bg-card border-b border-border flex items-center px-5 z-50">
{onToggleMenu && (
@@ -24,6 +26,8 @@ export function TopNav({ onLogout, onToggleMenu }: TopNavProps) {
type="button"
onClick={onToggleMenu}
aria-label="打开菜单"
aria-expanded={isMenuOpen}
aria-controls="app-drawer"
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"
>

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 {
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 { Activity } from 'lucide-react';
import { caseApi, riskApi, alertApi, envApi } from '@/services/api';
import { StatCard } from '@/components/StatCard';
import { ErrorBanner } from '@/components/ErrorBanner';
import { LoadingState, Segmented } from '@/components/ui';
import { TESTIDS } from '@/utils/testids';
import type {
CaseTrendPoint,
DistrictCaseData,
@@ -33,27 +11,30 @@ import type {
DiagnosisBreakdown,
Alert,
} 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 ---
interface KpiData {
totalCases: number;
todayCases: number;
changeRatio: number | null;
activeAlerts: number;
highRiskGrids: number;
avgAQI: number;
}
const METRIC_OPTIONS: { value: MetricKey; label: string }[] = [
{ value: 'all', label: '全部' },
{ value: 'outpatient', label: '门诊' },
{ value: 'inpatient', label: '住院' },
];
interface MergedTrendItem {
date: string;
cases: number;
aqi: number;
}
function formatDateLabel(dateStr: string): string {
const d = new Date(dateStr);
return `${d.getMonth() + 1}/${d.getDate()}`;
}
const METRIC_LABEL: Record<MetricKey, string> = {
all: '病例',
outpatient: '门诊',
inpatient: '住院',
};
function computeChangeRatio(trend: CaseTrendPoint[]): number | null {
if (trend.length < 8) return null;
@@ -66,12 +47,15 @@ function computeChangeRatio(trend: CaseTrendPoint[]): number | null {
export function OverviewDashboard() {
const [kpi, setKpi] = useState<KpiData | null>(null);
const [mergedTrend, setMergedTrend] = useState<MergedTrendItem[]>([]);
const [topDistricts, setTopDistricts] = useState<DistrictCaseData[]>([]);
const [districts, setDistricts] = useState<DistrictCaseData[]>([]);
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 [errors, setErrors] = useState<string[]>([]);
// 门诊/住院/全部 — 同时驱动 choropleth 与 Top5 区县条形图。
const [metric, setMetric] = useState<MetricKey>('all');
useEffect(() => {
let cancelled = false;
@@ -88,7 +72,7 @@ export function OverviewDashboard() {
start30.setDate(start30.getDate() - 30);
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([
caseApi.getStats(),
caseApi.getTrend({ start_date: start14Str, end_date: endStr, group_by: 'day' }),
@@ -97,7 +81,7 @@ export function OverviewDashboard() {
envApi.getPollutants(7),
]);
// Trend sources
// Trend + district sources.
const [trend30R, districtsR, diagStatsR] = await Promise.allSettled([
caseApi.getTrend({ start_date: start30Str, end_date: endStr, group_by: 'day' }),
caseApi.getDistricts(),
@@ -108,7 +92,7 @@ export function OverviewDashboard() {
const newErrors: string[] = [];
// --- Build KPI ---
// --- KPI ---
let totalCases = 0;
if (statsR.status === 'fulfilled') {
const s = statsR.value;
@@ -121,9 +105,7 @@ export function OverviewDashboard() {
let changeRatio: number | null = null;
if (trend14R.status === 'fulfilled') {
const trend = trend14R.value.trend || [];
if (trend.length > 0) {
todayCases = trend[trend.length - 1].total;
}
if (trend.length > 0) todayCases = trend[trend.length - 1].total;
changeRatio = computeChangeRatio(trend);
} else {
newErrors.push('今日病例数据加载失败');
@@ -158,33 +140,24 @@ export function OverviewDashboard() {
}
setKpi({ totalCases, todayCases, changeRatio, activeAlerts, highRiskGrids, avgAQI });
setErrors(newErrors);
// --- Merge case trend + AQI ---
if (trend30R.status === 'fulfilled') {
const trend30 = trend30R.value.trend || [];
const aqiMap: Record<string, number> = {};
if (pollutantsR.status === 'fulfilled') {
for (const p of pollutantData) {
aqiMap[p.date] = p.AQI || 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);
for (const p of pollutantData) aqiMap[p.date] = p.AQI || 0;
setMergedTrend(
trend30.map((t) => ({ date: t.date, cases: t.total, aqi: aqiMap[t.date] || 0 }))
);
} else if (!newErrors.includes('今日病例数据加载失败')) {
newErrors.push('趋势数据加载失败');
}
// --- Top 5 Districts ---
// --- Districts (feeds choropleth + Top5 via normalize/join) ---
if (districtsR.status === 'fulfilled') {
const districts = districtsR.value.districts || [];
const sorted = [...districts].sort((a, b) => b.total - a.total);
setTopDistricts(sorted.slice(0, 5));
setDistricts(districtsR.value.districts || []);
} else {
newErrors.push('区县数据加载失败');
}
// --- Top 5 Diagnoses ---
@@ -204,43 +177,33 @@ export function OverviewDashboard() {
const p1 = alertList.filter((a) => a.priority === 'P1').length;
const p2 = alertList.filter((a) => a.priority === 'P2').length;
setAlertPie([
{ name: 'P1 紧急', value: p1, color: '#EF4444' },
{ name: 'P2 关注', value: p2, color: '#F59E0B' },
{ name: 'P1 紧急', value: p1, color: CHART_COLORS.alertP1 },
{ name: 'P2 关注', value: p2, color: CHART_COLORS.alertP2 },
]);
setErrors(newErrors);
setIsLoading(false);
};
fetchAll();
return () => {
cancelled = true;
};
}, []);
const changeTrend = useMemo(() => {
if (kpi?.changeRatio == null) return undefined;
if (kpi.changeRatio > 0) {
return { direction: 'up' as const, value: `${kpi.changeRatio.toFixed(1)}%` };
}
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]);
// 归一并聚合到 13 区一次,供 choropleth 与 Top5 共享。
const joinedDistricts = useMemo(() => joinDistrictCases(districts), [districts]);
const metricLookup = useMemo(
() => buildMetricLookup(joinedDistricts, metric),
[joinedDistricts, metric]
);
// --- Loading state ---
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>
);
return <LoadingState label="加载概览数据…" testid={TESTIDS.pageLoading} />;
}
return (
<div data-testid="page-overview" className="flex flex-col h-full overflow-auto">
{/* Error banner */}
<div data-testid={TESTIDS.pageOverview} className="flex flex-col h-full overflow-auto">
{errors.length > 0 && (
<div className="px-6 pt-4">
<ErrorBanner
@@ -252,249 +215,58 @@ export function OverviewDashboard() {
)}
<div className="p-6 space-y-6">
{/* Page header */}
<div>
<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>
</div>
{/* Section 1: KPI Row */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard
icon={<Users className="w-4 h-4 text-blue-600" />}
label="累计病例总数"
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趋势
{/* Page header + honesty badge + metric toggle */}
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
<Activity className="w-5 h-5 text-primary" />
<span
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"
>
2023-12
</span>
</h1>
<p className="text-[12px] text-text-secondary"></p>
</div>
{mergedTrend.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={mergedTrend} 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>
)}
<Segmented
options={METRIC_OPTIONS}
value={metric}
onChange={setMetric}
testid={TESTIDS.outinpatientToggle}
/>
</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">
{/* Section 3: Top 5 Districts */}
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
Top 5
</div>
{topDistricts.length > 0 ? (
<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>
<TopDistrictsBar
districts={joinedDistricts}
metric={metric}
metricLabel={METRIC_LABEL[metric]}
/>
<TopDiagnosesBar diagnoses={topDiagnoses} />
</div>
{/* Section 5: Alert Severity Donut */}
<div className="card p-4">
<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>
{/* Alert severity donut */}
<AlertSeverityDonut data={alertPie} />
</div>
</div>
);

View File

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