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>
This commit is contained in:
2026-06-21 21:42:52 +08:00
parent 33f0f497d3
commit 4df6c71628
19 changed files with 1963 additions and 10 deletions

View File

@@ -13,7 +13,7 @@ from logging_config import setup_logging
from middleware.request_logger import RequestLoggerMiddleware from middleware.request_logger import RequestLoggerMiddleware
from auth.router import router as auth_router from auth.router import router as auth_router
from auth.service import seed_default_admin 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() setup_logging()
@@ -59,6 +59,7 @@ app.include_router(geocoded.router)
app.include_router(grid.router) app.include_router(grid.router)
app.include_router(chat.router) app.include_router(chat.router)
app.include_router(environment.router) app.include_router(environment.router)
app.include_router(statistics.router)
@app.get("/") @app.get("/")

View File

@@ -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=[])

View File

@@ -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);
});
});

View File

@@ -55,6 +55,7 @@ const modules: { id: string; label: string; icon: React.ReactNode; items: NavIte
{ to: '/analysis/reports', label: '报表中心', testid: TESTIDS.navReports }, { to: '/analysis/reports', label: '报表中心', testid: TESTIDS.navReports },
{ to: '/analysis/demographics', label: '人群分析', testid: TESTIDS.navDemographics }, { to: '/analysis/demographics', label: '人群分析', testid: TESTIDS.navDemographics },
{ to: '/analysis/disease', label: '疾病分析', testid: TESTIDS.navDisease }, { to: '/analysis/disease', label: '疾病分析', testid: TESTIDS.navDisease },
{ to: '/analysis/clinical', label: '临床分析', testid: TESTIDS.navClinical },
{ to: '/analysis/environment', label: '环境健康', testid: TESTIDS.navEnvironment }, { to: '/analysis/environment', label: '环境健康', testid: TESTIDS.navEnvironment },
], ],
}, },

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,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 (
<div
data-testid={TESTIDS.clinicalKpis}
className="grid grid-cols-2 sm:grid-cols-5 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={<Wallet className="w-4 h-4 text-primary" />}
label="人均费用"
value={`¥${Math.round(kpis.mean_cost).toLocaleString()}`}
/>
<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,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 <div className="text-center py-8 text-text-muted text-sm"></div>;
}
const chartData = [...data]
.sort((a, b) => a.mean_cost - b.mean_cost)
.map((d) => ({ ...d, displayName: truncate(d.diagnosis, 8) }));
return (
<ResponsiveContainer width="100%" height={Math.max(240, chartData.length * 34)}>
<BarChart
data={chartData}
layout="vertical"
margin={{ top: 5, right: 20, left: 12, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={CLINICAL_COLORS.grid} horizontal={false} />
<XAxis
type="number"
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }}
tickFormatter={(v: number) => `¥${(v / 1000).toFixed(0)}k`}
/>
<YAxis
type="category"
dataKey="displayName"
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axisLabel }}
width={72}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(v: number) => [`¥${Math.round(v).toLocaleString()}`, '人均费用']}
/>
<Bar dataKey="mean_cost" fill={CLINICAL_COLORS.cost} barSize={16} radius={[0, 3, 3, 0]} />
</BarChart>
</ResponsiveContainer>
);
});

View File

@@ -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 <div className="text-center py-8 text-text-muted text-sm"></div>;
}
return (
<ResponsiveContainer width="100%" height={300}>
<ScatterChart margin={{ top: 10, right: 16, left: 6, bottom: 16 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CLINICAL_COLORS.grid} />
<XAxis
type="number"
dataKey="los"
name="住院天数"
unit="天"
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }}
/>
<YAxis
type="number"
dataKey="cost"
name="费用"
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }}
width={52}
tickFormatter={(v: number) => `¥${(v / 1000).toFixed(0)}k`}
/>
<Tooltip
contentStyle={TOOLTIP_STYLE}
cursor={{ strokeDasharray: '3 3' }}
formatter={(value: number, name: string) =>
name === '费用'
? [`¥${value.toLocaleString()}`, name]
: [`${value}`, name]
}
/>
<Scatter data={data} fill={CLINICAL_COLORS.scatter} fillOpacity={0.5} />
</ScatterChart>
</ResponsiveContainer>
);
});

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,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<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,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<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">
</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="住院费用分布">
<HistogramChart data={data.cost_histogram ?? []} color={CLINICAL_COLORS.cost} countLabel="人次" />
</Card>
<Card title="各病种平均费用">
<CostByDiseaseChart data={data.cost_by_disease ?? []} />
</Card>
</div>
{/* 费用 vs 住院天数 散点 */}
<Card title="费用 vs 住院天数">
<CostVsLosScatter data={data.cost_vs_los ?? []} />
</Card>
{/* 出院结局 + 入院途径 双环 */}
<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

@@ -11,8 +11,9 @@ import {
Cell, Cell,
ReferenceLine, ReferenceLine,
} from 'recharts'; } from 'recharts';
import { Stethoscope, Activity } from 'lucide-react'; import { Stethoscope, Activity, MessageSquareText } from 'lucide-react';
import { caseApi } from '@/services/api'; import { caseApi, statsApi } from '@/services/api';
import type { SymptomsResponse } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner'; import { ErrorBanner } from '@/components/ErrorBanner';
import type { import type {
DiagnosisDistributionItem, 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 <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 --- // --- Page Component ---
export function DiseaseAnalysis() { export function DiseaseAnalysis() {
const [diagDistribution, setDiagDistribution] = useState<DiagnosisDistributionItem[]>([]); const [diagDistribution, setDiagDistribution] = useState<DiagnosisDistributionItem[]>([]);
const [seasonality, setSeasonality] = useState<DiseaseSeasonalityPoint[]>([]); const [seasonality, setSeasonality] = useState<DiseaseSeasonalityPoint[]>([]);
const [districts, setDistricts] = useState<DistrictCaseData[]>([]); const [districts, setDistricts] = useState<DistrictCaseData[]>([]);
const [symptoms, setSymptoms] = useState<SymptomsResponse['symptoms']>([]);
const [revisitRatio, setRevisitRatio] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [errors, setErrors] = useState<string[]>([]); const [errors, setErrors] = useState<string[]>([]);
@@ -390,10 +443,11 @@ export function DiseaseAnalysis() {
setIsLoading(true); setIsLoading(true);
setErrors([]); setErrors([]);
const [distR, seasonR, districtR] = await Promise.allSettled([ const [distR, seasonR, districtR, symptomR] = await Promise.allSettled([
caseApi.getDiagnosisDistribution(15), caseApi.getDiagnosisDistribution(15),
caseApi.getDiseaseSeasonality(), caseApi.getDiseaseSeasonality(),
caseApi.getDistricts(), caseApi.getDistricts(),
statsApi.getSymptoms(20),
]); ]);
if (cancelled) return; if (cancelled) return;
@@ -418,6 +472,15 @@ export function DiseaseAnalysis() {
newErrors.push('区县数据加载失败'); 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); setErrors(newErrors);
setIsLoading(false); setIsLoading(false);
}; };
@@ -497,6 +560,26 @@ export function DiseaseAnalysis() {
</div> </div>
<DiagnosisSummaryTable diagnoses={diagDistribution} districtsData={districts} /> <DiagnosisSummaryTable diagnoses={diagDistribution} districtsData={districts} />
</div> </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>
</div> </div>
); );

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { LoadingState } from '@/components/ui'; import { LoadingState, Segmented } from '@/components/ui';
import { import {
BarChart, BarChart,
Bar, Bar,
@@ -12,7 +12,8 @@ import {
ReferenceLine, ReferenceLine,
} from 'recharts'; } from 'recharts';
import { useAnalysisStore } from '@/stores/analysisStore'; 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 { ErrorBanner } from '@/components/ErrorBanner';
import { MetricHeatmapTable } from '@/components/MetricHeatmapTable'; import { MetricHeatmapTable } from '@/components/MetricHeatmapTable';
import { BarChart3, MapPin, Users, Shield } from 'lucide-react'; import { BarChart3, MapPin, Users, Shield } from 'lucide-react';
@@ -36,6 +37,10 @@ export function DistrictComparison() {
const [caseDistrictData, setCaseDistrictData] = useState<DistrictCaseData[]>([]); const [caseDistrictData, setCaseDistrictData] = useState<DistrictCaseData[]>([]);
const [caseDataLoading, setCaseDataLoading] = useState(false); const [caseDataLoading, setCaseDataLoading] = useState(false);
const [caseDataError, setCaseDataError] = useState<string | null>(null); 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(() => { useEffect(() => {
fetchDistricts(); fetchDistricts();
@@ -61,6 +66,26 @@ export function DistrictComparison() {
return () => { cancelled = true; }; 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 = { const metricConfig = {
avg_aqi: { label: '平均AQI', color: '#2563EB', unit: '' }, avg_aqi: { label: '平均AQI', color: '#2563EB', unit: '' },
avg_risk: { label: '平均风险', color: '#DC2626', unit: '' }, avg_risk: { label: '平均风险', color: '#DC2626', unit: '' },
@@ -104,6 +129,12 @@ export function DistrictComparison() {
return { data, cityOIAvg }; return { data, cityOIAvg };
}, [caseDistrictData]); }, [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 heatmapMetrics = useMemo(() => {
const caseMap = new Map(caseDistrictData.map((d) => [d.district, d])); const caseMap = new Map(caseDistrictData.map((d) => [d.district, d]));
const rows: string[] = []; const rows: string[] = [];
@@ -155,6 +186,25 @@ export function DistrictComparison() {
onDismiss={() => setCaseDataError(null)} 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"> <div className="mb-5">
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2"> <h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-primary" /> <BarChart3 className="w-5 h-5 text-primary" />
@@ -380,6 +430,79 @@ export function DistrictComparison() {
)} )}
</div> </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 */} {/* O/I Ratio Comparison Bar Chart */}
<div className="card p-4 mb-4"> <div className="card p-4 mb-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4"> <div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from 'react'; import { useEffect, useState, useMemo, Fragment } from 'react';
import { import {
LineChart, LineChart,
Line, Line,
@@ -12,9 +12,13 @@ import {
ResponsiveContainer, ResponsiveContainer,
ReferenceLine, ReferenceLine,
Cell, Cell,
ScatterChart,
Scatter,
ZAxis,
} from 'recharts'; } from 'recharts';
import { Wind } from 'lucide-react'; 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 { ErrorBanner } from '@/components/ErrorBanner';
import { CalendarHeatmap } from '@/components/CalendarHeatmap'; import { CalendarHeatmap } from '@/components/CalendarHeatmap';
import type { import type {
@@ -58,6 +62,55 @@ function getAQICategory(aqi: number): typeof AQI_CATEGORIES[number] {
return AQI_CATEGORIES[AQI_CATEGORIES.length - 1]; 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 { function findMaxLag(correlations: LagCorrelationItem[], pollutant: string): number | null {
if (correlations.length === 0) return null; if (correlations.length === 0) return null;
const pollData = correlations.filter( const pollData = correlations.filter(
@@ -83,6 +136,7 @@ export function EnvironmentalHealth() {
const [pollutants365, setPollutants365] = useState<PollutantPoint[]>([]); const [pollutants365, setPollutants365] = useState<PollutantPoint[]>([]);
const [pollutants30, setPollutants30] = useState<PollutantPoint[]>([]); const [pollutants30, setPollutants30] = useState<PollutantPoint[]>([]);
const [caseTrend, setCaseTrend] = useState<CaseTrendPoint[]>([]); const [caseTrend, setCaseTrend] = useState<CaseTrendPoint[]>([]);
const [envCorr, setEnvCorr] = useState<EnvCorrelationResponse | null>(null);
// UI states // UI states
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -104,11 +158,12 @@ export function EnvironmentalHealth() {
setIsLoading(true); setIsLoading(true);
setErrors([]); setErrors([]);
const [lagR, p365R, p30R, caseTrendR] = await Promise.allSettled([ const [lagR, p365R, p30R, caseTrendR, envCorrR] = await Promise.allSettled([
envApi.getLagCorrelations(), envApi.getLagCorrelations(),
envApi.getPollutants(365), envApi.getPollutants(365),
envApi.getPollutants(30), envApi.getPollutants(30),
caseApi.getTrend({ group_by: 'day' }), caseApi.getTrend({ group_by: 'day' }),
statsApi.getEnvCorrelation(),
]); ]);
if (cancelled) return; if (cancelled) return;
@@ -145,6 +200,12 @@ export function EnvironmentalHealth() {
newErrors.push('病例趋势数据加载失败'); newErrors.push('病例趋势数据加载失败');
} }
if (envCorrR.status === 'fulfilled') {
setEnvCorr(envCorrR.value);
} else {
newErrors.push('污染物关联分析数据加载失败');
}
setErrors(newErrors); setErrors(newErrors);
setIsLoading(false); setIsLoading(false);
}; };
@@ -266,6 +327,61 @@ export function EnvironmentalHealth() {
})); }));
}, [pollutants30]); }, [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 --- // --- Loading state ---
if (isLoading) { if (isLoading) {
return ( return (
@@ -706,6 +822,229 @@ export function EnvironmentalHealth() {
</div> </div>
)} )}
</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>
</div> </div>
); );

View File

@@ -16,7 +16,8 @@ import {
ReferenceLine, ReferenceLine,
} from 'recharts'; } from 'recharts';
import { useAnalysisStore } from '@/stores/analysisStore'; 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 { ErrorBanner } from '@/components/ErrorBanner';
import { TrendingUp, Calendar, Activity } from 'lucide-react'; import { TrendingUp, Calendar, Activity } from 'lucide-react';
import type { CaseTrendPoint } from '@/types'; import type { CaseTrendPoint } from '@/types';
@@ -48,6 +49,7 @@ export function TrendAnalysis() {
const [selectedPollutants, setSelectedPollutants] = useState<string[]>(['aqi', 'pm25']); const [selectedPollutants, setSelectedPollutants] = useState<string[]>(['aqi', 'pm25']);
const [multiYearData, setMultiYearData] = useState<Record<string, CaseTrendPoint[]>>({}); const [multiYearData, setMultiYearData] = useState<Record<string, CaseTrendPoint[]>>({});
const [multiYearLoading, setMultiYearLoading] = useState(false); const [multiYearLoading, setMultiYearLoading] = useState(false);
const [weekdayData, setWeekdayData] = useState<TemporalResponse['weekday']>([]);
useEffect(() => { useEffect(() => {
fetchTrend(selectedDays); fetchTrend(selectedDays);
@@ -82,6 +84,19 @@ export function TrendAnalysis() {
return () => { cancelled = true; }; 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 // Merge multi-year data by month (data is monthly) over a fixed 1..12 sequence
const mergedMultiYearData = (() => { const mergedMultiYearData = (() => {
const yearColors: Record<string, string> = { '2022': '#94A3B8', '2023': '#3B82F6', '2024': '#EF4444' }; const yearColors: Record<string, string> = { '2022': '#94A3B8', '2023': '#3B82F6', '2024': '#EF4444' };
@@ -444,6 +459,52 @@ export function TrendAnalysis() {
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </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> </div>
); );
} }

View File

@@ -33,6 +33,9 @@ const DiseaseAnalysis = lazy(() =>
const EnvironmentalHealth = lazy(() => const EnvironmentalHealth = lazy(() =>
import('@/pages/EnvironmentalHealth').then((m) => ({ default: m.EnvironmentalHealth })) import('@/pages/EnvironmentalHealth').then((m) => ({ default: m.EnvironmentalHealth }))
); );
const ClinicalAnalysis = lazy(() =>
import('@/pages/ClinicalAnalysis').then((m) => ({ default: m.ClinicalAnalysis }))
);
// 用 Suspense 包裹懒加载页面,统一加载态。 // 用 Suspense 包裹懒加载页面,统一加载态。
function lazyElement(Page: ComponentType): JSX.Element { function lazyElement(Page: ComponentType): JSX.Element {
@@ -56,6 +59,7 @@ export const appRoutes: RouteObject[] = [
{ path: 'analysis/demographics', element: lazyElement(DemographicAnalysis) }, { path: 'analysis/demographics', element: lazyElement(DemographicAnalysis) },
{ path: 'analysis/disease', element: lazyElement(DiseaseAnalysis) }, { path: 'analysis/disease', element: lazyElement(DiseaseAnalysis) },
{ path: 'analysis/environment', element: lazyElement(EnvironmentalHealth) }, { path: 'analysis/environment', element: lazyElement(EnvironmentalHealth) },
{ path: 'analysis/clinical', element: lazyElement(ClinicalAnalysis) },
// 未知路径回退到监测面板。 // 未知路径回退到监测面板。
{ path: '*', element: <Navigate to="/monitoring" replace /> }, { path: '*', element: <Navigate to="/monitoring" replace /> },
]; ];

View File

@@ -343,4 +343,48 @@ export const reportApi = {
getLatestSummary: (): Promise<ReportSummary> => cachedGet('/reports/summary/latest'), getLatestSummary: (): Promise<ReportSummary> => 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<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; export default api;

View File

@@ -22,6 +22,7 @@ export const TESTIDS = {
navDemographics: 'nav-demographics', navDemographics: 'nav-demographics',
navDisease: 'nav-disease', navDisease: 'nav-disease',
navEnvironment: 'nav-environment', navEnvironment: 'nav-environment',
navClinical: 'nav-clinical',
// 页面挂载点 // 页面挂载点
pageMonitoring: 'page-monitoring', pageMonitoring: 'page-monitoring',
@@ -34,6 +35,8 @@ export const TESTIDS = {
pageDemographics: 'page-demographics', pageDemographics: 'page-demographics',
pageDisease: 'page-disease', pageDisease: 'page-disease',
pageEnvironment: 'page-environment', pageEnvironment: 'page-environment',
pageClinical: 'page-clinical',
clinicalKpis: 'clinical-kpis',
// 综合概览 大屏 // 综合概览 大屏
kpiRow: 'kpi-row', kpiRow: 'kpi-row',