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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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="日病例",
|
||||
|
||||
82
backend/tests/test_district_normalization.py
Normal file
82
backend/tests/test_district_normalization.py
Normal 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
|
||||
119
frontend/e2e/overview.spec.ts
Normal file
119
frontend/e2e/overview.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
BIN
frontend/public/wuhan_districts.geojson
LFS
Normal file
BIN
frontend/public/wuhan_districts.geojson
LFS
Normal file
Binary file not shown.
@@ -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>
|
||||
|
||||
45
frontend/src/components/RouteErrorBoundary.tsx
Normal file
45
frontend/src/components/RouteErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
67
frontend/src/components/overview/AlertSeverityDonut.tsx
Normal file
67
frontend/src/components/overview/AlertSeverityDonut.tsx
Normal 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);
|
||||
100
frontend/src/components/overview/CaseAqiTrend.tsx
Normal file
100
frontend/src/components/overview/CaseAqiTrend.tsx
Normal 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">
|
||||
近30日病例与AQI趋势
|
||||
</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);
|
||||
168
frontend/src/components/overview/DistrictChoropleth.tsx
Normal file
168
frontend/src/components/overview/DistrictChoropleth.tsx
Normal 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);
|
||||
84
frontend/src/components/overview/KpiRow.tsx
Normal file
84
frontend/src/components/overview/KpiRow.tsx
Normal 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);
|
||||
64
frontend/src/components/overview/TopDiagnosesBar.tsx
Normal file
64
frontend/src/components/overview/TopDiagnosesBar.tsx
Normal 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);
|
||||
91
frontend/src/components/overview/TopDistrictsBar.tsx
Normal file
91
frontend/src/components/overview/TopDistrictsBar.tsx
Normal 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);
|
||||
22
frontend/src/components/overview/chartColors.ts
Normal file
22
frontend/src/components/overview/chartColors.ts
Normal 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;
|
||||
111
frontend/src/components/overview/districtNormalize.test.ts
Normal file
111
frontend/src/components/overview/districtNormalize.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
112
frontend/src/components/overview/districtNormalize.ts
Normal file
112
frontend/src/components/overview/districtNormalize.ts
Normal 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;
|
||||
}
|
||||
@@ -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">
|
||||
近30日病例与AQI趋势
|
||||
{/* 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>
|
||||
);
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user