6 Commits

Author SHA1 Message Date
fe8bed58f5 feat: remove cost/费用 statistics from clinical analytics
Per request — drop all monetary statistics (住院费用 is sensitive).
- Backend statistics.py: remove mean_cost KPI + cost_histogram / cost_by_disease
  / cost_vs_los from /inpatient-clinical (models, computation, response)
- Frontend: drop 人均费用 KPI card (now 4 KPIs), 住院费用分布, 各病种平均费用,
  费用×住院天数散点; delete CostByDiseaseChart + CostVsLosScatter components;
  trim statsApi type + e2e fixture + chartColors

Clinical page now: KPI(总人次/中位住院日/治愈好转率/急诊占比) + LOS dist + LOS-by-disease
box + outcome donut + admission-route donut + age-band BMI box.

Gates: backend 106 pytest · tsc 0 · build ok · clinical+user-flows e2e 19/19 ·
live endpoint confirmed cost-free

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:50:30 +08:00
4df6c71628 feat: deep statistical analytics — clinical, symptoms, incidence, env correlation, weekday
Adds a substantial layer of data-backed statistics (all grounded in verified,
clean source data — no fabricated metrics).

Backend (new routers/statistics.py, prefix /api/stats; +106 pytest still green):
- /inpatient-clinical: LOS dist + by-disease quartiles, cost dist + by-disease +
  cost-vs-LOS, outcome counts, admission-route counts, BMI-by-age, KPIs
  (5822 admissions, median LOS 4d, mean ¥6294, cure 99.1%, emergency 47%)
- /symptoms: 主诉 keyword frequencies (发热/咳嗽/肺炎…) + revisit ratio (36%)
- /incidence-rate: per-10k-population standardized rate by district (cases ÷ pop)
- /env-correlation: pollutant×cases Pearson + 7×7 pairwise matrix + PM2.5 scatter
- /temporal: weekday distribution (+ month/yoy returned but UI omits them — data
  is December-only, so seasonality/YoY would be misleading)

Frontend:
- NEW 住院临床分析 page (/analysis/clinical, nav 临床分析): 9 charts + KPI row —
  LOS histogram + box-by-disease, cost histogram + scatter + by-disease, outcome
  donut (severity-colored), admission-route donut, age-band BMI box
- DiseaseAnalysis: 主诉症状词频 horizontal bar + revisit ratio
- DistrictComparison: 标化发病率(每万人)with 病例数↔发病率 toggle (rate is
  epidemiologically correct; raw counts mislead by population)
- EnvironmentalHealth: pollutant-cases correlation bar + 7×7 correlation heatmap +
  PM2.5×cases scatter with least-squares regression line
- TrendAnalysis: 星期就诊分布 + honest "data is December-only" note
- statsApi client + types

Gates: tsc 0 · build ok · functional e2e 43/43 (incl 2 new clinical) · verified
live against real backend data via dev proxy

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:42:52 +08:00
33f0f497d3 feat: Phase 4 — responsive analysis pages + perf harness + god-component splits
Final phase of the UX modernization. Four conflict-free lanes.

Responsive (D4 — desktop+mobile 并重):
- 7 analysis pages made usable at 375px: grid-cols-4/5 → grid-cols-2 sm:*
  responsive variants; raw tables wrapped in overflow-x-auto; page overflow guards
- new e2e/responsive.spec.ts loops all 7 analysis routes at 375px asserting no
  horizontal scroll

Perf harness:
- playwright.config.ts gains an isolated `perf` project (testMatch /perf/), default
  chromium project excludes it (testIgnore)
- new e2e/perf.spec.ts: CDP Network.emulateNetworkConditions (Fast 3G) +
  PerformanceObserver LCP on /overview kpi-row + route-transition timing; numbers
  reported as a relative regression signal (dev-server, not a prod SLA), not gated

God-component splits (pure refactors, behavior-preserving):
- MonitoringDashboard 686 → 239 lines: extracted components/monitoring/* (StatsBar,
  OverviewTab, CaseStatsTab, DistrictStatsTab) + useMonitoringData hook; URL-granularity
  source-of-truth + drilldown reconcile kept in the orchestrator (no desync regression)
- AlertsDashboard 816 → 301 lines: extracted components/alerts/* (Toolbar, List,
  RiskPanel, MapPanel, DetailModal, …); role/privacy/grid-hide logic kept in the
  orchestrator — doctor-view privacy invariant (zero patient-point) still holds

Gates: tsc 0 · vitest 75 · functional e2e 37/37 (incl doctor-view privacy +
granularity + responsive) · build ok · perf project runs + reports

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:00:15 +08:00
8ad7e086bb feat: Phase 3 — 视角/perspective presets + URL-driven granularity + doctor privacy
Phase 3 of the UX modernization. Frontend-only role view-presets (D2 — no
backend, no JWT role claim, honestly labeled 视角 not 权限). Three conflict-free lanes.

Role infrastructure (worker-session):
- sessionStore with single swappable getRoleSource() seam (localStorage today,
  one-line swap to /api/auth/me for future RBAC); Role = official|community|doctor|admin
- 视角 switcher in TopNav (replaces hardcoded "admin"); on change persists role +
  navigates to that perspective's default landing
- roleViews.ts: ROLE_LABELS + roleDefaultPath (official→/overview?granularity=district,
  community→/monitoring?granularity=street, doctor→/alerts?view=cluster, admin→/monitoring)
- RoleRedirect index route → current role's default; * fallback unchanged

URL-driven granularity (worker-monitoring):
- granularity (city|district|street) query param is the source of truth; drilldownStore
  DERIVES from it via a one-way effect; deep-linkable + reload-safe
- reconciled the imperative desync — DistrictBreakdown no longer calls
  useDrilldownStore.getState(); MonitoringDashboard owns useSearchParams, writes URL
- granularity-control Segmented (全市/区域/街道); district-rollup testid

Role-aware alerts + privacy (worker-alerts):
- doctor/cluster view: individual alert markers hard-locked off (effective flag is
  single source of truth; toggle not rendered) → aggregated density only; DiseaseFilter
  mounted; privacy invariant testable via hidden patient-point DOM mirror (count=0)
- 官员: 100m grid hidden (grid-layer-wrapper unrendered); admin/community unchanged

Gates: tsc 0 · vitest 75 · e2e 30/30 (incl 10 new P3 tests) · build ok

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:46:38 +08:00
9a94156acc 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>
2026-06-21 20:24:26 +08:00
3db3b12480 feat(frontend): Phase 1 UX foundation — react-router v6 + responsive AppShell
Atomic foundation for the consensus-approved UX modernization (makes the
platform URL-addressable, refresh-safe, and mobile-usable for hospital demos).

- Migrate hand-rolled useState page switching → react-router v6 (routes.tsx,
  thin App.tsx auth gate, NavLink SideNav, lazy+Suspense per route)
- Add responsive AppShell: persistent rail (lg:) ⇄ off-canvas drawer + hamburger
  (<lg); kills hardcoded ml-[200px]; usable at 375px
- Add ui primitive kit (Skeleton/LoadingState/EmptyState/Card/Panel/Segmented)
- Sweep all raw "加载中..." text loaders → skeleton primitives (G4)
- Centralize test ids (utils/testids.ts); rewrite e2e for URL nav (17/17 pass:
  deep-link, refresh-preserves-page, back, 375px drawer/no-scroll)
- Harden DemographicAnalysis against API shape mismatch (defensive normalize)
- gitignore playwright-report/ and test-results/

Gates: tsc --noEmit 0 · pnpm build ok · e2e 17/17 · grep 加载中 zero outside ui/

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:12:13 +08:00
85 changed files with 7054 additions and 1867 deletions

2
.gitignore vendored
View File

@@ -14,6 +14,8 @@ cache/
logs/
mlruns/
.playwright-mcp/
frontend/playwright-report/
frontend/test-results/
*Zone.Identifier
# Transcription processing intermediates
Outputs/transcript/chunks/

View File

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

View File

@@ -13,7 +13,7 @@ from logging_config import setup_logging
from middleware.request_logger import RequestLoggerMiddleware
from auth.router import router as auth_router
from auth.service import seed_default_admin
from routers import risk, alerts, analysis, insights, reports, cases, geocoded, grid, chat, environment
from routers import risk, alerts, analysis, insights, reports, cases, geocoded, grid, chat, environment, statistics
setup_logging()
@@ -59,6 +59,7 @@ app.include_router(geocoded.router)
app.include_router(grid.router)
app.include_router(chat.router)
app.include_router(environment.router)
app.include_router(statistics.router)
@app.get("/")

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,566 @@
"""
统计分析 API 路由 (prefix /api/stats)
为前端统计仪表盘提供聚合后的临床、症状、发病率、环境相关性和时序数据。
所有数据从 processed/*.parquet 计算得出(文件型后端,无数据库)。
设计原则:
- 模块级缓存载入的 parquet与其他路由一致
- 仅返回聚合结果,绝不直接 dump 原始行,保持 payload 小。
- 每个端点用 try/except 包裹,失败时返回合法的空结构(绝不让 UI 收到 500
- pandas 计算放入线程池 (asyncio.to_thread),避免阻塞事件循环。
"""
import asyncio
import glob
import logging
import threading
from pathlib import Path
from typing import Optional, cast
import numpy as np
import pandas as pd
from fastapi import APIRouter, Query
from pydantic import BaseModel
from data.case_loader import (
get_inpatient_data,
get_outpatient_data,
get_combined_data,
load_cases_by_district_daily,
normalize_district,
)
logger = logging.getLogger("cbpoa.statistics")
router = APIRouter(prefix="/api/stats", tags=["statistics"])
PROJECT_ROOT = Path(__file__).parent.parent.parent
PROCESSED_DIR = PROJECT_ROOT / "processed"
# ============== Module-level caches ==============
_cache: dict[str, object] = {}
_cache_lock = threading.RLock()
# 7 个污染物(与 feature snapshot 列名一致)
POLLUTANTS = ["AQI", "PM25", "PM10", "SO2", "NO2", "O3", "CO"]
# 主诉症状关键词(固定列表,子串匹配)。注意顺序:更具体的在前避免被宽泛词吞掉,
# 但因为是独立子串计数,顺序不影响结果,仅为可读性分组。
SYMPTOM_KEYWORDS = [
"发热", "咳嗽", "", "喘息", "", "流涕", "鼻塞", "咽痛", "咽喉",
"", "气促", "呼吸困难", "肺炎", "复诊", "随诊", "复查",
"腹泻", "呕吐", "头痛", "乏力", "胸闷", "鼻涕", "发烧", "感冒",
]
REVISIT_KEYWORDS = ["复诊", "随诊", "复查"]
def _district_population() -> pd.Series:
"""各区人口population_density 求和,按 grid_district_mapping 归属)。
返回 index 为规范化区名(13)、值为人口的 Series。结果缓存。
"""
cached = _cache.get("district_population")
if cached is not None:
return cast(pd.Series, cached)
with _cache_lock:
cached = _cache.get("district_population")
if cached is not None:
return cast(pd.Series, cached)
mapping = pd.read_parquet(PROCESSED_DIR / "grid_district_mapping.parquet")
grid = pd.read_parquet(
PROCESSED_DIR / "grid_100m_with_dem_pop.parquet",
columns=["grid_id", "population_density"],
)
joined = mapping.merge(grid, on="grid_id", how="inner")
joined = joined.dropna(subset=["district_name"])
joined["district_name"] = joined["district_name"].map(normalize_district)
pop = joined.groupby("district_name")["population_density"].sum()
_cache["district_population"] = pop
return pop
def _feature_snapshots() -> pd.DataFrame:
"""合并所有可用的 features_*.parquet 快照(缓存)。
用于污染物 vs 病例的相关性分析。每个快照按格点给出污染物 + 病例计数 + 区。
"""
cached = _cache.get("features")
if cached is not None:
return cast(pd.DataFrame, cached)
with _cache_lock:
cached = _cache.get("features")
if cached is not None:
return cast(pd.DataFrame, cached)
paths = sorted(glob.glob(str(PROCESSED_DIR / "features_*.parquet")))
if not paths:
df = pd.DataFrame(
columns=POLLUTANTS + ["outpatient_count", "inpatient_count", "total_cases", "district"]
)
else:
frames = [pd.read_parquet(p) for p in paths]
df = pd.concat(frames, ignore_index=True)
_cache["features"] = df
return df
# ============== Response Models ==============
class KeyValueCount(BaseModel):
bin_label: str
count: int
class InpatientKpis(BaseModel):
total_admissions: int
median_los_days: float
cure_rate: float
emergency_admit_ratio: float
class LosByDisease(BaseModel):
diagnosis: str
p25: float
median: float
p75: float
n: int
class LabelCount(BaseModel):
outcome: Optional[str] = None
route: Optional[str] = None
count: int
class OutcomeCount(BaseModel):
outcome: str
count: int
class RouteCount(BaseModel):
route: str
count: int
class BmiByAge(BaseModel):
age_band: str
p25: float
median: float
p75: float
n: int
class InpatientClinicalResponse(BaseModel):
kpis: InpatientKpis
los_histogram: list[KeyValueCount]
los_by_disease: list[LosByDisease]
outcome_counts: list[OutcomeCount]
admission_route_counts: list[RouteCount]
bmi_by_age_band: list[BmiByAge]
class SymptomItem(BaseModel):
keyword: str
count: int
class SymptomsResponse(BaseModel):
symptoms: list[SymptomItem]
revisit_ratio: float
class IncidenceItem(BaseModel):
district: str
total_cases: int
population: float
rate_per_10k: float
class IncidenceResponse(BaseModel):
districts: list[IncidenceItem]
class CorrItem(BaseModel):
pollutant: str
corr_with_cases: float
class ScatterPoint(BaseModel):
pm25: float
aqi: float
cases: float
class PairwiseCorr(BaseModel):
a: str
b: str
corr: float
class EnvCorrelationResponse(BaseModel):
correlation_matrix: list[CorrItem]
scatter: list[ScatterPoint]
pollutant_pairwise: list[PairwiseCorr]
class WeekdayPoint(BaseModel):
weekday: str
outpatient: int
inpatient: int
total: int
class MonthYearPoint(BaseModel):
year: int
month: int
total: int
class YoYPoint(BaseModel):
period: str
current: int
previous: int
growth_pct: float
class TemporalResponse(BaseModel):
weekday: list[WeekdayPoint]
month_year: list[MonthYearPoint]
yoy: list[YoYPoint]
# ============== Helpers ==============
def _empty_inpatient_clinical() -> InpatientClinicalResponse:
return InpatientClinicalResponse(
kpis=InpatientKpis(
total_admissions=0, median_los_days=0.0,
cure_rate=0.0, emergency_admit_ratio=0.0,
),
los_histogram=[], los_by_disease=[], outcome_counts=[],
admission_route_counts=[], bmi_by_age_band=[],
)
def _safe_float(v) -> float:
try:
f = float(v)
if np.isnan(f) or np.isinf(f):
return 0.0
return round(f, 4)
except (TypeError, ValueError):
return 0.0
# ============== Endpoint 1: inpatient clinical ==============
def _compute_inpatient_clinical() -> InpatientClinicalResponse:
df = get_inpatient_data().copy()
if df.empty:
return _empty_inpatient_clinical()
# LOS = (出院日期 - 入院日期).days, valid 0-60
in_date = pd.to_datetime(df["入院日期"], errors="coerce")
out_date = pd.to_datetime(df["出院日期"], errors="coerce")
df["los"] = (out_date - in_date).dt.days
df_los = df[(df["los"] >= 0) & (df["los"] <= 60)]
total = len(df)
median_los = float(df_los["los"].median()) if len(df_los) else 0.0
outcome = df["出院情况"].fillna("未知")
cure_n = int(outcome.isin(["治愈", "好转"]).sum())
cure_rate = cure_n / total if total else 0.0
route = df["入院途径"].fillna("未知")
emerg_n = int((route == "急诊").sum())
emerg_ratio = emerg_n / total if total else 0.0
kpis = InpatientKpis(
total_admissions=total,
median_los_days=round(median_los, 2),
cure_rate=round(cure_rate, 4),
emergency_admit_ratio=round(emerg_ratio, 4),
)
# LOS histogram bins: 0,1,2,3,4,5,6,7,8-14,15+
los_histogram: list[KeyValueCount] = []
los_vals = df_los["los"]
for b in range(0, 8):
los_histogram.append(KeyValueCount(bin_label=str(b), count=int((los_vals == b).sum())))
los_histogram.append(KeyValueCount(bin_label="8-14", count=int(((los_vals >= 8) & (los_vals <= 14)).sum())))
los_histogram.append(KeyValueCount(bin_label="15+", count=int((los_vals >= 15).sum())))
# LOS by disease (top 8 diagnoses by n)
los_by_disease: list[LosByDisease] = []
if len(df_los):
top_diag = df_los["诊断名称"].value_counts().head(8).index.tolist()
for d in top_diag:
grp = df_los[df_los["诊断名称"] == d]["los"]
los_by_disease.append(LosByDisease(
diagnosis=str(d),
p25=round(float(grp.quantile(0.25)), 2),
median=round(float(grp.median()), 2),
p75=round(float(grp.quantile(0.75)), 2),
n=int(len(grp)),
))
# outcome counts
outcome_counts = [
OutcomeCount(outcome=str(k), count=int(v))
for k, v in outcome.value_counts().items()
]
# admission route counts
admission_route_counts = [
RouteCount(route=str(k), count=int(v))
for k, v in route.value_counts().items()
]
# BMI by age band. BMI = 体重kg / (身高m)^2; plausible 8-40.
bmi_by_age_band: list[BmiByAge] = []
h = pd.to_numeric(df["身高"], errors="coerce") # cm
w = pd.to_numeric(df["体重"], errors="coerce") # kg
age = pd.to_numeric(df["年龄"], errors="coerce")
bmi = w / ((h / 100.0) ** 2)
bmi_df = pd.DataFrame({"age": age, "bmi": bmi})
bmi_df = bmi_df[(bmi_df["bmi"] >= 8) & (bmi_df["bmi"] <= 40) & bmi_df["age"].notna()]
age_bands = [(0, 3, "0-2"), (3, 6, "3-5"), (6, 9, "6-8"),
(9, 12, "9-11"), (12, 15, "12-14"), (15, 19, "15-18")]
for lo, hi, label in age_bands:
grp = bmi_df[(bmi_df["age"] >= lo) & (bmi_df["age"] < hi)]["bmi"]
if len(grp) == 0:
continue
bmi_by_age_band.append(BmiByAge(
age_band=label,
p25=round(float(grp.quantile(0.25)), 2),
median=round(float(grp.median()), 2),
p75=round(float(grp.quantile(0.75)), 2),
n=int(len(grp)),
))
return InpatientClinicalResponse(
kpis=kpis,
los_histogram=los_histogram,
los_by_disease=los_by_disease,
outcome_counts=outcome_counts,
admission_route_counts=admission_route_counts,
bmi_by_age_band=bmi_by_age_band,
)
@router.get("/inpatient-clinical", response_model=InpatientClinicalResponse, summary="住院临床统计")
async def inpatient_clinical():
"""住院临床概览KPI、住院天数(LOS)分布、费用分布、转归、入院途径、BMI 分布。"""
try:
return await asyncio.to_thread(_compute_inpatient_clinical)
except Exception:
logger.exception("inpatient-clinical failed")
return _empty_inpatient_clinical()
# ============== Endpoint 2: symptoms ==============
def _compute_symptoms(top: int) -> SymptomsResponse:
df = get_outpatient_data()
if df.empty or "主诉" not in df.columns:
return SymptomsResponse(symptoms=[], revisit_ratio=0.0)
chief = df["主诉"].dropna().astype(str)
total = len(chief)
if total == 0:
return SymptomsResponse(symptoms=[], revisit_ratio=0.0)
counts: list[SymptomItem] = []
for kw in SYMPTOM_KEYWORDS:
c = int(chief.str.contains(kw, regex=False).sum())
if c > 0:
counts.append(SymptomItem(keyword=kw, count=c))
counts.sort(key=lambda x: x.count, reverse=True)
counts = counts[:top]
revisit_mask = chief.str.contains("|".join(REVISIT_KEYWORDS), regex=True)
revisit_ratio = float(revisit_mask.sum()) / total if total else 0.0
return SymptomsResponse(symptoms=counts, revisit_ratio=round(revisit_ratio, 4))
@router.get("/symptoms", response_model=SymptomsResponse, summary="门诊主诉症状词频")
async def symptoms(top: int = Query(20, ge=1, le=50, description="返回前 N 个症状词")):
"""从门诊主诉中提取固定症状关键词的出现频次,并计算复诊比例。"""
try:
return await asyncio.to_thread(_compute_symptoms, top)
except Exception:
logger.exception("symptoms failed")
return SymptomsResponse(symptoms=[], revisit_ratio=0.0)
# ============== Endpoint 3: incidence rate ==============
def _compute_incidence() -> IncidenceResponse:
daily = load_cases_by_district_daily()
if daily.empty:
return IncidenceResponse(districts=[])
case_totals = daily.groupby("district")["total_cases"].sum()
pop = _district_population()
items: list[IncidenceItem] = []
for district in case_totals.index:
total_cases = int(case_totals.get(district, 0))
population = float(pop.get(district, 0.0))
rate = (total_cases / population * 10000) if population > 0 else 0.0
items.append(IncidenceItem(
district=str(district),
total_cases=total_cases,
population=round(population, 1),
rate_per_10k=round(rate, 2),
))
items.sort(key=lambda x: x.rate_per_10k, reverse=True)
return IncidenceResponse(districts=items)
@router.get("/incidence-rate", response_model=IncidenceResponse, summary="各区发病率")
async def incidence_rate():
"""各区病例总数 / 区人口 * 10000得到每万人发病率13 区)。"""
try:
return await asyncio.to_thread(_compute_incidence)
except Exception:
logger.exception("incidence-rate failed")
return IncidenceResponse(districts=[])
# ============== Endpoint 4: env correlation ==============
def _compute_env_correlation() -> EnvCorrelationResponse:
df = _feature_snapshots()
if df.empty or "total_cases" not in df.columns:
return EnvCorrelationResponse(correlation_matrix=[], scatter=[], pollutant_pairwise=[])
# 污染物 vs 病例 的 Pearson 相关(按格点,汇集所有快照)
correlation_matrix: list[CorrItem] = []
cases = pd.to_numeric(df["total_cases"], errors="coerce")
for p in POLLUTANTS:
if p not in df.columns:
continue
series = pd.to_numeric(df[p], errors="coerce")
valid = series.notna() & cases.notna()
if valid.sum() < 2 or series[valid].std() == 0 or cases[valid].std() == 0:
corr = 0.0
else:
corr = float(series[valid].corr(cases[valid]))
correlation_matrix.append(CorrItem(pollutant=p, corr_with_cases=_safe_float(corr)))
# scatter: 采样 cases>0 的格点up to 500
scatter: list[ScatterPoint] = []
has_cols = all(c in df.columns for c in ["PM25", "AQI", "total_cases"])
if has_cols:
sdf = df[["PM25", "AQI", "total_cases"]].copy()
sdf = sdf[pd.to_numeric(sdf["total_cases"], errors="coerce") > 0].dropna()
if len(sdf) > 500:
sdf = sdf.sample(n=500, random_state=42)
for _, r in sdf.iterrows():
scatter.append(ScatterPoint(
pm25=_safe_float(r["PM25"]),
aqi=_safe_float(r["AQI"]),
cases=_safe_float(r["total_cases"]),
))
# pollutant pairwise (upper triangle)
pollutant_pairwise: list[PairwiseCorr] = []
present = [p for p in POLLUTANTS if p in df.columns]
pol_df = df[present].apply(pd.to_numeric, errors="coerce")
corr_mat = pol_df.corr()
for i, a in enumerate(present):
for b in present[i + 1:]:
try:
v = corr_mat.loc[a, b]
except KeyError:
v = 0.0
pollutant_pairwise.append(PairwiseCorr(a=a, b=b, corr=_safe_float(v)))
return EnvCorrelationResponse(
correlation_matrix=correlation_matrix,
scatter=scatter,
pollutant_pairwise=pollutant_pairwise,
)
@router.get("/env-correlation", response_model=EnvCorrelationResponse, summary="环境-病例相关性")
async def env_correlation():
"""污染物与病例的相关矩阵、PM2.5/AQI 散点、污染物两两相关(热力图)。"""
try:
return await asyncio.to_thread(_compute_env_correlation)
except Exception:
logger.exception("env-correlation failed")
return EnvCorrelationResponse(correlation_matrix=[], scatter=[], pollutant_pairwise=[])
# ============== Endpoint 5: temporal ==============
_WEEKDAY_LABELS = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
def _compute_temporal() -> TemporalResponse:
df = get_combined_data().copy()
if df.empty:
return TemporalResponse(weekday=[], month_year=[], yoy=[])
df["date"] = pd.to_datetime(df["date"], errors="coerce")
df = df[df["date"].notna()]
if df.empty:
return TemporalResponse(weekday=[], month_year=[], yoy=[])
# weekday (0=周一..6=周日)
df["wd"] = df["date"].dt.weekday
weekday: list[WeekdayPoint] = []
for wd in range(7):
sub = df[df["wd"] == wd]
out = int((sub["type"] == "outpatient").sum())
inp = int((sub["type"] == "inpatient").sum())
weekday.append(WeekdayPoint(
weekday=_WEEKDAY_LABELS[wd], outpatient=out, inpatient=inp, total=out + inp,
))
# month_year (seasonality grid)
df["year"] = df["date"].dt.year
df["month"] = df["date"].dt.month
my = df.groupby(["year", "month"]).size()
month_year = [
MonthYearPoint(year=int(y), month=int(m), total=int(c))
for (y, m), c in my.items()
]
month_year.sort(key=lambda x: (x.year, x.month))
# yoy: monthly current vs same-month-prior-year (only if multiple years exist)
yoy: list[YoYPoint] = []
years = sorted(df["year"].unique().tolist())
if len(years) > 1:
monthly_totals = {(int(y), int(m)): int(c) for (y, m), c in my.items()}
for (y, m), cur in sorted(monthly_totals.items()):
prev = monthly_totals.get((y - 1, m))
if prev is None:
continue
growth = ((cur - prev) / prev * 100) if prev else 0.0
yoy.append(YoYPoint(
period=f"{y}-{m:02d}",
current=cur,
previous=prev,
growth_pct=round(growth, 2),
))
return TemporalResponse(weekday=weekday, month_year=month_year, yoy=yoy)
@router.get("/temporal", response_model=TemporalResponse, summary="时序统计")
async def temporal():
"""按星期、年-月(季节性网格)聚合,以及同比(YoY)增长(若有多年数据)。"""
try:
return await asyncio.to_thread(_compute_temporal)
except Exception:
logger.exception("temporal failed")
return TemporalResponse(weekday=[], month_year=[], yoy=[])

View File

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

View File

@@ -0,0 +1,90 @@
/**
* 住院临床分析页(/analysis/clinical验收测试。
* 与 user-flows.spec.ts 一致的鉴权策略addInitScript 注入 cbpoa_token
* 用 page.route 拦截 /api/**,对 inpatient-clinical 返回合法小样本,其余返回 {}。
*/
import { test, expect, Page } from '@playwright/test';
import { TESTIDS } from '../src/utils/testids';
const CLINICAL_FIXTURE = {
kpis: {
total_admissions: 5822,
median_los_days: 4,
cure_rate: 0.991,
emergency_admit_ratio: 0.47,
},
los_histogram: [
{ bin_label: '1-2', count: 1200 },
{ bin_label: '3-4', count: 2100 },
{ bin_label: '5-7', count: 1500 },
],
los_by_disease: [
{ diagnosis: '肺炎', p25: 3, median: 5, p75: 7, n: 800 },
{ diagnosis: '支气管炎', p25: 2, median: 4, p75: 6, n: 600 },
],
outcome_counts: [
{ outcome: '治愈', count: 3474 },
{ outcome: '好转', count: 2298 },
{ outcome: '其他', count: 35 },
{ outcome: '未愈', count: 12 },
{ outcome: '死亡', count: 3 },
],
admission_route_counts: [
{ route: '急诊', count: 2700 },
{ route: '门诊', count: 3122 },
],
bmi_by_age_band: [
{ age_band: '0-2', p25: 14, median: 16, p75: 18, n: 400 },
{ age_band: '3-6', p25: 15, median: 17, p75: 19, n: 500 },
],
};
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();
if (url.includes('/stats/inpatient-clinical')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(CLINICAL_FIXTURE),
});
return;
}
// 其余接口返回空对象,本页不依赖。
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({}),
});
});
}
test.describe('住院临床分析页', () => {
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('deep-link /analysis/clinical mounts page-clinical + clinical-kpis', async ({ page }) => {
await page.goto('/analysis/clinical');
await expect(page.locator(`[data-testid="${TESTIDS.pageClinical}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.clinicalKpis}"]`)).toBeVisible();
});
test('no horizontal scroll at 375px', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto('/analysis/clinical');
await expect(page.locator(`[data-testid="${TESTIDS.pageClinical}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.clinicalKpis}"]`)).toBeVisible();
const noHorizontalScroll = await page.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth
);
expect(noHorizontalScroll).toBe(true);
});
});

View File

@@ -0,0 +1,143 @@
/**
* Phase-3 acceptance tests: role-aware 预警 (alerts) view.
*
* Two view-preset invariants (D2 — frontend presets, NOT access control):
*
* 1. PRIVACY INVARIANT (doctor / ?view=cluster): the doctor sees ONLY the aggregated
* density raster + the disease filter — ZERO individual patient/case point markers.
* The page mirrors every individual marker it would actually render into a hidden
* data-testid="patient-point" element (the live Leaflet CircleMarkers are canvas/SVG
* objects with no testid and can't be counted directly). In cluster mode the page
* forces showAlertMarkers=false, so that mirror set is empty → patient-point count 0.
*
* 2. 官员 (official) GRID-HIDE: the 100m 网格 is meaningless for leadership, so the grid
* toggle wrapper (data-testid="grid-layer-wrapper") is not rendered at all.
*
* Auth + API mocking mirror e2e/user-flows.spec.ts so the suite runs hermetically
* (no live :8000 backend). Role is seeded via localStorage['cbpoa_role'].
*/
import { test, expect, Page } from '@playwright/test';
import { TESTIDS } from '../src/utils/testids';
/**
* Seed auth token (+ optional role) and mock all /api/** calls before page load.
* Response shapes copied from user-flows.spec.ts.
*/
async function seedAuthAndMockApi(page: Page, role?: string) {
await page.addInitScript((r) => {
localStorage.setItem('cbpoa_token', 'e2e-test-token');
if (r) localStorage.setItem('cbpoa_role', r);
}, role ?? '');
await page.route('/api/**', (route) => {
const url = route.request().url();
if (url.includes('/alerts')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ alerts: [], total: 0 }),
});
return;
}
if (url.includes('/history/aggregated')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ aggregations: [], total_records: 0 }),
});
return;
}
if (url.includes('/grids')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
});
return;
}
if (url.includes('/cases/demographics')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
age_distribution: [],
gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
age_diagnosis_matrix: [],
}),
});
return;
}
if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
return;
}
if (url.includes('/diagnoses') || url.includes('/diagnosis-list')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
return;
}
if (url.includes('/cases/districts') || url.includes('/districts')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
return;
}
if (url.includes('/cases/trend') || url.includes('/cases')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], total: 0 }),
});
return;
}
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], items: [], total: 0 }),
});
});
}
test.describe('role-aware 预警 view (Phase 3)', () => {
test.use({ viewport: { width: 1280, height: 800 } });
test('医生 /alerts?view=cluster: cluster-view mounts, disease filter present, ZERO patient points', async ({
page,
}) => {
await seedAuthAndMockApi(page, 'doctor');
await page.goto('/alerts?view=cluster');
// Page + the aggregated density (cluster) map both mount.
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.clusterView}"]`)).toBeVisible();
// The disease filter is the doctor's core tool — it must be on the page.
await expect(page.getByText('按病种筛选')).toBeVisible();
// PRIVACY INVARIANT: not a single individual patient/case point may be rendered.
// Asserted at the data level (the mirrored DOM set), independent of Leaflet internals.
await expect(page.getByTestId(TESTIDS.patientPoint)).toHaveCount(0);
// The 预警标记 toggle (which would turn individual markers on) must be absent,
// so there is no way for the doctor to opt out of the privacy invariant.
await expect(page.getByRole('button', { name: '预警标记' })).toHaveCount(0);
});
test('官员 /alerts: 100m grid hidden — grid-layer-wrapper not rendered', async ({ page }) => {
await seedAuthAndMockApi(page, 'official');
await page.goto('/alerts');
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
// The grid toggle wrapper must be entirely absent for leadership.
await expect(page.getByTestId(TESTIDS.gridLayerWrapper)).toHaveCount(0);
});
test('admin /alerts: full behavior — grid toggle present, no forced cluster view', async ({ page }) => {
await seedAuthAndMockApi(page, 'admin');
await page.goto('/alerts');
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
// Admin keeps the grid toggle and is NOT forced into cluster view.
await expect(page.getByTestId(TESTIDS.gridLayerWrapper)).toHaveCount(1);
await expect(page.locator(`[data-testid="${TESTIDS.clusterView}"]`)).toHaveCount(0);
});
});

View File

@@ -0,0 +1,78 @@
import { test, expect, Page } from '@playwright/test';
import { TESTIDS } from '../src/utils/testids';
// 与 user-flows.spec.ts 一致的鉴权策略:注入 token 绕过登录门,并 mock /api/**
// 让用例脱离活的后端 hermetic 运行。
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();
if (url.includes('/history/aggregated')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ aggregations: [], total_records: 0 }) });
return;
}
if (url.includes('/grids')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ type: 'FeatureCollection', features: [] }) });
return;
}
if (url.includes('/streets')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ streets: [] }) });
return;
}
if (url.includes('/cases/districts') || url.includes('/districts')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
return;
}
if (url.includes('/cases/trend') || url.includes('/cases')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [], trend: [], total: 0 }) });
return;
}
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [], items: [], total: 0 }) });
});
}
// 粒度granularity以 URL query 参数为真相来源source of truth
// 监测页通过 useSearchParams 读取它drilldownStore 单向派生。
test.describe('monitoring granularity URL source-of-truth', () => {
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('deep-link granularity=street mounts page and reflects street in control', async ({ page }) => {
await page.goto('/monitoring?granularity=street');
await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible();
const control = page.getByTestId(TESTIDS.granularityControl);
await expect(control).toBeVisible();
// 街道分段为激活态Segmented 给激活按钮加 bg-primary
const streetBtn = page.getByTestId(`${TESTIDS.granularityControl}-street`);
await expect(streetBtn).toHaveClass(/bg-primary/);
});
test('clicking a granularity control updates the URL granularity param', async ({ page }) => {
await page.goto('/monitoring?granularity=street');
await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible();
// 切到「区域」应把 URL 写为 granularity=district。
await page.getByTestId(`${TESTIDS.granularityControl}-district`).click();
await expect(page).toHaveURL(/granularity=district/);
// 切到「全市」应把 URL 写为 granularity=city。
await page.getByTestId(`${TESTIDS.granularityControl}-city`).click();
await expect(page).toHaveURL(/granularity=city/);
});
test('deep-link granularity=district survives a reload', async ({ page }) => {
await page.goto('/monitoring?granularity=district');
await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible();
await expect(page).toHaveURL(/granularity=district/);
await page.reload();
await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible();
await expect(page).toHaveURL(/granularity=district/);
await expect(page.getByTestId(`${TESTIDS.granularityControl}-district`)).toHaveClass(/bg-primary/);
});
});

View File

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

232
frontend/e2e/perf.spec.ts Normal file
View File

@@ -0,0 +1,232 @@
/**
* Performance-measurement harness for the leadership 大屏 (/overview).
*
* Runs ONLY in the dedicated `perf` Playwright project (see playwright.config.ts
* testMatch) so emulated network throttling never pollutes the functional suite.
*
* What it measures:
* 1. LCP (Largest Contentful Paint) of /overview under emulated Fast 3G.
* 2. Client-side route-transition time from /overview → /monitoring.
*
* Throttling model: /api/** is mocked to resolve INSTANTLY (see seedAuthAndMockApi),
* so the backend contributes ~0ms. That is deliberate — it isolates the realistic
* SPA cost on a slow link: the *static asset graph* (app JS/CSS bundle + the
* /wuhan_districts.geojson choropleth payload, which is served by the real dev
* server, not mocked). Fast 3G therefore shapes exactly the bytes a cold-cache
* leadership client must pull before first paint, which is what LCP should reflect.
*
* Assertion policy (per the UX-modernization plan): LCP and route-transition
* targets (2500ms LCP / 800ms transition) are REPORTED, not hard CI gates — a
* miss under throttle on a loaded CI box must not fail the build. We therefore
* record each number against its target as a test annotation + console line and
* let the test PASS regardless of the target. (Note: `expect.soft` would still
* mark the test failed at teardown, so it's the wrong tool for a report-only
* target — annotations are.) Hard assertions guard ONLY that the measurement
* machinery worked: LCP was observed (> 0) and the nav actually landed.
*
* Caveat on absolute values: this runs against the Vite DEV server (unbundled,
* unminified ESM with per-module requests). Dev LCP under Fast 3G is therefore
* far higher than a production build would be — these numbers are a relative
* regression signal for this harness, not a production SLA.
*/
import { test, expect, Page } from '@playwright/test';
import { TESTIDS } from '../src/utils/testids';
// Reported (soft) targets — see file header.
const LCP_TARGET_MS = 2500;
const ROUTE_TRANSITION_TARGET_MS = 800;
// Emulated "Fast 3G" network conditions (Chrome DevTools preset).
const FAST_3G = {
offline: false,
downloadThroughput: (1.6 * 1024 * 1024) / 8, // 1.6 Mbps
uploadThroughput: (750 * 1024) / 8, // 750 Kbps
latency: 150, // ms RTT
};
/**
* Seed auth + mock /api/** so the page renders hermetically. Mirrors the helper
* in user-flows.spec.ts, with one deliberate difference: /wuhan_districts.geojson
* is a real static asset and is NOT under /api, so page.route('/api/**') already
* lets it pass through to the dev server (the realistic, throttled payload).
*/
async function seedAuthAndMockApi(page: Page) {
await page.addInitScript(() => {
localStorage.setItem('cbpoa_token', 'e2e-test-token');
});
// Mock backend responses instantly so Fast-3G shapes only the static asset
// graph (JS/CSS + geojson), not API latency.
await page.route('/api/**', (route) => {
const url = route.request().url();
if (url.includes('/alerts')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ alerts: [], total: 0 }),
});
return;
}
if (url.includes('/history/aggregated')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ aggregations: [], total_records: 0 }),
});
return;
}
if (url.includes('/grids')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
});
return;
}
if (url.includes('/cases/demographics')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
age_distribution: [],
gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
age_diagnosis_matrix: [],
}),
});
return;
}
if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
return;
}
if (url.includes('/cases/districts') || url.includes('/districts')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
return;
}
if (url.includes('/cases/trend') || url.includes('/cases')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], total: 0 }),
});
return;
}
// Default fallback — safe empty shape.
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], items: [], total: 0 }),
});
});
}
test.describe('Performance — /overview under emulated Fast 3G', () => {
test('LCP and route-transition are measured and reported', async ({ page }, testInfo) => {
// Fast-3G throttling makes the cold asset-graph download slow; the default 30s
// test budget can be eaten by the initial /overview load alone. Give the whole
// measurement flow generous headroom — this bounds the harness, not the metrics.
test.setTimeout(120_000);
await seedAuthAndMockApi(page);
// Install the LCP observer BEFORE any navigation so it captures the very
// first paint. buffered:true also replays entries emitted before observe().
await page.addInitScript(() => {
(window as unknown as { __lcp: number }).__lcp = 0;
new PerformanceObserver((list) => {
const entries = list.getEntries();
(window as unknown as { __lcp: number }).__lcp = entries[entries.length - 1].startTime;
}).observe({ type: 'largest-contentful-paint', buffered: true });
});
// Apply Fast 3G throttling via CDP before navigating.
const client = await page.context().newCDPSession(page);
await client.send('Network.enable');
await client.send('Network.emulateNetworkConditions', FAST_3G);
// --- LCP measurement -----------------------------------------------------
// Generous wait: under Fast 3G the throttled JS bundle download dominates, so
// first meaningful paint legitimately exceeds the 5s default expect timeout.
// The LCP NUMBER we read is the real measured value — this timeout only bounds
// how long we'll wait for the asset graph to arrive before failing the harness.
await page.goto('/overview');
await expect(page.locator(`[data-testid="${TESTIDS.kpiRow}"]`)).toBeVisible({ timeout: 30_000 });
// LCP finalizes on the last contentful paint; give the observer a beat to flush
// the entry for the kpi-row we just saw before reading it.
await page.waitForTimeout(200);
const lcp = await page.evaluate(() => (window as unknown as { __lcp: number }).__lcp);
// --- Route-transition measurement ---------------------------------------
// Expand the 监测 module if its NavLink is collapsed, then click it.
const railSel = `[data-testid="${TESTIDS.sidebarRail}"]`;
const navMonitoring = page.locator(`${railSel} [data-testid="${TESTIDS.navMonitoring}"]`);
if (!(await navMonitoring.isVisible())) {
await page.locator(`${railSel} button`).filter({ hasText: '监测' }).first().click();
}
await expect(navMonitoring).toBeVisible();
const t0 = await page.evaluate(() => performance.now());
await navMonitoring.click();
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible({
timeout: 30_000,
});
const t1 = await page.evaluate(() => performance.now());
const routeTransitionMs = t1 - t0;
// --- Report --------------------------------------------------------------
// eslint-disable-next-line no-console
console.log(`[perf] /overview LCP (Fast 3G): ${lcp.toFixed(0)} ms (target < ${LCP_TARGET_MS})`);
// eslint-disable-next-line no-console
console.log(
`[perf] /overview → /monitoring route transition: ${routeTransitionMs.toFixed(0)} ms (target < ${ROUTE_TRANSITION_TARGET_MS})`
);
await testInfo.attach('perf-metrics', {
contentType: 'application/json',
body: JSON.stringify(
{
lcpMs: Math.round(lcp),
lcpTargetMs: LCP_TARGET_MS,
routeTransitionMs: Math.round(routeTransitionMs),
routeTransitionTargetMs: ROUTE_TRANSITION_TARGET_MS,
network: 'Fast 3G (emulated via CDP)',
},
null,
2
),
});
// --- Reported targets (NOT gates) ---------------------------------------
// Record each metric vs. its target as a passing/over annotation. A miss is
// visible in the report and console but does NOT fail the test.
const lcpVerdict = lcp < LCP_TARGET_MS ? 'within' : 'over';
const routeVerdict = routeTransitionMs < ROUTE_TRANSITION_TARGET_MS ? 'within' : 'over';
testInfo.annotations.push({
type: 'perf-lcp',
description: `${Math.round(lcp)}ms (target ${LCP_TARGET_MS}ms — ${lcpVerdict})`,
});
testInfo.annotations.push({
type: 'perf-route-transition',
description: `${Math.round(routeTransitionMs)}ms (target ${ROUTE_TRANSITION_TARGET_MS}ms — ${routeVerdict})`,
});
if (lcpVerdict === 'over' || routeVerdict === 'over') {
// eslint-disable-next-line no-console
console.warn(
`[perf] target exceeded (LCP ${lcpVerdict}, route ${routeVerdict}) — reported, not gated (dev-server throttled run).`
);
}
// --- Hard assertions (gates) --------------------------------------------
// Only the measurement machinery is gated: the observer fired and the nav
// landed (page-monitoring visibility is already hard-asserted above).
expect(lcp, 'LCP observer should have recorded a paint').toBeGreaterThan(0);
expect(routeTransitionMs, 'route transition should elapse measurable time').toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,135 @@
/**
* Phase-4 responsive acceptance: every analysis page must be usable at 375px
* (the narrowest mobile viewport in D4) with NO horizontal scroll.
*
* Auth + backend mocking mirror e2e/user-flows.spec.ts (seedAuthAndMockApi):
* seed localStorage['cbpoa_token'] so the login gate is skipped, then mock all
* /api/** calls so the suite runs hermetically without a live :8000 backend.
*/
import { test, expect, Page } from '@playwright/test';
import { TESTIDS } from '../src/utils/testids';
// Each analysis route paired with its page-* mount testid.
const ANALYSIS_PAGES: Array<{ route: string; testid: string }> = [
{ route: '/analysis/trend', testid: TESTIDS.pageTrend },
{ route: '/analysis/district', testid: TESTIDS.pageDistrict },
{ route: '/analysis/insights', testid: TESTIDS.pageInsights },
{ route: '/analysis/reports', testid: TESTIDS.pageReports },
{ route: '/analysis/demographics', testid: TESTIDS.pageDemographics },
{ route: '/analysis/disease', testid: TESTIDS.pageDisease },
{ route: '/analysis/environment', testid: TESTIDS.pageEnvironment },
];
/** Seed auth token and mock all /api/** calls before each page load. */
async function seedAuthAndMockApi(page: Page) {
await page.addInitScript(() => {
localStorage.setItem('cbpoa_token', 'e2e-test-token');
});
// Mock backend responses so the suite is hermetic — no live :8000 required.
// Each response must match the TypeScript interface shape; returning {} causes
// pages to throw when accessing expected array properties.
await page.route('/api/**', (route) => {
const url = route.request().url();
if (url.includes('/alerts')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ alerts: [], total: 0 }),
});
return;
}
if (url.includes('/history/aggregated')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ aggregations: [], total_records: 0 }),
});
return;
}
if (url.includes('/grids')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
});
return;
}
if (url.includes('/cases/demographics')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
age_distribution: [],
gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
age_diagnosis_matrix: [],
}),
});
return;
}
if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([]),
});
return;
}
if (url.includes('/cases/districts') || url.includes('/districts')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([]),
});
return;
}
if (url.includes('/cases/trend') || url.includes('/cases')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], total: 0 }),
});
return;
}
// Default fallback — return a safe empty object.
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], items: [], total: 0 }),
});
});
}
test.describe('Responsive — analysis pages @375px', () => {
test.use({ viewport: { width: 375, height: 812 } });
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
for (const { route, testid } of ANALYSIS_PAGES) {
test(`${route} mounts and has no horizontal scroll at 375px`, async ({ page }) => {
await page.goto(route);
// Page must mount.
await expect(page.locator(`[data-testid="${testid}"]`)).toBeVisible();
// No horizontal overflow: scrollWidth must not exceed clientWidth (+1px slack
// for sub-pixel rounding).
const noHorizontalScroll = await page.evaluate(
() =>
document.documentElement.scrollWidth <=
document.documentElement.clientWidth + 1
);
expect(noHorizontalScroll, `${route} overflows horizontally at 375px`).toBe(true);
});
}
});

133
frontend/e2e/roles.spec.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* Phase-3 acceptance tests: 视角/perspective switcher (D2 — frontend view-presets only).
*
* Roles are NOT access control: the switcher only changes the default landing page +
* granularity/filter presets. This suite verifies the switcher renders, selecting a role
* navigates to that role's default landing URL (with its query params), and the choice
* survives a reload (persisted to localStorage['cbpoa_role']).
*
* Auth + API mocking mirror e2e/user-flows.spec.ts so the suite runs hermetically.
*/
import { test, expect, Page } from '@playwright/test';
import { TESTIDS } from '../src/utils/testids';
/** Seed auth token and mock all /api/** calls (shapes copied from user-flows.spec.ts). */
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();
if (url.includes('/alerts')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ alerts: [], total: 0 }),
});
return;
}
if (url.includes('/history/aggregated')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ aggregations: [], total_records: 0 }),
});
return;
}
if (url.includes('/grids')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
});
return;
}
if (url.includes('/cases/demographics')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
age_distribution: [],
gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
age_diagnosis_matrix: [],
}),
});
return;
}
if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
return;
}
if (url.includes('/cases/districts') || url.includes('/districts')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
return;
}
if (url.includes('/cases/trend') || url.includes('/cases')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], total: 0 }),
});
return;
}
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], items: [], total: 0 }),
});
});
}
test.describe('视角/perspective switcher (Phase 3)', () => {
// Use a desktop viewport so the top bar renders the switcher inline.
test.use({ viewport: { width: 1280, height: 800 } });
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('perspective-switcher is visible in the top bar', async ({ page }) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`)).toBeVisible();
});
test('selecting 厅领导 (official) navigates to /overview?granularity=district', async ({ page }) => {
await page.goto('/monitoring');
const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`);
await expect(switcher).toBeVisible();
await switcher.selectOption('official');
await expect(page).toHaveURL(/\/overview/);
await expect(page).toHaveURL(/granularity=district/);
});
test('selecting 医生 (doctor) navigates to /alerts?view=cluster', async ({ page }) => {
await page.goto('/monitoring');
const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`);
await expect(switcher).toBeVisible();
await switcher.selectOption('doctor');
await expect(page).toHaveURL(/\/alerts/);
await expect(page).toHaveURL(/view=cluster/);
});
test('selected role persists across reload (localStorage cbpoa_role)', async ({ page }) => {
await page.goto('/monitoring');
const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`);
await switcher.selectOption('doctor');
await expect(page).toHaveURL(/\/alerts/);
// localStorage should now hold the chosen role.
const stored = await page.evaluate(() => localStorage.getItem('cbpoa_role'));
expect(stored).toBe('doctor');
await page.reload();
// After reload the switcher reflects the persisted role.
await expect(page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`)).toHaveValue('doctor');
});
});

View File

@@ -1,242 +1,291 @@
/**
* US-007 + US-008: E2E user flow and UI state tests.
* Simulates real user workflows through the CBPOA system.
* Phase-1 acceptance tests: URL-based navigation, responsive layout, and core user flows.
* Rewrites the previous click-nav suite for react-router v6 URL navigation.
*
* Auth strategy: seed localStorage['cbpoa_token'] via addInitScript (App.tsx gates on
* token presence only; no server validation). Backend is mocked via page.route so the
* suite runs hermetically without a live :8000 backend.
*/
import { test, expect } from '@playwright/test';
import { test, expect, Page } from '@playwright/test';
import { TESTIDS } from '../src/utils/testids';
const BASE_URL = 'http://localhost:3000';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
test.describe('认证流程 (Authentication Flow)', () => {
test('显示登录页面', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(1000);
// Should see login form or app (if cached token)
const isLogin = await page.locator('input').count();
const isApp = await page.locator('nav').count();
expect(isLogin > 0 || isApp > 0).toBeTruthy();
/** Seed auth token and mock all /api/** calls before each page load. */
async function seedAuthAndMockApi(page: Page) {
// Prevent login gate from appearing.
await page.addInitScript(() => {
localStorage.setItem('cbpoa_token', 'e2e-test-token');
});
test('登录表单可交互', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(1000);
// Mock backend responses so the suite is hermetic — no live :8000 required.
// Each response must match the TypeScript interface shape; returning {} causes
// pages to throw when accessing expected array properties.
await page.route('/api/**', (route) => {
const url = route.request().url();
const inputs = page.locator('input');
const count = await inputs.count();
if (count >= 2) {
// Login page is shown
await inputs.first().fill('admin');
await inputs.nth(1).fill('admin123');
const loginBtn = page.locator('button[type="submit"], button:has-text("登录"), button:has-text("Login")');
const btnCount = await loginBtn.count();
if (btnCount > 0) {
await loginBtn.first().click();
await page.waitForTimeout(2000);
}
if (url.includes('/alerts')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ alerts: [], total: 0 }),
});
return;
}
// If no inputs, user is already logged in (token in localStorage)
});
});
test.describe('监测面板 (Monitoring Dashboard)', () => {
test('面板加载并显示统计卡片', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(3000);
// Should show monitoring page by default
const statCards = page.locator('[class*="stat"], [class*="card"], [class*="Stat"]');
const cardsCount = await statCards.count();
// Should see some content
const bodyText = await page.textContent('body');
expect(bodyText).toBeTruthy();
});
test('时间线控件可交互', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(3000);
// Look for timeline controls
const playButton = page.locator('button:has-text("播放"), button[title*="play" i], button[class*="play" i]');
const prevButton = page.locator('button:has-text("前一天"), button[title*="prev" i]');
const nextButton = page.locator('button:has-text("后一天"), button[title*="next" i]');
if (await playButton.count() > 0) {
await playButton.first().click();
await page.waitForTimeout(1000);
if (url.includes('/history/aggregated')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ aggregations: [], total_records: 0 }),
});
return;
}
});
test('疾病筛选器可用', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(3000);
const selects = page.locator('select, [role="combobox"], [class*="select" i], [class*="filter" i]');
const count = await selects.count();
expect(count >= 0).toBeTruthy();
});
});
test.describe('预警面板 (Alerts Dashboard)', () => {
test('导航到预警面板', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
// Navigate to alerts - click sidebar link
const alertsLink = page.locator('a[href*="alert" i], button:has-text("预警"), button:has-text("告警"), span:has-text("预警"), span:has-text("告警")');
if (await alertsLink.count() > 0) {
await alertsLink.first().click();
await page.waitForTimeout(2000);
if (url.includes('/grids')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
});
return;
}
});
test('预警列表加载', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const alertsLink = page.locator('a[href*="alert" i], button:has-text("预警"), span:has-text("预警")');
if (await alertsLink.count() > 0) {
await alertsLink.first().click();
await page.waitForTimeout(3000);
const bodyText = await page.textContent('body');
expect(bodyText).toBeTruthy();
// DemographicsResponse — used by DemographicAnalysis page.
if (url.includes('/cases/demographics')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
age_distribution: [],
gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
age_diagnosis_matrix: [],
}),
});
return;
}
});
});
test.describe('趋势分析 (Trend Analysis)', () => {
test('导航到趋势分析页面', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const trendLink = page.locator('button:has-text("趋势"), span:has-text("趋势"), a[href*="trend" i]');
if (await trendLink.count() > 0) {
await trendLink.first().click();
await page.waitForTimeout(2000);
// DiseaseAnalysis calls: diagnosis-distribution, seasonality, districts.
if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([]),
});
return;
}
});
test('趋势图渲染', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const trendLink = page.locator('button:has-text("趋势"), span:has-text("趋势")');
if (await trendLink.count() > 0) {
await trendLink.first().click();
await page.waitForTimeout(3000);
// Recharts renders SVG charts
const svgCharts = page.locator('svg.recharts-surface');
const chartCount = await svgCharts.count();
expect(chartCount >= 0).toBeTruthy();
if (url.includes('/cases/districts') || url.includes('/districts')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([]),
});
return;
}
});
});
test.describe('区县对比 (District Comparison)', () => {
test('导航到区县对比页面', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const districtLink = page.locator('button:has-text("区县"), button:has-text("对比"), span:has-text("区县")');
if (await districtLink.count() > 0) {
await districtLink.first().click();
await page.waitForTimeout(2000);
// Trend / time-series endpoints.
if (url.includes('/cases/trend') || url.includes('/cases')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], total: 0 }),
});
return;
}
});
});
test.describe('报告中心 (Reports Center)', () => {
test('导航到报告中心', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const reportsLink = page.locator('button:has-text("报告"), span:has-text("报告"), a[href*="report" i]');
if (await reportsLink.count() > 0) {
await reportsLink.first().click();
await page.waitForTimeout(2000);
}
});
test('报告列表加载', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const reportsLink = page.locator('button:has-text("报告"), span:has-text("报告")');
if (await reportsLink.count() > 0) {
await reportsLink.first().click();
await page.waitForTimeout(3000);
const bodyText = await page.textContent('body');
expect(bodyText).toBeTruthy();
}
});
});
test.describe('UI 状态与错误处理 (UI States & Error Handling)', () => {
test('页面加载显示加载指示器而非白屏', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(500);
const bodyHTML = await page.innerHTML('body');
// Should have some content, even during loading
expect(bodyHTML.length).toBeGreaterThan(0);
});
test('侧边栏导航切换页面正常', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const navLinks = page.locator('nav a, nav button, [class*="side" i] a, [class*="side" i] button');
const count = await navLinks.count();
if (count >= 2) {
await navLinks.first().click();
await page.waitForTimeout(1000);
await navLinks.nth(1).click();
await page.waitForTimeout(1000);
}
});
test('未出现明显 console 报错', async ({ page }) => {
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
errors.push(msg.text());
}
});
page.on('pageerror', (err) => {
errors.push(err.message);
// Default fallback — return a safe empty object.
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], items: [], total: 0 }),
});
});
}
await page.goto(BASE_URL);
await page.waitForTimeout(3000);
// ---------------------------------------------------------------------------
// URL-based navigation (react-router v6)
// ---------------------------------------------------------------------------
const filtered = errors.filter(
(e) => !e.includes('favicon') && !e.includes('404') && !e.includes('OLMap')
test.describe('URL-based navigation', () => {
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('deep-link: /analysis/disease mounts page-disease directly', async ({ page }) => {
await page.goto('/analysis/disease');
await expect(page.locator(`[data-testid="${TESTIDS.pageDisease}"]`)).toBeVisible();
});
test('refresh preserves page: reload on /analysis/disease keeps URL and mounts page-disease', async ({
page,
}) => {
await page.goto('/analysis/disease');
await expect(page.locator(`[data-testid="${TESTIDS.pageDisease}"]`)).toBeVisible();
await page.reload();
await expect(page).toHaveURL(/\/analysis\/disease/);
await expect(page.locator(`[data-testid="${TESTIDS.pageDisease}"]`)).toBeVisible();
});
test('browser back: from /analysis/trend back to /monitoring restores page-monitoring', async ({
page,
}) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
await page.goto('/analysis/trend');
await expect(page.locator(`[data-testid="${TESTIDS.pageTrend}"]`)).toBeVisible();
await page.goBack();
await expect(page).toHaveURL(/\/monitoring/);
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
});
test('NavLink click updates URL to /alerts and mounts page-alerts', async ({ page }) => {
// Start on /monitoring. The SideNav collapses all modules except the active one,
// so nav-alerts (inside the "预警" module) is hidden behind a collapsed section.
// We must expand the "预警" module first by clicking its header button.
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
// Expand the "预警" module section so nav-alerts NavLink becomes visible.
// Both sidebar-rail and app-drawer render a SideNav; scope to sidebar-rail to avoid
// strict-mode ambiguity (the app-drawer's copy is also in the DOM but off-screen).
await page
.locator(`[data-testid="${TESTIDS.sidebarRail}"] button`)
.filter({ hasText: '预警' })
.click();
await expect(
page.locator(`[data-testid="${TESTIDS.sidebarRail}"] [data-testid="${TESTIDS.navAlerts}"]`)
).toBeVisible();
await page
.locator(`[data-testid="${TESTIDS.sidebarRail}"] [data-testid="${TESTIDS.navAlerts}"]`)
.click();
await expect(page).toHaveURL(/\/alerts/);
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
});
test('root / redirects to /monitoring', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveURL(/\/monitoring/);
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
});
test('unknown path redirects to /monitoring', async ({ page }) => {
await page.goto('/does-not-exist');
await expect(page).toHaveURL(/\/monitoring/);
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
});
});
// ---------------------------------------------------------------------------
// Responsive layout
// ---------------------------------------------------------------------------
test.describe('Responsive layout — mobile @375px', () => {
test.use({ viewport: { width: 375, height: 812 } });
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('hamburger is visible and sidebar-rail is hidden at 375px', async ({ page }) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.hamburger}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.sidebarRail}"]`)).not.toBeVisible();
});
test('tapping hamburger slides app-drawer into viewport', async ({ page }) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
// Drawer should be off-screen (translate-x-full) before toggle.
const drawer = page.locator(`[data-testid="${TESTIDS.appDrawer}"]`);
await expect(drawer).not.toBeInViewport();
await page.locator(`[data-testid="${TESTIDS.hamburger}"]`).click();
// After toggle, drawer slides in and becomes visible in viewport.
await expect(drawer).toBeInViewport();
});
test('no horizontal scroll on default route at 375px', async ({ page }) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
const noHorizontalScroll = await page.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth
);
expect(filtered).toHaveLength(0);
expect(noHorizontalScroll).toBe(true);
});
});
test.describe('响应式布局 (Responsive Layout)', () => {
test('移动端视口下不崩溃', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
test.describe('Responsive layout — desktop @1280px', () => {
test.use({ viewport: { width: 1280, height: 800 } });
const bodyText = await page.textContent('body');
expect(bodyText).toBeTruthy();
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('平板视口下正常显示', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
test('sidebar-rail is visible and hamburger is hidden at 1280px', async ({ page }) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
const bodyText = await page.textContent('body');
expect(bodyText).toBeTruthy();
await expect(page.locator(`[data-testid="${TESTIDS.sidebarRail}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.hamburger}"]`)).not.toBeVisible();
});
});
// ---------------------------------------------------------------------------
// Core page loading
// ---------------------------------------------------------------------------
test.describe('Core pages load via URL nav', () => {
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('/monitoring loads page-monitoring', async ({ page }) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
});
test('/alerts loads page-alerts', async ({ page }) => {
await page.goto('/alerts');
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
});
test('/analysis/trend loads page-trend', async ({ page }) => {
await page.goto('/analysis/trend');
await expect(page.locator(`[data-testid="${TESTIDS.pageTrend}"]`)).toBeVisible();
});
test('/analysis/district loads page-district', async ({ page }) => {
await page.goto('/analysis/district');
await expect(page.locator(`[data-testid="${TESTIDS.pageDistrict}"]`)).toBeVisible();
});
test('/analysis/reports loads page-reports', async ({ page }) => {
await page.goto('/analysis/reports');
await expect(page.locator(`[data-testid="${TESTIDS.pageReports}"]`)).toBeVisible();
});
test('/analysis/demographics loads page-demographics', async ({ page }) => {
await page.goto('/analysis/demographics');
await expect(page.locator(`[data-testid="${TESTIDS.pageDemographics}"]`)).toBeVisible();
});
test('/analysis/environment loads page-environment', async ({ page }) => {
await page.goto('/analysis/environment');
await expect(page.locator(`[data-testid="${TESTIDS.pageEnvironment}"]`)).toBeVisible();
});
});

View File

@@ -15,6 +15,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.30.4",
"recharts": "^2.12.0",
"zustand": "^4.5.0"
},

View File

@@ -14,6 +14,16 @@ export default defineConfig({
projects: [
{
name: 'chromium',
// Functional suite. Exclude the throttled perf spec so emulated Fast-3G
// latency never bleeds into (or slows) the normal acceptance run.
testIgnore: /perf\.spec\.ts/,
use: { ...devices['Desktop Chrome'] },
},
{
// Dedicated perf project — only perf.spec.ts runs here, under CDP network
// throttling. Kept separate so functional and perf measurements don't mix.
name: 'perf',
testMatch: /perf\.spec\.ts/,
use: { ...devices['Desktop Chrome'] },
},
],

View File

@@ -26,6 +26,9 @@ importers:
react-leaflet:
specifier: ^4.2.1
version: 4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-router-dom:
specifier: ^6.30.4
version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
recharts:
specifier: ^2.12.0
version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -385,6 +388,10 @@ packages:
react: ^18.0.0
react-dom: ^18.0.0
'@remix-run/router@1.23.3':
resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==}
engines: {node: '>=14.0.0'}
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -1504,6 +1511,19 @@ packages:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
react-router-dom@6.30.4:
resolution: {integrity: sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
react-router@6.30.4:
resolution: {integrity: sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react-smooth@4.0.4:
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
peerDependencies:
@@ -2157,6 +2177,8 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@remix-run/router@1.23.3': {}
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.60.2':
@@ -3233,6 +3255,18 @@ snapshots:
react-refresh@0.17.0: {}
react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@remix-run/router': 1.23.3
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-router: 6.30.4(react@18.3.1)
react-router@6.30.4(react@18.3.1):
dependencies:
'@remix-run/router': 1.23.3
react: 18.3.1
react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
fast-equals: 5.4.0

Binary file not shown.

View File

@@ -1,19 +1,9 @@
import { useEffect, useState, Component, ReactNode, Suspense, lazy, useCallback } from 'react';
import { TopNav } from '@/components/TopNav';
import { SideNav } from '@/components/SideNav';
import { useEffect, useState, Component, ReactNode, useCallback } from 'react';
import { BrowserRouter, Routes, Route, useRoutes } from 'react-router-dom';
import { AppShell } from '@/components/AppShell';
import { useRiskStore } from '@/stores';
import { Login } from '@/pages/Login';
const MonitoringDashboard = lazy(() => import('@/pages/MonitoringDashboard').then(m => ({ default: m.MonitoringDashboard })));
const AlertsDashboard = lazy(() => import('@/pages/AlertsDashboard').then(m => ({ default: m.AlertsDashboard })));
const TrendAnalysis = lazy(() => import('@/pages/TrendAnalysis').then(m => ({ default: m.TrendAnalysis })));
const DistrictComparison = lazy(() => import('@/pages/DistrictComparison').then(m => ({ default: m.DistrictComparison })));
const Insights = lazy(() => import('@/pages/Insights').then(m => ({ default: m.Insights })));
const ReportsCenter = lazy(() => import('@/pages/ReportsCenter').then(m => ({ default: m.ReportsCenter })));
const DemographicAnalysis = lazy(() => import('@/pages/DemographicAnalysis').then(m => ({ default: m.DemographicAnalysis })));
const DiseaseAnalysis = lazy(() => import('@/pages/DiseaseAnalysis').then(m => ({ default: m.DiseaseAnalysis })));
const EnvironmentalHealth = lazy(() => import('@/pages/EnvironmentalHealth').then(m => ({ default: m.EnvironmentalHealth })));
import { appRoutes } from '@/routes';
interface Props {
children: ReactNode;
@@ -55,28 +45,25 @@ class ErrorBoundary extends Component<Props, State> {
}
}
function PageLoader() {
return (
<div className="flex items-center justify-center h-[60vh]">
<div className="text-text-secondary text-[13px]">...</div>
</div>
);
// 已登录AppShell 提供布局骨架,子路由表渲染到其 <Outlet/>。
function AuthedApp({ onLogout }: { onLogout: () => void }) {
const element = useRoutes([
{
element: <AppShell onLogout={onLogout} />,
children: appRoutes,
},
]);
return element;
}
function App() {
const [activePage, setActivePage] = useState('monitoring');
const [token, setToken] = useState<string | null>(() => localStorage.getItem('cbpoa_token'));
const alerts = useRiskStore((s) => s.alerts);
const fetchAlerts = useRiskStore((s) => s.fetchAlerts);
useEffect(() => {
if (token) fetchAlerts();
}, [fetchAlerts, token]);
const handlePageChange = useCallback((page: string) => {
setActivePage(page);
}, []);
const handleLogin = useCallback((newToken: string) => {
setToken(newToken);
}, []);
@@ -86,41 +73,18 @@ function App() {
setToken(null);
}, []);
if (!token) {
return (
<ErrorBoundary>
<Login onLogin={handleLogin} />
</ErrorBoundary>
);
}
return (
<ErrorBoundary>
<div className="min-h-screen bg-bg-page flex flex-col">
<TopNav onLogout={handleLogout} />
<div className="flex flex-1 pt-[52px]">
<SideNav
activePage={activePage}
onPageChange={handlePageChange}
alertCount={alerts.length}
/>
<main className="flex-1 ml-[200px] p-5 min-w-0">
<Suspense fallback={<PageLoader />}>
{activePage === 'monitoring' && <MonitoringDashboard />}
{activePage === 'alerts' && <AlertsDashboard />}
{activePage === 'trend-analysis' && <TrendAnalysis />}
{activePage === 'district-comparison' && <DistrictComparison />}
{activePage === 'insights' && <Insights />}
{activePage === 'reports' && <ReportsCenter />}
{activePage === 'demographics' && <DemographicAnalysis />}
{activePage === 'disease' && <DiseaseAnalysis />}
{activePage === 'environment' && <EnvironmentalHealth />}
</Suspense>
</main>
</div>
</div>
<BrowserRouter>
{token ? (
<AuthedApp onLogout={handleLogout} />
) : (
// 鉴权门:无 token 时所有路由都进入登录页。
<Routes>
<Route path="*" element={<Login onLogin={handleLogin} />} />
</Routes>
)}
</BrowserRouter>
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,141 @@
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';
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} isMenuOpen={drawerOpen} />
<div className="flex flex-1 min-h-0">
{/* lg 及以上:持久侧栏导轨 */}
<aside
data-testid={TESTIDS.sidebarRail}
className="hidden lg:block w-[200px] shrink-0 bg-bg-card border-r border-border"
>
<SideNav
alertCount={alerts.length}
expanded={expandedNav}
onExpandedChange={setExpandedNav}
/>
</aside>
{/* lg 以下:离屏抽屉 + 遮罩 */}
{drawerOpen && (
<div
className="fixed inset-0 z-40 bg-black/40 lg:hidden"
onClick={closeDrawer}
aria-hidden="true"
/>
)}
<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}
expanded={expandedNav}
onExpandedChange={setExpandedNav}
/>
</aside>
<main className="flex-1 min-w-0 overflow-auto p-5">
<RouteErrorBoundary>
<Outlet />
</RouteErrorBoundary>
</main>
</div>
</div>
);
}

View File

@@ -1,18 +1,62 @@
import { useEffect, useRef, useState, memo } from 'react';
import L from 'leaflet';
import { geocodedApi } from '@/services/api';
import { TESTIDS } from '@/utils/testids';
import type { GeocodedCase } from '@/types';
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
// 视图模式:
// 'points' —— 个体病例点(默认,非医生视角)。逐病例渲染 circleMarker
// 并在 DOM 中输出隐藏的 patient-point 镜像供 e2e 计数。
// 'density' —— 聚合密度(医生视角,隐私不变量)。仅按行政区/街道聚合的密度圆,
// 不渲染任何个体点patient-point 数量必须为 0。
type CaseMapMode = 'points' | 'density';
interface CaseLocationMapProps {
height?: string;
district?: string | null;
street?: string | null;
date?: string | null;
mode?: CaseMapMode;
}
function CaseLocationMapComponent({ height = '400px', district = null, street = null, date = null }: CaseLocationMapProps) {
// 聚合中心:按 street无则 district分组取经纬度均值 + 计数。
interface DensityCluster {
key: string;
label: string;
latitude: number;
longitude: number;
count: number;
}
function aggregateClusters(cases: GeocodedCase[]): DensityCluster[] {
const groups: Record<string, { latSum: number; lonSum: number; count: number; label: string }> = {};
for (const c of cases) {
if (!c.latitude || !c.longitude) continue;
const key = `${c.district}/${c.street || ''}`;
const label = c.street ? `${c.district} ${c.street}` : c.district;
if (!groups[key]) groups[key] = { latSum: 0, lonSum: 0, count: 0, label };
groups[key].latSum += c.latitude;
groups[key].lonSum += c.longitude;
groups[key].count += 1;
}
return Object.entries(groups).map(([key, g]) => ({
key,
label: g.label,
latitude: g.latSum / g.count,
longitude: g.lonSum / g.count,
count: g.count,
}));
}
function CaseLocationMapComponent({
height = '400px',
district = null,
street = null,
date = null,
mode = 'points',
}: CaseLocationMapProps) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
const layerRef = useRef<L.LayerGroup | null>(null);
@@ -20,6 +64,10 @@ function CaseLocationMapComponent({ height = '400px', district = null, street =
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [caseCount, setCaseCount] = useState(0);
// points 模式下的隐藏 DOM 镜像(每病例一项,供 e2e 对 patient-point 计数)。
// density 模式下保持为空数组 —— 隐私不变量:医生视角下 patient-point 必须为 0。
const [pointKeys, setPointKeys] = useState<string[]>([]);
const [clusterCount, setClusterCount] = useState(0);
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
@@ -79,6 +127,42 @@ function CaseLocationMapComponent({ height = '400px', district = null, street =
if (cancelledRef.current) return;
if (mode === 'density') {
// 医生视角:仅渲染聚合密度圆(按街道/区聚合),不渲染任何个体点。
const clusters = aggregateClusters(unique);
const maxCount = clusters.reduce((m, c) => Math.max(m, c.count), 1);
for (const cl of clusters) {
// 半径随计数缩放828px明确表达「密度」而非个体位置。
const radius = 8 + Math.round((cl.count / maxCount) * 20);
const marker = L.circleMarker([cl.latitude, cl.longitude], {
radius,
fillColor: '#7c3aed',
fillOpacity: 0.35,
color: '#7c3aed',
weight: 1.5,
});
marker.bindTooltip(
`<div style="font-size:12px"><strong>${cl.label}</strong><br/>病例数: ${cl.count}</div>`,
{ direction: 'top', offset: [0, -4] }
);
marker.addTo(layer);
}
setClusterCount(clusters.length);
setCaseCount(unique.length);
setPointKeys([]); // 隐私不变量density 下无个体点镜像
setIsLoading(false);
if (clusters.length > 0) {
const bounds = L.latLngBounds(clusters.map((c) => [c.latitude, c.longitude]));
map.fitBounds(bounds, { padding: [30, 30] });
}
return;
}
// points 模式(默认):逐病例渲染个体 circleMarker。
const keys: string[] = [];
for (const c of unique) {
if (!c.latitude || !c.longitude) continue;
@@ -100,9 +184,12 @@ function CaseLocationMapComponent({ height = '400px', district = null, street =
);
marker.addTo(layer);
keys.push(c.case_id);
}
setClusterCount(0);
setCaseCount(unique.length);
setPointKeys(keys); // 隐藏 DOM 镜像供 e2e 计数
setIsLoading(false);
// Fit bounds to case locations
@@ -124,23 +211,41 @@ function CaseLocationMapComponent({ height = '400px', district = null, street =
map.remove();
mapInstanceRef.current = null;
};
}, [district, street, date]);
}, [district, street, date, mode]);
const isDensity = mode === 'density';
return (
<div className="relative">
<div className="relative" data-case-map-mode={mode}>
<div ref={mapRef} style={{ height, width: '100%', borderRadius: '8px' }} />
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/80 rounded-lg">
<div className="text-sm text-gray-500">...</div>
</div>
)}
{!isLoading && (
{!isLoading && !isDensity && (
<div className="absolute top-2 right-2 bg-white/90 px-3 py-1.5 rounded shadow text-xs">
<span className="text-blue-600 font-semibold">{caseCount.toLocaleString()}</span>
<span className="ml-2 text-red-500"> </span>
<span className="ml-1 text-blue-500"> </span>
</div>
)}
{!isLoading && isDensity && (
<div className="absolute top-2 right-2 bg-white/90 px-3 py-1.5 rounded shadow text-xs">
<span className="text-purple-600 font-semibold">{clusterCount.toLocaleString()}</span>
<span className="ml-2 text-gray-500"></span>
</div>
)}
{/*
隐藏 DOM 镜像points 模式下每病例输出一个 patient-point 节点,使 e2e 能对
Leaflet canvas 之外的真实 DOM 做计数断言。density 模式下 pointKeys 恒为空,
因此医生视角下 [data-testid=patient-point] 数量必为 0隐私不变量
*/}
<div className="hidden" aria-hidden="true">
{pointKeys.map((id) => (
<span key={id} data-testid={TESTIDS.patientPoint} data-case-id={id} />
))}
</div>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { memo, useEffect, useRef, useState, useCallback } from 'react';
import { Skeleton } from '@/components/ui';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { geocodedApi } from '@/services/api';
@@ -340,7 +341,7 @@ function CaseMapComponent({ height = '480px' }: CaseMapProps) {
<div className="bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm px-3 py-2">
<div className="text-[11px] text-text-secondary">
{isLoading ? (
<span className="text-text-muted">...</span>
<Skeleton className="h-3 w-20 inline-block align-middle" />
) : error ? (
<span className="text-danger">: {error}</span>
) : (

View File

@@ -0,0 +1,13 @@
import { Navigate } from 'react-router-dom';
import { useSessionStore } from '@/stores';
import { roleDefaultPath } from '@/utils/roleViews';
/**
* 根据当前视角role把裸路径 `/` 重定向到该视角的默认落地页。
* D2纯前端视图预设 —— role 只决定默认落地页,不是访问控制。
* 角色来源被隔离在 sessionStore 的 getRoleSource() 接缝里。
*/
export function RoleRedirect(): JSX.Element {
const role = useSessionStore((s) => s.role);
return <Navigate to={roleDefaultPath(role)} replace />;
}

View File

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

View File

@@ -1,76 +1,97 @@
import { useState } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { TESTIDS } from '@/utils/testids';
interface SideNavProps {
activePage: string;
onPageChange: (page: string) => void;
alertCount?: number;
// 抽屉模式下点击导航项后关闭抽屉(持久侧栏可不传)。
onNavigate?: () => void;
// 受控的展开手风琴分组:由 AppShell 提供时,导轨与抽屉两份实例保持同步。
// 不传则回退到内部 state向后兼容独立使用。
expanded?: string | null;
onExpandedChange?: (moduleId: string | null) => void;
}
const modules: { id: string; label: string; icon: React.ReactNode; items: { id: string; label: string }[] }[] = [
interface NavItem {
to: string;
label: string;
testid: string;
}
const modules: { id: string; label: string; icon: React.ReactNode; items: NavItem[] }[] = [
{
id: 'monitoring',
label: '监测',
icon: (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/>
<path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z" />
</svg>
),
items: [
{ id: 'monitoring', label: '监测面板' },
],
items: [{ to: '/monitoring', label: '监测面板', testid: TESTIDS.navMonitoring }],
},
{
id: 'alert',
label: '预警',
icon: (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" />
</svg>
),
items: [
{ id: 'alerts', label: '预警地图' },
],
items: [{ to: '/alerts', label: '预警地图', testid: TESTIDS.navAlerts }],
},
{
id: 'analysis',
label: '分析',
icon: (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"/>
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z" />
</svg>
),
items: [
{ id: 'trend-analysis', label: '趋势分析' },
{ id: 'district-comparison', label: '区域对比' },
{ id: 'insights', label: '智能洞察' },
{ id: 'reports', label: '报表中心' },
{ id: 'demographics', label: '人群分析' },
{ id: 'disease', label: '疾病分析' },
{ id: 'environment', label: '环境健康' },
{ to: '/overview', label: '总览', testid: TESTIDS.navOverview },
{ to: '/analysis/trend', label: '趋势分析', testid: TESTIDS.navTrend },
{ to: '/analysis/district', label: '区域对比', testid: TESTIDS.navDistrict },
{ to: '/analysis/insights', label: '智能洞察', testid: TESTIDS.navInsights },
{ to: '/analysis/reports', label: '报表中心', testid: TESTIDS.navReports },
{ to: '/analysis/demographics', label: '人群分析', testid: TESTIDS.navDemographics },
{ to: '/analysis/disease', label: '疾病分析', testid: TESTIDS.navDisease },
{ to: '/analysis/clinical', label: '临床分析', testid: TESTIDS.navClinical },
{ to: '/analysis/environment', label: '环境健康', testid: TESTIDS.navEnvironment },
],
},
];
export function SideNav({
activePage,
onPageChange,
alertCount = 0,
onNavigate,
expanded: expandedProp,
onExpandedChange,
}: SideNavProps) {
const [expanded, setExpanded] = useState<string | null>('monitoring');
const location = useLocation();
const handleItemClick = (moduleId: string, itemId: string) => {
setExpanded(moduleId);
onPageChange(itemId);
// 当前路径命中的模块默认展开。
const moduleForPath = (pathname: string) =>
modules.find((m) => m.items.some((item) => pathname.startsWith(item.to)))?.id ?? 'monitoring';
// 受控/非受控双模式:父级传入 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);
const module = modules.find((m) => m.id === moduleId);
if (!module) return false;
return module.items.some(item => item.id === activePage);
return module.items.some((item) => location.pathname.startsWith(item.to));
};
return (
<aside className="w-[200px] bg-bg-card border-r border-border fixed top-[52px] left-0 bottom-0 overflow-y-auto py-4 px-2">
<nav className="h-full overflow-y-auto py-4 px-2">
{modules.map((module) => (
<div key={module.id} className="mb-4">
<button
@@ -81,9 +102,7 @@ export function SideNav({
: 'text-text-primary hover:bg-bg-hover'
}`}
>
<span className="w-4 h-4 flex items-center justify-center">
{module.icon}
</span>
<span className="w-4 h-4 flex items-center justify-center">{module.icon}</span>
<span>{module.label}</span>
{module.id === 'alert' && alertCount > 0 && (
<span className="ml-auto bg-danger-light text-danger text-[10px] font-semibold px-[5px] py-[2px] rounded">
@@ -95,22 +114,26 @@ export function SideNav({
{expanded === module.id && (
<div className="mt-1 pl-7">
{module.items.map((item) => (
<button
key={item.id}
onClick={() => handleItemClick(module.id, item.id)}
className={`w-full text-left px-3 py-[7px] rounded text-[13px] font-medium transition-colors ${
activePage === item.id
? 'bg-bg-active text-primary'
: 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
}`}
<NavLink
key={item.to}
to={item.to}
data-testid={item.testid}
onClick={onNavigate}
className={({ isActive }) =>
`block w-full text-left px-3 py-[7px] rounded text-[13px] font-medium transition-colors ${
isActive
? 'bg-bg-active text-primary'
: 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
}`
}
>
{item.label}
</button>
</NavLink>
))}
</div>
)}
</div>
))}
</aside>
</nav>
);
}

View File

@@ -1,7 +1,15 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { TESTIDS } from '@/utils/testids';
import { useSessionStore, ROLES, type Role } from '@/stores/sessionStore';
import { ROLE_LABELS, roleDefaultPath } from '@/utils/roleViews';
interface TopNavProps {
onLogout?: () => void;
// 移动端汉堡按钮:切换侧栏抽屉。
onToggleMenu?: () => void;
// 抽屉是否展开(用于汉堡按钮的 aria-expanded
isMenuOpen?: boolean;
}
function Clock() {
@@ -13,14 +21,61 @@ function Clock() {
return <span>{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>;
}
export function TopNav({ onLogout }: TopNavProps) {
// 视角切换器纯前端视图预设D2。刻意标注「视角」而非「权限」——不是访问控制。
// 切换时持久化角色并跳转到该视角的默认落地页。
function PerspectiveSwitcher() {
const role = useSessionStore((s) => s.role);
const setRole = useSessionStore((s) => s.setRole);
const navigate = useNavigate();
const handleChange = (next: Role) => {
setRole(next);
navigate(roleDefaultPath(next));
};
return (
<nav className="h-[52px] bg-bg-card border-b border-border flex items-center px-5 fixed top-0 left-0 right-0 z-50">
<label className="flex items-center gap-1.5 text-[13px] text-text-secondary">
<span className="text-text-muted hidden sm:inline"></span>
<select
data-testid={TESTIDS.perspectiveSwitcher}
value={role}
onChange={(e) => handleChange(e.target.value as Role)}
aria-label="切换视角"
className="bg-bg-card border border-border rounded-md px-2 py-1 text-[13px] text-text-primary hover:bg-bg-hover focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer"
>
{ROLES.map((r) => (
<option key={r} value={r} data-testid={`${TESTIDS.perspectiveOption}-${r}`}>
{ROLE_LABELS[r]}
</option>
))}
</select>
</label>
);
}
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 && (
<button
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"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
)}
<div className="flex items-center gap-3">
<div className="w-7 h-7 bg-primary rounded-md flex items-center justify-center">
<svg className="w-4 h-4 fill-white" viewBox="0 0 24 24">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 3c1.93 0 3.5 1.57 3.5 3.5S13.93 13 12 13s-3.5-1.57-3.5-3.5S10.07 6 12 6zm7 13H5v-.23c0-.62.28-1.2.76-1.58C7.47 15.82 9.64 15 12 15s4.53.82 6.24 2.19c.48.38.76.97.76 1.58V19z"/>
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 3c1.93 0 3.5 1.57 3.5 3.5S13.93 13 12 13s-3.5-1.57-3.5-3.5S10.07 6 12 6zm7 13H5v-.23c0-.62.28-1.2.76-1.58C7.47 15.82 9.64 15 12 15s4.53.82 6.24 2.19c.48.38.76.97.76 1.58V19z" />
</svg>
</div>
<span className="font-display font-semibold text-[15px] text-text-primary">
@@ -28,21 +83,21 @@ export function TopNav({ onLogout }: TopNavProps) {
</span>
</div>
<div className="w-px h-5 bg-border ml-4 mr-4" />
<div className="w-px h-5 bg-border ml-4 mr-4 hidden sm:block" />
<span className="text-[13px] text-text-secondary">
<span className="text-[13px] text-text-secondary hidden sm:inline">
</span>
<div className="ml-auto flex items-center gap-5">
<span className="text-[12px] text-text-muted">
<span className="text-[12px] text-text-muted hidden sm:inline">
<Clock />
</span>
<div className="flex items-center gap-2 text-[13px] text-text-secondary">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg>
admin
<PerspectiveSwitcher />
</div>
{onLogout && (
<button

View File

@@ -0,0 +1,108 @@
import React from 'react';
import type { CellInfo } from '@/components/AlertMap';
import { HORIZON_LABELS } from './types';
import type { ExtendedAlert } from './types';
interface CellInfoPanelProps {
cellInfo: CellInfo;
onClose: () => void;
}
// Cell info panel - shown when clicking grid cell without alert
export const CellInfoPanel = React.memo(function CellInfoPanel({ cellInfo, onClose }: CellInfoPanelProps) {
return (
<div className="fixed bottom-5 left-1/2 -translate-x-1/2 bg-bg-card rounded-lg border border-border-light shadow-lg z-50 px-5 py-4 min-w-[320px]">
<div className="flex items-center justify-between mb-3">
<span className="text-[14px] font-semibold text-text-primary"> (100m)</span>
<button onClick={onClose} className="text-text-muted hover:text-text-primary text-[18px] leading-none">&times;</button>
</div>
<div className="space-y-2 text-[12px]">
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="font-mono text-text-primary">{cellInfo.grid_id}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="font-mono text-text-primary">{cellInfo.lat.toFixed(4)}, {cellInfo.lon.toFixed(4)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className={`font-bold ${cellInfo.risk >= 0.8 ? 'text-danger' : cellInfo.risk >= 0.6 ? 'text-warning' : cellInfo.risk >= 0.4 ? 'text-primary' : 'text-success'}`}>
{(cellInfo.risk * 100).toFixed(1)}%
</span>
</div>
<div className="flex gap-3 pt-1">
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
<div className="text-[10px] text-text-muted">1</div>
<div className="font-bold text-[13px]">{(cellInfo.risk_1d * 100).toFixed(0)}%</div>
</div>
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
<div className="text-[10px] text-text-muted">3</div>
<div className="font-bold text-[13px]">{(cellInfo.risk_3d * 100).toFixed(0)}%</div>
</div>
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
<div className="text-[10px] text-text-muted">7</div>
<div className="font-bold text-[13px]">{(cellInfo.risk_7d * 100).toFixed(0)}%</div>
</div>
</div>
{cellInfo.nearestAlertId && (
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="text-text-primary">{(cellInfo.nearestAlertDist * 111).toFixed(1)} km</span>
</div>
)}
{!cellInfo.nearestAlertId && (
<div className="text-[11px] text-text-muted mt-1 pt-2 border-t border-border">
</div>
)}
</div>
</div>
);
});
interface AlertDetailModalProps {
alert: ExtendedAlert;
onClose: () => void;
}
// Alert detail modal
export const AlertDetailModal = React.memo(function AlertDetailModal({ alert, onClose }: AlertDetailModalProps) {
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center" onClick={onClose}>
<div className="bg-bg-card rounded-lg p-6 max-w-md w-full mx-4" onClick={e => e.stopPropagation()}>
<h3 className="font-display text-[16px] font-semibold mb-3"></h3>
<div className="space-y-2 text-[13px]">
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className={`font-bold ${alert.priority === 'P1' ? 'text-danger' : 'text-warning'}`}>
{alert.priority}
</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="font-bold">{Math.round(alert.risk_value * 100)}%</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span>{HORIZON_LABELS[alert.forecast_horizon]}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span>{alert.region}</span>
</div>
<div className="pt-2 border-t border-border">
<div className="text-text-muted mb-1"></div>
<div className="text-[12px]">{alert.reason}</div>
</div>
</div>
<button
onClick={onClose}
className="mt-4 w-full px-4 py-2 bg-primary text-white rounded hover:bg-primary/80 transition-colors text-[13px]"
>
</button>
</div>
</div>
);
});

View File

@@ -0,0 +1,194 @@
import React from 'react';
import { TESTIDS } from '@/utils/testids';
import { DiseaseFilter } from '@/components/DiseaseFilter';
import { HORIZON_LABELS } from './types';
interface AlertsFilterBarProps {
selectedHorizon: number | 'all';
onHorizonChange: (horizon: number | 'all') => void;
selectedPriority: 'all' | 'P1' | 'P2';
onPriorityChange: (priority: 'all' | 'P1' | 'P2') => void;
riskRange: [number, number];
onRiskRangeChange: (range: [number, number]) => void;
showMap: boolean;
onToggleMap: () => void;
showAlertMarkers: boolean;
onToggleAlertMarkers: () => void;
showGrid: boolean;
onToggleGrid: () => void;
sortBy: 'risk' | 'time';
onSortByChange: (sortBy: 'risk' | 'time') => void;
// 视角驱动的两条不变量(结果由 orchestrator 计算后下传):
isCluster: boolean; // 聚类(医生)视角:隐藏「预警标记」切换 + 挂载病种过滤
isOfficial: boolean; // 官员视角:隐藏网格切换
}
// Toolbar Row 2: Filters (时效/优先级/风险值/图层切换/排序).
export const AlertsFilterBar = React.memo(function AlertsFilterBar({
selectedHorizon,
onHorizonChange,
selectedPriority,
onPriorityChange,
riskRange,
onRiskRangeChange,
showMap,
onToggleMap,
showAlertMarkers,
onToggleAlertMarkers,
showGrid,
onToggleGrid,
sortBy,
onSortByChange,
isCluster,
isOfficial,
}: AlertsFilterBarProps) {
return (
<div className="card p-3 mb-4">
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-1">
{(['all', 1, 3, 7] as const).map((horizon) => (
<button
key={horizon}
onClick={() => onHorizonChange(horizon)}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
selectedHorizon === horizon
? 'bg-primary text-white'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
{horizon === 'all' ? '全部' : HORIZON_LABELS[horizon]}
</button>
))}
</div>
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-1">
{(['all', 'P1', 'P2'] as const).map((priority) => (
<button
key={priority}
onClick={() => onPriorityChange(priority)}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
selectedPriority === priority
? priority === 'P1'
? 'bg-danger text-white'
: priority === 'P2'
? 'bg-warning text-white'
: 'bg-primary text-white'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
{priority === 'all' ? '全部' : priority}
</button>
))}
</div>
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={1}
step={0.05}
value={riskRange[0]}
onChange={(e) => onRiskRangeChange([parseFloat(e.target.value) || 0, riskRange[1]])}
className="w-16 px-2 py-1.5 text-[12px] border border-border rounded bg-bg-page text-text-primary focus:outline-none focus:border-primary"
/>
<span className="text-[12px] text-text-muted">-</span>
<input
type="number"
min={0}
max={1}
step={0.05}
value={riskRange[1]}
onChange={(e) => onRiskRangeChange([riskRange[0], parseFloat(e.target.value) || 1])}
className="w-16 px-2 py-1.5 text-[12px] border border-border rounded bg-bg-page text-text-primary focus:outline-none focus:border-primary"
/>
</div>
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-1">
<button
onClick={onToggleMap}
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
showMap
? 'bg-primary/10 text-primary border border-primary/30'
: 'bg-bg-page text-text-muted border border-border'
}`}
>
</button>
{/* 预警标记切换:聚类(医生)视角隐藏整块——个体病例点不可开启(隐私不变量)。 */}
{!isCluster && (
<button
onClick={onToggleAlertMarkers}
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
showAlertMarkers
? 'bg-primary/10 text-primary border border-primary/30'
: 'bg-bg-page text-text-muted border border-border'
}`}
>
</button>
)}
{/* 网格切换官员视角隐藏整块100m 网格对其无意义/太超前)。 */}
{!isOfficial && (
<div data-testid={TESTIDS.gridLayerWrapper}>
<button
onClick={onToggleGrid}
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
showGrid
? 'bg-primary/10 text-primary border border-primary/30'
: 'bg-bg-page text-text-muted border border-border'
}`}
>
</button>
</div>
)}
{/* 聚类(医生)视角:病种过滤是其核心工具,挂载于此。 */}
{isCluster && <DiseaseFilter />}
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-1">
<button
onClick={() => onSortByChange('risk')}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
sortBy === 'risk'
? 'bg-bg-card text-primary border border-primary'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
</button>
<button
onClick={() => onSortByChange('time')}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
sortBy === 'time'
? 'bg-bg-card text-primary border border-primary'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
</button>
</div>
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,57 @@
import React from 'react';
interface AlertsHeaderProps {
total: number;
p1: number;
p2: number;
activeTab: 'list' | 'stats';
onTabChange: (tab: 'list' | 'stats') => void;
}
// 页头(标题 + 计数)+ 页内 tab 切换条(不走 router
export const AlertsHeader = React.memo(function AlertsHeader({
total,
p1,
p2,
activeTab,
onTabChange,
}: AlertsHeaderProps) {
return (
<>
{/* Header */}
<div className="flex items-center justify-between mb-4 flex-wrap gap-x-4 gap-y-2">
<div className="min-w-0">
<h1 className="font-display text-[18px] font-semibold mb-1"></h1>
<p className="text-[12px] text-text-muted truncate">
100m网格风险预测 · · -
</p>
</div>
<div className="flex items-center gap-3 text-[11px] shrink-0 flex-wrap">
<span className="text-text-muted"> <span className="font-semibold text-text-primary">{total}</span> </span>
<span className="px-2 py-1 bg-danger/10 border border-danger/20 rounded text-danger font-semibold">P1: {p1}</span>
<span className="px-2 py-1 bg-warning/10 border border-warning/20 rounded text-warning font-semibold">P2: {p2}</span>
</div>
</div>
{/* Tab strip — in-page, no router */}
<div className="flex gap-1 mb-4 border-b border-border">
{([
{ key: 'list', label: '预警列表' },
{ key: 'stats', label: '风险统计' },
] as const).map((tab) => (
<button
key={tab.key}
onClick={() => onTabChange(tab.key)}
className={`px-4 py-2 text-[13px] font-medium -mb-px border-b-2 transition-colors ${
activeTab === tab.key
? 'border-primary text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'
}`}
>
{tab.label}
</button>
))}
</div>
</>
);
});

View File

@@ -0,0 +1,138 @@
import React, { useCallback } from 'react';
import { HORIZON_LABELS } from './types';
import type { ExtendedAlert, RiskStats } from './types';
interface RiskDistributionSummaryProps {
riskStats: RiskStats;
total: number;
}
// 预警列表 tab 顶部的风险分布概要4 卡)。
export const RiskDistributionSummary = React.memo(function RiskDistributionSummary({
riskStats,
total,
}: RiskDistributionSummaryProps) {
return (
<div className="grid grid-cols-4 gap-3 mb-4">
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"> (0.8)</div>
<div className="text-xl font-bold text-danger">{riskStats.high}</div>
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-danger rounded-full" style={{ width: `${total > 0 ? (riskStats.high / total) * 100 : 0}%` }} />
</div>
</div>
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"> (0.6-0.8)</div>
<div className="text-xl font-bold text-warning">{riskStats.mediumHigh}</div>
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-warning rounded-full" style={{ width: `${total > 0 ? (riskStats.mediumHigh / total) * 100 : 0}%` }} />
</div>
</div>
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"> (0.4-0.6)</div>
<div className="text-xl font-bold text-primary">{riskStats.medium}</div>
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full" style={{ width: `${total > 0 ? (riskStats.medium / total) * 100 : 0}%` }} />
</div>
</div>
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"></div>
<div className="text-xl font-bold text-text-primary">{(riskStats.avgRisk * 100).toFixed(1)}%</div>
<div className="mt-1.5 text-[10px] text-text-muted">
: {riskStats.topDistricts.slice(0, 2).map(([d, n]) => `${d}(${n})`).join(', ')}
</div>
</div>
</div>
);
});
interface AlertCardProps {
alert: ExtendedAlert;
isSelected?: boolean;
alertId: string;
onCardClick: (id: string) => void;
}
const AlertCard = React.memo(function AlertCard({ alert, isSelected, alertId, onCardClick }: AlertCardProps) {
const isP1 = alert.priority === 'P1';
const riskPercent = Math.round(alert.risk_value * 100);
const handleClick = useCallback(() => {
onCardClick(alertId);
}, [alertId, onCardClick]);
return (
<div
className={`card overflow-hidden transition-colors cursor-pointer ${
isSelected ? 'border-primary ring-1 ring-primary' : 'hover:border-primary'
}`}
onClick={handleClick}
>
<div className={`px-4 py-3 border-b ${isP1 ? 'bg-danger/5 border-danger/20' : 'bg-warning/5 border-warning/20'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${isP1 ? 'bg-danger' : 'bg-warning'}`} />
<span className={`text-[11px] font-bold ${isP1 ? 'text-danger' : 'text-warning'}`}>
{alert.priority}
</span>
<span className="text-[10px] text-text-muted">
{HORIZON_LABELS[alert.forecast_horizon] || '未知'}
</span>
</div>
<span className={`text-[18px] font-bold ${isP1 ? 'text-danger' : 'text-warning'}`}>
{riskPercent}%
</span>
</div>
</div>
<div className="p-4">
<div className="mb-3">
<div className="text-[13px] font-semibold mb-1">
{alert.region} - {alert.street}
</div>
<div className="text-[11px] text-text-muted">
{alert.grid_id}
</div>
</div>
<div className={`text-[12px] px-3 py-2 rounded mb-3 ${
isP1 ? 'bg-danger/10 text-danger' : 'bg-warning/10 text-warning'
}`}>
{alert.reason}
</div>
<div className="flex items-center justify-between text-[11px] text-text-muted">
<span>{alert.forecast_time}</span>
<span>{alert.timestamp}</span>
</div>
</div>
</div>
);
});
interface AlertsListProps {
filteredAlerts: ExtendedAlert[];
selectedAlert: string | null;
onCardClick: (id: string) => void;
}
export const AlertsList = React.memo(function AlertsList({ filteredAlerts, selectedAlert, onCardClick }: AlertsListProps) {
return (
<div className="space-y-3 max-h-[calc(100vh-280px)] overflow-y-auto">
{filteredAlerts.slice(0, 50).map((alert) => (
<AlertCard
key={alert.alert_id}
alert={alert}
isSelected={selectedAlert === alert.alert_id}
alertId={alert.alert_id}
onCardClick={onCardClick}
/>
))}
{filteredAlerts.length > 50 && (
<div className="text-center text-text-muted text-[12px] py-2">
{filteredAlerts.length - 50}
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,129 @@
import React from 'react';
import { LoadingState } from '@/components/ui';
import type { CellInfo } from '@/components/AlertMap';
import { AlertsToolbar } from './AlertsToolbar';
import { AlertsFilterBar } from './AlertsFilterBar';
import { AlertsMapPanel } from './AlertsMapPanel';
import { AlertsList, RiskDistributionSummary } from './AlertsList';
import type { ExtendedAlert, RiskStats } from './types';
interface AlertsListTabProps {
// toolbar
forecastDay: 1 | 3 | 7;
onForecastDayChange: (day: 1 | 3 | 7) => void;
isFullscreen: boolean;
onToggleFullscreen: () => void;
onExportCsv: () => void;
onExportJson: () => void;
// filter bar
selectedHorizon: number | 'all';
onHorizonChange: (horizon: number | 'all') => void;
selectedPriority: 'all' | 'P1' | 'P2';
onPriorityChange: (priority: 'all' | 'P1' | 'P2') => void;
riskRange: [number, number];
onRiskRangeChange: (range: [number, number]) => void;
showMap: boolean;
onToggleMap: () => void;
showAlertMarkers: boolean;
onToggleAlertMarkers: () => void;
showGrid: boolean;
onToggleGrid: () => void;
sortBy: 'risk' | 'time';
onSortByChange: (sortBy: 'risk' | 'time') => void;
// data
riskStats: RiskStats;
filteredAlerts: ExtendedAlert[];
isLoading: boolean;
selectedGridId: string | null;
selectedAlert: string | null;
onGridClick: (gridId: string) => void;
onCellInfo: (info: CellInfo) => void;
onCardClick: (id: string) => void;
// privacy/role results (computed by orchestrator)
effectiveShowAlertMarkers: boolean;
isCluster: boolean;
isOfficial: boolean;
}
export const AlertsListTab = React.memo(function AlertsListTab(props: AlertsListTabProps) {
const {
filteredAlerts,
isLoading,
isCluster,
isFullscreen,
showMap,
riskStats,
} = props;
return (
<>
<AlertsToolbar
forecastDay={props.forecastDay}
onForecastDayChange={props.onForecastDayChange}
isFullscreen={isFullscreen}
onToggleFullscreen={props.onToggleFullscreen}
onExportCsv={props.onExportCsv}
onExportJson={props.onExportJson}
/>
<AlertsFilterBar
selectedHorizon={props.selectedHorizon}
onHorizonChange={props.onHorizonChange}
selectedPriority={props.selectedPriority}
onPriorityChange={props.onPriorityChange}
riskRange={props.riskRange}
onRiskRangeChange={props.onRiskRangeChange}
showMap={showMap}
onToggleMap={props.onToggleMap}
showAlertMarkers={props.showAlertMarkers}
onToggleAlertMarkers={props.onToggleAlertMarkers}
showGrid={props.showGrid}
onToggleGrid={props.onToggleGrid}
sortBy={props.sortBy}
onSortByChange={props.onSortByChange}
isCluster={isCluster}
isOfficial={props.isOfficial}
/>
<RiskDistributionSummary riskStats={riskStats} total={filteredAlerts.length} />
{isLoading ? (
<div className="card p-8">
<LoadingState />
</div>
) : filteredAlerts.length === 0 && !isCluster ? (
// 聚类(医生)视角即使没有个体预警,也要展示聚合密度栅格——故不走空状态分支。
<div className="card p-8 text-center">
<svg className="w-12 h-12 mx-auto mb-3 text-text-muted opacity-50" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
</svg>
<div className="text-text-muted text-[13px]"></div>
</div>
) : (
<div className={`grid gap-4 ${isFullscreen ? 'grid-cols-1' : 'grid-cols-[1fr_400px]'}`}>
{showMap && (
<AlertsMapPanel
selectedGridId={props.selectedGridId}
onGridClick={props.onGridClick}
onCellInfo={props.onCellInfo}
forecastDay={props.forecastDay}
effectiveShowAlertMarkers={props.effectiveShowAlertMarkers}
showGrid={props.showGrid}
filteredAlerts={filteredAlerts}
isFullscreen={isFullscreen}
isCluster={isCluster}
isOfficial={props.isOfficial}
/>
)}
{!isFullscreen && (
<AlertsList
filteredAlerts={filteredAlerts}
selectedAlert={props.selectedAlert}
onCardClick={props.onCardClick}
/>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,64 @@
import React from 'react';
import { TESTIDS } from '@/utils/testids';
import { AlertMap } from '@/components/AlertMap';
import type { CellInfo } from '@/components/AlertMap';
import type { ExtendedAlert } from './types';
interface AlertsMapPanelProps {
selectedGridId: string | null;
onGridClick: (gridId: string) => void;
onCellInfo: (info: CellInfo) => void;
forecastDay: 1 | 3 | 7;
// effectiveShowAlertMarkers唯一真值cluster 模式恒为 false隐私不变量由 orchestrator 计算。
effectiveShowAlertMarkers: boolean;
showGrid: boolean;
filteredAlerts: ExtendedAlert[];
isFullscreen: boolean;
isCluster: boolean;
isOfficial: boolean;
}
export const AlertsMapPanel = React.memo(function AlertsMapPanel({
selectedGridId,
onGridClick,
onCellInfo,
forecastDay,
effectiveShowAlertMarkers,
showGrid,
filteredAlerts,
isFullscreen,
isCluster,
isOfficial,
}: AlertsMapPanelProps) {
return (
<div data-testid={isCluster ? TESTIDS.clusterView : undefined}>
<AlertMap
selectedGridId={selectedGridId}
onGridClick={onGridClick}
onCellInfo={onCellInfo}
forecastDay={forecastDay}
showAlertMarkers={effectiveShowAlertMarkers}
showGrid={isOfficial ? false : showGrid}
filteredAlerts={filteredAlerts}
isFullscreen={isFullscreen}
/>
{/*
隐私不变量的「数据级」可断言点:每渲染一个个体病例点标记,就在此输出一个
data-testid="patient-point" 的隐藏标记。Leaflet 的 CircleMarker 是 canvas/SVG
内部对象、不带 testid无法被 e2e 直接计数;这里把「实际会显示的个体点集合」
镜像成 DOM使测试可断言医生/聚类视角下 patient-point 计数恒为 0
而无需窥探 Leaflet 内部。effectiveShowAlertMarkers 在 cluster 模式恒为 false
故该集合为空。
*/}
{effectiveShowAlertMarkers &&
filteredAlerts.map((a) => (
<span
key={a.alert_id}
data-testid={TESTIDS.patientPoint}
className="hidden"
aria-hidden
/>
))}
</div>
);
});

View File

@@ -0,0 +1,130 @@
import React, { useMemo } from 'react';
import { LoadingState } from '@/components/ui';
import { StatCard } from '@/components/StatCard';
import { StatisticalCharts } from '@/components/StatisticalCharts';
import { PieChart, Pie, Cell, Tooltip as RechartsTooltip, Legend, ResponsiveContainer } from 'recharts';
import type { RiskStats } from './types';
interface AlertsRiskPanelProps {
riskStats: RiskStats;
trendData: Array<{ date: string; cases: number; risk: number }>;
trendLoading: boolean;
trendError: string | null;
}
export const AlertsRiskPanel = React.memo(function AlertsRiskPanel({
riskStats,
trendData,
trendLoading,
trendError,
}: AlertsRiskPanelProps) {
// Severity donut data (P1/P2)
const alertPie = useMemo(() => ([
{ name: 'P1 (紧急)', value: riskStats.p1, color: '#ef4444' },
{ name: 'P2 (关注)', value: riskStats.p2, color: '#f59e0b' },
]), [riskStats.p1, riskStats.p2]);
const topDistrictMax = useMemo(
() => riskStats.topDistricts.reduce((m, [, n]) => Math.max(m, n), 0),
[riskStats.topDistricts],
);
return (
<div className="space-y-4">
{/* Risk distribution as StatCards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="高风险 (≥0.8)" value={riskStats.high} color="#ef4444" />
<StatCard label="中高风险 (0.6-0.8)" value={riskStats.mediumHigh} color="#f59e0b" />
<StatCard label="中风险 (0.4-0.6)" value={riskStats.medium} color="#3b82f6" />
<StatCard label="平均风险" value={`${(riskStats.avgRisk * 100).toFixed(1)}%`} />
</div>
{/* Risk trend chart (real data from /api/analysis/trend) */}
{trendLoading ? (
<div className="card p-8"><LoadingState /></div>
) : trendError ? (
<div className="card p-8 text-center text-danger text-[13px]">{trendError}</div>
) : trendData.length === 0 ? (
<div className="card p-8 text-center text-text-muted text-[13px]"></div>
) : (
<StatisticalCharts
data={trendData}
showCases={false}
showRisk
height={280}
/>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Top high-risk districts bar */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
Top 5
</div>
{riskStats.topDistricts.length > 0 ? (
<div className="space-y-3">
{riskStats.topDistricts.map(([district, count]) => (
<div key={district}>
<div className="flex items-center justify-between text-[12px] mb-1">
<span className="text-text-primary font-medium">{district}</span>
<span className="text-text-muted">{count} </span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-danger rounded-full"
style={{ width: `${topDistrictMax > 0 ? (count / topDistrictMax) * 100 : 0}%` }}
/>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-text-muted text-[13px]"></div>
)}
</div>
{/* Alert severity donut (P1/P2) */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
</div>
{riskStats.p1 > 0 || riskStats.p2 > 0 ? (
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={alertPie}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={4}
dataKey="value"
nameKey="name"
>
{alertPie.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<RechartsTooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number, name: string) => [value, name]}
/>
<Legend
wrapperStyle={{ fontSize: '12px' }}
formatter={(value: string) => <span className="text-text-secondary">{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-text-muted text-[13px]"></div>
)}
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,73 @@
import React from 'react';
interface AlertsToolbarProps {
forecastDay: 1 | 3 | 7;
onForecastDayChange: (day: 1 | 3 | 7) => void;
isFullscreen: boolean;
onToggleFullscreen: () => void;
onExportCsv: () => void;
onExportJson: () => void;
}
// Toolbar Row 1: 网格预测时效 + 全屏 + 导出.
export const AlertsToolbar = React.memo(function AlertsToolbar({
forecastDay,
onForecastDayChange,
isFullscreen,
onToggleFullscreen,
onExportCsv,
onExportJson,
}: AlertsToolbarProps) {
return (
<div className="card p-3 mb-3">
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-0.5 bg-bg-page p-0.5 rounded">
{([1, 3, 7] as const).map((day) => (
<button
key={day}
onClick={() => onForecastDayChange(day)}
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
forecastDay === day
? 'bg-bg-card text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{day}
</button>
))}
</div>
</div>
<div className="w-px h-6 bg-border" />
<button
onClick={onToggleFullscreen}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
isFullscreen
? 'bg-bg-card text-primary border border-primary'
: 'bg-bg-page text-text-secondary border border-border'
}`}
>
{isFullscreen ? '退出全屏' : '全屏'}
</button>
<div className="w-px h-6 bg-border" />
<button
onClick={onExportCsv}
className="px-3 py-1.5 text-[12px] font-medium rounded bg-bg-page text-text-secondary border border-border hover:border-primary transition-colors"
>
CSV
</button>
<button
onClick={onExportJson}
className="px-3 py-1.5 text-[12px] font-medium rounded bg-bg-page text-text-secondary border border-border hover:border-primary transition-colors"
>
JSON
</button>
</div>
</div>
);
});

View File

@@ -0,0 +1,32 @@
// Shared types for the alerts dashboard subcomponents.
export interface ExtendedAlert {
alert_id: string;
grid_id: string;
region: string;
street: string;
latitude: number;
longitude: number;
risk_value: number;
risk_level: 'high' | 'medium_high' | 'medium' | 'medium_low' | 'low';
priority: 'P1' | 'P2';
forecast_horizon: number;
forecast_time: string;
reason: string;
timestamp: string;
}
export const HORIZON_LABELS: Record<number, string> = {
1: '1 天后',
3: '3 天后',
7: '7 天后',
};
export interface RiskStats {
p1: number;
p2: number;
high: number;
mediumHigh: number;
medium: number;
avgRisk: number;
topDistricts: [string, number][];
}

View File

@@ -0,0 +1,163 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import { useRiskStore } from '@/stores';
import { analysisApi } from '@/services/api';
import type { ExtendedAlert, RiskStats } from './types';
interface UseAlertsDataParams {
selectedHorizon: number | 'all';
selectedPriority: 'all' | 'P1' | 'P2';
sortBy: 'risk' | 'time';
debouncedRiskRange: [number, number];
activeTab: 'list' | 'stats';
}
interface TrendPoint { date: string; cases: number; risk: number }
// 预警仪表盘的数据层:派生 extendedAlerts/filteredAlerts/riskStats、按需拉取风险趋势、
// 以及 CSV/JSON 导出辅助。角色/隐私计算保留在 orchestrator不在此处。
export function useAlertsData({
selectedHorizon,
selectedPriority,
sortBy,
debouncedRiskRange,
activeTab,
}: UseAlertsDataParams) {
const alerts = useRiskStore((s) => s.alerts);
// Risk-trend data for the 风险统计 tab, fetched on demand
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
const [trendLoading, setTrendLoading] = useState(false);
const [trendError, setTrendError] = useState<string | null>(null);
const [trendLoaded, setTrendLoaded] = useState(false);
// Fetch real risk-trend data when the 风险统计 tab is first opened
useEffect(() => {
if (activeTab !== 'stats' || trendLoaded) return;
let cancelled = false;
setTrendLoading(true);
setTrendError(null);
analysisApi
.getTrend(14)
.then((res: { dates?: string[]; values?: number[] }) => {
if (cancelled) return;
const dates = res?.dates ?? [];
const values = res?.values ?? [];
setTrendData(dates.map((date, i) => ({ date, cases: 0, risk: values[i] ?? 0 })));
setTrendLoaded(true);
})
.catch((err: unknown) => {
if (cancelled) return;
setTrendError(err instanceof Error ? err.message : '加载风险趋势失败');
})
.finally(() => {
if (!cancelled) setTrendLoading(false);
});
return () => { cancelled = true; };
}, [activeTab, trendLoaded]);
const extendedAlerts: ExtendedAlert[] = useMemo(() => {
const now = Date.now();
return (alerts || []).map((alert) => {
const forecastDate = new Date(alert.forecast_time);
const diffDays = Math.ceil((forecastDate.getTime() - now) / (1000 * 60 * 60 * 24));
const horizon = diffDays <= 1 ? 1 : diffDays <= 3 ? 3 : 7;
return {
...alert,
latitude: alert.latitude || 0,
longitude: alert.longitude || 0,
forecast_horizon: horizon,
};
});
}, [alerts]);
const filteredAlerts = useMemo(() => {
return extendedAlerts
.filter((alert) => {
const horizonMatch = selectedHorizon === 'all' || alert.forecast_horizon === selectedHorizon;
const priorityMatch = selectedPriority === 'all' || alert.priority === selectedPriority;
const riskMatch = alert.risk_value >= debouncedRiskRange[0] && alert.risk_value <= debouncedRiskRange[1];
return horizonMatch && priorityMatch && riskMatch;
})
.sort((a, b) => {
if (sortBy === 'risk') {
return b.risk_value - a.risk_value;
}
return new Date(b.forecast_time).getTime() - new Date(a.forecast_time).getTime();
});
}, [extendedAlerts, selectedHorizon, selectedPriority, sortBy, debouncedRiskRange]);
// Risk distribution stats (includes p1/p2 counts) — single pass over each array
const riskStats: RiskStats = useMemo(() => {
// p1/p2 reflect the full (unfiltered) alert set
let p1 = 0;
let p2 = 0;
for (const a of extendedAlerts) {
if (a.priority === 'P1') p1++;
else if (a.priority === 'P2') p2++;
}
// Single pass over filteredAlerts: counters + sum + district map
let high = 0;
let mediumHigh = 0;
let medium = 0;
let sum = 0;
const byDistrict: Record<string, number> = {};
for (const a of filteredAlerts) {
const v = a.risk_value;
if (v >= 0.8) high++;
else if (v >= 0.6) mediumHigh++;
else if (v >= 0.4) medium++;
sum += v;
const d = a.region || '未知';
byDistrict[d] = (byDistrict[d] || 0) + 1;
}
const avgRisk = filteredAlerts.length > 0 ? sum / filteredAlerts.length : 0;
const topDistricts = Object.entries(byDistrict)
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
return { p1, p2, high, mediumHigh, medium, avgRisk, topDistricts };
}, [extendedAlerts, filteredAlerts]);
// Export utilities
const exportToCsv = useCallback(() => {
const headers = ['alert_id', 'grid_id', 'region', 'street', 'latitude', 'longitude', 'risk_value', 'priority', 'forecast_horizon', 'reason', 'timestamp'];
const rows = filteredAlerts.map(a => [
a.alert_id, a.grid_id, a.region, a.street,
a.latitude, a.longitude, a.risk_value, a.priority,
a.forecast_horizon, `"${a.reason}"`, a.timestamp,
]);
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `alerts_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
}, [filteredAlerts]);
const exportToJson = useCallback(() => {
const json = JSON.stringify(filteredAlerts, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `alerts_${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
}, [filteredAlerts]);
return {
extendedAlerts,
filteredAlerts,
riskStats,
trendData,
trendLoading,
trendError,
exportToCsv,
exportToJson,
};
}

View File

@@ -0,0 +1,87 @@
import { memo } from 'react';
import { CLINICAL_COLORS } from './chartColors';
export interface BoxRow {
label: string;
p25: number;
median: number;
p75: number;
n: number;
}
interface BoxPlotRowsProps {
rows: BoxRow[];
/** 数值单位后缀,如 "天" / ""。 */
unit?: string;
/** 标签列宽px。 */
labelWidth?: number;
}
/**
* 横向箱线图p25中位p75。Recharts 无原生 box plot
* 故用纯 div 渲染:每行一条从 p25 到 p75 的横条,中位处一根竖向刻度。
* 复用于「各病种住院天数」与「年龄别BMI」。
*/
export const BoxPlotRows = memo(function BoxPlotRows({
rows,
unit = '',
labelWidth = 96,
}: BoxPlotRowsProps) {
if (!rows || rows.length === 0) {
return <div className="text-center py-8 text-text-muted text-sm"></div>;
}
// 统一横轴域:覆盖所有行的 p25..p75留一点边距。
const domainMin = Math.min(...rows.map((r) => r.p25));
const domainMax = Math.max(...rows.map((r) => r.p75));
const span = domainMax - domainMin || 1;
const pct = (v: number) => ((v - domainMin) / span) * 100;
return (
<div className="space-y-2.5">
{rows.map((r) => {
const left = pct(r.p25);
const right = pct(r.p75);
const width = Math.max(right - left, 0.5);
const medianLeft = pct(r.median);
return (
<div key={r.label} className="flex items-center gap-2 text-[11px]">
<div
className="shrink-0 truncate text-text-secondary text-right"
style={{ width: labelWidth }}
title={r.label}
>
{r.label}
</div>
<div className="relative flex-1 h-5 rounded bg-bg-hover">
{/* p25p75 箱体 */}
<div
className="absolute top-1 bottom-1 rounded-sm"
style={{
left: `${left}%`,
width: `${width}%`,
backgroundColor: CLINICAL_COLORS.box,
opacity: 0.35,
}}
/>
{/* 中位刻度 */}
<div
className="absolute top-0.5 bottom-0.5 w-[2px] rounded"
style={{
left: `${medianLeft}%`,
backgroundColor: CLINICAL_COLORS.boxMedian,
}}
title={`中位 ${r.median}${unit}`}
/>
</div>
<div className="shrink-0 w-28 text-text-muted tabular-nums">
{r.p25}<span className="font-semibold text-text-secondary">{r.median}</span>{r.p75}
{unit}
<span className="ml-1 text-[10px] text-text-muted">n={r.n}</span>
</div>
</div>
);
})}
</div>
);
});

View File

@@ -0,0 +1,42 @@
import { memo } from 'react';
import { Users, CalendarDays, HeartPulse, Siren } from 'lucide-react';
import { StatCard } from '@/components/StatCard';
import { TESTIDS } from '@/utils/testids';
import type { InpatientClinicalResponse } from '@/services/api';
interface ClinicalKpiRowProps {
kpis: InpatientClinicalResponse['kpis'];
}
/** 住院临床 4 项核心指标。375px 下 2 列sm 起 4 列。 */
export const ClinicalKpiRow = memo(function ClinicalKpiRow({ kpis }: ClinicalKpiRowProps) {
return (
<div
data-testid={TESTIDS.clinicalKpis}
className="grid grid-cols-2 sm:grid-cols-4 gap-3"
>
<StatCard
icon={<Users className="w-4 h-4 text-primary" />}
label="住院总人次"
value={kpis.total_admissions.toLocaleString()}
/>
<StatCard
icon={<CalendarDays className="w-4 h-4 text-primary" />}
label="中位住院日"
value={`${kpis.median_los_days}`}
/>
<StatCard
icon={<HeartPulse className="w-4 h-4 text-success" />}
label="治愈好转率"
value={`${(kpis.cure_rate * 100).toFixed(1)}%`}
color="#16A34A"
/>
<StatCard
icon={<Siren className="w-4 h-4 text-warning" />}
label="急诊入院占比"
value={`${(kpis.emergency_admit_ratio * 100).toFixed(1)}%`}
color="#D97706"
/>
</div>
);
});

View File

@@ -0,0 +1,56 @@
import { memo } from 'react';
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
export interface DonutSlice {
name: string;
value: number;
}
interface DonutChartProps {
data: DonutSlice[];
/** name -> color。未命中时按 palette 顺序回退。 */
colorMap?: Record<string, string>;
}
/** 通用环形图。复用于「出院结局构成」与「入院途径构成」。 */
export const DonutChart = memo(function DonutChart({ data, colorMap }: DonutChartProps) {
if (!data || data.length === 0) {
return <div className="text-center py-8 text-text-muted text-sm"></div>;
}
const total = data.reduce((s, d) => s + d.value, 0);
const colorFor = (name: string, idx: number) =>
colorMap?.[name] ??
CLINICAL_COLORS.routePalette[idx % CLINICAL_COLORS.routePalette.length] ??
CLINICAL_COLORS.outcomeFallback;
return (
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={data}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={56}
outerRadius={88}
paddingAngle={2}
>
{data.map((d, idx) => (
<Cell key={d.name} fill={colorFor(d.name, idx)} />
))}
</Pie>
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(v: number, name: string) => [
`${v.toLocaleString()}${total > 0 ? ((v / total) * 100).toFixed(1) : '0'}%`,
name,
]}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
</PieChart>
</ResponsiveContainer>
);
});

View File

@@ -0,0 +1,51 @@
import { memo } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
interface HistogramChartProps {
data: { bin_label: string; count: number }[];
color?: string;
/** tooltip 中数量的标签,如 "住院天数"。 */
countLabel?: string;
}
/** 通用直方图。用于「住院天数分布」。 */
export const HistogramChart = memo(function HistogramChart({
data,
color = CLINICAL_COLORS.los,
countLabel = '人次',
}: HistogramChartProps) {
if (!data || data.length === 0) {
return <div className="text-center py-8 text-text-muted text-sm"></div>;
}
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data} margin={{ top: 5, right: 12, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CLINICAL_COLORS.grid} vertical={false} />
<XAxis
dataKey="bin_label"
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }}
interval={0}
angle={-30}
textAnchor="end"
height={50}
/>
<YAxis tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }} width={40} />
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(v: number) => [`${v.toLocaleString()}`, countLabel]}
/>
<Bar dataKey="count" fill={color} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);
});

View File

@@ -0,0 +1,34 @@
/**
* 住院临床分析页图表字面色值集中处。
* Recharts 需要原始 hex无法用 Tailwind class故在此集中定义避免散落 magic hex。
*/
export const CLINICAL_COLORS = {
primary: '#2563EB', // primary
los: '#2563EB',
box: '#3B82F6', // 箱体填充
boxMedian: '#1D4ED8', // 中位刻度
grid: '#E2E8F0',
axis: '#64748B',
axisLabel: '#374151',
tooltipBorder: '#E2E8F0',
tooltipText: '#1E293B',
// 出院结局按严重程度配色:治愈/好转偏绿,未愈/死亡偏红,其他中性
outcome: {
: '#16A34A',
: '#4ADE80',
: '#94A3B8',
: '#F97316',
: '#DC2626',
} as Record<string, string>,
outcomeFallback: '#94A3B8',
// 入院途径 donut 顺序色板
routePalette: ['#2563EB', '#0891B2', '#7C3AED', '#D97706', '#16A34A', '#DC2626'],
} as const;
/** Recharts tooltip 通用样式。 */
export const TOOLTIP_STYLE = {
backgroundColor: '#FFFFFF',
border: `1px solid ${CLINICAL_COLORS.tooltipBorder}`,
borderRadius: '8px',
fontSize: '12px',
} as const;

View File

@@ -0,0 +1,145 @@
import { memo } from 'react';
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { ErrorBanner } from '@/components/ErrorBanner';
import { CalendarHeatmap } from '@/components/CalendarHeatmap';
import type { TopDiagnosis } from './types';
function formatDateLabel(dateStr: string): string {
const d = new Date(dateStr);
return `${d.getMonth() + 1}/${d.getDate()}`;
}
interface CaseStatsTabProps {
loading: boolean;
loaded: boolean;
error: string | null;
currentDate: string;
topDiagnoses: TopDiagnosis[];
caseTrend: Array<{ date: string; cases: number; aqi: number }>;
heatmapData: Array<{ date: string; value: number }>;
heatmapYear: number | null;
onRetry: () => void;
onDismissError: () => void;
}
// 病例统计 tab —— 诊断分布 / 病例与AQI趋势 / 日历热力图。纯展示,数据由父级按需加载。
export const CaseStatsTab = memo(function CaseStatsTab({
loading,
loaded,
error,
currentDate,
topDiagnoses,
caseTrend,
heatmapData,
heatmapYear,
onRetry,
onDismissError,
}: CaseStatsTabProps) {
if (loading && !loaded) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="space-y-6">
{error && (
<ErrorBanner
error={error}
onRetry={onRetry}
onDismiss={onDismissError}
/>
)}
{/* Top 5 诊断分布 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top 5 </h3>
{topDiagnoses.length > 0 ? (
<ResponsiveContainer width="100%" height={240}>
<BarChart
data={[...topDiagnoses].reverse()}
layout="vertical"
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
<YAxis
type="category"
dataKey="diagnosis"
tick={{ fontSize: 11, fill: '#374151' }}
width={100}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{ backgroundColor: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '8px', fontSize: '12px' }}
formatter={(value: number) => [value.toLocaleString(), '病例数']}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={16} />
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={16} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
{/* 近30日病例与AQI趋势 (driven off Monitoring timeline currentDate) */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-1">AQI趋势</h3>
<p className="text-xs text-gray-500 mb-4"> {currentDate} 30</p>
{caseTrend.length > 0 ? (
<ResponsiveContainer width="100%" height={260}>
<LineChart data={caseTrend} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
dataKey="date"
tickFormatter={formatDateLabel}
tick={{ fontSize: 10, fill: '#64748B' }}
interval="preserveStartEnd"
axisLine={{ stroke: '#E2E8F0' }}
/>
<YAxis yAxisId="left" tick={{ fontSize: 10, fill: '#64748B' }} axisLine={{ stroke: '#E2E8F0' }} />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10, fill: '#F59E0B' }} axisLine={{ stroke: '#E2E8F0' }} />
<Tooltip
contentStyle={{ backgroundColor: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '8px', fontSize: '12px' }}
labelStyle={{ color: '#1E293B', fontWeight: 600 }}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Line yAxisId="left" type="monotone" dataKey="cases" name="病例数" stroke="#3B82F6" strokeWidth={2} dot={false} activeDot={{ r: 3 }} />
<Line yAxisId="right" type="monotone" dataKey="aqi" name="AQI" stroke="#F59E0B" strokeWidth={2} strokeDasharray="5 5" dot={false} activeDot={{ r: 3 }} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
{/* 日历热力图 (year derived from data) */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{heatmapYear ? `${heatmapYear}` : ''}
</h3>
{heatmapYear && heatmapData.length > 0 ? (
<CalendarHeatmap data={heatmapData} year={heatmapYear} />
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,66 @@
import { memo } from 'react';
import { ErrorBanner } from '@/components/ErrorBanner';
import { MetricHeatmapTable } from '@/components/MetricHeatmapTable';
interface DistrictStatsTabProps {
loading: boolean;
loaded: boolean;
error: string | null;
rows: string[];
data: Record<string, Record<string, number>>;
onRetry: () => void;
onDismissError: () => void;
onSort: (col: string) => void;
}
// 区域统计 tab —— 区域指标热力表。纯展示,排序键由父级持有。
export const DistrictStatsTab = memo(function DistrictStatsTab({
loading,
loaded,
error,
rows,
data,
onRetry,
onDismissError,
onSort,
}: DistrictStatsTabProps) {
if (loading && !loaded) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="space-y-6">
{error && (
<ErrorBanner
error={error}
onRetry={onRetry}
onDismiss={onDismissError}
/>
)}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-1"></h3>
<p className="text-xs text-gray-500 mb-4"></p>
{rows.length > 0 ? (
<MetricHeatmapTable
rows={rows}
columns={[
{ key: 'total', label: '病例' },
{ key: 'outpatient', label: '门诊' },
{ key: 'inpatient', label: '住院' },
{ key: 'inpatient_ratio', label: '住院占比%' },
]}
data={data}
onSort={onSort}
/>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,56 @@
import { memo } from 'react';
import { Calendar, Activity, TrendingUp, TrendingDown, Stethoscope, Zap, BarChart3 } from 'lucide-react';
import { StatCard } from '@/components/StatCard';
import type { MonitoringStats } from './types';
interface MonitoringStatsBarProps {
stats: MonitoringStats;
sparkline7d: number[];
}
// 监测页顶部统计条 —— 纯展示已自适应grid-cols-2 sm:grid-cols-3 lg:grid-cols-6
export const MonitoringStatsBar = memo(function MonitoringStatsBar({ stats, sparkline7d }: MonitoringStatsBarProps) {
return (
<div className="flex-1 min-w-0 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard
icon={<Calendar className="w-4 h-4 text-blue-600" />}
label="当日病例"
value={stats.todayCases !== null ? stats.todayCases.toLocaleString() : '--'}
/>
<StatCard
icon={<Activity className="w-4 h-4 text-indigo-600" />}
label="7日均值"
value={stats.avg7d.toLocaleString()}
sparkline={sparkline7d.length >= 2 ? { data: sparkline7d, color: '#6366F1' } : undefined}
/>
<StatCard
icon={
stats.trend === 'up' ? <TrendingUp className="w-4 h-4 text-red-500" /> :
stats.trend === 'down' ? <TrendingDown className="w-4 h-4 text-green-500" /> :
<Activity className="w-4 h-4 text-gray-400" />
}
label="趋势"
value={stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳'}
trend={{
direction: stats.trend === 'up' ? 'up' : stats.trend === 'down' ? 'down' : 'stable',
value: stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳',
}}
/>
<StatCard
icon={<Zap className="w-4 h-4 text-amber-500" />}
label="峰值日"
value={`${stats.maxDay.cases.toLocaleString()} (${stats.maxDay.date.slice(5)})`}
/>
<StatCard
icon={<BarChart3 className="w-4 h-4 text-purple-500" />}
label="标准差"
value={stats.stdDev.toLocaleString()}
/>
<StatCard
icon={<Stethoscope className="w-4 h-4 text-orange-500" />}
label="门诊 / 住院"
value={`${stats.totalOutpatient.toLocaleString()} / ${stats.totalInpatient.toLocaleString()}`}
/>
</div>
);
});

View File

@@ -0,0 +1,142 @@
import { memo, useMemo, useCallback } from 'react';
import { StatisticalCharts } from '@/components/StatisticalCharts';
import { CaseLocationMap } from '@/components/CaseLocationMap';
import { Segmented } from '@/components/ui';
import { TESTIDS } from '@/utils/testids';
import type { Granularity, DistrictCaseRow } from './types';
interface OverviewTabProps {
isLoading: boolean;
chartData: Array<{ date: string; cases: number; aqi?: number }>;
districtCases: DistrictCaseRow[];
selectedDistrict: string | null;
selectedStreet: string | null;
currentDate: string;
granularity: Granularity;
onGranularityChange: (g: Granularity) => void;
onDistrictSelect: (district: string) => void;
}
// 概览 tab —— 病例分布地图 + 统计图表 + 区县 roll-up粒度真相来源在父级 URL
export const OverviewTab = memo(function OverviewTab({
isLoading,
chartData,
districtCases,
selectedDistrict,
selectedStreet,
currentDate,
granularity,
onGranularityChange,
onDistrictSelect,
}: OverviewTabProps) {
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Case Location Map */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<CaseLocationMap height="400px" district={selectedDistrict} street={selectedStreet} date={currentDate} />
</div>
{/* Statistical Charts */}
<StatisticalCharts
data={chartData}
height={350}
showCases={true}
showAQI={true}
/>
{/* District breakdown — 区域 roll-upURL 粒度真相来源) */}
<div data-testid={TESTIDS.districtRollup} className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900"></h3>
<Segmented<Granularity>
testid={TESTIDS.granularityControl}
size="sm"
options={[
{ value: 'city', label: '全市' },
{ value: 'district', label: '区域' },
{ value: 'street', label: '街道' },
]}
value={granularity}
onChange={onGranularityChange}
/>
</div>
<div className="space-y-2">
<DistrictBreakdown
districtCases={districtCases}
selectedDistrict={selectedDistrict}
onDistrictSelect={onDistrictSelect}
/>
</div>
<div className="flex items-center gap-4 mt-3 pt-2 border-t border-gray-100">
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<span className="w-3 h-3 bg-orange-400 rounded-sm" />
</div>
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<span className="w-3 h-3 bg-red-400 rounded-sm" />
</div>
</div>
</div>
</div>
);
});
interface DistrictBreakdownProps {
districtCases: DistrictCaseRow[];
selectedDistrict: string | null;
// 点击区域条目时上抛——由父组件驱动 URL粒度真相来源不在此处 mutate store。
onDistrictSelect: (district: string) => void;
}
const DistrictBreakdown = memo(function DistrictBreakdown({ districtCases, selectedDistrict, onDistrictSelect }: DistrictBreakdownProps) {
const sortedCases = useMemo(() => [...districtCases].sort((a, b) => b.total - a.total), [districtCases]);
const maxTotal = useMemo(() => sortedCases.length > 0 ? sortedCases[0].total : 1, [sortedCases]);
const handleDistrictClick = useCallback((district: string) => {
onDistrictSelect(district);
}, [onDistrictSelect]);
return (
<>
{sortedCases.map((d) => {
const outPct = d.total > 0 ? (d.outpatient / d.total) * 100 : 0;
const inPct = d.total > 0 ? (d.inpatient / d.total) * 100 : 0;
const barWidth = (d.total / maxTotal) * 100;
return (
<div
key={d.district}
className={`flex items-center gap-3 p-2 rounded cursor-pointer transition-colors ${
selectedDistrict === d.district ? 'bg-blue-50' : 'hover:bg-gray-50'
}`}
onClick={() => handleDistrictClick(d.district)}
>
<div className="w-16 text-sm text-gray-700 text-right shrink-0">{d.district}</div>
<div className="flex-1 h-6 bg-gray-100 rounded overflow-hidden flex">
<div
className="bg-orange-400 h-full transition-all"
style={{ width: `${barWidth * outPct / 100}%` }}
title={`门诊: ${d.outpatient.toLocaleString()}`}
/>
<div
className="bg-red-400 h-full transition-all"
style={{ width: `${barWidth * inPct / 100}%` }}
title={`住院: ${d.inpatient.toLocaleString()}`}
/>
</div>
<div className="w-20 text-right text-sm font-medium text-gray-900 shrink-0">
{d.total.toLocaleString()}
</div>
</div>
);
})}
</>
);
});

View File

@@ -0,0 +1,38 @@
// 监测页内部共享类型。Granularity 的真相来源仍是 URL由 MonitoringDashboard 拥有;
// 此处只暴露类型与子组件复用的 props 形状。
export type Granularity = 'city' | 'district' | 'street';
export const GRANULARITY_VALUES: readonly Granularity[] = ['city', 'district', 'street'] as const;
export function parseGranularity(raw: string | null): Granularity {
return GRANULARITY_VALUES.includes(raw as Granularity) ? (raw as Granularity) : 'city';
}
// 概览 tab 区县条目所需的最小字段(来自 monitoringStore 的 districtCases
export interface DistrictCaseRow {
district: string;
total: number;
outpatient: number;
inpatient: number;
}
export interface MonitoringStats {
totalCases: number;
avgCases: number;
maxDay: { date: string; cases: number };
minDay: { date: string; cases: number };
stdDev: number;
trend: 'up' | 'down' | 'stable';
totalOutpatient: number;
totalInpatient: number;
avg7d: number;
todayCases: number | null;
noData: boolean;
}
export interface TopDiagnosis {
diagnosis: string;
outpatient: number;
inpatient: number;
total: number;
}

View File

@@ -0,0 +1,317 @@
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { useMonitoringStore } from '@/stores';
import { useDiseaseStore } from '@/stores/diseaseStore';
import { gridApi, caseApi, envApi } from '@/services/api';
import type { DistrictCaseData } from '@/types';
import type { MonitoringStats, TopDiagnosis } from './types';
type MonitoringTab = 'overview' | 'cases' | 'districts';
interface UseMonitoringDataArgs {
activeTab: MonitoringTab;
currentDate: string;
selectedDistrict: string | null;
}
// 监测页数据层:图表 90 天窗口、病例统计/区域统计两个按需 tab 的加载与派生。
// 不触碰 URL/drilldown粒度真相来源仍由 MonitoringDashboard 持有),只消费 currentDate 与
// selectedDistrict 作为入参,避免把 store-mutation 逻辑下沉到子组件。
export function useMonitoringData({ activeTab, currentDate, selectedDistrict }: UseMonitoringDataArgs) {
const [chartData, setChartData] = useState<Array<{ date: string; cases: number; aqi?: number }>>([]);
// --- 病例统计 tab state (fetched on demand) ---
const [topDiagnoses, setTopDiagnoses] = useState<TopDiagnosis[]>([]);
const [caseTrend, setCaseTrend] = useState<Array<{ date: string; cases: number; aqi: number }>>([]);
const [heatmapData, setHeatmapData] = useState<Array<{ date: string; value: number }>>([]);
const [heatmapYear, setHeatmapYear] = useState<number | null>(null);
const [casesTabLoaded, setCasesTabLoaded] = useState(false);
const [casesTabLoading, setCasesTabLoading] = useState(false);
const [casesTabError, setCasesTabError] = useState<string | null>(null);
// --- 区域统计 tab state (fetched on demand) ---
const [districtMetrics, setDistrictMetrics] = useState<DistrictCaseData[]>([]);
const [districtSortKey, setDistrictSortKey] = useState<string>('total');
const [districtTabLoaded, setDistrictTabLoaded] = useState(false);
const [districtTabLoading, setDistrictTabLoading] = useState(false);
const [districtTabError, setDistrictTabError] = useState<string | null>(null);
const districtCases = useMonitoringStore((s) => s.districtCases);
const fetchDistrictCases = useMonitoringStore((s) => s.fetchDistrictCases);
const { selectedDiagnoses } = useDiseaseStore();
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Load chart data for 90-day window ending at the given reference date
const loadChartData = useCallback((refDate: string, district?: string) => {
const end = new Date(refDate);
const start = new Date(refDate);
start.setDate(start.getDate() - 90);
const startStr = start.toISOString().split('T')[0];
const endStr = end.toISOString().split('T')[0];
if (selectedDiagnoses.length > 0 && selectedDiagnoses.length <= 3) {
caseApi.getTrend({
start_date: startStr,
end_date: endStr,
group_by: 'day',
diagnosis: selectedDiagnoses.join(','),
}).then((data) => {
const trend = data.trend || [];
setChartData(
trend.map((t: { date: string; total: number }) => ({ date: t.date, cases: t.total }))
);
}).catch((e) => { console.error('Failed to load chart data:', e); });
} else {
gridApi.getHistoricalAggregated(startStr, endStr, 'daily', district)
.then((data) => {
const rows = data.aggregations || [];
const dailyCases: Record<string, number> = {};
rows.forEach((item: { date: string; total_cases: number }) => {
dailyCases[item.date] = (dailyCases[item.date] || 0) + item.total_cases;
});
setChartData(
Object.entries(dailyCases)
.map(([date, cases]) => ({ date, cases }))
.sort((a, b) => a.date.localeCompare(b.date))
);
}).catch((e) => { console.error('Failed to load chart data:', e); });
}
// Fetch districtCases with date filter (single day = currentDate)
const diagnosisParam = selectedDiagnoses.length > 0 ? selectedDiagnoses.join(',') : undefined;
fetchDistrictCases(diagnosisParam, undefined, refDate);
}, [fetchDistrictCases, selectedDiagnoses]);
// 提供给外部(手动刷新 / 病种过滤)触发的去抖加载。
const debouncedLoadChart = useCallback(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
loadChartData(currentDate, selectedDistrict || undefined);
}, 300);
}, [loadChartData, currentDate, selectedDistrict]);
// Re-fetch when currentDate, district, or diagnoses change
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
loadChartData(currentDate, selectedDistrict || undefined);
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [currentDate, selectedDistrict, loadChartData]);
// Enhanced stats: window stats + current-date snapshot
const stats = useMemo<MonitoringStats>(() => {
const noData = chartData.length === 0;
const totalCases = noData ? 0 : chartData.reduce((sum, d) => sum + d.cases, 0);
const avgCases = noData ? 0 : Math.round(totalCases / chartData.length);
let maxDay = { date: '--', cases: 0 };
let minDay = { date: '--', cases: 0 };
let stdDev = 0;
let trend: 'up' | 'down' | 'stable' = 'stable';
if (!noData) {
maxDay = chartData.reduce((max, d) => d.cases > max.cases ? d : max, chartData[0]);
minDay = chartData.reduce((min, d) => d.cases < min.cases ? d : min, chartData[0]);
const variance = chartData.reduce((sum, d) => sum + (d.cases - avgCases) ** 2, 0) / chartData.length;
stdDev = Math.round(Math.sqrt(variance));
const halfIdx = Math.floor(chartData.length / 2);
const firstHalf = chartData.slice(0, halfIdx);
const secondHalf = chartData.slice(halfIdx);
const firstAvg = firstHalf.reduce((s, d) => s + d.cases, 0) / firstHalf.length;
const secondAvg = secondHalf.reduce((s, d) => s + d.cases, 0) / secondHalf.length;
trend = secondAvg > firstAvg * 1.1 ? 'up' : secondAvg < firstAvg * 0.9 ? 'down' : 'stable';
}
// 7-day moving average (last 7 days of the window)
const last7 = chartData.slice(-7);
const avg7d = last7.length > 0 ? Math.round(last7.reduce((s, d) => s + d.cases, 0) / last7.length) : 0;
// Current date snapshot: find the data point matching currentDate
const todaySnapshot = chartData.find((d) => d.date === currentDate);
const todayCases = todaySnapshot?.cases ?? null;
// Case type breakdown from districtCases
const totalOutpatient = districtCases.reduce((s, d) => s + d.outpatient, 0);
const totalInpatient = districtCases.reduce((s, d) => s + d.inpatient, 0);
return {
totalCases, avgCases, maxDay, minDay,
stdDev, trend, totalOutpatient, totalInpatient,
avg7d, todayCases, noData,
};
}, [chartData, districtCases, currentDate]);
// 7-day sparkline for the StatCard bar (last 7 days of the loaded window)
const sparkline7d = useMemo(() => chartData.slice(-7).map((d) => d.cases), [chartData]);
// --- On-demand loader: 病例统计 tab ---
// Drives the trend off the Monitoring timeline (30-day window ending at currentDate),
// NOT a fixed now-30d window. Year for the heatmap is derived from the data.
const loadCasesTab = useCallback(async (refDate: string) => {
setCasesTabLoading(true);
setCasesTabError(null);
const end = new Date(refDate);
const start = new Date(refDate);
start.setDate(start.getDate() - 30);
const startStr = start.toISOString().split('T')[0];
const endStr = end.toISOString().split('T')[0];
const yearStart = `${end.getFullYear()}-01-01`;
const yearEnd = `${end.getFullYear()}-12-31`;
const [statsR, trendR, pollutantsR, yearTrendR] = await Promise.allSettled([
caseApi.getStats(),
caseApi.getTrend({ start_date: startStr, end_date: endStr, group_by: 'day' }),
envApi.getPollutants(30),
caseApi.getTrend({ start_date: yearStart, end_date: yearEnd, group_by: 'day' }),
]);
const errs: string[] = [];
if (statsR.status === 'fulfilled') {
const topDiag = statsR.value.top_diagnoses || [];
setTopDiagnoses(
topDiag.slice(0, 5).map((d) => ({
diagnosis: d.diagnosis,
outpatient: d.outpatient,
inpatient: d.inpatient,
total: d.outpatient + d.inpatient,
}))
);
} else {
errs.push('诊断分布加载失败');
}
const aqiMap: Record<string, number> = {};
if (pollutantsR.status === 'fulfilled') {
for (const p of pollutantsR.value.data || []) {
aqiMap[p.date] = p.AQI || 0;
}
}
if (trendR.status === 'fulfilled') {
const trend = trendR.value.trend || [];
setCaseTrend(
trend.map((t) => ({ date: t.date, cases: t.total, aqi: aqiMap[t.date] || 0 }))
);
} else {
errs.push('趋势数据加载失败');
}
// Calendar heatmap: daily cases for the data's actual year (derived from trend data)
if (yearTrendR.status === 'fulfilled') {
const yearTrend = yearTrendR.value.trend || [];
if (yearTrend.length > 0) {
const derivedYear = new Date(yearTrend[0].date).getFullYear();
setHeatmapYear(derivedYear);
setHeatmapData(yearTrend.map((t) => ({ date: t.date, value: t.total })));
} else {
setHeatmapYear(end.getFullYear());
setHeatmapData([]);
}
} else {
errs.push('日历热力图加载失败');
}
setCasesTabError(errs.length > 0 ? errs.join('') : null);
setCasesTabLoading(false);
setCasesTabLoaded(true);
}, []);
// --- On-demand loader: 区域统计 tab ---
const loadDistrictTab = useCallback(async () => {
setDistrictTabLoading(true);
setDistrictTabError(null);
try {
const res = await caseApi.getDistricts();
setDistrictMetrics(res.districts || []);
setDistrictTabError(null);
} catch {
setDistrictTabError('区域统计加载失败');
} finally {
setDistrictTabLoading(false);
setDistrictTabLoaded(true);
}
}, []);
// Fetch tab data the first time a tab is opened (avoids loading everything upfront)
useEffect(() => {
if (activeTab === 'cases' && !casesTabLoaded && !casesTabLoading) {
loadCasesTab(currentDate);
}
if (activeTab === 'districts' && !districtTabLoaded && !districtTabLoading) {
loadDistrictTab();
}
}, [activeTab, casesTabLoaded, casesTabLoading, districtTabLoaded, districtTabLoading, currentDate, loadCasesTab, loadDistrictTab]);
// When the timeline date moves, refresh an already-opened 病例统计 tab so its
// trend window tracks the Monitoring timeline rather than going stale.
useEffect(() => {
if (activeTab === 'cases' && casesTabLoaded) {
loadCasesTab(currentDate);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentDate]);
// 区域统计 table: sortable district rows + heatmap columns
const districtTableRows = useMemo(() => {
const sorted = [...districtMetrics].sort((a, b) => {
switch (districtSortKey) {
case 'outpatient': return b.outpatient - a.outpatient;
case 'inpatient': return b.inpatient - a.inpatient;
case 'inpatient_ratio': return (b.inpatient_ratio ?? 0) - (a.inpatient_ratio ?? 0);
default: return b.total - a.total;
}
});
return sorted.map((d) => d.district);
}, [districtMetrics, districtSortKey]);
const districtTableData = useMemo(() => {
const map: Record<string, Record<string, number>> = {};
for (const d of districtMetrics) {
map[d.district] = {
total: d.total,
outpatient: d.outpatient,
inpatient: d.inpatient,
inpatient_ratio: Math.round((d.inpatient_ratio ?? 0) * 1000) / 10,
};
}
return map;
}, [districtMetrics]);
return {
// 概览
chartData,
stats,
sparkline7d,
districtCases,
// 病例统计
topDiagnoses,
caseTrend,
heatmapData,
heatmapYear,
casesTabLoaded,
casesTabLoading,
casesTabError,
setCasesTabError,
loadCasesTab,
// 区域统计
districtTableRows,
districtTableData,
districtTabLoaded,
districtTabLoading,
districtTabError,
setDistrictTabError,
setDistrictSortKey,
loadDistrictTab,
// 图表手动加载(错误重试 / 病种过滤)
loadChartData,
debouncedLoadChart,
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
import React, { memo } from 'react';
export const Card = memo(function Card({
children,
className,
title,
actions,
testid,
}: {
children: React.ReactNode;
className?: string;
title?: React.ReactNode;
actions?: React.ReactNode;
testid?: string;
}): JSX.Element {
const hasHeader = title != null || actions != null;
return (
<div
data-testid={testid}
className={[
'bg-bg-card rounded-lg border border-border',
className,
]
.filter(Boolean)
.join(' ')}
>
{hasHeader && (
<div className="flex items-center justify-between gap-2 px-4 py-3 border-b border-border-light">
{title != null && (
<div className="text-sm font-medium text-text-primary">{title}</div>
)}
{actions != null && (
<div className="flex items-center gap-2 shrink-0">{actions}</div>
)}
</div>
)}
<div className="p-4">{children}</div>
</div>
);
});

View File

@@ -0,0 +1,29 @@
import React, { memo } from 'react';
export const EmptyState = memo(function EmptyState({
title,
description,
icon,
action,
}: {
title: string;
description?: string;
icon?: React.ReactNode;
action?: React.ReactNode;
}): JSX.Element {
return (
<div
data-testid="empty-state"
className="flex flex-col items-center justify-center gap-3 py-12 px-6 text-center"
>
{icon && (
<div className="text-text-muted text-4xl">{icon}</div>
)}
<p className="text-sm font-medium text-text-secondary">{title}</p>
{description && (
<p className="text-xs text-text-muted max-w-xs">{description}</p>
)}
{action && <div className="mt-2">{action}</div>}
</div>
);
});

View File

@@ -0,0 +1,30 @@
import { Skeleton } from './Skeleton';
export function LoadingState({
label,
testid,
lines = 3,
}: {
label?: string;
testid?: string;
lines?: number;
}): JSX.Element {
return (
<div
data-testid={testid ?? 'loading-state'}
className="flex flex-col items-center justify-center gap-3 p-6 w-full"
>
<div className="flex flex-col gap-2 w-full max-w-sm">
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
className={`h-4 ${i === lines - 1 ? 'w-2/3' : 'w-full'}`}
/>
))}
</div>
{label && (
<p className="text-xs text-text-muted">{label}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,22 @@
import React, { memo } from 'react';
export const Panel = memo(function Panel({
children,
className,
testid,
}: {
children: React.ReactNode;
className?: string;
testid?: string;
}): JSX.Element {
return (
<div
data-testid={testid}
className={['bg-bg-hover/50 rounded-md p-3', className]
.filter(Boolean)
.join(' ')}
>
{children}
</div>
);
});

View File

@@ -0,0 +1,53 @@
import { memo } from 'react';
export const Segmented = memo(function Segmented<T extends string>({
options,
value,
onChange,
size = 'md',
testid,
}: {
options: { value: T; label: string }[];
value: T;
onChange: (v: T) => void;
size?: 'sm' | 'md';
testid?: string;
}): JSX.Element {
const sizeClasses = size === 'sm'
? 'px-2.5 py-0.5 text-xs'
: 'px-3.5 py-1 text-[13px]';
return (
<div
data-testid={testid}
className="inline-flex items-center gap-0.5 rounded-full bg-bg-hover p-0.5"
>
{options.map((opt) => {
const isActive = opt.value === value;
return (
<button
key={opt.value}
type="button"
data-testid={testid ? `${testid}-${opt.value}` : undefined}
onClick={() => onChange(opt.value)}
className={[
'rounded-full font-medium transition-colors',
sizeClasses,
isActive
? 'bg-primary text-white shadow-sm'
: 'text-text-secondary hover:bg-bg-active',
].join(' ')}
>
{opt.label}
</button>
);
})}
</div>
);
}) as <T extends string>(props: {
options: { value: T; label: string }[];
value: T;
onChange: (v: T) => void;
size?: 'sm' | 'md';
testid?: string;
}) => JSX.Element;

View File

@@ -0,0 +1,22 @@
import { memo } from 'react';
export const Skeleton = memo(function Skeleton({
className,
rounded,
}: {
className?: string;
rounded?: boolean;
}): JSX.Element {
return (
<div
data-testid="skeleton"
className={[
'animate-pulse bg-bg-hover',
rounded ? 'rounded-full' : 'rounded',
className,
]
.filter(Boolean)
.join(' ')}
/>
);
});

View File

@@ -0,0 +1,6 @@
export { Skeleton } from './Skeleton';
export { LoadingState } from './LoadingState';
export { EmptyState } from './EmptyState';
export { Card } from './Card';
export { Panel } from './Panel';
export { Segmented } from './Segmented';

View File

@@ -1,37 +1,26 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import React from 'react';
import { useRiskStore } from '@/stores';
import { AlertMap } from '@/components/AlertMap';
import { useSearchParams } from 'react-router-dom';
import { useRiskStore, useSessionStore } from '@/stores';
import { TESTIDS } from '@/utils/testids';
import type { CellInfo } from '@/components/AlertMap';
import { ErrorBanner } from '@/components/ErrorBanner';
import { StatCard } from '@/components/StatCard';
import { StatisticalCharts } from '@/components/StatisticalCharts';
import { analysisApi } from '@/services/api';
import { PieChart, Pie, Cell, Tooltip as RechartsTooltip, Legend, ResponsiveContainer } from 'recharts';
interface ExtendedAlert {
alert_id: string;
grid_id: string;
region: string;
street: string;
latitude: number;
longitude: number;
risk_value: number;
risk_level: 'high' | 'medium_high' | 'medium' | 'medium_low' | 'low';
priority: 'P1' | 'P2';
forecast_horizon: number;
forecast_time: string;
reason: string;
timestamp: string;
}
const HORIZON_LABELS: Record<number, string> = {
1: '1 天后',
3: '3 天后',
7: '7 天后',
};
import { AlertsHeader } from '@/components/alerts/AlertsHeader';
import { AlertsListTab } from '@/components/alerts/AlertsListTab';
import { AlertsRiskPanel } from '@/components/alerts/AlertsRiskPanel';
import { AlertDetailModal, CellInfoPanel } from '@/components/alerts/AlertDetailModal';
import type { ExtendedAlert, RiskStats } from '@/components/alerts/types';
export function AlertsDashboard() {
// 视角驱动的两条不变量D2纯前端视图预设非访问控制
// 1. 官员(厅领导)不展示 100m 网格(「对他没意义/太超前」)——强制 showGrid=false 且隐藏网格切换。
// 2. 医生(或 ?view=cluster= 聚类/密度视角:只看聚合栅格密度 + 病种过滤,
// 绝不渲染任何个体病例点(隐私不变量)——强制 showAlertMarkers=false 且隐藏「预警标记」切换。
const role = useSessionStore((s) => s.role);
const [searchParams] = useSearchParams();
const view = searchParams.get('view');
const isOfficial = role === 'official';
const isCluster = role === 'doctor' || view === 'cluster';
const alerts = useRiskStore((s) => s.alerts);
const isLoading = useRiskStore((s) => s.isLoading);
const error = useRiskStore((s) => s.error);
@@ -48,9 +37,15 @@ export function AlertsDashboard() {
const [debouncedRiskRange, setDebouncedRiskRange] = useState<[number, number]>([0.6, 1.0]);
const [forecastDay, setForecastDay] = useState<1 | 3 | 7>(1);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showGrid, setShowGrid] = useState(true);
// 官员视角默认隐藏网格(见上);其余角色默认显示。
const [showGrid, setShowGrid] = useState(!isOfficial);
const [cellInfo, setCellInfo] = useState<CellInfo | null>(null);
// 隐私不变量:聚类(医生)视角下,个体病例点标记永远关闭,且无法被打开。
// 这里把「用户意图的开关状态」与「实际生效的状态」分开effectiveShowAlertMarkers
// 是唯一传给地图/渲染的真值cluster 模式恒为 false与用户点击无关。
const effectiveShowAlertMarkers = isCluster ? false : showAlertMarkers;
// In-page tab strip (no router) — matches existing activePage pattern
const [activeTab, setActiveTab] = useState<'list' | 'stats'>('list');
@@ -130,7 +125,7 @@ export function AlertsDashboard() {
}, [extendedAlerts, selectedHorizon, selectedPriority, sortBy, debouncedRiskRange]);
// Risk distribution stats (includes p1/p2 counts) — single pass over each array
const riskStats = useMemo(() => {
const riskStats: RiskStats = useMemo(() => {
// p1/p2 reflect the full (unfiltered) alert set
let p1 = 0;
let p2 = 0;
@@ -167,17 +162,6 @@ export function AlertsDashboard() {
return filteredAlerts.find(a => a.alert_id === selectedAlert);
}, [filteredAlerts, selectedAlert]);
// Severity donut data (P1/P2) for the 风险统计 tab
const alertPie = useMemo(() => ([
{ name: 'P1 (紧急)', value: riskStats.p1, color: '#ef4444' },
{ name: 'P2 (关注)', value: riskStats.p2, color: '#f59e0b' },
]), [riskStats.p1, riskStats.p2]);
const topDistrictMax = useMemo(
() => riskStats.topDistricts.reduce((m, [, n]) => Math.max(m, n), 0),
[riskStats.topDistricts],
);
const selectedGridId = useMemo(() => {
if (!selectedAlert) return null;
const alert = filteredAlerts.find(a => a.alert_id === selectedAlert);
@@ -220,7 +204,7 @@ export function AlertsDashboard() {
a.forecast_horizon, `"${a.reason}"`, a.timestamp,
]);
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -241,7 +225,7 @@ export function AlertsDashboard() {
}, [filteredAlerts]);
return (
<div className={isFullscreen ? 'fixed inset-0 z-40 bg-bg-page pt-[52px] p-5' : 'p-5'}>
<div data-testid={TESTIDS.pageAlerts} className={isFullscreen ? 'fixed inset-0 z-40 bg-bg-page pt-[52px] p-5' : 'p-5'}>
{error && (
<ErrorBanner
error={error}
@@ -250,566 +234,68 @@ export function AlertsDashboard() {
/>
)}
{/* Header */}
<div className="flex items-center justify-between mb-4 flex-wrap gap-x-4 gap-y-2">
<div className="min-w-0">
<h1 className="font-display text-[18px] font-semibold mb-1"></h1>
<p className="text-[12px] text-text-muted truncate">
100m网格风险预测 · · -
</p>
</div>
<div className="flex items-center gap-3 text-[11px] shrink-0 flex-wrap">
<span className="text-text-muted"> <span className="font-semibold text-text-primary">{filteredAlerts.length}</span> </span>
<span className="px-2 py-1 bg-danger/10 border border-danger/20 rounded text-danger font-semibold">P1: {riskStats.p1}</span>
<span className="px-2 py-1 bg-warning/10 border border-warning/20 rounded text-warning font-semibold">P2: {riskStats.p2}</span>
</div>
</div>
{/* Tab strip — in-page, no router */}
<div className="flex gap-1 mb-4 border-b border-border">
{([
{ key: 'list', label: '预警列表' },
{ key: 'stats', label: '风险统计' },
] as const).map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-[13px] font-medium -mb-px border-b-2 transition-colors ${
activeTab === tab.key
? 'border-primary text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'
}`}
>
{tab.label}
</button>
))}
</div>
<AlertsHeader
total={filteredAlerts.length}
p1={riskStats.p1}
p2={riskStats.p2}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
{activeTab === 'list' && (
<>
{/* Toolbar Row 1: Forecast + Fullscreen + Export */}
<div className="card p-3 mb-3">
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-0.5 bg-bg-page p-0.5 rounded">
{([1, 3, 7] as const).map((day) => (
<button
key={day}
onClick={() => setForecastDay(day)}
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
forecastDay === day
? 'bg-bg-card text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{day}
</button>
))}
</div>
</div>
<div className="w-px h-6 bg-border" />
<button
onClick={() => setIsFullscreen(!isFullscreen)}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
isFullscreen
? 'bg-bg-card text-primary border border-primary'
: 'bg-bg-page text-text-secondary border border-border'
}`}
>
{isFullscreen ? '退出全屏' : '全屏'}
</button>
<div className="w-px h-6 bg-border" />
<button
onClick={exportToCsv}
className="px-3 py-1.5 text-[12px] font-medium rounded bg-bg-page text-text-secondary border border-border hover:border-primary transition-colors"
>
CSV
</button>
<button
onClick={exportToJson}
className="px-3 py-1.5 text-[12px] font-medium rounded bg-bg-page text-text-secondary border border-border hover:border-primary transition-colors"
>
JSON
</button>
</div>
</div>
{/* Toolbar Row 2: Filters */}
<div className="card p-3 mb-4">
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-1">
{(['all', 1, 3, 7] as const).map((horizon) => (
<button
key={horizon}
onClick={() => setSelectedHorizon(horizon)}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
selectedHorizon === horizon
? 'bg-primary text-white'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
{horizon === 'all' ? '全部' : HORIZON_LABELS[horizon]}
</button>
))}
</div>
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-1">
{(['all', 'P1', 'P2'] as const).map((priority) => (
<button
key={priority}
onClick={() => setSelectedPriority(priority)}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
selectedPriority === priority
? priority === 'P1'
? 'bg-danger text-white'
: priority === 'P2'
? 'bg-warning text-white'
: 'bg-primary text-white'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
{priority === 'all' ? '全部' : priority}
</button>
))}
</div>
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={1}
step={0.05}
value={riskRange[0]}
onChange={(e) => setRiskRange([parseFloat(e.target.value) || 0, riskRange[1]])}
className="w-16 px-2 py-1.5 text-[12px] border border-border rounded bg-bg-page text-text-primary focus:outline-none focus:border-primary"
/>
<span className="text-[12px] text-text-muted">-</span>
<input
type="number"
min={0}
max={1}
step={0.05}
value={riskRange[1]}
onChange={(e) => setRiskRange([riskRange[0], parseFloat(e.target.value) || 1])}
className="w-16 px-2 py-1.5 text-[12px] border border-border rounded bg-bg-page text-text-primary focus:outline-none focus:border-primary"
/>
</div>
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-1">
<button
onClick={() => setShowMap(!showMap)}
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
showMap
? 'bg-primary/10 text-primary border border-primary/30'
: 'bg-bg-page text-text-muted border border-border'
}`}
>
</button>
<button
onClick={() => setShowAlertMarkers(!showAlertMarkers)}
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
showAlertMarkers
? 'bg-primary/10 text-primary border border-primary/30'
: 'bg-bg-page text-text-muted border border-border'
}`}
>
</button>
<button
onClick={() => setShowGrid(!showGrid)}
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
showGrid
? 'bg-primary/10 text-primary border border-primary/30'
: 'bg-bg-page text-text-muted border border-border'
}`}
>
</button>
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-1">
<button
onClick={() => setSortBy('risk')}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
sortBy === 'risk'
? 'bg-bg-card text-primary border border-primary'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
</button>
<button
onClick={() => setSortBy('time')}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
sortBy === 'time'
? 'bg-bg-card text-primary border border-primary'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
</button>
</div>
</div>
</div>
</div>
{/* Risk distribution summary */}
<div className="grid grid-cols-4 gap-3 mb-4">
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"> (0.8)</div>
<div className="text-xl font-bold text-danger">{riskStats.high}</div>
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-danger rounded-full" style={{ width: `${filteredAlerts.length > 0 ? (riskStats.high / filteredAlerts.length) * 100 : 0}%` }} />
</div>
</div>
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"> (0.6-0.8)</div>
<div className="text-xl font-bold text-warning">{riskStats.mediumHigh}</div>
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-warning rounded-full" style={{ width: `${filteredAlerts.length > 0 ? (riskStats.mediumHigh / filteredAlerts.length) * 100 : 0}%` }} />
</div>
</div>
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"> (0.4-0.6)</div>
<div className="text-xl font-bold text-primary">{riskStats.medium}</div>
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full" style={{ width: `${filteredAlerts.length > 0 ? (riskStats.medium / filteredAlerts.length) * 100 : 0}%` }} />
</div>
</div>
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"></div>
<div className="text-xl font-bold text-text-primary">{(riskStats.avgRisk * 100).toFixed(1)}%</div>
<div className="mt-1.5 text-[10px] text-text-muted">
: {riskStats.topDistricts.slice(0, 2).map(([d, n]) => `${d}(${n})`).join(', ')}
</div>
</div>
</div>
{isLoading ? (
<div className="card p-8 text-center">
<div className="text-text-secondary text-[13px]">...</div>
</div>
) : filteredAlerts.length === 0 ? (
<div className="card p-8 text-center">
<svg className="w-12 h-12 mx-auto mb-3 text-text-muted opacity-50" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
</svg>
<div className="text-text-muted text-[13px]"></div>
</div>
) : (
<div className={`grid gap-4 ${isFullscreen ? 'grid-cols-1' : 'grid-cols-[1fr_400px]'}`}>
{showMap && (
<AlertMap
selectedGridId={selectedGridId}
onGridClick={handleGridClick}
onCellInfo={handleCellInfo}
forecastDay={forecastDay}
showAlertMarkers={showAlertMarkers}
showGrid={showGrid}
filteredAlerts={filteredAlerts}
isFullscreen={isFullscreen}
/>
)}
{!isFullscreen && (
<div className="space-y-3 max-h-[calc(100vh-280px)] overflow-y-auto">
{filteredAlerts.slice(0, 50).map((alert) => (
<AlertCard
key={alert.alert_id}
alert={alert}
isSelected={selectedAlert === alert.alert_id}
alertId={alert.alert_id}
onCardClick={handleAlertCardClick}
/>
))}
{filteredAlerts.length > 50 && (
<div className="text-center text-text-muted text-[12px] py-2">
{filteredAlerts.length - 50}
</div>
)}
</div>
)}
</div>
)}
</>
<AlertsListTab
forecastDay={forecastDay}
onForecastDayChange={setForecastDay}
isFullscreen={isFullscreen}
onToggleFullscreen={() => setIsFullscreen(!isFullscreen)}
onExportCsv={exportToCsv}
onExportJson={exportToJson}
selectedHorizon={selectedHorizon}
onHorizonChange={setSelectedHorizon}
selectedPriority={selectedPriority}
onPriorityChange={setSelectedPriority}
riskRange={riskRange}
onRiskRangeChange={setRiskRange}
showMap={showMap}
onToggleMap={() => setShowMap(!showMap)}
showAlertMarkers={showAlertMarkers}
onToggleAlertMarkers={() => setShowAlertMarkers(!showAlertMarkers)}
showGrid={showGrid}
onToggleGrid={() => setShowGrid(!showGrid)}
sortBy={sortBy}
onSortByChange={setSortBy}
riskStats={riskStats}
filteredAlerts={filteredAlerts}
isLoading={isLoading}
selectedGridId={selectedGridId}
selectedAlert={selectedAlert}
onGridClick={handleGridClick}
onCellInfo={handleCellInfo}
onCardClick={handleAlertCardClick}
effectiveShowAlertMarkers={effectiveShowAlertMarkers}
isCluster={isCluster}
isOfficial={isOfficial}
/>
)}
{activeTab === 'stats' && (
<div className="space-y-4">
{/* Risk distribution as StatCards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="高风险 (≥0.8)" value={riskStats.high} color="#ef4444" />
<StatCard label="中高风险 (0.6-0.8)" value={riskStats.mediumHigh} color="#f59e0b" />
<StatCard label="中风险 (0.4-0.6)" value={riskStats.medium} color="#3b82f6" />
<StatCard label="平均风险" value={`${(riskStats.avgRisk * 100).toFixed(1)}%`} />
</div>
{/* Risk trend chart (real data from /api/analysis/trend) */}
{trendLoading ? (
<div className="card p-8 text-center text-text-secondary text-[13px]">...</div>
) : trendError ? (
<div className="card p-8 text-center text-danger text-[13px]">{trendError}</div>
) : trendData.length === 0 ? (
<div className="card p-8 text-center text-text-muted text-[13px]"></div>
) : (
<StatisticalCharts
data={trendData}
showCases={false}
showRisk
height={280}
/>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Top high-risk districts bar */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
Top 5
</div>
{riskStats.topDistricts.length > 0 ? (
<div className="space-y-3">
{riskStats.topDistricts.map(([district, count]) => (
<div key={district}>
<div className="flex items-center justify-between text-[12px] mb-1">
<span className="text-text-primary font-medium">{district}</span>
<span className="text-text-muted">{count} </span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-danger rounded-full"
style={{ width: `${topDistrictMax > 0 ? (count / topDistrictMax) * 100 : 0}%` }}
/>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-text-muted text-[13px]"></div>
)}
</div>
{/* Alert severity donut (P1/P2) */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
</div>
{riskStats.p1 > 0 || riskStats.p2 > 0 ? (
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={alertPie}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={4}
dataKey="value"
nameKey="name"
>
{alertPie.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<RechartsTooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number, name: string) => [value, name]}
/>
<Legend
wrapperStyle={{ fontSize: '12px' }}
formatter={(value: string) => <span className="text-text-secondary">{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-text-muted text-[13px]"></div>
)}
</div>
</div>
</div>
<AlertsRiskPanel
riskStats={riskStats}
trendData={trendData}
trendLoading={trendLoading}
trendError={trendError}
/>
)}
{/* Cell info panel - shown when clicking grid cell without alert */}
{cellInfo && !selectedAlertData && (
<div className="fixed bottom-5 left-1/2 -translate-x-1/2 bg-bg-card rounded-lg border border-border-light shadow-lg z-50 px-5 py-4 min-w-[320px]">
<div className="flex items-center justify-between mb-3">
<span className="text-[14px] font-semibold text-text-primary"> (100m)</span>
<button onClick={clearCellInfo} className="text-text-muted hover:text-text-primary text-[18px] leading-none">&times;</button>
</div>
<div className="space-y-2 text-[12px]">
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="font-mono text-text-primary">{cellInfo.grid_id}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="font-mono text-text-primary">{cellInfo.lat.toFixed(4)}, {cellInfo.lon.toFixed(4)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className={`font-bold ${cellInfo.risk >= 0.8 ? 'text-danger' : cellInfo.risk >= 0.6 ? 'text-warning' : cellInfo.risk >= 0.4 ? 'text-primary' : 'text-success'}`}>
{(cellInfo.risk * 100).toFixed(1)}%
</span>
</div>
<div className="flex gap-3 pt-1">
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
<div className="text-[10px] text-text-muted">1</div>
<div className="font-bold text-[13px]">{(cellInfo.risk_1d * 100).toFixed(0)}%</div>
</div>
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
<div className="text-[10px] text-text-muted">3</div>
<div className="font-bold text-[13px]">{(cellInfo.risk_3d * 100).toFixed(0)}%</div>
</div>
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
<div className="text-[10px] text-text-muted">7</div>
<div className="font-bold text-[13px]">{(cellInfo.risk_7d * 100).toFixed(0)}%</div>
</div>
</div>
{cellInfo.nearestAlertId && (
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="text-text-primary">{(cellInfo.nearestAlertDist * 111).toFixed(1)} km</span>
</div>
)}
{!cellInfo.nearestAlertId && (
<div className="text-[11px] text-text-muted mt-1 pt-2 border-t border-border">
</div>
)}
</div>
</div>
<CellInfoPanel cellInfo={cellInfo} onClose={clearCellInfo} />
)}
{/* Alert detail modal */}
{selectedAlertData && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center" onClick={clearSelectedAlert}>
<div className="bg-bg-card rounded-lg p-6 max-w-md w-full mx-4" onClick={e => e.stopPropagation()}>
<h3 className="font-display text-[16px] font-semibold mb-3"></h3>
<div className="space-y-2 text-[13px]">
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className={`font-bold ${selectedAlertData.priority === 'P1' ? 'text-danger' : 'text-warning'}`}>
{selectedAlertData.priority}
</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="font-bold">{Math.round(selectedAlertData.risk_value * 100)}%</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span>{HORIZON_LABELS[selectedAlertData.forecast_horizon]}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span>{selectedAlertData.region}</span>
</div>
<div className="pt-2 border-t border-border">
<div className="text-text-muted mb-1"></div>
<div className="text-[12px]">{selectedAlertData.reason}</div>
</div>
</div>
<button
onClick={clearSelectedAlert}
className="mt-4 w-full px-4 py-2 bg-primary text-white rounded hover:bg-primary/80 transition-colors text-[13px]"
>
</button>
</div>
</div>
<AlertDetailModal alert={selectedAlertData} onClose={clearSelectedAlert} />
)}
</div>
);
}
interface AlertCardProps {
alert: ExtendedAlert;
isSelected?: boolean;
alertId: string;
onCardClick: (id: string) => void;
}
const AlertCard = React.memo(function AlertCard({ alert, isSelected, alertId, onCardClick }: AlertCardProps) {
const isP1 = alert.priority === 'P1';
const riskPercent = Math.round(alert.risk_value * 100);
const handleClick = useCallback(() => {
onCardClick(alertId);
}, [alertId, onCardClick]);
return (
<div
className={`card overflow-hidden transition-colors cursor-pointer ${
isSelected ? 'border-primary ring-1 ring-primary' : 'hover:border-primary'
}`}
onClick={handleClick}
>
<div className={`px-4 py-3 border-b ${isP1 ? 'bg-danger/5 border-danger/20' : 'bg-warning/5 border-warning/20'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${isP1 ? 'bg-danger' : 'bg-warning'}`} />
<span className={`text-[11px] font-bold ${isP1 ? 'text-danger' : 'text-warning'}`}>
{alert.priority}
</span>
<span className="text-[10px] text-text-muted">
{HORIZON_LABELS[alert.forecast_horizon] || '未知'}
</span>
</div>
<span className={`text-[18px] font-bold ${isP1 ? 'text-danger' : 'text-warning'}`}>
{riskPercent}%
</span>
</div>
</div>
<div className="p-4">
<div className="mb-3">
<div className="text-[13px] font-semibold mb-1">
{alert.region} - {alert.street}
</div>
<div className="text-[11px] text-text-muted">
{alert.grid_id}
</div>
</div>
<div className={`text-[12px] px-3 py-2 rounded mb-3 ${
isP1 ? 'bg-danger/10 text-danger' : 'bg-warning/10 text-warning'
}`}>
{alert.reason}
</div>
<div className="flex items-center justify-between text-[11px] text-text-muted">
<span>{alert.forecast_time}</span>
<span>{alert.timestamp}</span>
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,158 @@
import { useEffect, useState } from 'react';
import { Activity } from 'lucide-react';
import { statsApi, type InpatientClinicalResponse } from '@/services/api';
import { Card, LoadingState, EmptyState } from '@/components/ui';
import { ErrorBanner } from '@/components/ErrorBanner';
import { TESTIDS } from '@/utils/testids';
import { ClinicalKpiRow } from '@/components/clinical/ClinicalKpiRow';
import { HistogramChart } from '@/components/clinical/HistogramChart';
import { BoxPlotRows, type BoxRow } from '@/components/clinical/BoxPlotRows';
import { DonutChart, type DonutSlice } from '@/components/clinical/DonutChart';
import { CLINICAL_COLORS } from '@/components/clinical/chartColors';
/** 数据是否完全为空KPI 0 人次且各序列均空)。 */
function isEmpty(d: InpatientClinicalResponse): boolean {
return (
(!d.kpis || d.kpis.total_admissions === 0) &&
(d.los_histogram?.length ?? 0) === 0 &&
(d.outcome_counts?.length ?? 0) === 0
);
}
export function ClinicalAnalysis() {
const [data, setData] = useState<InpatientClinicalResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const fetchData = async () => {
setIsLoading(true);
setError(null);
try {
const res = await statsApi.getInpatientClinical();
if (cancelled) return;
setData(res);
} catch {
if (cancelled) return;
setError('住院临床数据加载失败');
} finally {
if (!cancelled) setIsLoading(false);
}
};
fetchData();
return () => {
cancelled = true;
};
}, []);
const 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-text-secondary">
BMI
</p>
</div>
);
if (isLoading) {
return (
<div data-testid={TESTIDS.pageClinical} className="flex flex-col h-full overflow-auto p-6 space-y-6">
{header}
<LoadingState label="正在加载住院临床数据…" />
</div>
);
}
if (error || !data) {
return (
<div data-testid={TESTIDS.pageClinical} className="flex flex-col h-full overflow-auto p-6 space-y-6">
{header}
<ErrorBanner
error={error ?? '住院临床数据加载失败'}
onRetry={() => window.location.reload()}
onDismiss={() => setError(null)}
/>
</div>
);
}
if (isEmpty(data)) {
return (
<div data-testid={TESTIDS.pageClinical} className="flex flex-col h-full overflow-auto p-6 space-y-6">
{header}
<EmptyState title="暂无住院临床数据" description="当前筛选范围内没有可用的住院记录。" />
</div>
);
}
// 出院结局:治愈/好转在前(按严重程度排序展示更直观)。
const outcomeSlices: DonutSlice[] = (data.outcome_counts ?? []).map((o) => ({
name: o.outcome,
value: o.count,
}));
const routeSlices: DonutSlice[] = (data.admission_route_counts ?? []).map((r) => ({
name: r.route,
value: r.count,
}));
const losBox: BoxRow[] = (data.los_by_disease ?? []).map((d) => ({
label: d.diagnosis,
p25: d.p25,
median: d.median,
p75: d.p75,
n: d.n,
}));
const bmiBox: BoxRow[] = (data.bmi_by_age_band ?? []).map((d) => ({
label: d.age_band,
p25: d.p25,
median: d.median,
p75: d.p75,
n: d.n,
}));
return (
<div
data-testid={TESTIDS.pageClinical}
className="flex flex-col h-full overflow-auto"
>
<div className="p-6 space-y-6">
{header}
{/* KPI 行 */}
<ClinicalKpiRow kpis={data.kpis} />
{/* 住院天数:分布 + 各病种箱线 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card title="住院天数分布">
<HistogramChart data={data.los_histogram ?? []} color={CLINICAL_COLORS.los} countLabel="人次" />
</Card>
<Card title="各病种住院天数P25中位P75">
<BoxPlotRows rows={losBox} unit="天" />
</Card>
</div>
{/* 出院结局 + 入院途径 双环 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card title="出院结局构成">
<DonutChart data={outcomeSlices} colorMap={CLINICAL_COLORS.outcome} />
</Card>
<Card title="入院途径构成">
<DonutChart data={routeSlices} />
</Card>
</div>
{/* 年龄别 BMI 箱线 */}
<Card title="年龄别 BMIP25中位P75">
<BoxPlotRows rows={bmiBox} />
</Card>
</div>
</div>
);
}

View File

@@ -14,6 +14,8 @@ import {
import { Users, Activity } from 'lucide-react';
import { caseApi } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import { LoadingState } from '@/components/ui';
import { TESTIDS } from '@/utils/testids';
import type { DemographicsResponse, AgeBin, AgeDiagnosisMatrixItem } from '@/types';
// --- Chart 3 helpers ---
@@ -108,9 +110,11 @@ export function DemographicAnalysis() {
}, []);
// --- Derived data ---
// 防御性归一化:后端返回意外形状(缺字段/类型不符)时降级为空,避免 .map 抛错
// 冒泡到根 ErrorBoundary 把整页白屏(与 DiseaseAnalysis/EnvironmentalHealth 的处理一致)。
const ageData: AgeBin[] = useMemo(() => {
if (!data) return [];
return data.age_distribution.map((d) => ({
const bins = Array.isArray(data?.age_distribution) ? data!.age_distribution : [];
return bins.map((d) => ({
age_bin: d.age_bin,
outpatient: d.outpatient,
inpatient: d.inpatient,
@@ -119,8 +123,8 @@ export function DemographicAnalysis() {
const genderData = useMemo(() => {
if (!data) return [];
const male = data.gender_split.male.inpatient;
const female = data.gender_split.female.inpatient;
const male = data.gender_split?.male?.inpatient ?? 0;
const female = data.gender_split?.female?.inpatient ?? 0;
return [
{ name: '男性', value: male, color: '#3B82F6' },
{ name: '女性', value: female, color: '#EC4899' },
@@ -132,8 +136,8 @@ export function DemographicAnalysis() {
}, [genderData]);
const heatmapData = useMemo(() => {
if (!data) return { diagnoses: [], matrix: [], totals: [] };
return buildHeatmapMatrix(data.age_diagnosis_matrix);
const matrix = Array.isArray(data?.age_diagnosis_matrix) ? data!.age_diagnosis_matrix : [];
return buildHeatmapMatrix(matrix);
}, [data]);
const heatmapMax = useMemo(() => {
@@ -148,11 +152,7 @@ export function DemographicAnalysis() {
// --- 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 testid={TESTIDS.pageLoading} />;
}
const isEmpty =
@@ -162,7 +162,7 @@ export function DemographicAnalysis() {
heatmapData.matrix.length === 0);
return (
<div className="flex flex-col h-full overflow-auto">
<div data-testid="page-demographics" className="flex flex-col h-full overflow-auto">
{error && (
<div className="px-6 pt-4">
<ErrorBanner

View File

@@ -11,8 +11,9 @@ import {
Cell,
ReferenceLine,
} from 'recharts';
import { Stethoscope, Activity } from 'lucide-react';
import { caseApi } from '@/services/api';
import { Stethoscope, Activity, MessageSquareText } from 'lucide-react';
import { caseApi, statsApi } from '@/services/api';
import type { SymptomsResponse } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import type {
DiagnosisDistributionItem,
@@ -154,8 +155,9 @@ function SeasonalityHeatmap({ data }: { data: DiseaseSeasonalityPoint[] }) {
</div>
)}
<div className="overflow-x-auto">
<div
className="grid gap-px bg-gray-200 border border-gray-200 rounded overflow-hidden"
className="grid gap-px bg-gray-200 border border-gray-200 rounded overflow-hidden min-w-[480px]"
style={{
gridTemplateColumns: `minmax(90px, auto) repeat(${uniqueMonths > 0 ? uniqueMonths : 12}, 1fr)`,
}}
@@ -198,6 +200,7 @@ function SeasonalityHeatmap({ data }: { data: DiseaseSeasonalityPoint[] }) {
</Fragment>
))}
</div>
</div>
</div>
);
}
@@ -372,12 +375,64 @@ function DiagnosisSummaryTable({
);
}
// --- Chart 5: Outpatient Symptom Keyword Frequency ---
function SymptomFrequencyChart({ data }: { data: SymptomsResponse['symptoms'] }) {
if (!data || data.length === 0) {
return <div className="text-center py-8 text-gray-400 text-sm"></div>;
}
// Sort by count desc; chart renders bottom-up so reverse for top-at-top display.
const sorted = [...data].sort((a, b) => b.count - a.count);
const chartData = sorted.map((d) => ({
...d,
displayName: truncate(d.keyword, 8),
}));
return (
<ResponsiveContainer width="100%" height={Math.max(320, chartData.length * 22)}>
<BarChart
data={[...chartData].reverse()}
layout="vertical"
margin={{ top: 5, right: 20, left: 40, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
<XAxis
type="number"
tick={{ fontSize: 10, fill: '#64748B' }}
tickFormatter={(v) => v.toLocaleString()}
/>
<YAxis
type="category"
dataKey="displayName"
tick={{ fontSize: 10, fill: '#374151' }}
width={70}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number) => [value.toLocaleString(), '出现次数']}
/>
<Bar dataKey="count" name="count" fill="#3B82F6" barSize={14} radius={[0, 3, 3, 0]} />
</BarChart>
</ResponsiveContainer>
);
}
// --- Page Component ---
export function DiseaseAnalysis() {
const [diagDistribution, setDiagDistribution] = useState<DiagnosisDistributionItem[]>([]);
const [seasonality, setSeasonality] = useState<DiseaseSeasonalityPoint[]>([]);
const [districts, setDistricts] = useState<DistrictCaseData[]>([]);
const [symptoms, setSymptoms] = useState<SymptomsResponse['symptoms']>([]);
const [revisitRatio, setRevisitRatio] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [errors, setErrors] = useState<string[]>([]);
@@ -388,10 +443,11 @@ export function DiseaseAnalysis() {
setIsLoading(true);
setErrors([]);
const [distR, seasonR, districtR] = await Promise.allSettled([
const [distR, seasonR, districtR, symptomR] = await Promise.allSettled([
caseApi.getDiagnosisDistribution(15),
caseApi.getDiseaseSeasonality(),
caseApi.getDistricts(),
statsApi.getSymptoms(20),
]);
if (cancelled) return;
@@ -416,6 +472,15 @@ export function DiseaseAnalysis() {
newErrors.push('区县数据加载失败');
}
if (symptomR.status === 'fulfilled') {
setSymptoms(symptomR.value.symptoms || []);
setRevisitRatio(
typeof symptomR.value.revisit_ratio === 'number' ? symptomR.value.revisit_ratio : null,
);
} else {
newErrors.push('症状词频数据加载失败');
}
setErrors(newErrors);
setIsLoading(false);
};
@@ -442,7 +507,7 @@ export function DiseaseAnalysis() {
const avgOIRatio = totalIn > 0 ? totalOut / totalIn : 0;
return (
<div className="flex flex-col h-full overflow-auto">
<div data-testid="page-disease" className="flex flex-col h-full overflow-auto">
{errors.length > 0 && (
<div className="px-6 pt-4">
<ErrorBanner
@@ -495,6 +560,26 @@ export function DiseaseAnalysis() {
</div>
<DiagnosisSummaryTable diagnoses={diagDistribution} districtsData={districts} />
</div>
{/* Chart 5: Outpatient Symptom Keyword Frequency */}
<div data-testid="symptom-freq" className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-1 flex items-center gap-2">
<MessageSquareText className="w-3.5 h-3.5 text-gray-400" />
</div>
<div className="text-[11px] text-gray-500 mb-4">
/
{revisitRatio !== null && (
<span className="ml-2">
<span className="font-semibold text-gray-700">
{(revisitRatio * 100).toFixed(1)}%
</span>
</span>
)}
</div>
<SymptomFrequencyChart data={symptoms} />
</div>
</div>
</div>
);

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { LoadingState, Segmented } from '@/components/ui';
import {
BarChart,
Bar,
@@ -11,7 +12,8 @@ import {
ReferenceLine,
} from 'recharts';
import { useAnalysisStore } from '@/stores/analysisStore';
import { caseApi } from '@/services/api';
import { caseApi, statsApi } from '@/services/api';
import type { IncidenceRateResponse } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import { MetricHeatmapTable } from '@/components/MetricHeatmapTable';
import { BarChart3, MapPin, Users, Shield } from 'lucide-react';
@@ -35,6 +37,10 @@ export function DistrictComparison() {
const [caseDistrictData, setCaseDistrictData] = useState<DistrictCaseData[]>([]);
const [caseDataLoading, setCaseDataLoading] = useState(false);
const [caseDataError, setCaseDataError] = useState<string | null>(null);
const [incidence, setIncidence] = useState<IncidenceRateResponse['districts']>([]);
const [incidenceLoading, setIncidenceLoading] = useState(false);
const [incidenceError, setIncidenceError] = useState<string | null>(null);
const [incidenceMetric, setIncidenceMetric] = useState<'rate' | 'count'>('rate');
useEffect(() => {
fetchDistricts();
@@ -60,6 +66,26 @@ export function DistrictComparison() {
return () => { cancelled = true; };
}, []);
useEffect(() => {
let cancelled = false;
setIncidenceLoading(true);
setIncidenceError(null);
statsApi.getIncidenceRate()
.then((res) => {
if (!cancelled) {
setIncidence(res.districts || []);
setIncidenceLoading(false);
}
})
.catch((e) => {
if (!cancelled) {
setIncidenceError((e as Error).message || '加载发病率数据失败');
setIncidenceLoading(false);
}
});
return () => { cancelled = true; };
}, []);
const metricConfig = {
avg_aqi: { label: '平均AQI', color: '#2563EB', unit: '' },
avg_risk: { label: '平均风险', color: '#DC2626', unit: '' },
@@ -103,6 +129,12 @@ export function DistrictComparison() {
return { data, cityOIAvg };
}, [caseDistrictData]);
const incidenceChart = useMemo(() => {
const valueKey = incidenceMetric === 'rate' ? 'rate_per_10k' : 'total_cases';
const data = [...incidence].sort((a, b) => b[valueKey] - a[valueKey]);
return { data, valueKey };
}, [incidence, incidenceMetric]);
const heatmapMetrics = useMemo(() => {
const caseMap = new Map(caseDistrictData.map((d) => [d.district, d]));
const rows: string[] = [];
@@ -127,7 +159,7 @@ export function DistrictComparison() {
};
return (
<div>
<div data-testid="page-district" className="overflow-x-hidden">
{error && (
<ErrorBanner
error={error}
@@ -154,6 +186,25 @@ export function DistrictComparison() {
onDismiss={() => setCaseDataError(null)}
/>
)}
{incidenceError && (
<ErrorBanner
error={incidenceError}
onRetry={() => {
setIncidenceError(null);
setIncidenceLoading(true);
statsApi.getIncidenceRate()
.then((res) => {
setIncidence(res.districts || []);
setIncidenceLoading(false);
})
.catch((e) => {
setIncidenceError((e as Error).message || '加载发病率数据失败');
setIncidenceLoading(false);
});
}}
onDismiss={() => setIncidenceError(null)}
/>
)}
<div className="mb-5">
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-primary" />
@@ -184,8 +235,8 @@ export function DistrictComparison() {
</div>
{isLoading && (
<div className="mb-4 text-center py-8 bg-bg-card rounded-lg border border-border">
<span className="text-text-secondary">...</span>
<div className="mb-4 bg-bg-card rounded-lg border border-border">
<LoadingState />
</div>
)}
@@ -244,7 +295,7 @@ export function DistrictComparison() {
</ResponsiveContainer>
</div>
<div className="grid grid-cols-4 gap-4">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{sortedData.map((district, index) => (
<div key={district.district} className="card p-4">
<div className="flex items-center justify-between mb-3">
@@ -379,13 +430,86 @@ export function DistrictComparison() {
)}
</div>
{/* Standardized Incidence Rate (per 10k) with count/rate toggle */}
<div data-testid="incidence-rate" className="card p-4 mb-4">
<div className="flex items-center justify-between gap-2 mb-4 flex-wrap">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide">
{incidenceMetric === 'rate' ? '标化发病率(每万人)区域排名' : '病例数 区域排名'}
</div>
<Segmented
testid="incidence-rate-toggle"
size="sm"
value={incidenceMetric}
onChange={setIncidenceMetric}
options={[
{ value: 'count', label: '病例数' },
{ value: 'rate', label: '每万人发病率' },
]}
/>
</div>
<p className="text-[11px] text-text-muted mb-4">
</p>
{incidenceLoading && <LoadingState />}
{!incidenceLoading && incidenceChart.data.length > 0 ? (
<ResponsiveContainer width="100%" height={380}>
<BarChart
data={incidenceChart.data}
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
layout="vertical"
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
<XAxis
type="number"
tick={{ fontSize: 12, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
tickFormatter={(v: number) => v.toLocaleString()}
/>
<YAxis
type="category"
dataKey="district"
tick={{ fontSize: 12, fill: '#1E293B', fontWeight: 500 }}
axisLine={{ stroke: '#E2E8F0' }}
width={80}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(_value: number, _name, item) => {
const p = item?.payload as IncidenceRateResponse['districts'][number];
return [
`病例数 ${p.total_cases.toLocaleString()} · 人口 ${p.population.toLocaleString()} · 每万人 ${p.rate_per_10k.toLocaleString()}`,
incidenceMetric === 'rate' ? '标化发病率' : '病例数',
];
}}
/>
<Bar
dataKey={incidenceChart.valueKey}
name={incidenceMetric === 'rate' ? '每万人发病率' : '病例数'}
radius={[0, 4, 4, 0]}
maxBarSize={32}
fill={incidenceMetric === 'rate' ? '#DC2626' : '#2563EB'}
/>
</BarChart>
</ResponsiveContainer>
) : (
!incidenceLoading && (
<div className="text-center py-8 text-text-secondary"></div>
)
)}
</div>
{/* O/I Ratio Comparison Bar Chart */}
<div className="card p-4 mb-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
/ (O/I Ratio)
</div>
{caseDataLoading && (
<div className="text-center py-8 text-text-secondary">...</div>
<LoadingState />
)}
{!caseDataLoading && oiRatioData.data.length > 0 ? (
<ResponsiveContainer width="100%" height={350}>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from 'react';
import { useEffect, useState, useMemo, Fragment } from 'react';
import {
LineChart,
Line,
@@ -12,9 +12,13 @@ import {
ResponsiveContainer,
ReferenceLine,
Cell,
ScatterChart,
Scatter,
ZAxis,
} from 'recharts';
import { Wind } from 'lucide-react';
import { envApi, caseApi } from '@/services/api';
import { envApi, caseApi, statsApi } from '@/services/api';
import type { EnvCorrelationResponse } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import { CalendarHeatmap } from '@/components/CalendarHeatmap';
import type {
@@ -58,6 +62,55 @@ function getAQICategory(aqi: number): typeof AQI_CATEGORIES[number] {
return AQI_CATEGORIES[AQI_CATEGORIES.length - 1];
}
// Canonical pollutant order for the 7×7 correlation matrix.
const CORR_POLLUTANTS = ['AQI', 'PM25', 'PM10', 'SO2', 'NO2', 'O3', 'CO'];
function pollutantLabel(key: string): string {
switch (key) {
case 'PM25':
return 'PM2.5';
case 'SO2':
return 'SO₂';
case 'NO2':
return 'NO₂';
case 'O3':
return 'O₃';
default:
return key;
}
}
// Diverging color scale for a correlation value in [-1, 1].
// Blue (negative) → white (0) → red (positive); opacity scales with |corr|.
function corrColor(corr: number): string {
const v = Math.max(-1, Math.min(1, corr));
if (v >= 0) return `rgba(220, 38, 38, ${0.12 + 0.88 * v})`;
return `rgba(37, 99, 235, ${0.12 + 0.88 * -v})`;
}
// Least-squares linear regression: returns slope/intercept over (x, y) points.
function linearRegression(
points: { x: number; y: number }[],
): { slope: number; intercept: number } | null {
const n = points.length;
if (n < 2) return null;
let sx = 0;
let sy = 0;
let sxx = 0;
let sxy = 0;
for (const p of points) {
sx += p.x;
sy += p.y;
sxx += p.x * p.x;
sxy += p.x * p.y;
}
const denom = n * sxx - sx * sx;
if (Math.abs(denom) < 1e-9) return null;
const slope = (n * sxy - sx * sy) / denom;
const intercept = (sy - slope * sx) / n;
return { slope, intercept };
}
function findMaxLag(correlations: LagCorrelationItem[], pollutant: string): number | null {
if (correlations.length === 0) return null;
const pollData = correlations.filter(
@@ -83,6 +136,7 @@ export function EnvironmentalHealth() {
const [pollutants365, setPollutants365] = useState<PollutantPoint[]>([]);
const [pollutants30, setPollutants30] = useState<PollutantPoint[]>([]);
const [caseTrend, setCaseTrend] = useState<CaseTrendPoint[]>([]);
const [envCorr, setEnvCorr] = useState<EnvCorrelationResponse | null>(null);
// UI states
const [isLoading, setIsLoading] = useState(true);
@@ -104,11 +158,12 @@ export function EnvironmentalHealth() {
setIsLoading(true);
setErrors([]);
const [lagR, p365R, p30R, caseTrendR] = await Promise.allSettled([
const [lagR, p365R, p30R, caseTrendR, envCorrR] = await Promise.allSettled([
envApi.getLagCorrelations(),
envApi.getPollutants(365),
envApi.getPollutants(30),
caseApi.getTrend({ group_by: 'day' }),
statsApi.getEnvCorrelation(),
]);
if (cancelled) return;
@@ -145,6 +200,12 @@ export function EnvironmentalHealth() {
newErrors.push('病例趋势数据加载失败');
}
if (envCorrR.status === 'fulfilled') {
setEnvCorr(envCorrR.value);
} else {
newErrors.push('污染物关联分析数据加载失败');
}
setErrors(newErrors);
setIsLoading(false);
};
@@ -266,6 +327,61 @@ export function EnvironmentalHealth() {
}));
}, [pollutants30]);
// --- Correlation: pollutant × cases (grid-level Pearson) ---
const corrWithCases = useMemo(() => {
const matrix = envCorr?.correlation_matrix ?? [];
return matrix
.map((m) => ({
pollutant: m.pollutant,
label: pollutantLabel(m.pollutant),
corr: m.corr_with_cases,
}))
.sort((a, b) => Math.abs(b.corr) - Math.abs(a.corr));
}, [envCorr]);
// --- Correlation: pollutant pairwise 7×7 heatmap ---
// pollutant_pairwise carries the upper triangle (21 pairs); we mirror it into
// a symmetric lookup and fill the diagonal with 1.
const pairwiseGrid = useMemo(() => {
const pairs = envCorr?.pollutant_pairwise ?? [];
if (pairs.length === 0) return null;
const lookup = new Map<string, number>();
for (const p of pairs) {
lookup.set(`${p.a}|${p.b}`, p.corr);
lookup.set(`${p.b}|${p.a}`, p.corr);
}
return CORR_POLLUTANTS.map((rowKey) =>
CORR_POLLUTANTS.map((colKey) => {
if (rowKey === colKey) return 1;
return lookup.get(`${rowKey}|${colKey}`) ?? null;
}),
);
}, [envCorr]);
// --- Scatter: PM2.5 × cases + least-squares regression line ---
const scatterPoints = useMemo(() => {
return (envCorr?.scatter ?? []).map((s) => ({ pm25: s.pm25, cases: s.cases }));
}, [envCorr]);
const regression = useMemo(() => {
return linearRegression(scatterPoints.map((p) => ({ x: p.pm25, y: p.cases })));
}, [scatterPoints]);
// Two endpoints across the observed PM2.5 range to draw the trend line.
const regressionLine = useMemo(() => {
if (!regression || scatterPoints.length === 0) return [];
let minX = Infinity;
let maxX = -Infinity;
for (const p of scatterPoints) {
if (p.pm25 < minX) minX = p.pm25;
if (p.pm25 > maxX) maxX = p.pm25;
}
return [
{ pm25: minX, cases: regression.slope * minX + regression.intercept },
{ pm25: maxX, cases: regression.slope * maxX + regression.intercept },
];
}, [regression, scatterPoints]);
// --- Loading state ---
if (isLoading) {
return (
@@ -276,7 +392,7 @@ export function EnvironmentalHealth() {
}
return (
<div className="flex flex-col h-full overflow-auto">
<div data-testid="page-environment" className="flex flex-col h-full overflow-auto">
{/* Error banner */}
{errors.length > 0 && (
<div className="px-6 pt-4">
@@ -706,6 +822,229 @@ export function EnvironmentalHealth() {
</div>
)}
</div>
{/* === 污染物-病例关联分析 === */}
<div data-testid="env-correlation" className="space-y-6">
<div>
<h2 className="font-display text-[15px] font-semibold mb-1">
-
</h2>
<p className="text-[11px] text-text-muted">
Pearson grid-level
</p>
</div>
{/* (a) Pollutant × Cases correlation */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
×
</div>
{corrWithCases.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={corrWithCases}
layout="vertical"
margin={{ top: 5, right: 20, left: 50, bottom: 5 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="#E2E8F0"
horizontal={false}
/>
<XAxis
type="number"
domain={[-1, 1]}
tick={{ fontSize: 11, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
/>
<YAxis
type="category"
dataKey="label"
tick={{ fontSize: 11, fill: '#374151' }}
width={50}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number) => [value.toFixed(3), '相关系数']}
/>
<ReferenceLine x={0} stroke="#94A3B8" strokeWidth={1} />
<Bar dataKey="corr" barSize={20} radius={[0, 4, 4, 0]}>
{corrWithCases.map((entry, idx) => (
<Cell key={idx} fill={corrColor(entry.corr)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<p className="text-[10px] text-text-muted mt-3 text-center">
Pearson ==
</p>
</>
) : (
<div className="text-center py-8 text-text-muted text-sm">
</div>
)}
</div>
{/* (b) Pollutant pairwise correlation heatmap */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
</div>
{pairwiseGrid ? (
<>
<div className="overflow-x-auto">
<div
className="grid gap-px min-w-[320px]"
style={{
gridTemplateColumns: `48px repeat(${CORR_POLLUTANTS.length}, minmax(0, 1fr))`,
}}
>
{/* Header row */}
<div />
{CORR_POLLUTANTS.map((key) => (
<div
key={`h-${key}`}
className="text-[10px] font-medium text-text-muted text-center py-1"
>
{pollutantLabel(key)}
</div>
))}
{/* Body rows */}
{pairwiseGrid.map((row, ri) => (
<Fragment key={`r-${CORR_POLLUTANTS[ri]}`}>
<div className="text-[10px] font-medium text-text-muted flex items-center justify-end pr-2">
{pollutantLabel(CORR_POLLUTANTS[ri])}
</div>
{row.map((val, ci) => (
<div
key={`c-${ri}-${ci}`}
className="aspect-square flex items-center justify-center text-[9px] font-medium rounded-sm"
style={{
backgroundColor:
val === null ? '#F1F5F9' : corrColor(val),
color:
val !== null && Math.abs(val) > 0.55
? '#FFFFFF'
: '#475569',
}}
title={`${pollutantLabel(CORR_POLLUTANTS[ri])} × ${pollutantLabel(
CORR_POLLUTANTS[ci],
)}: ${val === null ? 'N/A' : val.toFixed(2)}`}
>
{val === null ? '-' : val.toFixed(2)}
</div>
))}
</Fragment>
))}
</div>
</div>
<p className="text-[10px] text-text-muted mt-3 text-center">
线=1.00==
</p>
</>
) : (
<div className="text-center py-8 text-text-muted text-sm">
</div>
)}
</div>
{/* (c) PM2.5 × cases scatter + regression line */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
PM2.5 × + 线
</div>
{scatterPoints.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={320}>
<ScatterChart margin={{ top: 5, right: 20, left: 10, bottom: 15 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
type="number"
dataKey="pm25"
name="PM2.5"
unit="μg/m³"
tick={{ fontSize: 11, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
label={{
value: 'PM2.5 (μg/m³)',
position: 'insideBottom',
offset: -8,
fontSize: 11,
fill: '#64748B',
}}
/>
<YAxis
type="number"
dataKey="cases"
name="病例数"
tick={{ fontSize: 11, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
label={{
value: '病例数',
angle: -90,
position: 'insideLeft',
offset: 0,
fontSize: 11,
fill: '#64748B',
}}
/>
<ZAxis range={[30, 30]} />
<Tooltip
cursor={{ strokeDasharray: '3 3' }}
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number, name: string) => [
Math.round(value).toLocaleString(),
name,
]}
/>
<Scatter
name="网格点"
data={scatterPoints}
fill="#7C3AED"
fillOpacity={0.4}
/>
{regressionLine.length === 2 && (
<Scatter
name="回归线"
data={regressionLine}
line={{ stroke: '#DC2626', strokeWidth: 2 }}
lineType="joint"
fill="#DC2626"
shape={() => <g />}
legendType="none"
/>
)}
</ScatterChart>
</ResponsiveContainer>
{regression && (
<p className="text-[10px] text-text-muted mt-3 text-center">
{regression.slope.toFixed(2)} × PM2.5 +{' '}
{regression.intercept.toFixed(1)}
</p>
)}
</>
) : (
<div className="text-center py-8 text-text-muted text-sm">
</div>
)}
</div>
</div>
</div>
</div>
);

View File

@@ -1,4 +1,5 @@
import { useEffect, useState, useMemo } from 'react';
import { LoadingState } from '@/components/ui';
import { useAnalysisStore } from '@/stores/analysisStore';
import { ErrorBanner } from '@/components/ErrorBanner';
import { ChatBot } from '@/components/ChatBot';
@@ -186,7 +187,7 @@ export function Insights() {
: [];
return (
<div>
<div data-testid="page-insights" className="overflow-x-hidden">
{error && (
<ErrorBanner
error={error}
@@ -205,13 +206,13 @@ export function Insights() {
</div>
{isLoading && (
<div className="mb-4 text-center py-8 bg-bg-card rounded-lg border border-border">
<span className="text-text-secondary">...</span>
<div className="mb-4 bg-bg-card rounded-lg border border-border">
<LoadingState />
</div>
)}
{insights && (
<div className="grid grid-cols-4 gap-4 mb-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-4">
{stats.map((stat) => (
<div key={stat.label} className="card p-4">
<div className="flex items-center gap-2 mb-2">
@@ -231,7 +232,7 @@ export function Insights() {
)}
{insights && (
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{(insights.cards || []).map((card) => {
const config = TYPE_CONFIG[card.type];
const Icon = config.icon;
@@ -307,8 +308,8 @@ export function Insights() {
</p>
{anomalyLoading && (
<div className="card p-8 text-center">
<span className="text-text-secondary">...</span>
<div className="card p-8">
<LoadingState />
</div>
)}

View File

@@ -1,11 +1,14 @@
import { useState, FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '@/services/api';
import { TESTIDS } from '@/utils/testids';
interface LoginProps {
onLogin: (token: string) => void;
}
export function Login({ onLogin }: LoginProps) {
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
@@ -20,6 +23,7 @@ export function Login({ onLogin }: LoginProps) {
const token = res.data.access_token;
localStorage.setItem('cbpoa_token', token);
onLogin(token);
navigate('/monitoring');
} catch {
setError('用户名或密码错误');
} finally {
@@ -28,7 +32,7 @@ export function Login({ onLogin }: LoginProps) {
};
return (
<div className="min-h-screen bg-bg-page flex items-center justify-center">
<div data-testid={TESTIDS.pageLogin} className="min-h-screen bg-bg-page flex items-center justify-center">
<form
onSubmit={handleSubmit}
className="bg-white rounded-lg shadow-md p-8 w-full max-w-sm"
@@ -68,6 +72,7 @@ export function Login({ onLogin }: LoginProps) {
<button
type="submit"
disabled={loading}
data-testid={TESTIDS.loginSubmit}
className="w-full py-2 bg-primary text-white rounded text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
{loading ? '登录中...' : '登录'}

View File

@@ -1,46 +1,21 @@
import { useEffect, useState, useMemo, useRef, useCallback, memo } from 'react';
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { Calendar, Activity, TrendingUp, TrendingDown, Stethoscope, Zap, BarChart3 } from 'lucide-react';
import { useEffect, useState, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useTimelineStore, useMonitoringStore } from '@/stores';
import { useDiseaseStore } from '@/stores/diseaseStore';
import { useDrilldownStore } from '@/stores/drilldownStore';
import { gridApi, caseApi, envApi } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import { TimelinePlayer } from '@/components/TimelinePlayer';
import { StatisticalCharts } from '@/components/StatisticalCharts';
import { CaseLocationMap } from '@/components/CaseLocationMap';
import { DiseaseFilter } from '@/components/DiseaseFilter';
import { AdminBreadcrumb } from '@/components/AdminBreadcrumb';
import { StatCard } from '@/components/StatCard';
import { CalendarHeatmap } from '@/components/CalendarHeatmap';
import { MetricHeatmapTable } from '@/components/MetricHeatmapTable';
import type { DistrictCaseData } from '@/types';
import { MonitoringStatsBar } from '@/components/monitoring/MonitoringStatsBar';
import { OverviewTab } from '@/components/monitoring/OverviewTab';
import { CaseStatsTab } from '@/components/monitoring/CaseStatsTab';
import { DistrictStatsTab } from '@/components/monitoring/DistrictStatsTab';
import { useMonitoringData } from '@/components/monitoring/useMonitoringData';
import { parseGranularity } from '@/components/monitoring/types';
import type { Granularity } from '@/components/monitoring/types';
type MonitoringTab = 'overview' | 'cases' | 'districts';
interface TopDiagnosis {
diagnosis: string;
outpatient: number;
inpatient: number;
total: number;
}
function formatDateLabel(dateStr: string): string {
const d = new Date(dateStr);
return `${d.getMonth() + 1}/${d.getDate()}`;
}
interface MonitoringDashboardProps {
defaultStartDate?: string;
defaultEndDate?: string;
@@ -50,27 +25,9 @@ export function MonitoringDashboard({
defaultStartDate = '2022-12-01',
defaultEndDate = '2024-12-30',
}: MonitoringDashboardProps) {
const [chartData, setChartData] = useState<Array<{ date: string; cases: number; aqi?: number }>>([]);
// In-page tab strip (local state, no router — mirrors the existing activePage pattern)
const [activeTab, setActiveTab] = useState<MonitoringTab>('overview');
// --- 病例统计 tab state (fetched on demand) ---
const [topDiagnoses, setTopDiagnoses] = useState<TopDiagnosis[]>([]);
const [caseTrend, setCaseTrend] = useState<Array<{ date: string; cases: number; aqi: number }>>([]);
const [heatmapData, setHeatmapData] = useState<Array<{ date: string; value: number }>>([]);
const [heatmapYear, setHeatmapYear] = useState<number | null>(null);
const [casesTabLoaded, setCasesTabLoaded] = useState(false);
const [casesTabLoading, setCasesTabLoading] = useState(false);
const [casesTabError, setCasesTabError] = useState<string | null>(null);
// --- 区域统计 tab state (fetched on demand) ---
const [districtMetrics, setDistrictMetrics] = useState<DistrictCaseData[]>([]);
const [districtSortKey, setDistrictSortKey] = useState<string>('total');
const [districtTabLoaded, setDistrictTabLoaded] = useState(false);
const [districtTabLoading, setDistrictTabLoading] = useState(false);
const [districtTabError, setDistrictTabError] = useState<string | null>(null);
const {
currentDate,
isPlaying,
@@ -81,257 +38,83 @@ export function MonitoringDashboard({
setDateRange,
} = useTimelineStore();
const districtCases = useMonitoringStore((s) => s.districtCases);
const error = useMonitoringStore((s) => s.error);
const clearError = useMonitoringStore((s) => s.clearError);
const fetchDistrictCases = useMonitoringStore((s) => s.fetchDistrictCases);
const isLoading = useMonitoringStore((s) => s.isLoading);
const { selectedDistrict, selectedStreet } = useDrilldownStore();
const { selectedDiagnoses } = useDiseaseStore();
const drillDown = useDrilldownStore((s) => s.drillDown);
const resetDrillDown = useDrilldownStore((s) => s.resetDrillDown);
// --- URL 是粒度的真相来源drilldownStore 由 URL 派生 ---
const [searchParams, setSearchParams] = useSearchParams();
const granularity = parseGranularity(searchParams.get('granularity'));
const districtParam = searchParams.get('district');
const streetParam = searchParams.get('street');
// 把 URL 写入:粒度控件、面包屑、区域点击都通过它驱动 URL再由下方 effect 同步 store。
const updateUrl = useCallback(
(next: { granularity: Granularity; district?: string | null; street?: string | null }) => {
setSearchParams(
(prev) => {
const sp = new URLSearchParams(prev);
sp.set('granularity', next.granularity);
if (next.district) sp.set('district', next.district);
else sp.delete('district');
if (next.street) sp.set('street', next.street);
else sp.delete('street');
return sp;
},
{ replace: false }
);
},
[setSearchParams]
);
// 粒度控件回调:切换粒度即写 URL深链可分享
const handleGranularityChange = useCallback(
(g: Granularity) => {
if (g === 'city') updateUrl({ granularity: 'city' });
else if (g === 'district') updateUrl({ granularity: 'district', district: selectedDistrict });
else updateUrl({ granularity: 'street', district: selectedDistrict, street: selectedStreet });
},
[updateUrl, selectedDistrict, selectedStreet]
);
// 区域 roll-up 点击回调:驱动 URL 而非直接 mutate store消除命令式 desync
const handleDistrictSelect = useCallback(
(district: string) => {
if (selectedDistrict === district) updateUrl({ granularity: 'city' });
else updateUrl({ granularity: 'district', district });
},
[updateUrl, selectedDistrict]
);
// store 从 URL 同步URL 派生 store单向。mount 与 param 变化时执行。
useEffect(() => {
if (granularity === 'city') {
if (selectedDistrict !== null || selectedStreet !== null) resetDrillDown();
return;
}
if (granularity === 'district') {
if (districtParam && selectedDistrict !== districtParam) drillDown('district', districtParam);
else if (!districtParam && selectedDistrict !== null) resetDrillDown();
return;
}
// granularity === 'street'
if (districtParam && selectedDistrict !== districtParam) drillDown('district', districtParam);
if (streetParam && selectedStreet !== streetParam) drillDown('street', streetParam);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [granularity, districtParam, streetParam]);
useEffect(() => {
setDateRange(defaultStartDate, defaultEndDate);
setCurrentDate(defaultEndDate);
}, [defaultStartDate, defaultEndDate, setDateRange, setCurrentDate]);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Load chart data for 90-day window ending at the given reference date
const loadChartData = useCallback((refDate: string, district?: string) => {
const end = new Date(refDate);
const start = new Date(refDate);
start.setDate(start.getDate() - 90);
const startStr = start.toISOString().split('T')[0];
const endStr = end.toISOString().split('T')[0];
if (selectedDiagnoses.length > 0 && selectedDiagnoses.length <= 3) {
caseApi.getTrend({
start_date: startStr,
end_date: endStr,
group_by: 'day',
diagnosis: selectedDiagnoses.join(','),
}).then((data) => {
const trend = data.trend || [];
setChartData(
trend.map((t: { date: string; total: number }) => ({ date: t.date, cases: t.total }))
);
}).catch((e) => { console.error('Failed to load chart data:', e); });
} else {
gridApi.getHistoricalAggregated(startStr, endStr, 'daily', district)
.then((data) => {
const rows = data.aggregations || [];
const dailyCases: Record<string, number> = {};
rows.forEach((item: { date: string; total_cases: number }) => {
dailyCases[item.date] = (dailyCases[item.date] || 0) + item.total_cases;
});
setChartData(
Object.entries(dailyCases)
.map(([date, cases]) => ({ date, cases }))
.sort((a, b) => a.date.localeCompare(b.date))
);
}).catch((e) => { console.error('Failed to load chart data:', e); });
}
// Fetch districtCases with date filter (single day = currentDate)
const diagnosisParam = selectedDiagnoses.length > 0 ? selectedDiagnoses.join(',') : undefined;
fetchDistrictCases(diagnosisParam, undefined, refDate);
}, [fetchDistrictCases, selectedDiagnoses]);
// Re-fetch when currentDate, district, or diagnoses change
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
loadChartData(currentDate, selectedDistrict || undefined);
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [currentDate, selectedDistrict, loadChartData]);
// Enhanced stats: window stats + current-date snapshot
const stats = useMemo(() => {
const noData = chartData.length === 0;
const totalCases = noData ? 0 : chartData.reduce((sum, d) => sum + d.cases, 0);
const avgCases = noData ? 0 : Math.round(totalCases / chartData.length);
let maxDay = { date: '--', cases: 0 };
let minDay = { date: '--', cases: 0 };
let stdDev = 0;
let trend: 'up' | 'down' | 'stable' = 'stable';
if (!noData) {
maxDay = chartData.reduce((max, d) => d.cases > max.cases ? d : max, chartData[0]);
minDay = chartData.reduce((min, d) => d.cases < min.cases ? d : min, chartData[0]);
const variance = chartData.reduce((sum, d) => sum + (d.cases - avgCases) ** 2, 0) / chartData.length;
stdDev = Math.round(Math.sqrt(variance));
const halfIdx = Math.floor(chartData.length / 2);
const firstHalf = chartData.slice(0, halfIdx);
const secondHalf = chartData.slice(halfIdx);
const firstAvg = firstHalf.reduce((s, d) => s + d.cases, 0) / firstHalf.length;
const secondAvg = secondHalf.reduce((s, d) => s + d.cases, 0) / secondHalf.length;
trend = secondAvg > firstAvg * 1.1 ? 'up' : secondAvg < firstAvg * 0.9 ? 'down' : 'stable';
}
// 7-day moving average (last 7 days of the window)
const last7 = chartData.slice(-7);
const avg7d = last7.length > 0 ? Math.round(last7.reduce((s, d) => s + d.cases, 0) / last7.length) : 0;
// Current date snapshot: find the data point matching currentDate
const todaySnapshot = chartData.find((d) => d.date === currentDate);
const todayCases = todaySnapshot?.cases ?? null;
// Case type breakdown from districtCases
const totalOutpatient = districtCases.reduce((s, d) => s + d.outpatient, 0);
const totalInpatient = districtCases.reduce((s, d) => s + d.inpatient, 0);
return {
totalCases, avgCases, maxDay, minDay,
stdDev, trend, totalOutpatient, totalInpatient,
avg7d, todayCases, noData,
};
}, [chartData, districtCases, currentDate]);
// 7-day sparkline for the StatCard bar (last 7 days of the loaded window)
const sparkline7d = useMemo(() => chartData.slice(-7).map((d) => d.cases), [chartData]);
// --- On-demand loader: 病例统计 tab ---
// Drives the trend off the Monitoring timeline (30-day window ending at currentDate),
// NOT a fixed now-30d window. Year for the heatmap is derived from the data.
const loadCasesTab = useCallback(async (refDate: string) => {
setCasesTabLoading(true);
setCasesTabError(null);
const end = new Date(refDate);
const start = new Date(refDate);
start.setDate(start.getDate() - 30);
const startStr = start.toISOString().split('T')[0];
const endStr = end.toISOString().split('T')[0];
const yearStart = `${end.getFullYear()}-01-01`;
const yearEnd = `${end.getFullYear()}-12-31`;
const [statsR, trendR, pollutantsR, yearTrendR] = await Promise.allSettled([
caseApi.getStats(),
caseApi.getTrend({ start_date: startStr, end_date: endStr, group_by: 'day' }),
envApi.getPollutants(30),
caseApi.getTrend({ start_date: yearStart, end_date: yearEnd, group_by: 'day' }),
]);
const errs: string[] = [];
if (statsR.status === 'fulfilled') {
const topDiag = statsR.value.top_diagnoses || [];
setTopDiagnoses(
topDiag.slice(0, 5).map((d) => ({
diagnosis: d.diagnosis,
outpatient: d.outpatient,
inpatient: d.inpatient,
total: d.outpatient + d.inpatient,
}))
);
} else {
errs.push('诊断分布加载失败');
}
const aqiMap: Record<string, number> = {};
if (pollutantsR.status === 'fulfilled') {
for (const p of pollutantsR.value.data || []) {
aqiMap[p.date] = p.AQI || 0;
}
}
if (trendR.status === 'fulfilled') {
const trend = trendR.value.trend || [];
setCaseTrend(
trend.map((t) => ({ date: t.date, cases: t.total, aqi: aqiMap[t.date] || 0 }))
);
} else {
errs.push('趋势数据加载失败');
}
// Calendar heatmap: daily cases for the data's actual year (derived from trend data)
if (yearTrendR.status === 'fulfilled') {
const yearTrend = yearTrendR.value.trend || [];
if (yearTrend.length > 0) {
const derivedYear = new Date(yearTrend[0].date).getFullYear();
setHeatmapYear(derivedYear);
setHeatmapData(yearTrend.map((t) => ({ date: t.date, value: t.total })));
} else {
setHeatmapYear(end.getFullYear());
setHeatmapData([]);
}
} else {
errs.push('日历热力图加载失败');
}
setCasesTabError(errs.length > 0 ? errs.join('') : null);
setCasesTabLoading(false);
setCasesTabLoaded(true);
}, []);
// --- On-demand loader: 区域统计 tab ---
const loadDistrictTab = useCallback(async () => {
setDistrictTabLoading(true);
setDistrictTabError(null);
try {
const res = await caseApi.getDistricts();
setDistrictMetrics(res.districts || []);
setDistrictTabError(null);
} catch {
setDistrictTabError('区域统计加载失败');
} finally {
setDistrictTabLoading(false);
setDistrictTabLoaded(true);
}
}, []);
// Fetch tab data the first time a tab is opened (avoids loading everything upfront)
useEffect(() => {
if (activeTab === 'cases' && !casesTabLoaded && !casesTabLoading) {
loadCasesTab(currentDate);
}
if (activeTab === 'districts' && !districtTabLoaded && !districtTabLoading) {
loadDistrictTab();
}
}, [activeTab, casesTabLoaded, casesTabLoading, districtTabLoaded, districtTabLoading, currentDate, loadCasesTab, loadDistrictTab]);
// When the timeline date moves, refresh an already-opened 病例统计 tab so its
// trend window tracks the Monitoring timeline rather than going stale.
useEffect(() => {
if (activeTab === 'cases' && casesTabLoaded) {
loadCasesTab(currentDate);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentDate]);
// 区域统计 table: sortable district rows + heatmap columns
const districtTableRows = useMemo(() => {
const sorted = [...districtMetrics].sort((a, b) => {
switch (districtSortKey) {
case 'outpatient': return b.outpatient - a.outpatient;
case 'inpatient': return b.inpatient - a.inpatient;
case 'inpatient_ratio': return (b.inpatient_ratio ?? 0) - (a.inpatient_ratio ?? 0);
default: return b.total - a.total;
}
});
return sorted.map((d) => d.district);
}, [districtMetrics, districtSortKey]);
const districtTableData = useMemo(() => {
const map: Record<string, Record<string, number>> = {};
for (const d of districtMetrics) {
map[d.district] = {
total: d.total,
outpatient: d.outpatient,
inpatient: d.inpatient,
inpatient_ratio: Math.round((d.inpatient_ratio ?? 0) * 1000) / 10,
};
}
return map;
}, [districtMetrics]);
// 数据层:图表窗口 + 病例统计/区域统计两个按需 tab 的加载与派生。
// 不持有 URL/drilldown 真相来源,只消费 currentDate 与 selectedDistrict。
const data = useMonitoringData({ activeTab, currentDate, selectedDistrict });
const handleDateChange = useCallback((date: string) => {
setCurrentDate(date);
@@ -342,14 +125,14 @@ export function MonitoringDashboard({
}, [setPlaying]);
return (
<div className="flex flex-col h-full">
<div data-testid="page-monitoring" className="flex flex-col h-full">
{error && (
<div className="px-6 pt-4">
<ErrorBanner
error={error}
onRetry={() => {
clearError();
loadChartData(currentDate, selectedDistrict || undefined);
data.loadChartData(currentDate, selectedDistrict || undefined);
}}
onDismiss={clearError}
/>
@@ -358,59 +141,14 @@ export function MonitoringDashboard({
{/* Top stats bar — standardized with StatCard */}
<div className="bg-white border-b border-gray-200 px-6 py-4 shrink-0">
<div className="flex items-start justify-between flex-wrap gap-x-4 gap-y-3">
<div className="flex-1 min-w-0 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard
icon={<Calendar className="w-4 h-4 text-blue-600" />}
label="当日病例"
value={stats.todayCases !== null ? stats.todayCases.toLocaleString() : '--'}
/>
<StatCard
icon={<Activity className="w-4 h-4 text-indigo-600" />}
label="7日均值"
value={stats.avg7d.toLocaleString()}
sparkline={sparkline7d.length >= 2 ? { data: sparkline7d, color: '#6366F1' } : undefined}
/>
<StatCard
icon={
stats.trend === 'up' ? <TrendingUp className="w-4 h-4 text-red-500" /> :
stats.trend === 'down' ? <TrendingDown className="w-4 h-4 text-green-500" /> :
<Activity className="w-4 h-4 text-gray-400" />
}
label="趋势"
value={stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳'}
trend={{
direction: stats.trend === 'up' ? 'up' : stats.trend === 'down' ? 'down' : 'stable',
value: stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳',
}}
/>
<StatCard
icon={<Zap className="w-4 h-4 text-amber-500" />}
label="峰值日"
value={`${stats.maxDay.cases.toLocaleString()} (${stats.maxDay.date.slice(5)})`}
/>
<StatCard
icon={<BarChart3 className="w-4 h-4 text-purple-500" />}
label="标准差"
value={stats.stdDev.toLocaleString()}
/>
<StatCard
icon={<Stethoscope className="w-4 h-4 text-orange-500" />}
label="门诊 / 住院"
value={`${stats.totalOutpatient.toLocaleString()} / ${stats.totalInpatient.toLocaleString()}`}
/>
</div>
<MonitoringStatsBar stats={data.stats} sparkline7d={data.sparkline7d} />
{/* Disease filter */}
<div className="flex items-center gap-2 shrink-0">
{stats.noData && (
{data.stats.noData && (
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded"></span>
)}
<DiseaseFilter onFilterChange={() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
loadChartData(currentDate, selectedDistrict || undefined);
}, 300);
}} />
<DiseaseFilter onFilterChange={data.debouncedLoadChart} />
<AdminBreadcrumb />
</div>
</div>
@@ -441,178 +179,47 @@ export function MonitoringDashboard({
<div className="flex-1 overflow-auto p-6 pb-24">
{/* 概览 tab — unchanged Monitoring content */}
{activeTab === 'overview' && (
isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : (
<div className="space-y-6">
{/* Case Location Map */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<CaseLocationMap height="400px" district={selectedDistrict} street={selectedStreet} date={currentDate} />
</div>
{/* Statistical Charts */}
<StatisticalCharts
data={chartData}
height={350}
showCases={true}
showAQI={true}
/>
{/* District breakdown */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<div className="space-y-2">
<DistrictBreakdown districtCases={districtCases} selectedDistrict={selectedDistrict} />
</div>
<div className="flex items-center gap-4 mt-3 pt-2 border-t border-gray-100">
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<span className="w-3 h-3 bg-orange-400 rounded-sm" />
</div>
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<span className="w-3 h-3 bg-red-400 rounded-sm" />
</div>
</div>
</div>
</div>
)
<OverviewTab
isLoading={isLoading}
chartData={data.chartData}
districtCases={data.districtCases}
selectedDistrict={selectedDistrict}
selectedStreet={selectedStreet}
currentDate={currentDate}
granularity={granularity}
onGranularityChange={handleGranularityChange}
onDistrictSelect={handleDistrictSelect}
/>
)}
{/* 病例统计 tab */}
{activeTab === 'cases' && (
casesTabLoading && !casesTabLoaded ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : (
<div className="space-y-6">
{casesTabError && (
<ErrorBanner
error={casesTabError}
onRetry={() => loadCasesTab(currentDate)}
onDismiss={() => setCasesTabError(null)}
/>
)}
{/* Top 5 诊断分布 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top 5 </h3>
{topDiagnoses.length > 0 ? (
<ResponsiveContainer width="100%" height={240}>
<BarChart
data={[...topDiagnoses].reverse()}
layout="vertical"
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
<YAxis
type="category"
dataKey="diagnosis"
tick={{ fontSize: 11, fill: '#374151' }}
width={100}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{ backgroundColor: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '8px', fontSize: '12px' }}
formatter={(value: number) => [value.toLocaleString(), '病例数']}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={16} />
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={16} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
{/* 近30日病例与AQI趋势 (driven off Monitoring timeline currentDate) */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-1">AQI趋势</h3>
<p className="text-xs text-gray-500 mb-4"> {currentDate} 30</p>
{caseTrend.length > 0 ? (
<ResponsiveContainer width="100%" height={260}>
<LineChart data={caseTrend} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
dataKey="date"
tickFormatter={formatDateLabel}
tick={{ fontSize: 10, fill: '#64748B' }}
interval="preserveStartEnd"
axisLine={{ stroke: '#E2E8F0' }}
/>
<YAxis yAxisId="left" tick={{ fontSize: 10, fill: '#64748B' }} axisLine={{ stroke: '#E2E8F0' }} />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10, fill: '#F59E0B' }} axisLine={{ stroke: '#E2E8F0' }} />
<Tooltip
contentStyle={{ backgroundColor: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '8px', fontSize: '12px' }}
labelStyle={{ color: '#1E293B', fontWeight: 600 }}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Line yAxisId="left" type="monotone" dataKey="cases" name="病例数" stroke="#3B82F6" strokeWidth={2} dot={false} activeDot={{ r: 3 }} />
<Line yAxisId="right" type="monotone" dataKey="aqi" name="AQI" stroke="#F59E0B" strokeWidth={2} strokeDasharray="5 5" dot={false} activeDot={{ r: 3 }} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
{/* 日历热力图 (year derived from data) */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{heatmapYear ? `${heatmapYear}` : ''}
</h3>
{heatmapYear && heatmapData.length > 0 ? (
<CalendarHeatmap data={heatmapData} year={heatmapYear} />
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
</div>
)
<CaseStatsTab
loading={data.casesTabLoading}
loaded={data.casesTabLoaded}
error={data.casesTabError}
currentDate={currentDate}
topDiagnoses={data.topDiagnoses}
caseTrend={data.caseTrend}
heatmapData={data.heatmapData}
heatmapYear={data.heatmapYear}
onRetry={() => data.loadCasesTab(currentDate)}
onDismissError={() => data.setCasesTabError(null)}
/>
)}
{/* 区域统计 tab */}
{activeTab === 'districts' && (
districtTabLoading && !districtTabLoaded ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : (
<div className="space-y-6">
{districtTabError && (
<ErrorBanner
error={districtTabError}
onRetry={() => loadDistrictTab()}
onDismiss={() => setDistrictTabError(null)}
/>
)}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-1"></h3>
<p className="text-xs text-gray-500 mb-4"></p>
{districtTableRows.length > 0 ? (
<MetricHeatmapTable
rows={districtTableRows}
columns={[
{ key: 'total', label: '病例' },
{ key: 'outpatient', label: '门诊' },
{ key: 'inpatient', label: '住院' },
{ key: 'inpatient_ratio', label: '住院占比%' },
]}
data={districtTableData}
onSort={(col) => setDistrictSortKey(col)}
/>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
</div>
)
<DistrictStatsTab
loading={data.districtTabLoading}
loaded={data.districtTabLoaded}
error={data.districtTabError}
rows={data.districtTableRows}
data={data.districtTableData}
onRetry={() => data.loadDistrictTab()}
onDismissError={() => data.setDistrictTabError(null)}
onSort={(col) => data.setDistrictSortKey(col)}
/>
)}
</div>
@@ -630,57 +237,3 @@ export function MonitoringDashboard({
</div>
);
}
interface DistrictBreakdownProps {
districtCases: Array<{ district: string; total: number; outpatient: number; inpatient: number }>;
selectedDistrict: string | null;
}
const DistrictBreakdown = memo(function DistrictBreakdown({ districtCases, selectedDistrict }: DistrictBreakdownProps) {
const sortedCases = useMemo(() => [...districtCases].sort((a, b) => b.total - a.total), [districtCases]);
const maxTotal = useMemo(() => sortedCases.length > 0 ? sortedCases[0].total : 1, [sortedCases]);
const handleDistrictClick = useCallback((district: string) => {
if (selectedDistrict === district) {
useDrilldownStore.getState().drillUp();
} else {
useDrilldownStore.getState().drillDown('district', district);
}
}, [selectedDistrict]);
return (
<>
{sortedCases.map((d) => {
const outPct = d.total > 0 ? (d.outpatient / d.total) * 100 : 0;
const inPct = d.total > 0 ? (d.inpatient / d.total) * 100 : 0;
const barWidth = (d.total / maxTotal) * 100;
return (
<div
key={d.district}
className={`flex items-center gap-3 p-2 rounded cursor-pointer transition-colors ${
selectedDistrict === d.district ? 'bg-blue-50' : 'hover:bg-gray-50'
}`}
onClick={() => handleDistrictClick(d.district)}
>
<div className="w-16 text-sm text-gray-700 text-right shrink-0">{d.district}</div>
<div className="flex-1 h-6 bg-gray-100 rounded overflow-hidden flex">
<div
className="bg-orange-400 h-full transition-all"
style={{ width: `${barWidth * outPct / 100}%` }}
title={`门诊: ${d.outpatient.toLocaleString()}`}
/>
<div
className="bg-red-400 h-full transition-all"
style={{ width: `${barWidth * inPct / 100}%` }}
title={`住院: ${d.inpatient.toLocaleString()}`}
/>
</div>
<div className="w-20 text-right text-sm font-medium text-gray-900 shrink-0">
{d.total.toLocaleString()}
</div>
</div>
);
})}
</>
);
});

View File

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

View File

@@ -1,4 +1,5 @@
import { useEffect, useState, useCallback } from 'react';
import { LoadingState } from '@/components/ui';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { FileText, Download, Activity, TrendingUp, TrendingDown, AlertTriangle, ChevronLeft, RefreshCw } from 'lucide-react';
import { useReportsStore } from '@/stores/reportsStore';
@@ -94,7 +95,7 @@ function ReportList({ onSelect }: { onSelect: (id: string) => void }) {
</div>
{isLoading ? (
<div className="text-center py-8 text-sm text-gray-500">...</div>
<LoadingState />
) : reports.length === 0 ? (
<div className="text-center py-12">
<FileText className="w-12 h-12 text-gray-300 mx-auto mb-3" />
@@ -102,6 +103,7 @@ function ReportList({ onSelect }: { onSelect: (id: string) => void }) {
</div>
) : (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
@@ -134,6 +136,7 @@ function ReportList({ onSelect }: { onSelect: (id: string) => void }) {
))}
</tbody>
</table>
</div>
</div>
)}
</div>
@@ -170,7 +173,7 @@ function ReportDetail({ reportId, onBack }: { reportId: string; onBack: () => vo
<ChevronLeft className="w-4 h-4" />
</button>
<div className="flex items-center justify-between mb-4">
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<div>
<h2 className="text-lg font-semibold text-gray-900">{metadata.title}</h2>
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500">
@@ -207,7 +210,7 @@ function ReportDetail({ reportId, onBack }: { reportId: string; onBack: () => vo
</div>
{/* Summary cards */}
<div className="grid grid-cols-5 gap-3 mb-6">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 mb-6">
{[
{ label: '总病例数', value: summary.total_cases.toLocaleString(), icon: Activity, color: 'text-blue-600', bg: 'bg-blue-50' },
{ label: '平均风险', value: (summary.avg_risk * 100).toFixed(1) + '%', icon: AlertTriangle, color: 'text-orange-600', bg: 'bg-orange-50' },
@@ -233,7 +236,7 @@ function ReportDetail({ reportId, onBack }: { reportId: string; onBack: () => vo
</div>
{/* Sections and charts in 2-column layout */}
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
{sections.map((section, idx) => (
<div key={idx} className="bg-white rounded-lg border border-gray-200 p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-2">{section.title}</h3>
@@ -329,7 +332,7 @@ export function ReportsCenter() {
}, [fetchReportsList]);
return (
<div>
<div data-testid="page-reports" className="overflow-x-hidden">
{error && view === 'list' && (
<ErrorBanner error={error} onRetry={() => { clearError(); fetchReportsList(); }} onDismiss={clearError} />
)}

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { LoadingState } from '@/components/ui';
import {
LineChart,
Line,
@@ -15,7 +16,8 @@ import {
ReferenceLine,
} from 'recharts';
import { useAnalysisStore } from '@/stores/analysisStore';
import { caseApi } from '@/services/api';
import { caseApi, statsApi } from '@/services/api';
import type { TemporalResponse } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import { TrendingUp, Calendar, Activity } from 'lucide-react';
import type { CaseTrendPoint } from '@/types';
@@ -47,6 +49,7 @@ export function TrendAnalysis() {
const [selectedPollutants, setSelectedPollutants] = useState<string[]>(['aqi', 'pm25']);
const [multiYearData, setMultiYearData] = useState<Record<string, CaseTrendPoint[]>>({});
const [multiYearLoading, setMultiYearLoading] = useState(false);
const [weekdayData, setWeekdayData] = useState<TemporalResponse['weekday']>([]);
useEffect(() => {
fetchTrend(selectedDays);
@@ -81,6 +84,19 @@ export function TrendAnalysis() {
return () => { cancelled = true; };
}, []);
useEffect(() => {
let cancelled = false;
statsApi
.getTemporal()
.then((res) => {
if (!cancelled) setWeekdayData(res.weekday || []);
})
.catch(() => {
// weekday distribution is supplementary — fail silently
});
return () => { cancelled = true; };
}, []);
// Merge multi-year data by month (data is monthly) over a fixed 1..12 sequence
const mergedMultiYearData = (() => {
const yearColors: Record<string, string> = { '2022': '#94A3B8', '2023': '#3B82F6', '2024': '#EF4444' };
@@ -148,7 +164,7 @@ export function TrendAnalysis() {
};
return (
<div>
<div data-testid="page-trend" className="overflow-x-hidden">
{error && (
<ErrorBanner
error={error}
@@ -211,8 +227,8 @@ export function TrendAnalysis() {
</div>
{isLoading && (
<div className="mb-4 text-center py-8 bg-bg-card rounded-lg border border-border">
<span className="text-text-secondary">...</span>
<div className="mb-4 bg-bg-card rounded-lg border border-border">
<LoadingState />
</div>
)}
@@ -310,7 +326,7 @@ export function TrendAnalysis() {
)}
{latestData && (
<div className="grid grid-cols-4 gap-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{POLLUTANT_OPTIONS.filter((p) => selectedPollutants.includes(p.key)).slice(0, 4).map((p) => {
const value = latestData[p.key as keyof typeof latestData] as number;
const change = getChange(p.key);
@@ -350,7 +366,7 @@ export function TrendAnalysis() {
</div>
{multiYearLoading ? (
<div className="text-center py-8 text-text-secondary text-sm">...</div>
<LoadingState />
) : Object.keys(multiYearData).length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart
@@ -443,6 +459,52 @@ export function TrendAnalysis() {
</BarChart>
</ResponsiveContainer>
</div>
{/* Weekday case distribution (门诊/住院) */}
<div data-testid="weekday-dist" className="card p-4 mb-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
</div>
{weekdayData.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={280}>
<BarChart
data={weekdayData}
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
dataKey="weekday"
tick={{ fontSize: 12, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
/>
<YAxis
tick={{ fontSize: 12, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
label={{ value: '就诊量', angle: -90, position: 'insideLeft', offset: 0, fontSize: 11, fill: '#64748B' }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number, name: string) => [value.toLocaleString(), name]}
/>
<Legend wrapperStyle={{ fontSize: '12px', paddingTop: '12px' }} />
<Bar dataKey="outpatient" name="门诊" stackId="visits" fill="#3B82F6" radius={[0, 0, 0, 0]} />
<Bar dataKey="inpatient" name="住院" stackId="visits" fill="#EF4444" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
<p className="text-[10px] text-text-muted mt-3 text-center">
12/
</p>
</>
) : (
<div className="text-center py-8 text-text-muted text-sm"></div>
)}
</div>
</div>
);
}

65
frontend/src/routes.tsx Normal file
View File

@@ -0,0 +1,65 @@
import { lazy, Suspense, ComponentType } from 'react';
import { Navigate, RouteObject } from 'react-router-dom';
import { LoadingState } from '@/components/ui/LoadingState';
import { RoleRedirect } from '@/components/RoleRedirect';
import { TESTIDS } from '@/utils/testids';
// 按页面懒加载,路由层负责代码分割(原先位于 App.tsx
const MonitoringDashboard = lazy(() =>
import('@/pages/MonitoringDashboard').then((m) => ({ default: m.MonitoringDashboard }))
);
const AlertsDashboard = lazy(() =>
import('@/pages/AlertsDashboard').then((m) => ({ default: m.AlertsDashboard }))
);
const OverviewDashboard = lazy(() =>
import('@/pages/OverviewDashboard').then((m) => ({ default: m.OverviewDashboard }))
);
const TrendAnalysis = lazy(() =>
import('@/pages/TrendAnalysis').then((m) => ({ default: m.TrendAnalysis }))
);
const DistrictComparison = lazy(() =>
import('@/pages/DistrictComparison').then((m) => ({ default: m.DistrictComparison }))
);
const Insights = lazy(() => import('@/pages/Insights').then((m) => ({ default: m.Insights })));
const ReportsCenter = lazy(() =>
import('@/pages/ReportsCenter').then((m) => ({ default: m.ReportsCenter }))
);
const DemographicAnalysis = lazy(() =>
import('@/pages/DemographicAnalysis').then((m) => ({ default: m.DemographicAnalysis }))
);
const DiseaseAnalysis = lazy(() =>
import('@/pages/DiseaseAnalysis').then((m) => ({ default: m.DiseaseAnalysis }))
);
const EnvironmentalHealth = lazy(() =>
import('@/pages/EnvironmentalHealth').then((m) => ({ default: m.EnvironmentalHealth }))
);
const ClinicalAnalysis = lazy(() =>
import('@/pages/ClinicalAnalysis').then((m) => ({ default: m.ClinicalAnalysis }))
);
// 用 Suspense 包裹懒加载页面,统一加载态。
function lazyElement(Page: ComponentType): JSX.Element {
return (
<Suspense fallback={<LoadingState testid={TESTIDS.pageLoading} />}>
<Page />
</Suspense>
);
}
// AppShell 的子路由表(在 App.tsx 中作为 <Outlet/> 的内容渲染)。
export const appRoutes: RouteObject[] = [
{ index: true, element: <RoleRedirect /> },
{ path: 'overview', element: lazyElement(OverviewDashboard) },
{ path: 'monitoring', element: lazyElement(MonitoringDashboard) },
{ path: 'alerts', element: lazyElement(AlertsDashboard) },
{ path: 'analysis/trend', element: lazyElement(TrendAnalysis) },
{ path: 'analysis/district', element: lazyElement(DistrictComparison) },
{ path: 'analysis/insights', element: lazyElement(Insights) },
{ path: 'analysis/reports', element: lazyElement(ReportsCenter) },
{ path: 'analysis/demographics', element: lazyElement(DemographicAnalysis) },
{ path: 'analysis/disease', element: lazyElement(DiseaseAnalysis) },
{ path: 'analysis/environment', element: lazyElement(EnvironmentalHealth) },
{ path: 'analysis/clinical', element: lazyElement(ClinicalAnalysis) },
// 未知路径回退到监测面板。
{ path: '*', element: <Navigate to="/monitoring" replace /> },
];

View File

@@ -343,4 +343,44 @@ export const reportApi = {
getLatestSummary: (): Promise<ReportSummary> => cachedGet('/reports/summary/latest'),
};
// ===== 深度统计分析(/api/stats=====
export interface InpatientClinicalResponse {
kpis: {
total_admissions: number;
median_los_days: number;
cure_rate: number;
emergency_admit_ratio: number;
};
los_histogram: { bin_label: string; count: number }[];
los_by_disease: { diagnosis: string; p25: number; median: number; p75: number; n: number }[];
outcome_counts: { outcome: string; count: number }[];
admission_route_counts: { route: string; count: number }[];
bmi_by_age_band: { age_band: string; p25: number; median: number; p75: number; n: number }[];
}
export interface SymptomsResponse {
symptoms: { keyword: string; count: number }[];
revisit_ratio: number;
}
export interface IncidenceRateResponse {
districts: { district: string; total_cases: number; population: number; rate_per_10k: number }[];
}
export interface EnvCorrelationResponse {
correlation_matrix: { pollutant: string; corr_with_cases: number }[];
scatter: { pm25: number; aqi: number; cases: number }[];
pollutant_pairwise: { a: string; b: string; corr: number }[];
}
export interface TemporalResponse {
weekday: { weekday: string; outpatient: number; inpatient: number; total: number }[];
month_year: { year: number; month: number; total: number }[];
yoy: { period: string; current: number; previous: number; growth_pct: number }[];
}
export const statsApi = {
getInpatientClinical: (): Promise<InpatientClinicalResponse> => cachedGet('/stats/inpatient-clinical'),
getSymptoms: (top: number = 20): Promise<SymptomsResponse> => cachedGet('/stats/symptoms', { top }),
getIncidenceRate: (): Promise<IncidenceRateResponse> => cachedGet('/stats/incidence-rate'),
getEnvCorrelation: (): Promise<EnvCorrelationResponse> => cachedGet('/stats/env-correlation'),
getTemporal: (): Promise<TemporalResponse> => cachedGet('/stats/temporal'),
};
export default api;

View File

@@ -102,6 +102,7 @@ export const useRiskStore = create<RiskState>((set, get) => ({
export { useAnalysisStore } from './analysisStore';
export { useDrilldownStore } from './drilldownStore';
export { useDiseaseStore } from './diseaseStore';
export { useSessionStore } from './sessionStore';
interface TimelineState {

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { getRoleSource, type Role } from './sessionStore';
const STORAGE_KEY = 'cbpoa_role';
const ALL_ROLES: Role[] = ['official', 'community', 'doctor', 'admin'];
describe('sessionStore', () => {
beforeEach(() => {
localStorage.clear();
vi.resetModules();
});
afterEach(() => {
localStorage.clear();
});
describe('getRoleSource', () => {
it("defaults to 'admin' when localStorage is empty", () => {
expect(getRoleSource()).toBe('admin');
});
it("defaults to 'admin' on an invalid stored value", () => {
localStorage.setItem(STORAGE_KEY, 'hacker');
expect(getRoleSource()).toBe('admin');
});
it('returns each valid stored role', () => {
for (const r of ALL_ROLES) {
localStorage.setItem(STORAGE_KEY, r);
expect(getRoleSource()).toBe(r);
}
});
});
describe('useSessionStore', () => {
// useSessionStore 在模块加载时从 getRoleSource() 初始化一次,故用
// resetModules + 动态 import 来获得一个「按当前 localStorage 初始化」的全新实例。
it('initializes role from localStorage', async () => {
localStorage.setItem(STORAGE_KEY, 'doctor');
vi.resetModules();
const { useSessionStore } = await import('./sessionStore');
expect(useSessionStore.getState().role).toBe('doctor');
});
it('setRole updates state and persists to localStorage', async () => {
vi.resetModules();
const { useSessionStore } = await import('./sessionStore');
useSessionStore.getState().setRole('community');
expect(useSessionStore.getState().role).toBe('community');
expect(localStorage.getItem(STORAGE_KEY)).toBe('community');
useSessionStore.getState().setRole('official');
expect(useSessionStore.getState().role).toBe('official');
expect(localStorage.getItem(STORAGE_KEY)).toBe('official');
});
});
});

View File

@@ -0,0 +1,59 @@
import { create } from 'zustand';
/**
* 视角/perspective 会话存储 —— 纯前端视图预设D2不做后端鉴权不加 JWT role claim
*
* 角色仅决定「默认落地页 + 粒度 + 过滤预设」,不是访问控制。切换器在 UI 上标注为
* 「视角」而非「权限」,因此 URL 可编辑不构成可信度陷阱。
*
* 角色来源被隔离在单一可替换的 getRoleSource() 接缝里:今天读 localStorage
* 将来若需真正 RBAC只改这一个函数改读 /api/auth/me其余代码不变。
*/
export type Role = 'official' | 'community' | 'doctor' | 'admin';
export const ROLES: Role[] = ['official', 'community', 'doctor', 'admin'];
// 标签与默认落地路径已迁出到纯模块 '@/utils/roleViews'ROLE_LABELS / roleDefaultPath
// 保持单一来源。本 store 只负责「当前视角是什么」+ 可替换的角色来源接缝。
const STORAGE_KEY = 'cbpoa_role';
const DEFAULT_ROLE: Role = 'admin';
function isRole(v: unknown): v is Role {
return typeof v === 'string' && (ROLES as string[]).includes(v);
}
/**
* 单一可替换接缝:角色来源。今天 = localStorage将来 = /api/auth/me。
* 改 RBAC 只动这一个函数。
*/
export function getRoleSource(): Role {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return isRole(raw) ? raw : DEFAULT_ROLE;
} catch {
return DEFAULT_ROLE;
}
}
function persistRole(role: Role): void {
try {
localStorage.setItem(STORAGE_KEY, role);
} catch {
/* localStorage 不可用时静默降级 */
}
}
interface SessionState {
role: Role;
setRole: (role: Role) => void;
}
export const useSessionStore = create<SessionState>((set) => ({
role: getRoleSource(),
setRole: (role) => {
persistRole(role);
set({ role });
},
}));

View File

@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { ROLE_LABELS, roleDefaultPath } from './roleViews';
import type { Role } from '@/stores/sessionStore';
const ALL_ROLES: Role[] = ['official', 'community', 'doctor', 'admin'];
describe('roleViews', () => {
describe('ROLE_LABELS', () => {
it('has a non-empty label for all 4 roles', () => {
for (const r of ALL_ROLES) {
expect(ROLE_LABELS[r]).toBeTruthy();
}
});
it('uses the expected short labels (no 视角 suffix)', () => {
expect(ROLE_LABELS).toEqual({
official: '厅领导',
community: '社区',
doctor: '医生',
admin: '管理员',
});
});
});
describe('roleDefaultPath', () => {
it('official → /overview with granularity=district', () => {
expect(roleDefaultPath('official')).toBe('/overview?granularity=district');
});
it('community → /monitoring with granularity=street', () => {
expect(roleDefaultPath('community')).toBe('/monitoring?granularity=street');
});
it('doctor → /alerts with view=cluster', () => {
expect(roleDefaultPath('doctor')).toBe('/alerts?view=cluster');
});
it('admin → /monitoring (legacy full view; keeps user-flows baseline green)', () => {
expect(roleDefaultPath('admin')).toBe('/monitoring');
});
});
});

View File

@@ -0,0 +1,42 @@
import type { Role } from '@/stores/sessionStore';
/**
* 视角/perspective 的纯展示元数据 —— 标签 + 默认落地路径。
*
* 纯模块(无副作用、无 React、无 store 依赖),便于单元测试。
* D2角色只是「前端视图预设」决定默认落地页 / 粒度 / 过滤预设,不是访问控制。
*
* wave-2 workers 的契约入口:
* import { roleDefaultPath, ROLE_LABELS } from '@/utils/roleViews'
*/
/** 视角中文短标签switcher 在前面拼接「视角:」前缀,故此处不带「视角」后缀)。 */
export const ROLE_LABELS: Record<Role, string> = {
official: '厅领导',
community: '社区',
doctor: '医生',
admin: '管理员',
};
/**
* 每个视角的默认落地 URL。query 参数由 wave-2 workers 消费:
* - granularity=district|street → 粒度控件初值(监测/概览)
* - view=cluster → 预警页医生聚类视图
*/
export function roleDefaultPath(role: Role): string {
switch (role) {
case 'official':
return '/overview?granularity=district';
case 'community':
return '/monitoring?granularity=street';
case 'doctor':
return '/alerts?view=cluster';
case 'admin':
default:
// admin = 旧「全量」视角,历史落地页即 /monitoring与改造前 sessionStore 的
// ROLE_DEFAULT_PATH 一致)。保持 /monitoring 以兼容既有 user-flows 基线测试
// (裸 '/' 无 cbpoa_role ⇒ 默认 admin ⇒ /monitoring。其余三个视角带查询参数
// 由 wave-2 workers 消费,不受此选择影响。
return '/monitoring';
}
}

View File

@@ -0,0 +1,57 @@
// 集中管理所有 data-testid 字符串,供应用与 e2e 测试共享引用。
export const TESTIDS = {
// 布局骨架
appShell: 'app-shell',
hamburger: 'hamburger',
appDrawer: 'app-drawer',
sidebarRail: 'sidebar-rail',
pageLoading: 'page-loading',
// 登录
pageLogin: 'page-login',
loginSubmit: 'login-submit',
// 导航项
navMonitoring: 'nav-monitoring',
navAlerts: 'nav-alerts',
navOverview: 'nav-overview',
navTrend: 'nav-trend',
navDistrict: 'nav-district',
navInsights: 'nav-insights',
navReports: 'nav-reports',
navDemographics: 'nav-demographics',
navDisease: 'nav-disease',
navEnvironment: 'nav-environment',
navClinical: 'nav-clinical',
// 页面挂载点
pageMonitoring: 'page-monitoring',
pageAlerts: 'page-alerts',
pageOverview: 'page-overview',
pageTrend: 'page-trend',
pageDistrict: 'page-district',
pageInsights: 'page-insights',
pageReports: 'page-reports',
pageDemographics: 'page-demographics',
pageDisease: 'page-disease',
pageEnvironment: 'page-environment',
pageClinical: 'page-clinical',
clinicalKpis: 'clinical-kpis',
// 综合概览 大屏
kpiRow: 'kpi-row',
choroplethWrapper: 'choropleth-wrapper',
asofBadge: 'asof-badge',
outinpatientToggle: 'outinpatient-toggle',
// 视角/perspective + 粒度Phase 3
perspectiveSwitcher: 'perspective-switcher',
perspectiveOption: 'perspective-option', // 配合角色后缀,如 perspective-option-official
granularityControl: 'granularity-control',
districtRollup: 'district-rollup',
gridLayerWrapper: 'grid-layer-wrapper',
clusterView: 'cluster-view',
patientPoint: 'patient-point', // 个体病例点标记;医生视角下必须为 0
} as const;
export type TestId = (typeof TESTIDS)[keyof typeof TESTIDS];