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:
@@ -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("/")
|
||||||
|
|||||||
621
backend/routers/statistics.py
Normal file
621
backend/routers/statistics.py
Normal 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=[])
|
||||||
104
frontend/e2e/clinical.spec.ts
Normal file
104
frontend/e2e/clinical.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
87
frontend/src/components/clinical/BoxPlotRows.tsx
Normal file
87
frontend/src/components/clinical/BoxPlotRows.tsx
Normal 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">
|
||||||
|
{/* p25–p75 箱体 */}
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
});
|
||||||
47
frontend/src/components/clinical/ClinicalKpiRow.tsx
Normal file
47
frontend/src/components/clinical/ClinicalKpiRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
62
frontend/src/components/clinical/CostByDiseaseChart.tsx
Normal file
62
frontend/src/components/clinical/CostByDiseaseChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
55
frontend/src/components/clinical/CostVsLosScatter.tsx
Normal file
55
frontend/src/components/clinical/CostVsLosScatter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
56
frontend/src/components/clinical/DonutChart.tsx
Normal file
56
frontend/src/components/clinical/DonutChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
51
frontend/src/components/clinical/HistogramChart.tsx
Normal file
51
frontend/src/components/clinical/HistogramChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
36
frontend/src/components/clinical/chartColors.ts
Normal file
36
frontend/src/components/clinical/chartColors.ts
Normal 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;
|
||||||
175
frontend/src/pages/ClinicalAnalysis.tsx
Normal file
175
frontend/src/pages/ClinicalAnalysis.tsx
Normal 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="年龄别 BMI(P25–中位–P75)">
|
||||||
|
<BoxPlotRows rows={bmiBox} />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 /> },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user