diff --git a/backend/main.py b/backend/main.py
index 744b010..c04e11d 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -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("/")
diff --git a/backend/routers/statistics.py b/backend/routers/statistics.py
new file mode 100644
index 0000000..f260dca
--- /dev/null
+++ b/backend/routers/statistics.py
@@ -0,0 +1,621 @@
+"""
+统计分析 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
+ mean_cost: float
+ cure_rate: float
+ emergency_admit_ratio: float
+
+
+class LosByDisease(BaseModel):
+ diagnosis: str
+ p25: float
+ median: float
+ p75: float
+ n: int
+
+
+class CostByDisease(BaseModel):
+ diagnosis: str
+ mean_cost: float
+ n: int
+
+
+class CostVsLos(BaseModel):
+ los: int
+ cost: float
+
+
+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]
+ cost_histogram: list[KeyValueCount]
+ cost_by_disease: list[CostByDisease]
+ cost_vs_los: list[CostVsLos]
+ 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, mean_cost=0.0,
+ cure_rate=0.0, emergency_admit_ratio=0.0,
+ ),
+ los_histogram=[], los_by_disease=[], cost_histogram=[],
+ cost_by_disease=[], cost_vs_los=[], 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
+ cost = pd.to_numeric(df["住院总费用"], errors="coerce")
+ mean_cost = float(cost.mean()) if cost.notna().any() 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),
+ mean_cost=round(mean_cost, 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)),
+ ))
+
+ # Cost histogram: 0-2k,2-4k,4-6k,6-8k,8-10k,10k+
+ cost_valid = cost.dropna()
+ cost_bins = [(0, 2000, "0-2k"), (2000, 4000, "2-4k"), (4000, 6000, "4-6k"),
+ (6000, 8000, "6-8k"), (8000, 10000, "8-10k")]
+ cost_histogram: list[KeyValueCount] = []
+ for lo, hi, label in cost_bins:
+ cost_histogram.append(KeyValueCount(
+ bin_label=label, count=int(((cost_valid >= lo) & (cost_valid < hi)).sum())))
+ cost_histogram.append(KeyValueCount(bin_label="10k+", count=int((cost_valid >= 10000).sum())))
+
+ # Cost by disease (top 8 by n)
+ cost_by_disease: list[CostByDisease] = []
+ df_cost = df[cost.notna()].copy()
+ df_cost["_cost"] = cost[cost.notna()]
+ if len(df_cost):
+ top_cd = df_cost["诊断名称"].value_counts().head(8).index.tolist()
+ for d in top_cd:
+ grp = df_cost[df_cost["诊断名称"] == d]["_cost"]
+ cost_by_disease.append(CostByDisease(
+ diagnosis=str(d),
+ mean_cost=round(float(grp.mean()), 2),
+ n=int(len(grp)),
+ ))
+
+ # cost vs los scatter (up to 500 points)
+ cost_vs_los: list[CostVsLos] = []
+ scatter_df = df_los[cost.reindex(df_los.index).notna()].copy()
+ scatter_df["_cost"] = cost.reindex(scatter_df.index)
+ if len(scatter_df) > 500:
+ scatter_df = scatter_df.sample(n=500, random_state=42)
+ for _, r in scatter_df.iterrows():
+ cost_vs_los.append(CostVsLos(los=int(r["los"]), cost=round(float(r["_cost"]), 2)))
+
+ # 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,
+ cost_histogram=cost_histogram,
+ cost_by_disease=cost_by_disease,
+ cost_vs_los=cost_vs_los,
+ 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=[])
diff --git a/frontend/e2e/clinical.spec.ts b/frontend/e2e/clinical.spec.ts
new file mode 100644
index 0000000..958833f
--- /dev/null
+++ b/frontend/e2e/clinical.spec.ts
@@ -0,0 +1,104 @@
+/**
+ * 住院临床分析页(/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,
+ mean_cost: 6294,
+ 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 },
+ ],
+ cost_histogram: [
+ { bin_label: '0-3k', count: 1800 },
+ { bin_label: '3k-6k', count: 2200 },
+ ],
+ cost_by_disease: [
+ { diagnosis: '肺炎', mean_cost: 7200, n: 800 },
+ { diagnosis: '支气管炎', mean_cost: 5100, n: 600 },
+ ],
+ cost_vs_los: [
+ { los: 3, cost: 5000 },
+ { los: 5, cost: 7200 },
+ { los: 7, cost: 9100 },
+ ],
+ 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);
+ });
+});
diff --git a/frontend/src/components/SideNav.tsx b/frontend/src/components/SideNav.tsx
index 7291e98..081e92c 100644
--- a/frontend/src/components/SideNav.tsx
+++ b/frontend/src/components/SideNav.tsx
@@ -55,6 +55,7 @@ const modules: { id: string; label: string; icon: React.ReactNode; items: NavIte
{ 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 },
],
},
diff --git a/frontend/src/components/clinical/BoxPlotRows.tsx b/frontend/src/components/clinical/BoxPlotRows.tsx
new file mode 100644
index 0000000..0aa09cc
--- /dev/null
+++ b/frontend/src/components/clinical/BoxPlotRows.tsx
@@ -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
暂无数据
;
+ }
+
+ // 统一横轴域:覆盖所有行的 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 (
+
+ {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 (
+
+
+ {r.label}
+
+
+ {/* p25–p75 箱体 */}
+
+ {/* 中位刻度 */}
+
+
+
+ {r.p25}–{r.median}–{r.p75}
+ {unit}
+ n={r.n}
+
+
+ );
+ })}
+
+ );
+});
diff --git a/frontend/src/components/clinical/ClinicalKpiRow.tsx b/frontend/src/components/clinical/ClinicalKpiRow.tsx
new file mode 100644
index 0000000..b49c442
--- /dev/null
+++ b/frontend/src/components/clinical/ClinicalKpiRow.tsx
@@ -0,0 +1,47 @@
+import { memo } from 'react';
+import { Users, CalendarDays, Wallet, 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'];
+}
+
+/** 住院临床 5 项核心指标。375px 下 2 列,sm 起 5 列。 */
+export const ClinicalKpiRow = memo(function ClinicalKpiRow({ kpis }: ClinicalKpiRowProps) {
+ return (
+
+ }
+ label="住院总人次"
+ value={kpis.total_admissions.toLocaleString()}
+ />
+ }
+ label="中位住院日"
+ value={`${kpis.median_los_days} 天`}
+ />
+ }
+ label="人均费用"
+ value={`¥${Math.round(kpis.mean_cost).toLocaleString()}`}
+ />
+ }
+ label="治愈好转率"
+ value={`${(kpis.cure_rate * 100).toFixed(1)}%`}
+ color="#16A34A"
+ />
+ }
+ label="急诊入院占比"
+ value={`${(kpis.emergency_admit_ratio * 100).toFixed(1)}%`}
+ color="#D97706"
+ />
+
+ );
+});
diff --git a/frontend/src/components/clinical/CostByDiseaseChart.tsx b/frontend/src/components/clinical/CostByDiseaseChart.tsx
new file mode 100644
index 0000000..81e1bf5
--- /dev/null
+++ b/frontend/src/components/clinical/CostByDiseaseChart.tsx
@@ -0,0 +1,62 @@
+import { memo } from 'react';
+import {
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+} from 'recharts';
+import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
+
+interface CostByDiseaseChartProps {
+ data: { diagnosis: string; mean_cost: number; n: number }[];
+}
+
+function truncate(s: string, max: number): string {
+ return s.length > max ? s.slice(0, max) + '…' : s;
+}
+
+/** 各病种平均费用横向柱状图。 */
+export const CostByDiseaseChart = memo(function CostByDiseaseChart({
+ data,
+}: CostByDiseaseChartProps) {
+ if (!data || data.length === 0) {
+ return 暂无数据
;
+ }
+
+ const chartData = [...data]
+ .sort((a, b) => a.mean_cost - b.mean_cost)
+ .map((d) => ({ ...d, displayName: truncate(d.diagnosis, 8) }));
+
+ return (
+
+
+
+ `¥${(v / 1000).toFixed(0)}k`}
+ />
+
+ [`¥${Math.round(v).toLocaleString()}`, '人均费用']}
+ />
+
+
+
+ );
+});
diff --git a/frontend/src/components/clinical/CostVsLosScatter.tsx b/frontend/src/components/clinical/CostVsLosScatter.tsx
new file mode 100644
index 0000000..d5007ea
--- /dev/null
+++ b/frontend/src/components/clinical/CostVsLosScatter.tsx
@@ -0,0 +1,55 @@
+import { memo } from 'react';
+import {
+ ScatterChart,
+ Scatter,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+} from 'recharts';
+import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
+
+interface CostVsLosScatterProps {
+ data: { los: number; cost: number }[];
+}
+
+/** 费用 vs 住院天数散点。 */
+export const CostVsLosScatter = memo(function CostVsLosScatter({ data }: CostVsLosScatterProps) {
+ if (!data || data.length === 0) {
+ return 暂无数据
;
+ }
+
+ return (
+
+
+
+
+ `¥${(v / 1000).toFixed(0)}k`}
+ />
+
+ name === '费用'
+ ? [`¥${value.toLocaleString()}`, name]
+ : [`${value} 天`, name]
+ }
+ />
+
+
+
+ );
+});
diff --git a/frontend/src/components/clinical/DonutChart.tsx b/frontend/src/components/clinical/DonutChart.tsx
new file mode 100644
index 0000000..9cd6728
--- /dev/null
+++ b/frontend/src/components/clinical/DonutChart.tsx
@@ -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;
+}
+
+/** 通用环形图。复用于「出院结局构成」与「入院途径构成」。 */
+export const DonutChart = memo(function DonutChart({ data, colorMap }: DonutChartProps) {
+ if (!data || data.length === 0) {
+ return 暂无数据
;
+ }
+
+ 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 (
+
+
+
+ {data.map((d, idx) => (
+ |
+ ))}
+
+ [
+ `${v.toLocaleString()}(${total > 0 ? ((v / total) * 100).toFixed(1) : '0'}%)`,
+ name,
+ ]}
+ />
+
+
+
+ );
+});
diff --git a/frontend/src/components/clinical/HistogramChart.tsx b/frontend/src/components/clinical/HistogramChart.tsx
new file mode 100644
index 0000000..a06b9f2
--- /dev/null
+++ b/frontend/src/components/clinical/HistogramChart.tsx
@@ -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 暂无数据
;
+ }
+
+ return (
+
+
+
+
+
+ [`${v.toLocaleString()}`, countLabel]}
+ />
+
+
+
+ );
+});
diff --git a/frontend/src/components/clinical/chartColors.ts b/frontend/src/components/clinical/chartColors.ts
new file mode 100644
index 0000000..67d2762
--- /dev/null
+++ b/frontend/src/components/clinical/chartColors.ts
@@ -0,0 +1,36 @@
+/**
+ * 住院临床分析页图表字面色值集中处。
+ * Recharts 需要原始 hex,无法用 Tailwind class,故在此集中定义,避免散落 magic hex。
+ */
+export const CLINICAL_COLORS = {
+ primary: '#2563EB', // primary
+ los: '#2563EB',
+ cost: '#0891B2', // cyan — 费用维度
+ scatter: '#7C3AED', // violet — 散点
+ box: '#3B82F6', // 箱体填充
+ boxMedian: '#1D4ED8', // 中位刻度
+ grid: '#E2E8F0',
+ axis: '#64748B',
+ axisLabel: '#374151',
+ tooltipBorder: '#E2E8F0',
+ tooltipText: '#1E293B',
+ // 出院结局按严重程度配色:治愈/好转偏绿,未愈/死亡偏红,其他中性
+ outcome: {
+ 治愈: '#16A34A',
+ 好转: '#4ADE80',
+ 其他: '#94A3B8',
+ 未愈: '#F97316',
+ 死亡: '#DC2626',
+ } as Record,
+ 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;
diff --git a/frontend/src/pages/ClinicalAnalysis.tsx b/frontend/src/pages/ClinicalAnalysis.tsx
new file mode 100644
index 0000000..8a48f0c
--- /dev/null
+++ b/frontend/src/pages/ClinicalAnalysis.tsx
@@ -0,0 +1,175 @@
+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 { CostVsLosScatter } from '@/components/clinical/CostVsLosScatter';
+import { CostByDiseaseChart } from '@/components/clinical/CostByDiseaseChart';
+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(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(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 = (
+
+
+
+ 住院临床分析
+
+
+ 住院天数、费用、出院结局与入院途径等临床特征分析
+
+
+ );
+
+ if (isLoading) {
+ return (
+
+ {header}
+
+
+ );
+ }
+
+ if (error || !data) {
+ return (
+
+ {header}
+ window.location.reload()}
+ onDismiss={() => setError(null)}
+ />
+
+ );
+ }
+
+ if (isEmpty(data)) {
+ return (
+
+ {header}
+
+
+ );
+ }
+
+ // 出院结局:治愈/好转在前(按严重程度排序展示更直观)。
+ 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 (
+
+
+ {header}
+
+ {/* KPI 行 */}
+
+
+ {/* 住院天数:分布 + 各病种箱线 */}
+
+
+
+
+
+
+
+
+
+ {/* 费用:分布 + 各病种平均费用 */}
+
+
+
+
+
+
+
+
+
+ {/* 费用 vs 住院天数 散点 */}
+
+
+
+
+ {/* 出院结局 + 入院途径 双环 */}
+
+
+
+
+
+
+
+
+
+ {/* 年龄别 BMI 箱线 */}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/DiseaseAnalysis.tsx b/frontend/src/pages/DiseaseAnalysis.tsx
index 2d81f5e..a2d81f2 100644
--- a/frontend/src/pages/DiseaseAnalysis.tsx
+++ b/frontend/src/pages/DiseaseAnalysis.tsx
@@ -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,
@@ -374,12 +375,64 @@ function DiagnosisSummaryTable({
);
}
+// --- Chart 5: Outpatient Symptom Keyword Frequency ---
+
+function SymptomFrequencyChart({ data }: { data: SymptomsResponse['symptoms'] }) {
+ if (!data || data.length === 0) {
+ return 暂无数据
;
+ }
+
+ // 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 (
+
+
+
+ v.toLocaleString()}
+ />
+
+ [value.toLocaleString(), '出现次数']}
+ />
+
+
+
+ );
+}
+
// --- Page Component ---
export function DiseaseAnalysis() {
const [diagDistribution, setDiagDistribution] = useState([]);
const [seasonality, setSeasonality] = useState([]);
const [districts, setDistricts] = useState([]);
+ const [symptoms, setSymptoms] = useState([]);
+ const [revisitRatio, setRevisitRatio] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [errors, setErrors] = useState([]);
@@ -390,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;
@@ -418,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);
};
@@ -497,6 +560,26 @@ export function DiseaseAnalysis() {
+
+ {/* Chart 5: Outpatient Symptom Keyword Frequency */}
+
+
+
+ 门诊主诉症状词频
+
+
+ 主诉文本高频词(含复诊/随诊等就诊类型词)
+ {revisitRatio !== null && (
+
+ 复诊占比:
+
+ {(revisitRatio * 100).toFixed(1)}%
+
+
+ )}
+
+
+
);
diff --git a/frontend/src/pages/DistrictComparison.tsx b/frontend/src/pages/DistrictComparison.tsx
index 69b2606..f11d693 100644
--- a/frontend/src/pages/DistrictComparison.tsx
+++ b/frontend/src/pages/DistrictComparison.tsx
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
-import { LoadingState } from '@/components/ui';
+import { LoadingState, Segmented } from '@/components/ui';
import {
BarChart,
Bar,
@@ -12,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';
@@ -36,6 +37,10 @@ export function DistrictComparison() {
const [caseDistrictData, setCaseDistrictData] = useState([]);
const [caseDataLoading, setCaseDataLoading] = useState(false);
const [caseDataError, setCaseDataError] = useState(null);
+ const [incidence, setIncidence] = useState([]);
+ const [incidenceLoading, setIncidenceLoading] = useState(false);
+ const [incidenceError, setIncidenceError] = useState(null);
+ const [incidenceMetric, setIncidenceMetric] = useState<'rate' | 'count'>('rate');
useEffect(() => {
fetchDistricts();
@@ -61,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: '' },
@@ -104,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[] = [];
@@ -155,6 +186,25 @@ export function DistrictComparison() {
onDismiss={() => setCaseDataError(null)}
/>
)}
+ {incidenceError && (
+ {
+ 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)}
+ />
+ )}
@@ -380,6 +430,79 @@ export function DistrictComparison() {
)}
+ {/* Standardized Incidence Rate (per 10k) with count/rate toggle */}
+
+
+
+ {incidenceMetric === 'rate' ? '标化发病率(每万人)区域排名' : '病例数 区域排名'}
+
+
+
+
+ 标化发病率按人口归一化,可避免人口规模差异造成的误读(原始病例数会高估人口大区)。
+
+ {incidenceLoading &&
}
+ {!incidenceLoading && incidenceChart.data.length > 0 ? (
+
+
+
+ v.toLocaleString()}
+ />
+
+ {
+ const p = item?.payload as IncidenceRateResponse['districts'][number];
+ return [
+ `病例数 ${p.total_cases.toLocaleString()} · 人口 ${p.population.toLocaleString()} · 每万人 ${p.rate_per_10k.toLocaleString()}`,
+ incidenceMetric === 'rate' ? '标化发病率' : '病例数',
+ ];
+ }}
+ />
+
+
+
+ ) : (
+ !incidenceLoading && (
+
无发病率数据
+ )
+ )}
+
+
{/* O/I Ratio Comparison Bar Chart */}
diff --git a/frontend/src/pages/EnvironmentalHealth.tsx b/frontend/src/pages/EnvironmentalHealth.tsx
index fe071a6..799796f 100644
--- a/frontend/src/pages/EnvironmentalHealth.tsx
+++ b/frontend/src/pages/EnvironmentalHealth.tsx
@@ -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
([]);
const [pollutants30, setPollutants30] = useState([]);
const [caseTrend, setCaseTrend] = useState([]);
+ const [envCorr, setEnvCorr] = useState(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();
+ 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 (
@@ -706,6 +822,229 @@ export function EnvironmentalHealth() {
)}
+
+ {/* === 污染物-病例关联分析 === */}
+
+
+
+ 污染物-病例关联分析
+
+
+ 基于网格级 Pearson 相关系数(grid-level)
+
+
+
+ {/* (a) Pollutant × Cases correlation */}
+
+
+ 污染物 × 病例相关性
+
+ {corrWithCases.length > 0 ? (
+ <>
+
+
+
+
+
+ [value.toFixed(3), '相关系数']}
+ />
+
+
+ {corrWithCases.map((entry, idx) => (
+ |
+ ))}
+
+
+
+
+ 网格级 Pearson 相关系数(红=正相关,蓝=负相关,颜色深浅表示强度)
+
+ >
+ ) : (
+
+ 暂无数据
+
+ )}
+
+
+ {/* (b) Pollutant pairwise correlation heatmap */}
+
+
+ 污染物两两相关热力图
+
+ {pairwiseGrid ? (
+ <>
+
+
+ {/* Header row */}
+
+ {CORR_POLLUTANTS.map((key) => (
+
+ {pollutantLabel(key)}
+
+ ))}
+ {/* Body rows */}
+ {pairwiseGrid.map((row, ri) => (
+
+
+ {pollutantLabel(CORR_POLLUTANTS[ri])}
+
+ {row.map((val, ci) => (
+ 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)}
+
+ ))}
+
+ ))}
+
+
+
+ 对角线为自相关(=1.00);红=正相关,蓝=负相关
+
+ >
+ ) : (
+
+ 暂无数据
+
+ )}
+
+
+ {/* (c) PM2.5 × cases scatter + regression line */}
+
+
+ PM2.5 × 病例 散点 + 回归线
+
+ {scatterPoints.length > 0 ? (
+ <>
+
+
+
+
+
+
+ [
+ Math.round(value).toLocaleString(),
+ name,
+ ]}
+ />
+
+ {regressionLine.length === 2 && (
+ }
+ legendType="none"
+ />
+ )}
+
+
+ {regression && (
+
+ 最小二乘回归:病例 ≈ {regression.slope.toFixed(2)} × PM2.5 +{' '}
+ {regression.intercept.toFixed(1)}
+
+ )}
+ >
+ ) : (
+
+ 暂无数据
+
+ )}
+
+
);
diff --git a/frontend/src/pages/TrendAnalysis.tsx b/frontend/src/pages/TrendAnalysis.tsx
index c301dab..469eb3d 100644
--- a/frontend/src/pages/TrendAnalysis.tsx
+++ b/frontend/src/pages/TrendAnalysis.tsx
@@ -16,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';
@@ -48,6 +49,7 @@ export function TrendAnalysis() {
const [selectedPollutants, setSelectedPollutants] = useState(['aqi', 'pm25']);
const [multiYearData, setMultiYearData] = useState>({});
const [multiYearLoading, setMultiYearLoading] = useState(false);
+ const [weekdayData, setWeekdayData] = useState([]);
useEffect(() => {
fetchTrend(selectedDays);
@@ -82,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 = { '2022': '#94A3B8', '2023': '#3B82F6', '2024': '#EF4444' };
@@ -444,6 +459,52 @@ export function TrendAnalysis() {
+
+ {/* Weekday case distribution (门诊/住院) */}
+
+
+ 星期就诊分布
+
+ {weekdayData.length > 0 ? (
+ <>
+
+
+
+
+
+ [value.toLocaleString(), name]}
+ />
+
+
+
+
+
+
+ 注:现有病例数据集中在12月,季节性/同比分析待更多月份数据
+
+ >
+ ) : (
+
暂无就诊分布数据
+ )}
+
);
}
diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx
index 939dbfb..4e4754b 100644
--- a/frontend/src/routes.tsx
+++ b/frontend/src/routes.tsx
@@ -33,6 +33,9 @@ const DiseaseAnalysis = lazy(() =>
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 {
@@ -56,6 +59,7 @@ export const appRoutes: RouteObject[] = [
{ 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: },
];
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 9a90354..61e8053 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -343,4 +343,48 @@ export const reportApi = {
getLatestSummary: (): Promise => cachedGet('/reports/summary/latest'),
};
+// ===== 深度统计分析(/api/stats)=====
+export interface InpatientClinicalResponse {
+ kpis: {
+ total_admissions: number;
+ median_los_days: number;
+ mean_cost: 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 }[];
+ cost_histogram: { bin_label: string; count: number }[];
+ cost_by_disease: { diagnosis: string; mean_cost: number; n: number }[];
+ cost_vs_los: { los: number; cost: 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 => cachedGet('/stats/inpatient-clinical'),
+ getSymptoms: (top: number = 20): Promise => cachedGet('/stats/symptoms', { top }),
+ getIncidenceRate: (): Promise => cachedGet('/stats/incidence-rate'),
+ getEnvCorrelation: (): Promise => cachedGet('/stats/env-correlation'),
+ getTemporal: (): Promise => cachedGet('/stats/temporal'),
+};
+
export default api;
diff --git a/frontend/src/utils/testids.ts b/frontend/src/utils/testids.ts
index 9f67f5c..eab8feb 100644
--- a/frontend/src/utils/testids.ts
+++ b/frontend/src/utils/testids.ts
@@ -22,6 +22,7 @@ export const TESTIDS = {
navDemographics: 'nav-demographics',
navDisease: 'nav-disease',
navEnvironment: 'nav-environment',
+ navClinical: 'nav-clinical',
// 页面挂载点
pageMonitoring: 'page-monitoring',
@@ -34,6 +35,8 @@ export const TESTIDS = {
pageDemographics: 'page-demographics',
pageDisease: 'page-disease',
pageEnvironment: 'page-environment',
+ pageClinical: 'page-clinical',
+ clinicalKpis: 'clinical-kpis',
// 综合概览 大屏
kpiRow: 'kpi-row',