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,
|
"outpatient": None,
|
||||||
"inpatient": None,
|
"inpatient": None,
|
||||||
"combined": None,
|
"combined": None,
|
||||||
|
"cases_by_district_daily": None,
|
||||||
"loaded_at": None,
|
"loaded_at": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Guards the lazy build so concurrent callers don't duplicate the load/concat.
|
# Guards the lazy build so concurrent callers don't duplicate the load/concat.
|
||||||
_load_lock = threading.RLock()
|
_load_lock = threading.RLock()
|
||||||
|
|
||||||
|
# Canonical Wuhan administrative districts (13), matching the `name` field in
|
||||||
|
# Datas/武汉市.geojson. All district roll-ups must collapse to exactly these.
|
||||||
|
CANONICAL_DISTRICTS = [
|
||||||
|
'江岸区', '江汉区', '硚口区', '汉阳区', '武昌区', '青山区', '洪山区',
|
||||||
|
'东西湖区', '汉南区', '蔡甸区', '江夏区', '黄陂区', '新洲区',
|
||||||
|
]
|
||||||
|
# Bare (suffix-less) base name -> canonical 区-suffixed name.
|
||||||
|
_DISTRICT_BASE_TO_CANONICAL = {d[:-1]: d for d in CANONICAL_DISTRICTS}
|
||||||
|
_DISTRICT_SUFFIXES = ('区', '县', '市')
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_district(name: str) -> str:
|
||||||
|
"""Map a district label to its canonical 区-suffixed form.
|
||||||
|
|
||||||
|
The case parquet carries both bare ("武昌") and suffixed ("武昌区") spellings
|
||||||
|
of the same district, which double-counts in any roll-up. This collapses
|
||||||
|
them: known bare names map to their canonical form; already-suffixed names
|
||||||
|
pass through unchanged; anything else gets a "区" appended.
|
||||||
|
"""
|
||||||
|
if name is None:
|
||||||
|
return name
|
||||||
|
name = str(name).strip()
|
||||||
|
if name in _DISTRICT_BASE_TO_CANONICAL:
|
||||||
|
return _DISTRICT_BASE_TO_CANONICAL[name]
|
||||||
|
if name.endswith(_DISTRICT_SUFFIXES):
|
||||||
|
return name
|
||||||
|
return f"{name}区"
|
||||||
|
|
||||||
|
|
||||||
# Wuhan district mapping
|
# Wuhan district mapping
|
||||||
WUHAN_DISTRICTS = {
|
WUHAN_DISTRICTS = {
|
||||||
'江岸区': ['江岸'],
|
'江岸区': ['江岸'],
|
||||||
@@ -170,3 +200,40 @@ def get_inpatient_data() -> pd.DataFrame:
|
|||||||
"""Return the cached inpatient dataframe"""
|
"""Return the cached inpatient dataframe"""
|
||||||
load_data()
|
load_data()
|
||||||
return _cache["inpatient"] # type: ignore[return-value]
|
return _cache["inpatient"] # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def load_cases_by_district_daily() -> pd.DataFrame:
|
||||||
|
"""Load processed/cases_by_district_daily.parquet with districts normalized.
|
||||||
|
|
||||||
|
The on-disk parquet carries both bare and 区-suffixed spellings of each
|
||||||
|
district (26 labels = 13 districts × 2 spellings), so any groupby on the
|
||||||
|
raw `district` column double-counts. This is the single data-access
|
||||||
|
boundary: it normalizes labels to the canonical 13 and re-aggregates
|
||||||
|
(sum of outpatient_count / inpatient_count / total_cases per
|
||||||
|
normalized district + date), so every downstream consumer
|
||||||
|
(analysis / grid / insights) sees clean, deduped 13-district data.
|
||||||
|
|
||||||
|
Returns a copy with columns [date, district, outpatient_count,
|
||||||
|
inpatient_count, total_cases]. Raises FileNotFoundError if the parquet
|
||||||
|
is missing (callers handle this as they did before).
|
||||||
|
"""
|
||||||
|
path = PROCESSED_DIR / "cases_by_district_daily.parquet"
|
||||||
|
cached = _cache.get("cases_by_district_daily")
|
||||||
|
if cached is not None:
|
||||||
|
return cast(pd.DataFrame, cached).copy()
|
||||||
|
|
||||||
|
with _load_lock:
|
||||||
|
cached = _cache.get("cases_by_district_daily")
|
||||||
|
if cached is not None:
|
||||||
|
return cast(pd.DataFrame, cached).copy()
|
||||||
|
|
||||||
|
df = pd.read_parquet(path)
|
||||||
|
df["district"] = df["district"].map(normalize_district)
|
||||||
|
agg = (
|
||||||
|
df.groupby(["date", "district"], as_index=False)[
|
||||||
|
["outpatient_count", "inpatient_count", "total_cases"]
|
||||||
|
]
|
||||||
|
.sum()
|
||||||
|
)
|
||||||
|
_cache["cases_by_district_daily"] = agg # type: ignore[assignment]
|
||||||
|
return agg.copy()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import pandas as pd
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT, WUHAN_BOUNDS, LAT_STEP, LON_STEP
|
from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT, WUHAN_BOUNDS, LAT_STEP, LON_STEP
|
||||||
|
from data.case_loader import load_cases_by_district_daily
|
||||||
from utils.date_helpers import get_latest_date
|
from utils.date_helpers import get_latest_date
|
||||||
from utils.geojson import parse_geojson_file, load_districts
|
from utils.geojson import parse_geojson_file, load_districts
|
||||||
from utils.geo import point_in_polygon
|
from utils.geo import point_in_polygon
|
||||||
@@ -179,18 +180,16 @@ def _district_avg_aqi() -> dict:
|
|||||||
def _district_total_cases() -> dict:
|
def _district_total_cases() -> dict:
|
||||||
"""Real total recorded cases per district from cases_by_district_daily.
|
"""Real total recorded cases per district from cases_by_district_daily.
|
||||||
|
|
||||||
District labels in the case file are inconsistent ("武昌" vs "武昌区"),
|
District labels are normalized to the canonical 13 区-suffixed names at the
|
||||||
so names are normalized by stripping the "区" suffix and summed, then
|
data-access boundary (data.case_loader), so this is a plain per-district
|
||||||
keyed by the canonical mapping name (with "区"). Returns {district: cases}.
|
sum. Returns {district: cases}.
|
||||||
"""
|
"""
|
||||||
path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
|
try:
|
||||||
if not path.exists():
|
df = load_cases_by_district_daily()
|
||||||
|
except FileNotFoundError:
|
||||||
return {}
|
return {}
|
||||||
df = pd.read_parquet(path, columns=["district", "total_cases"])
|
by_district = df.groupby("district")["total_cases"].sum()
|
||||||
df = df.copy()
|
return {str(d): int(v) for d, v in by_district.items()}
|
||||||
df["base"] = df["district"].str.replace("区", "", regex=False)
|
|
||||||
by_base = df.groupby("base")["total_cases"].sum()
|
|
||||||
return {f"{base}区": int(v) for base, v in by_base.items()}
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=8)
|
@lru_cache(maxsize=8)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from models import (
|
|||||||
MultiDayPredictionRequest,
|
MultiDayPredictionRequest,
|
||||||
MultiDayPredictionResponse,
|
MultiDayPredictionResponse,
|
||||||
)
|
)
|
||||||
|
from data.case_loader import load_cases_by_district_daily
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["grid"])
|
router = APIRouter(prefix="/api", tags=["grid"])
|
||||||
|
|
||||||
@@ -43,14 +44,13 @@ def _compute_historical_aggregation(
|
|||||||
) -> HistoricalAggregationResponse:
|
) -> HistoricalAggregationResponse:
|
||||||
"""Run the full pandas aggregation pipeline (called in thread pool)."""
|
"""Run the full pandas aggregation pipeline (called in thread pool)."""
|
||||||
try:
|
try:
|
||||||
cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet")
|
cases_df = load_cases_by_district_daily()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return HistoricalAggregationResponse(
|
return HistoricalAggregationResponse(
|
||||||
aggregations=[], total_records=0,
|
aggregations=[], total_records=0,
|
||||||
date_range=(start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")),
|
date_range=(start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")),
|
||||||
timestamp=datetime.now().isoformat(),
|
timestamp=datetime.now().isoformat(),
|
||||||
)
|
)
|
||||||
cases_df = cases_df.copy()
|
|
||||||
cases_df['date'] = pd.to_datetime(cases_df['date'])
|
cases_df['date'] = pd.to_datetime(cases_df['date'])
|
||||||
|
|
||||||
filtered_cases = cases_df[
|
filtered_cases = cases_df[
|
||||||
@@ -196,7 +196,7 @@ def _grids_geojson_body(date: str, district: Optional[str], risk_level: Optional
|
|||||||
if district:
|
if district:
|
||||||
merged = merged[merged['district_name'].str.contains(district.replace('区', ''), na=False, regex=False)]
|
merged = merged[merged['district_name'].str.contains(district.replace('区', ''), na=False, regex=False)]
|
||||||
|
|
||||||
cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet").copy()
|
cases_df = load_cases_by_district_daily()
|
||||||
cases_df['date'] = pd.to_datetime(cases_df['date']).dt.strftime('%Y-%m-%d')
|
cases_df['date'] = pd.to_datetime(cases_df['date']).dt.strftime('%Y-%m-%d')
|
||||||
cases_df = cases_df[cases_df['date'] == date]
|
cases_df = cases_df[cases_df['date'] == date]
|
||||||
|
|
||||||
@@ -364,7 +364,7 @@ def _compute_grid_history(grid_id: str, days: int) -> dict:
|
|||||||
|
|
||||||
district = grid_info.iloc[0]['district_name']
|
district = grid_info.iloc[0]['district_name']
|
||||||
|
|
||||||
cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet")
|
cases_df = load_cases_by_district_daily()
|
||||||
cases_df['date'] = pd.to_datetime(cases_df['date'])
|
cases_df['date'] = pd.to_datetime(cases_df['date'])
|
||||||
|
|
||||||
end_date = datetime.now()
|
end_date = datetime.now()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from pydantic import BaseModel, Field
|
|||||||
from typing import Dict, List, Literal
|
from typing import Dict, List, Literal
|
||||||
|
|
||||||
from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT
|
from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT
|
||||||
|
from data.case_loader import load_cases_by_district_daily
|
||||||
from models import (
|
from models import (
|
||||||
InsightsResponse,
|
InsightsResponse,
|
||||||
InsightTrend,
|
InsightTrend,
|
||||||
@@ -491,22 +492,22 @@ async def get_insights_cards():
|
|||||||
|
|
||||||
cases_path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
|
cases_path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
|
||||||
if cases_path.exists():
|
if cases_path.exists():
|
||||||
cases_df = _cached_parquet(str(cases_path))
|
# Districts already normalized to the canonical 13 区-suffixed names.
|
||||||
|
cases_df = load_cases_by_district_daily()
|
||||||
|
cases_df["date"] = pd.to_datetime(cases_df["date"])
|
||||||
latest_case_date = cases_df["date"].max()
|
latest_case_date = cases_df["date"].max()
|
||||||
latest_cases = cases_df[cases_df["date"] == latest_case_date].copy()
|
latest_cases = cases_df[cases_df["date"] == latest_case_date]
|
||||||
latest_cases["base_district"] = latest_cases["district"].str.replace("区", "")
|
district_daily = latest_cases.groupby("district")["total_cases"].sum().sort_values(ascending=False)
|
||||||
district_daily = latest_cases.groupby("base_district")["total_cases"].sum().sort_values(ascending=False)
|
|
||||||
total_daily = int(district_daily.sum())
|
total_daily = int(district_daily.sum())
|
||||||
top_name = district_daily.index[0]
|
top_name = district_daily.index[0]
|
||||||
top_val = int(district_daily.iloc[0])
|
top_val = int(district_daily.iloc[0])
|
||||||
num_districts = len(district_daily)
|
num_districts = len(district_daily)
|
||||||
|
|
||||||
week_ago = latest_case_date - pd.Timedelta(days=6)
|
week_ago = latest_case_date - pd.Timedelta(days=6)
|
||||||
week_cases = cases_df[cases_df["date"] >= week_ago].copy()
|
week_cases = cases_df[cases_df["date"] >= week_ago]
|
||||||
week_cases["base_district"] = week_cases["district"].str.replace("区", "")
|
|
||||||
daily_totals = week_cases.groupby("date")["total_cases"].sum()
|
daily_totals = week_cases.groupby("date")["total_cases"].sum()
|
||||||
avg_daily = int(daily_totals.mean())
|
avg_daily = int(daily_totals.mean())
|
||||||
week_district = week_cases.groupby("base_district")["total_cases"].sum().sort_values(ascending=False)
|
week_district = week_cases.groupby("district")["total_cases"].sum().sort_values(ascending=False)
|
||||||
week_top_val = int(week_district.iloc[0])
|
week_top_val = int(week_district.iloc[0])
|
||||||
|
|
||||||
date_str = latest_case_date.strftime("%m月%d日")
|
date_str = latest_case_date.strftime("%m月%d日")
|
||||||
@@ -515,8 +516,8 @@ async def get_insights_cards():
|
|||||||
title=f"日病例统计 ({date_str})",
|
title=f"日病例统计 ({date_str})",
|
||||||
description=(
|
description=(
|
||||||
f"最近统计日({date_str})全市{num_districts}个区共记录{total_daily}例儿童呼吸道疾病病例,"
|
f"最近统计日({date_str})全市{num_districts}个区共记录{total_daily}例儿童呼吸道疾病病例,"
|
||||||
f"{top_name}区{top_val}例为当日最高。近7日日均{avg_daily}例,"
|
f"{top_name}{top_val}例为当日最高。近7日日均{avg_daily}例,"
|
||||||
f"{week_district.index[0]}区累计{week_top_val}例居首。"
|
f"{week_district.index[0]}累计{week_top_val}例居首。"
|
||||||
),
|
),
|
||||||
type="warning",
|
type="warning",
|
||||||
metric="日病例",
|
metric="日病例",
|
||||||
|
|||||||
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 { Outlet } from 'react-router-dom';
|
||||||
import { TopNav } from '@/components/TopNav';
|
import { TopNav } from '@/components/TopNav';
|
||||||
import { SideNav } from '@/components/SideNav';
|
import { SideNav } from '@/components/SideNav';
|
||||||
|
import { RouteErrorBoundary } from '@/components/RouteErrorBoundary';
|
||||||
import { useRiskStore } from '@/stores';
|
import { useRiskStore } from '@/stores';
|
||||||
import { TESTIDS } from '@/utils/testids';
|
import { TESTIDS } from '@/utils/testids';
|
||||||
|
|
||||||
@@ -9,17 +10,84 @@ interface AppShellProps {
|
|||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 收集容器内当前可聚焦的元素,供初始聚焦与焦点循环陷阱使用。
|
||||||
|
function getFocusable(container: HTMLElement): HTMLElement[] {
|
||||||
|
return Array.from(
|
||||||
|
container.querySelectorAll<HTMLElement>(
|
||||||
|
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||||
|
)
|
||||||
|
).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
||||||
|
}
|
||||||
|
|
||||||
// 仅负责布局骨架(顶栏 / 侧栏 / 内容区),不涉及路由匹配与鉴权。
|
// 仅负责布局骨架(顶栏 / 侧栏 / 内容区),不涉及路由匹配与鉴权。
|
||||||
export function AppShell({ onLogout }: AppShellProps) {
|
export function AppShell({ onLogout }: AppShellProps) {
|
||||||
const alerts = useRiskStore((s) => s.alerts);
|
const alerts = useRiskStore((s) => s.alerts);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
// 提升手风琴展开态:导轨与抽屉两份 SideNav 共享,保持同步。
|
||||||
|
const [expandedNav, setExpandedNav] = useState<string | null>('monitoring');
|
||||||
|
const drawerRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
const openDrawer = useCallback(() => setDrawerOpen(true), []);
|
const openDrawer = useCallback(() => setDrawerOpen(true), []);
|
||||||
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
|
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
|
||||||
|
|
||||||
|
// 抽屉作为模态:ESC 关闭、锁定 body 滚动、焦点移入并在关闭后归还给汉堡。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!drawerOpen) return;
|
||||||
|
|
||||||
|
const opener = document.activeElement as HTMLElement | null;
|
||||||
|
|
||||||
|
// 锁定 body 滚动,关闭时还原原值。
|
||||||
|
const prevOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
// 焦点移入抽屉(优先第一个可聚焦元素,否则聚焦抽屉容器本身)。
|
||||||
|
const drawer = drawerRef.current;
|
||||||
|
const focusables = drawer ? getFocusable(drawer) : [];
|
||||||
|
(focusables[0] ?? drawer)?.focus();
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
closeDrawer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 焦点循环陷阱:Tab 在抽屉内首尾元素之间循环。
|
||||||
|
if (e.key === 'Tab' && drawer) {
|
||||||
|
const items = getFocusable(drawer);
|
||||||
|
if (items.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
drawer.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = items[0];
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (e.shiftKey && (active === first || active === drawer)) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!e.shiftKey && active === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
document.body.style.overflow = prevOverflow;
|
||||||
|
// 关闭后把焦点还给打开抽屉的元素(汉堡按钮),回退到按 testid 查询。
|
||||||
|
const restoreTarget =
|
||||||
|
opener ??
|
||||||
|
document.querySelector<HTMLElement>(`[data-testid="${TESTIDS.hamburger}"]`);
|
||||||
|
restoreTarget?.focus();
|
||||||
|
};
|
||||||
|
}, [drawerOpen, closeDrawer]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid={TESTIDS.appShell} className="h-screen bg-bg-page flex flex-col overflow-hidden">
|
<div data-testid={TESTIDS.appShell} className="h-screen bg-bg-page flex flex-col overflow-hidden">
|
||||||
<TopNav onLogout={onLogout} onToggleMenu={openDrawer} />
|
<TopNav onLogout={onLogout} onToggleMenu={openDrawer} isMenuOpen={drawerOpen} />
|
||||||
|
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
{/* lg 及以上:持久侧栏导轨 */}
|
{/* lg 及以上:持久侧栏导轨 */}
|
||||||
@@ -27,7 +95,11 @@ export function AppShell({ onLogout }: AppShellProps) {
|
|||||||
data-testid={TESTIDS.sidebarRail}
|
data-testid={TESTIDS.sidebarRail}
|
||||||
className="hidden lg:block w-[200px] shrink-0 bg-bg-card border-r border-border"
|
className="hidden lg:block w-[200px] shrink-0 bg-bg-card border-r border-border"
|
||||||
>
|
>
|
||||||
<SideNav alertCount={alerts.length} />
|
<SideNav
|
||||||
|
alertCount={alerts.length}
|
||||||
|
expanded={expandedNav}
|
||||||
|
onExpandedChange={setExpandedNav}
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* lg 以下:离屏抽屉 + 遮罩 */}
|
{/* lg 以下:离屏抽屉 + 遮罩 */}
|
||||||
@@ -39,16 +111,29 @@ export function AppShell({ onLogout }: AppShellProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<aside
|
<aside
|
||||||
|
ref={drawerRef}
|
||||||
|
id="app-drawer"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="导航菜单"
|
||||||
|
tabIndex={-1}
|
||||||
data-testid={TESTIDS.appDrawer}
|
data-testid={TESTIDS.appDrawer}
|
||||||
className={`fixed top-0 left-0 bottom-0 z-50 w-[260px] max-w-[80vw] bg-bg-card border-r border-border shadow-xl transition-transform duration-200 lg:hidden ${
|
className={`fixed top-0 left-0 bottom-0 z-50 w-[260px] max-w-[80vw] bg-bg-card border-r border-border shadow-xl transition-transform duration-200 lg:hidden ${
|
||||||
drawerOpen ? 'translate-x-0' : '-translate-x-full'
|
drawerOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<SideNav alertCount={alerts.length} onNavigate={closeDrawer} />
|
<SideNav
|
||||||
|
alertCount={alerts.length}
|
||||||
|
onNavigate={closeDrawer}
|
||||||
|
expanded={expandedNav}
|
||||||
|
onExpandedChange={setExpandedNav}
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="flex-1 min-w-0 overflow-auto p-5">
|
<main className="flex-1 min-w-0 overflow-auto p-5">
|
||||||
<Outlet />
|
<RouteErrorBoundary>
|
||||||
|
<Outlet />
|
||||||
|
</RouteErrorBoundary>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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;
|
alertCount?: number;
|
||||||
// 抽屉模式下点击导航项后关闭抽屉(持久侧栏可不传)。
|
// 抽屉模式下点击导航项后关闭抽屉(持久侧栏可不传)。
|
||||||
onNavigate?: () => void;
|
onNavigate?: () => void;
|
||||||
|
// 受控的展开手风琴分组:由 AppShell 提供时,导轨与抽屉两份实例保持同步。
|
||||||
|
// 不传则回退到内部 state,向后兼容独立使用。
|
||||||
|
expanded?: string | null;
|
||||||
|
onExpandedChange?: (moduleId: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -56,14 +60,28 @@ const modules: { id: string; label: string; icon: React.ReactNode; items: NavIte
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function SideNav({ alertCount = 0, onNavigate }: SideNavProps) {
|
export function SideNav({
|
||||||
|
alertCount = 0,
|
||||||
|
onNavigate,
|
||||||
|
expanded: expandedProp,
|
||||||
|
onExpandedChange,
|
||||||
|
}: SideNavProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
// 当前路径命中的模块默认展开。
|
// 当前路径命中的模块默认展开。
|
||||||
const moduleForPath = (pathname: string) =>
|
const moduleForPath = (pathname: string) =>
|
||||||
modules.find((m) => m.items.some((item) => pathname.startsWith(item.to)))?.id ?? 'monitoring';
|
modules.find((m) => m.items.some((item) => pathname.startsWith(item.to)))?.id ?? 'monitoring';
|
||||||
|
|
||||||
const [expanded, setExpanded] = useState<string | null>(() => moduleForPath(location.pathname));
|
// 受控/非受控双模式:父级传入 expanded 时由父级管理,否则回退内部 state。
|
||||||
|
const [internalExpanded, setInternalExpanded] = useState<string | null>(() =>
|
||||||
|
moduleForPath(location.pathname)
|
||||||
|
);
|
||||||
|
const isControlled = expandedProp !== undefined;
|
||||||
|
const expanded = isControlled ? expandedProp : internalExpanded;
|
||||||
|
const setExpanded = (next: string | null) => {
|
||||||
|
if (isControlled) onExpandedChange?.(next);
|
||||||
|
else setInternalExpanded(next);
|
||||||
|
};
|
||||||
|
|
||||||
const isActiveModule = (moduleId: string) => {
|
const isActiveModule = (moduleId: string) => {
|
||||||
const module = modules.find((m) => m.id === moduleId);
|
const module = modules.find((m) => m.id === moduleId);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ interface TopNavProps {
|
|||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
// 移动端汉堡按钮:切换侧栏抽屉。
|
// 移动端汉堡按钮:切换侧栏抽屉。
|
||||||
onToggleMenu?: () => void;
|
onToggleMenu?: () => void;
|
||||||
|
// 抽屉是否展开(用于汉堡按钮的 aria-expanded)。
|
||||||
|
isMenuOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Clock() {
|
function Clock() {
|
||||||
@@ -16,7 +18,7 @@ function Clock() {
|
|||||||
return <span>{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>;
|
return <span>{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopNav({ onLogout, onToggleMenu }: TopNavProps) {
|
export function TopNav({ onLogout, onToggleMenu, isMenuOpen = false }: TopNavProps) {
|
||||||
return (
|
return (
|
||||||
<nav className="h-[52px] bg-bg-card border-b border-border flex items-center px-5 z-50">
|
<nav className="h-[52px] bg-bg-card border-b border-border flex items-center px-5 z-50">
|
||||||
{onToggleMenu && (
|
{onToggleMenu && (
|
||||||
@@ -24,6 +26,8 @@ export function TopNav({ onLogout, onToggleMenu }: TopNavProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onToggleMenu}
|
onClick={onToggleMenu}
|
||||||
aria-label="打开菜单"
|
aria-label="打开菜单"
|
||||||
|
aria-expanded={isMenuOpen}
|
||||||
|
aria-controls="app-drawer"
|
||||||
data-testid={TESTIDS.hamburger}
|
data-testid={TESTIDS.hamburger}
|
||||||
className="lg:hidden mr-3 -ml-1 w-9 h-9 flex items-center justify-center rounded-md text-text-secondary hover:bg-bg-hover transition-colors"
|
className="lg:hidden mr-3 -ml-1 w-9 h-9 flex items-center justify-center rounded-md text-text-secondary hover:bg-bg-hover transition-colors"
|
||||||
>
|
>
|
||||||
|
|||||||
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 { useEffect, useState, useMemo } from 'react';
|
||||||
import {
|
import { Activity } from 'lucide-react';
|
||||||
LineChart,
|
|
||||||
Line,
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
PieChart,
|
|
||||||
Pie,
|
|
||||||
Cell,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
ResponsiveContainer,
|
|
||||||
} from 'recharts';
|
|
||||||
import {
|
|
||||||
Activity,
|
|
||||||
AlertTriangle,
|
|
||||||
Droplets,
|
|
||||||
Building2,
|
|
||||||
TrendingUp,
|
|
||||||
TrendingDown,
|
|
||||||
Users,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { caseApi, riskApi, alertApi, envApi } from '@/services/api';
|
import { caseApi, riskApi, alertApi, envApi } from '@/services/api';
|
||||||
import { StatCard } from '@/components/StatCard';
|
|
||||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||||
|
import { LoadingState, Segmented } from '@/components/ui';
|
||||||
|
import { TESTIDS } from '@/utils/testids';
|
||||||
import type {
|
import type {
|
||||||
CaseTrendPoint,
|
CaseTrendPoint,
|
||||||
DistrictCaseData,
|
DistrictCaseData,
|
||||||
@@ -33,27 +11,30 @@ import type {
|
|||||||
DiagnosisBreakdown,
|
DiagnosisBreakdown,
|
||||||
Alert,
|
Alert,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
import { KpiRow, type KpiData } from '@/components/overview/KpiRow';
|
||||||
|
import { CaseAqiTrend, type MergedTrendItem } from '@/components/overview/CaseAqiTrend';
|
||||||
|
import { DistrictChoropleth } from '@/components/overview/DistrictChoropleth';
|
||||||
|
import { TopDistrictsBar } from '@/components/overview/TopDistrictsBar';
|
||||||
|
import { TopDiagnosesBar } from '@/components/overview/TopDiagnosesBar';
|
||||||
|
import { AlertSeverityDonut, type AlertSlice } from '@/components/overview/AlertSeverityDonut';
|
||||||
|
import { CHART_COLORS } from '@/components/overview/chartColors';
|
||||||
|
import {
|
||||||
|
joinDistrictCases,
|
||||||
|
buildMetricLookup,
|
||||||
|
type MetricKey,
|
||||||
|
} from '@/components/overview/districtNormalize';
|
||||||
|
|
||||||
// --- Types for fetched data ---
|
const METRIC_OPTIONS: { value: MetricKey; label: string }[] = [
|
||||||
interface KpiData {
|
{ value: 'all', label: '全部' },
|
||||||
totalCases: number;
|
{ value: 'outpatient', label: '门诊' },
|
||||||
todayCases: number;
|
{ value: 'inpatient', label: '住院' },
|
||||||
changeRatio: number | null;
|
];
|
||||||
activeAlerts: number;
|
|
||||||
highRiskGrids: number;
|
|
||||||
avgAQI: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MergedTrendItem {
|
const METRIC_LABEL: Record<MetricKey, string> = {
|
||||||
date: string;
|
all: '病例',
|
||||||
cases: number;
|
outpatient: '门诊',
|
||||||
aqi: number;
|
inpatient: '住院',
|
||||||
}
|
};
|
||||||
|
|
||||||
function formatDateLabel(dateStr: string): string {
|
|
||||||
const d = new Date(dateStr);
|
|
||||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeChangeRatio(trend: CaseTrendPoint[]): number | null {
|
function computeChangeRatio(trend: CaseTrendPoint[]): number | null {
|
||||||
if (trend.length < 8) return null;
|
if (trend.length < 8) return null;
|
||||||
@@ -66,12 +47,15 @@ function computeChangeRatio(trend: CaseTrendPoint[]): number | null {
|
|||||||
export function OverviewDashboard() {
|
export function OverviewDashboard() {
|
||||||
const [kpi, setKpi] = useState<KpiData | null>(null);
|
const [kpi, setKpi] = useState<KpiData | null>(null);
|
||||||
const [mergedTrend, setMergedTrend] = useState<MergedTrendItem[]>([]);
|
const [mergedTrend, setMergedTrend] = useState<MergedTrendItem[]>([]);
|
||||||
const [topDistricts, setTopDistricts] = useState<DistrictCaseData[]>([]);
|
const [districts, setDistricts] = useState<DistrictCaseData[]>([]);
|
||||||
const [topDiagnoses, setTopDiagnoses] = useState<DiagnosisBreakdown[]>([]);
|
const [topDiagnoses, setTopDiagnoses] = useState<DiagnosisBreakdown[]>([]);
|
||||||
const [alertPie, setAlertPie] = useState<{ name: string; value: number; color: string }[]>([]);
|
const [alertPie, setAlertPie] = useState<AlertSlice[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [errors, setErrors] = useState<string[]>([]);
|
const [errors, setErrors] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 门诊/住院/全部 — 同时驱动 choropleth 与 Top5 区县条形图。
|
||||||
|
const [metric, setMetric] = useState<MetricKey>('all');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -88,7 +72,7 @@ export function OverviewDashboard() {
|
|||||||
start30.setDate(start30.getDate() - 30);
|
start30.setDate(start30.getDate() - 30);
|
||||||
const start30Str = start30.toISOString().split('T')[0];
|
const start30Str = start30.toISOString().split('T')[0];
|
||||||
|
|
||||||
// KPI sources — Promise.allSettled to survive individual failures
|
// KPI sources — Promise.allSettled to survive individual failures.
|
||||||
const [statsR, trend14R, alertsR, riskStatsR, pollutantsR] = await Promise.allSettled([
|
const [statsR, trend14R, alertsR, riskStatsR, pollutantsR] = await Promise.allSettled([
|
||||||
caseApi.getStats(),
|
caseApi.getStats(),
|
||||||
caseApi.getTrend({ start_date: start14Str, end_date: endStr, group_by: 'day' }),
|
caseApi.getTrend({ start_date: start14Str, end_date: endStr, group_by: 'day' }),
|
||||||
@@ -97,7 +81,7 @@ export function OverviewDashboard() {
|
|||||||
envApi.getPollutants(7),
|
envApi.getPollutants(7),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Trend sources
|
// Trend + district sources.
|
||||||
const [trend30R, districtsR, diagStatsR] = await Promise.allSettled([
|
const [trend30R, districtsR, diagStatsR] = await Promise.allSettled([
|
||||||
caseApi.getTrend({ start_date: start30Str, end_date: endStr, group_by: 'day' }),
|
caseApi.getTrend({ start_date: start30Str, end_date: endStr, group_by: 'day' }),
|
||||||
caseApi.getDistricts(),
|
caseApi.getDistricts(),
|
||||||
@@ -108,7 +92,7 @@ export function OverviewDashboard() {
|
|||||||
|
|
||||||
const newErrors: string[] = [];
|
const newErrors: string[] = [];
|
||||||
|
|
||||||
// --- Build KPI ---
|
// --- KPI ---
|
||||||
let totalCases = 0;
|
let totalCases = 0;
|
||||||
if (statsR.status === 'fulfilled') {
|
if (statsR.status === 'fulfilled') {
|
||||||
const s = statsR.value;
|
const s = statsR.value;
|
||||||
@@ -121,9 +105,7 @@ export function OverviewDashboard() {
|
|||||||
let changeRatio: number | null = null;
|
let changeRatio: number | null = null;
|
||||||
if (trend14R.status === 'fulfilled') {
|
if (trend14R.status === 'fulfilled') {
|
||||||
const trend = trend14R.value.trend || [];
|
const trend = trend14R.value.trend || [];
|
||||||
if (trend.length > 0) {
|
if (trend.length > 0) todayCases = trend[trend.length - 1].total;
|
||||||
todayCases = trend[trend.length - 1].total;
|
|
||||||
}
|
|
||||||
changeRatio = computeChangeRatio(trend);
|
changeRatio = computeChangeRatio(trend);
|
||||||
} else {
|
} else {
|
||||||
newErrors.push('今日病例数据加载失败');
|
newErrors.push('今日病例数据加载失败');
|
||||||
@@ -158,33 +140,24 @@ export function OverviewDashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setKpi({ totalCases, todayCases, changeRatio, activeAlerts, highRiskGrids, avgAQI });
|
setKpi({ totalCases, todayCases, changeRatio, activeAlerts, highRiskGrids, avgAQI });
|
||||||
setErrors(newErrors);
|
|
||||||
|
|
||||||
// --- Merge case trend + AQI ---
|
// --- Merge case trend + AQI ---
|
||||||
if (trend30R.status === 'fulfilled') {
|
if (trend30R.status === 'fulfilled') {
|
||||||
const trend30 = trend30R.value.trend || [];
|
const trend30 = trend30R.value.trend || [];
|
||||||
const aqiMap: Record<string, number> = {};
|
const aqiMap: Record<string, number> = {};
|
||||||
if (pollutantsR.status === 'fulfilled') {
|
for (const p of pollutantData) aqiMap[p.date] = p.AQI || 0;
|
||||||
for (const p of pollutantData) {
|
setMergedTrend(
|
||||||
aqiMap[p.date] = p.AQI || 0;
|
trend30.map((t) => ({ date: t.date, cases: t.total, aqi: aqiMap[t.date] || 0 }))
|
||||||
}
|
);
|
||||||
}
|
|
||||||
// Only use data from the last 30 days for display
|
|
||||||
const merged: MergedTrendItem[] = trend30.map((t) => ({
|
|
||||||
date: t.date,
|
|
||||||
cases: t.total,
|
|
||||||
aqi: aqiMap[t.date] || 0,
|
|
||||||
}));
|
|
||||||
setMergedTrend(merged);
|
|
||||||
} else if (!newErrors.includes('今日病例数据加载失败')) {
|
} else if (!newErrors.includes('今日病例数据加载失败')) {
|
||||||
newErrors.push('趋势数据加载失败');
|
newErrors.push('趋势数据加载失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Top 5 Districts ---
|
// --- Districts (feeds choropleth + Top5 via normalize/join) ---
|
||||||
if (districtsR.status === 'fulfilled') {
|
if (districtsR.status === 'fulfilled') {
|
||||||
const districts = districtsR.value.districts || [];
|
setDistricts(districtsR.value.districts || []);
|
||||||
const sorted = [...districts].sort((a, b) => b.total - a.total);
|
} else {
|
||||||
setTopDistricts(sorted.slice(0, 5));
|
newErrors.push('区县数据加载失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Top 5 Diagnoses ---
|
// --- Top 5 Diagnoses ---
|
||||||
@@ -204,43 +177,33 @@ export function OverviewDashboard() {
|
|||||||
const p1 = alertList.filter((a) => a.priority === 'P1').length;
|
const p1 = alertList.filter((a) => a.priority === 'P1').length;
|
||||||
const p2 = alertList.filter((a) => a.priority === 'P2').length;
|
const p2 = alertList.filter((a) => a.priority === 'P2').length;
|
||||||
setAlertPie([
|
setAlertPie([
|
||||||
{ name: 'P1 紧急', value: p1, color: '#EF4444' },
|
{ name: 'P1 紧急', value: p1, color: CHART_COLORS.alertP1 },
|
||||||
{ name: 'P2 关注', value: p2, color: '#F59E0B' },
|
{ name: 'P2 关注', value: p2, color: CHART_COLORS.alertP2 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchAll();
|
fetchAll();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const changeTrend = useMemo(() => {
|
// 归一并聚合到 13 区一次,供 choropleth 与 Top5 共享。
|
||||||
if (kpi?.changeRatio == null) return undefined;
|
const joinedDistricts = useMemo(() => joinDistrictCases(districts), [districts]);
|
||||||
if (kpi.changeRatio > 0) {
|
const metricLookup = useMemo(
|
||||||
return { direction: 'up' as const, value: `${kpi.changeRatio.toFixed(1)}%` };
|
() => buildMetricLookup(joinedDistricts, metric),
|
||||||
}
|
[joinedDistricts, metric]
|
||||||
if (kpi.changeRatio < 0) {
|
);
|
||||||
return { direction: 'down' as const, value: `${Math.abs(kpi.changeRatio).toFixed(1)}%` };
|
|
||||||
}
|
|
||||||
return { direction: 'stable' as const, value: '0%' };
|
|
||||||
}, [kpi?.changeRatio]);
|
|
||||||
|
|
||||||
// --- Loading state ---
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <LoadingState label="加载概览数据…" testid={TESTIDS.pageLoading} />;
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="page-overview" className="flex flex-col h-full overflow-auto">
|
<div data-testid={TESTIDS.pageOverview} className="flex flex-col h-full overflow-auto">
|
||||||
{/* Error banner */}
|
|
||||||
{errors.length > 0 && (
|
{errors.length > 0 && (
|
||||||
<div className="px-6 pt-4">
|
<div className="px-6 pt-4">
|
||||||
<ErrorBanner
|
<ErrorBanner
|
||||||
@@ -252,249 +215,58 @@ export function OverviewDashboard() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* Page header */}
|
{/* Page header + honesty badge + metric toggle */}
|
||||||
<div>
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
|
<div>
|
||||||
<Activity className="w-5 h-5 text-primary" />
|
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
|
||||||
综合概览
|
<Activity className="w-5 h-5 text-primary" />
|
||||||
</h1>
|
综合概览
|
||||||
<p className="text-[12px] text-gray-500">病例、环境与预警关键指标总览</p>
|
<span
|
||||||
</div>
|
data-testid={TESTIDS.asofBadge}
|
||||||
|
className="ml-1 inline-flex items-center rounded-full bg-bg-hover px-2 py-0.5 text-[11px] font-medium text-text-secondary border border-border"
|
||||||
{/* Section 1: KPI Row */}
|
>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
数据截至2023-12
|
||||||
<StatCard
|
</span>
|
||||||
icon={<Users className="w-4 h-4 text-blue-600" />}
|
</h1>
|
||||||
label="累计病例总数"
|
<p className="text-[12px] text-text-secondary">病例、环境与预警关键指标总览</p>
|
||||||
value={kpi?.totalCases?.toLocaleString() ?? '--'}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={<Activity className="w-4 h-4 text-green-600" />}
|
|
||||||
label="今日病例"
|
|
||||||
value={kpi?.todayCases?.toLocaleString() ?? '--'}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={
|
|
||||||
(changeTrend?.direction === 'up' && <TrendingUp className="w-4 h-4 text-red-500" />) ||
|
|
||||||
(changeTrend?.direction === 'down' && <TrendingDown className="w-4 h-4 text-green-500" />) || (
|
|
||||||
<Activity className="w-4 h-4 text-gray-400" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
label="7日变化率"
|
|
||||||
value={changeTrend ? changeTrend.value : '--'}
|
|
||||||
trend={changeTrend}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={<AlertTriangle className="w-4 h-4 text-orange-500" />}
|
|
||||||
label="活跃预警数"
|
|
||||||
value={kpi?.activeAlerts?.toLocaleString() ?? '--'}
|
|
||||||
color={kpi && kpi.activeAlerts > 0 ? '#EF4444' : undefined}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={<Building2 className="w-4 h-4 text-red-500" />}
|
|
||||||
label="高风险网格"
|
|
||||||
value={kpi?.highRiskGrids?.toLocaleString() ?? '--'}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={<Droplets className="w-4 h-4 text-cyan-500" />}
|
|
||||||
label="平均AQI"
|
|
||||||
value={kpi?.avgAQI?.toLocaleString() ?? '--'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section 2: Case + AQI Mini Trend */}
|
|
||||||
<div className="card p-4">
|
|
||||||
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
|
|
||||||
近30日病例与AQI趋势
|
|
||||||
</div>
|
</div>
|
||||||
{mergedTrend.length > 0 ? (
|
<Segmented
|
||||||
<ResponsiveContainer width="100%" height={200}>
|
options={METRIC_OPTIONS}
|
||||||
<LineChart data={mergedTrend} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
value={metric}
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
onChange={setMetric}
|
||||||
<XAxis
|
testid={TESTIDS.outinpatientToggle}
|
||||||
dataKey="date"
|
/>
|
||||||
tickFormatter={formatDateLabel}
|
|
||||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
|
||||||
interval="preserveStartEnd"
|
|
||||||
axisLine={{ stroke: '#E2E8F0' }}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
yAxisId="left"
|
|
||||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
|
||||||
axisLine={{ stroke: '#E2E8F0' }}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
yAxisId="right"
|
|
||||||
orientation="right"
|
|
||||||
tick={{ fontSize: 10, fill: '#F59E0B' }}
|
|
||||||
axisLine={{ stroke: '#E2E8F0' }}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
border: '1px solid #E2E8F0',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontSize: '12px',
|
|
||||||
}}
|
|
||||||
labelStyle={{ color: '#1E293B', fontWeight: 600 }}
|
|
||||||
/>
|
|
||||||
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
|
||||||
<Line
|
|
||||||
yAxisId="left"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="cases"
|
|
||||||
name="病例数"
|
|
||||||
stroke="#3B82F6"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
activeDot={{ r: 3 }}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
yAxisId="right"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="aqi"
|
|
||||||
name="AQI"
|
|
||||||
stroke="#F59E0B"
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeDasharray="5 5"
|
|
||||||
dot={false}
|
|
||||||
activeDot={{ r: 3 }}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 3 + 4: Top Districts + Top Diagnoses side by side */}
|
{/* KPI Row */}
|
||||||
|
<KpiRow kpi={kpi} />
|
||||||
|
|
||||||
|
{/* Headline: Wuhan 13-district choropleth */}
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-3">
|
||||||
|
武汉市13区{METRIC_LABEL[metric]}分布(高风险高亮)
|
||||||
|
</div>
|
||||||
|
<DistrictChoropleth
|
||||||
|
metricLookup={metricLookup}
|
||||||
|
metricLabel={`${METRIC_LABEL[metric]}数`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Case + AQI trend */}
|
||||||
|
<CaseAqiTrend data={mergedTrend} />
|
||||||
|
|
||||||
|
{/* Top districts (metric-driven) + Top diagnoses */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{/* Section 3: Top 5 Districts */}
|
<TopDistrictsBar
|
||||||
<div className="card p-4">
|
districts={joinedDistricts}
|
||||||
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
|
metric={metric}
|
||||||
Top 5 区县病例分布
|
metricLabel={METRIC_LABEL[metric]}
|
||||||
</div>
|
/>
|
||||||
{topDistricts.length > 0 ? (
|
<TopDiagnosesBar diagnoses={topDiagnoses} />
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
|
||||||
<BarChart
|
|
||||||
data={[...topDistricts].reverse()}
|
|
||||||
layout="vertical"
|
|
||||||
margin={{ top: 0, right: 10, left: 30, bottom: 0 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
|
||||||
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
|
|
||||||
<YAxis
|
|
||||||
type="category"
|
|
||||||
dataKey="district"
|
|
||||||
tick={{ fontSize: 11, fill: '#374151' }}
|
|
||||||
width={60}
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
border: '1px solid #E2E8F0',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontSize: '12px',
|
|
||||||
}}
|
|
||||||
formatter={(value: number) => [value.toLocaleString(), '病例数']}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={20} />
|
|
||||||
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={20} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section 4: Top 5 Diagnoses */}
|
|
||||||
<div className="card p-4">
|
|
||||||
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
|
|
||||||
Top 5 诊断分布
|
|
||||||
</div>
|
|
||||||
{topDiagnoses.length > 0 ? (
|
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
|
||||||
<BarChart
|
|
||||||
data={[...topDiagnoses].reverse()}
|
|
||||||
layout="vertical"
|
|
||||||
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
|
||||||
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
|
|
||||||
<YAxis
|
|
||||||
type="category"
|
|
||||||
dataKey="diagnosis"
|
|
||||||
tick={{ fontSize: 11, fill: '#374151' }}
|
|
||||||
width={100}
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
border: '1px solid #E2E8F0',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontSize: '12px',
|
|
||||||
}}
|
|
||||||
formatter={(value: number) => [value.toLocaleString(), '病例数']}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={16} />
|
|
||||||
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={16} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 5: Alert Severity Donut */}
|
{/* Alert severity donut */}
|
||||||
<div className="card p-4">
|
<AlertSeverityDonut data={alertPie} />
|
||||||
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
|
|
||||||
预警严重度分布
|
|
||||||
</div>
|
|
||||||
{alertPie[0].value > 0 || alertPie[1].value > 0 ? (
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={alertPie}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius={50}
|
|
||||||
outerRadius={80}
|
|
||||||
paddingAngle={4}
|
|
||||||
dataKey="value"
|
|
||||||
nameKey="name"
|
|
||||||
>
|
|
||||||
{alertPie.map((entry, idx) => (
|
|
||||||
<Cell key={idx} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
border: '1px solid #E2E8F0',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontSize: '12px',
|
|
||||||
}}
|
|
||||||
formatter={(value: number, name: string) => [value, name]}
|
|
||||||
/>
|
|
||||||
<Legend
|
|
||||||
wrapperStyle={{ fontSize: '12px' }}
|
|
||||||
formatter={(value: string) => (
|
|
||||||
<span className="text-gray-700">{value}</span>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-gray-400 text-sm">暂无预警数据</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ export const TESTIDS = {
|
|||||||
pageDemographics: 'page-demographics',
|
pageDemographics: 'page-demographics',
|
||||||
pageDisease: 'page-disease',
|
pageDisease: 'page-disease',
|
||||||
pageEnvironment: 'page-environment',
|
pageEnvironment: 'page-environment',
|
||||||
|
|
||||||
|
// 综合概览 大屏
|
||||||
|
kpiRow: 'kpi-row',
|
||||||
|
choroplethWrapper: 'choropleth-wrapper',
|
||||||
|
asofBadge: 'asof-badge',
|
||||||
|
outinpatientToggle: 'outinpatient-toggle',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type TestId = (typeof TESTIDS)[keyof typeof TESTIDS];
|
export type TestId = (typeof TESTIDS)[keyof typeof TESTIDS];
|
||||||
|
|||||||
Reference in New Issue
Block a user