diff --git a/.gitignore b/.gitignore index 110a771..4520878 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ cache/ logs/ mlruns/ .playwright-mcp/ +frontend/playwright-report/ +frontend/test-results/ *Zone.Identifier # Transcription processing intermediates Outputs/transcript/chunks/ diff --git a/backend/data/case_loader.py b/backend/data/case_loader.py index 35e4486..7260a25 100644 --- a/backend/data/case_loader.py +++ b/backend/data/case_loader.py @@ -25,12 +25,42 @@ _cache: dict[str, Optional[pd.DataFrame | datetime]] = { "outpatient": None, "inpatient": None, "combined": None, + "cases_by_district_daily": None, "loaded_at": None, } # Guards the lazy build so concurrent callers don't duplicate the load/concat. _load_lock = threading.RLock() +# Canonical Wuhan administrative districts (13), matching the `name` field in +# Datas/武汉市.geojson. All district roll-ups must collapse to exactly these. +CANONICAL_DISTRICTS = [ + '江岸区', '江汉区', '硚口区', '汉阳区', '武昌区', '青山区', '洪山区', + '东西湖区', '汉南区', '蔡甸区', '江夏区', '黄陂区', '新洲区', +] +# Bare (suffix-less) base name -> canonical 区-suffixed name. +_DISTRICT_BASE_TO_CANONICAL = {d[:-1]: d for d in CANONICAL_DISTRICTS} +_DISTRICT_SUFFIXES = ('区', '县', '市') + + +def normalize_district(name: str) -> str: + """Map a district label to its canonical 区-suffixed form. + + The case parquet carries both bare ("武昌") and suffixed ("武昌区") spellings + of the same district, which double-counts in any roll-up. This collapses + them: known bare names map to their canonical form; already-suffixed names + pass through unchanged; anything else gets a "区" appended. + """ + if name is None: + return name + name = str(name).strip() + if name in _DISTRICT_BASE_TO_CANONICAL: + return _DISTRICT_BASE_TO_CANONICAL[name] + if name.endswith(_DISTRICT_SUFFIXES): + return name + return f"{name}区" + + # Wuhan district mapping WUHAN_DISTRICTS = { '江岸区': ['江岸'], @@ -170,3 +200,40 @@ def get_inpatient_data() -> pd.DataFrame: """Return the cached inpatient dataframe""" load_data() return _cache["inpatient"] # type: ignore[return-value] + + +def load_cases_by_district_daily() -> pd.DataFrame: + """Load processed/cases_by_district_daily.parquet with districts normalized. + + The on-disk parquet carries both bare and 区-suffixed spellings of each + district (26 labels = 13 districts × 2 spellings), so any groupby on the + raw `district` column double-counts. This is the single data-access + boundary: it normalizes labels to the canonical 13 and re-aggregates + (sum of outpatient_count / inpatient_count / total_cases per + normalized district + date), so every downstream consumer + (analysis / grid / insights) sees clean, deduped 13-district data. + + Returns a copy with columns [date, district, outpatient_count, + inpatient_count, total_cases]. Raises FileNotFoundError if the parquet + is missing (callers handle this as they did before). + """ + path = PROCESSED_DIR / "cases_by_district_daily.parquet" + cached = _cache.get("cases_by_district_daily") + if cached is not None: + return cast(pd.DataFrame, cached).copy() + + with _load_lock: + cached = _cache.get("cases_by_district_daily") + if cached is not None: + return cast(pd.DataFrame, cached).copy() + + df = pd.read_parquet(path) + df["district"] = df["district"].map(normalize_district) + agg = ( + df.groupby(["date", "district"], as_index=False)[ + ["outpatient_count", "inpatient_count", "total_cases"] + ] + .sum() + ) + _cache["cases_by_district_daily"] = agg # type: ignore[assignment] + return agg.copy() diff --git a/backend/main.py b/backend/main.py index 744b010..c04e11d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -13,7 +13,7 @@ from logging_config import setup_logging from middleware.request_logger import RequestLoggerMiddleware from auth.router import router as auth_router from auth.service import seed_default_admin -from routers import risk, alerts, analysis, insights, reports, cases, geocoded, grid, chat, environment +from routers import risk, alerts, analysis, insights, reports, cases, geocoded, grid, chat, environment, statistics setup_logging() @@ -59,6 +59,7 @@ app.include_router(geocoded.router) app.include_router(grid.router) app.include_router(chat.router) app.include_router(environment.router) +app.include_router(statistics.router) @app.get("/") diff --git a/backend/routers/analysis.py b/backend/routers/analysis.py index 1af86e1..be3dd31 100644 --- a/backend/routers/analysis.py +++ b/backend/routers/analysis.py @@ -12,6 +12,7 @@ import pandas as pd from pydantic import BaseModel, Field from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT, WUHAN_BOUNDS, LAT_STEP, LON_STEP +from data.case_loader import load_cases_by_district_daily from utils.date_helpers import get_latest_date from utils.geojson import parse_geojson_file, load_districts from utils.geo import point_in_polygon @@ -179,18 +180,16 @@ def _district_avg_aqi() -> dict: def _district_total_cases() -> dict: """Real total recorded cases per district from cases_by_district_daily. - District labels in the case file are inconsistent ("武昌" vs "武昌区"), - so names are normalized by stripping the "区" suffix and summed, then - keyed by the canonical mapping name (with "区"). Returns {district: cases}. + District labels are normalized to the canonical 13 区-suffixed names at the + data-access boundary (data.case_loader), so this is a plain per-district + sum. Returns {district: cases}. """ - path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet" - if not path.exists(): + try: + df = load_cases_by_district_daily() + except FileNotFoundError: return {} - df = pd.read_parquet(path, columns=["district", "total_cases"]) - df = df.copy() - df["base"] = df["district"].str.replace("区", "", regex=False) - by_base = df.groupby("base")["total_cases"].sum() - return {f"{base}区": int(v) for base, v in by_base.items()} + by_district = df.groupby("district")["total_cases"].sum() + return {str(d): int(v) for d, v in by_district.items()} @lru_cache(maxsize=8) diff --git a/backend/routers/grid.py b/backend/routers/grid.py index 5a4a42d..a12f5c2 100644 --- a/backend/routers/grid.py +++ b/backend/routers/grid.py @@ -21,6 +21,7 @@ from models import ( MultiDayPredictionRequest, MultiDayPredictionResponse, ) +from data.case_loader import load_cases_by_district_daily router = APIRouter(prefix="/api", tags=["grid"]) @@ -43,14 +44,13 @@ def _compute_historical_aggregation( ) -> HistoricalAggregationResponse: """Run the full pandas aggregation pipeline (called in thread pool).""" try: - cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet") + cases_df = load_cases_by_district_daily() except FileNotFoundError: return HistoricalAggregationResponse( aggregations=[], total_records=0, date_range=(start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")), timestamp=datetime.now().isoformat(), ) - cases_df = cases_df.copy() cases_df['date'] = pd.to_datetime(cases_df['date']) filtered_cases = cases_df[ @@ -196,7 +196,7 @@ def _grids_geojson_body(date: str, district: Optional[str], risk_level: Optional if district: merged = merged[merged['district_name'].str.contains(district.replace('区', ''), na=False, regex=False)] - cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet").copy() + cases_df = load_cases_by_district_daily() cases_df['date'] = pd.to_datetime(cases_df['date']).dt.strftime('%Y-%m-%d') cases_df = cases_df[cases_df['date'] == date] @@ -364,7 +364,7 @@ def _compute_grid_history(grid_id: str, days: int) -> dict: district = grid_info.iloc[0]['district_name'] - cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet") + cases_df = load_cases_by_district_daily() cases_df['date'] = pd.to_datetime(cases_df['date']) end_date = datetime.now() diff --git a/backend/routers/insights.py b/backend/routers/insights.py index 27d2d28..00d9308 100644 --- a/backend/routers/insights.py +++ b/backend/routers/insights.py @@ -11,6 +11,7 @@ from pydantic import BaseModel, Field from typing import Dict, List, Literal from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT +from data.case_loader import load_cases_by_district_daily from models import ( InsightsResponse, InsightTrend, @@ -491,22 +492,22 @@ async def get_insights_cards(): cases_path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet" if cases_path.exists(): - cases_df = _cached_parquet(str(cases_path)) + # Districts already normalized to the canonical 13 区-suffixed names. + cases_df = load_cases_by_district_daily() + cases_df["date"] = pd.to_datetime(cases_df["date"]) latest_case_date = cases_df["date"].max() - latest_cases = cases_df[cases_df["date"] == latest_case_date].copy() - latest_cases["base_district"] = latest_cases["district"].str.replace("区", "") - district_daily = latest_cases.groupby("base_district")["total_cases"].sum().sort_values(ascending=False) + latest_cases = cases_df[cases_df["date"] == latest_case_date] + district_daily = latest_cases.groupby("district")["total_cases"].sum().sort_values(ascending=False) total_daily = int(district_daily.sum()) top_name = district_daily.index[0] top_val = int(district_daily.iloc[0]) num_districts = len(district_daily) week_ago = latest_case_date - pd.Timedelta(days=6) - week_cases = cases_df[cases_df["date"] >= week_ago].copy() - week_cases["base_district"] = week_cases["district"].str.replace("区", "") + week_cases = cases_df[cases_df["date"] >= week_ago] daily_totals = week_cases.groupby("date")["total_cases"].sum() avg_daily = int(daily_totals.mean()) - week_district = week_cases.groupby("base_district")["total_cases"].sum().sort_values(ascending=False) + week_district = week_cases.groupby("district")["total_cases"].sum().sort_values(ascending=False) week_top_val = int(week_district.iloc[0]) date_str = latest_case_date.strftime("%m月%d日") @@ -515,8 +516,8 @@ async def get_insights_cards(): title=f"日病例统计 ({date_str})", description=( f"最近统计日({date_str})全市{num_districts}个区共记录{total_daily}例儿童呼吸道疾病病例," - f"{top_name}区{top_val}例为当日最高。近7日日均{avg_daily}例," - f"{week_district.index[0]}区累计{week_top_val}例居首。" + f"{top_name}{top_val}例为当日最高。近7日日均{avg_daily}例," + f"{week_district.index[0]}累计{week_top_val}例居首。" ), type="warning", metric="日病例", diff --git a/backend/routers/statistics.py b/backend/routers/statistics.py new file mode 100644 index 0000000..e3376bc --- /dev/null +++ b/backend/routers/statistics.py @@ -0,0 +1,566 @@ +""" +统计分析 API 路由 (prefix /api/stats) + +为前端统计仪表盘提供聚合后的临床、症状、发病率、环境相关性和时序数据。 +所有数据从 processed/*.parquet 计算得出(文件型后端,无数据库)。 + +设计原则: +- 模块级缓存载入的 parquet(与其他路由一致)。 +- 仅返回聚合结果,绝不直接 dump 原始行,保持 payload 小。 +- 每个端点用 try/except 包裹,失败时返回合法的空结构(绝不让 UI 收到 500)。 +- pandas 计算放入线程池 (asyncio.to_thread),避免阻塞事件循环。 +""" + +import asyncio +import glob +import logging +import threading +from pathlib import Path +from typing import Optional, cast + +import numpy as np +import pandas as pd +from fastapi import APIRouter, Query +from pydantic import BaseModel + +from data.case_loader import ( + get_inpatient_data, + get_outpatient_data, + get_combined_data, + load_cases_by_district_daily, + normalize_district, +) + +logger = logging.getLogger("cbpoa.statistics") + +router = APIRouter(prefix="/api/stats", tags=["statistics"]) + +PROJECT_ROOT = Path(__file__).parent.parent.parent +PROCESSED_DIR = PROJECT_ROOT / "processed" + +# ============== Module-level caches ============== + +_cache: dict[str, object] = {} +_cache_lock = threading.RLock() + +# 7 个污染物(与 feature snapshot 列名一致) +POLLUTANTS = ["AQI", "PM25", "PM10", "SO2", "NO2", "O3", "CO"] + +# 主诉症状关键词(固定列表,子串匹配)。注意顺序:更具体的在前避免被宽泛词吞掉, +# 但因为是独立子串计数,顺序不影响结果,仅为可读性分组。 +SYMPTOM_KEYWORDS = [ + "发热", "咳嗽", "咳", "喘息", "喘", "流涕", "鼻塞", "咽痛", "咽喉", + "痰", "气促", "呼吸困难", "肺炎", "复诊", "随诊", "复查", + "腹泻", "呕吐", "头痛", "乏力", "胸闷", "鼻涕", "发烧", "感冒", +] +REVISIT_KEYWORDS = ["复诊", "随诊", "复查"] + + +def _district_population() -> pd.Series: + """各区人口(population_density 求和,按 grid_district_mapping 归属)。 + + 返回 index 为规范化区名(13)、值为人口的 Series。结果缓存。 + """ + cached = _cache.get("district_population") + if cached is not None: + return cast(pd.Series, cached) + with _cache_lock: + cached = _cache.get("district_population") + if cached is not None: + return cast(pd.Series, cached) + mapping = pd.read_parquet(PROCESSED_DIR / "grid_district_mapping.parquet") + grid = pd.read_parquet( + PROCESSED_DIR / "grid_100m_with_dem_pop.parquet", + columns=["grid_id", "population_density"], + ) + joined = mapping.merge(grid, on="grid_id", how="inner") + joined = joined.dropna(subset=["district_name"]) + joined["district_name"] = joined["district_name"].map(normalize_district) + pop = joined.groupby("district_name")["population_density"].sum() + _cache["district_population"] = pop + return pop + + +def _feature_snapshots() -> pd.DataFrame: + """合并所有可用的 features_*.parquet 快照(缓存)。 + + 用于污染物 vs 病例的相关性分析。每个快照按格点给出污染物 + 病例计数 + 区。 + """ + cached = _cache.get("features") + if cached is not None: + return cast(pd.DataFrame, cached) + with _cache_lock: + cached = _cache.get("features") + if cached is not None: + return cast(pd.DataFrame, cached) + paths = sorted(glob.glob(str(PROCESSED_DIR / "features_*.parquet"))) + if not paths: + df = pd.DataFrame( + columns=POLLUTANTS + ["outpatient_count", "inpatient_count", "total_cases", "district"] + ) + else: + frames = [pd.read_parquet(p) for p in paths] + df = pd.concat(frames, ignore_index=True) + _cache["features"] = df + return df + + +# ============== Response Models ============== + + +class KeyValueCount(BaseModel): + bin_label: str + count: int + + +class InpatientKpis(BaseModel): + total_admissions: int + median_los_days: float + cure_rate: float + emergency_admit_ratio: float + + +class LosByDisease(BaseModel): + diagnosis: str + p25: float + median: float + p75: float + n: int + + +class LabelCount(BaseModel): + outcome: Optional[str] = None + route: Optional[str] = None + count: int + + +class OutcomeCount(BaseModel): + outcome: str + count: int + + +class RouteCount(BaseModel): + route: str + count: int + + +class BmiByAge(BaseModel): + age_band: str + p25: float + median: float + p75: float + n: int + + +class InpatientClinicalResponse(BaseModel): + kpis: InpatientKpis + los_histogram: list[KeyValueCount] + los_by_disease: list[LosByDisease] + outcome_counts: list[OutcomeCount] + admission_route_counts: list[RouteCount] + bmi_by_age_band: list[BmiByAge] + + +class SymptomItem(BaseModel): + keyword: str + count: int + + +class SymptomsResponse(BaseModel): + symptoms: list[SymptomItem] + revisit_ratio: float + + +class IncidenceItem(BaseModel): + district: str + total_cases: int + population: float + rate_per_10k: float + + +class IncidenceResponse(BaseModel): + districts: list[IncidenceItem] + + +class CorrItem(BaseModel): + pollutant: str + corr_with_cases: float + + +class ScatterPoint(BaseModel): + pm25: float + aqi: float + cases: float + + +class PairwiseCorr(BaseModel): + a: str + b: str + corr: float + + +class EnvCorrelationResponse(BaseModel): + correlation_matrix: list[CorrItem] + scatter: list[ScatterPoint] + pollutant_pairwise: list[PairwiseCorr] + + +class WeekdayPoint(BaseModel): + weekday: str + outpatient: int + inpatient: int + total: int + + +class MonthYearPoint(BaseModel): + year: int + month: int + total: int + + +class YoYPoint(BaseModel): + period: str + current: int + previous: int + growth_pct: float + + +class TemporalResponse(BaseModel): + weekday: list[WeekdayPoint] + month_year: list[MonthYearPoint] + yoy: list[YoYPoint] + + +# ============== Helpers ============== + + +def _empty_inpatient_clinical() -> InpatientClinicalResponse: + return InpatientClinicalResponse( + kpis=InpatientKpis( + total_admissions=0, median_los_days=0.0, + cure_rate=0.0, emergency_admit_ratio=0.0, + ), + los_histogram=[], los_by_disease=[], outcome_counts=[], + admission_route_counts=[], bmi_by_age_band=[], + ) + + +def _safe_float(v) -> float: + try: + f = float(v) + if np.isnan(f) or np.isinf(f): + return 0.0 + return round(f, 4) + except (TypeError, ValueError): + return 0.0 + + +# ============== Endpoint 1: inpatient clinical ============== + + +def _compute_inpatient_clinical() -> InpatientClinicalResponse: + df = get_inpatient_data().copy() + if df.empty: + return _empty_inpatient_clinical() + + # LOS = (出院日期 - 入院日期).days, valid 0-60 + in_date = pd.to_datetime(df["入院日期"], errors="coerce") + out_date = pd.to_datetime(df["出院日期"], errors="coerce") + df["los"] = (out_date - in_date).dt.days + df_los = df[(df["los"] >= 0) & (df["los"] <= 60)] + + total = len(df) + median_los = float(df_los["los"].median()) if len(df_los) else 0.0 + + outcome = df["出院情况"].fillna("未知") + cure_n = int(outcome.isin(["治愈", "好转"]).sum()) + cure_rate = cure_n / total if total else 0.0 + + route = df["入院途径"].fillna("未知") + emerg_n = int((route == "急诊").sum()) + emerg_ratio = emerg_n / total if total else 0.0 + + kpis = InpatientKpis( + total_admissions=total, + median_los_days=round(median_los, 2), + cure_rate=round(cure_rate, 4), + emergency_admit_ratio=round(emerg_ratio, 4), + ) + + # LOS histogram bins: 0,1,2,3,4,5,6,7,8-14,15+ + los_histogram: list[KeyValueCount] = [] + los_vals = df_los["los"] + for b in range(0, 8): + los_histogram.append(KeyValueCount(bin_label=str(b), count=int((los_vals == b).sum()))) + los_histogram.append(KeyValueCount(bin_label="8-14", count=int(((los_vals >= 8) & (los_vals <= 14)).sum()))) + los_histogram.append(KeyValueCount(bin_label="15+", count=int((los_vals >= 15).sum()))) + + # LOS by disease (top 8 diagnoses by n) + los_by_disease: list[LosByDisease] = [] + if len(df_los): + top_diag = df_los["诊断名称"].value_counts().head(8).index.tolist() + for d in top_diag: + grp = df_los[df_los["诊断名称"] == d]["los"] + los_by_disease.append(LosByDisease( + diagnosis=str(d), + p25=round(float(grp.quantile(0.25)), 2), + median=round(float(grp.median()), 2), + p75=round(float(grp.quantile(0.75)), 2), + n=int(len(grp)), + )) + + # outcome counts + outcome_counts = [ + OutcomeCount(outcome=str(k), count=int(v)) + for k, v in outcome.value_counts().items() + ] + + # admission route counts + admission_route_counts = [ + RouteCount(route=str(k), count=int(v)) + for k, v in route.value_counts().items() + ] + + # BMI by age band. BMI = 体重kg / (身高m)^2; plausible 8-40. + bmi_by_age_band: list[BmiByAge] = [] + h = pd.to_numeric(df["身高"], errors="coerce") # cm + w = pd.to_numeric(df["体重"], errors="coerce") # kg + age = pd.to_numeric(df["年龄"], errors="coerce") + bmi = w / ((h / 100.0) ** 2) + bmi_df = pd.DataFrame({"age": age, "bmi": bmi}) + bmi_df = bmi_df[(bmi_df["bmi"] >= 8) & (bmi_df["bmi"] <= 40) & bmi_df["age"].notna()] + age_bands = [(0, 3, "0-2"), (3, 6, "3-5"), (6, 9, "6-8"), + (9, 12, "9-11"), (12, 15, "12-14"), (15, 19, "15-18")] + for lo, hi, label in age_bands: + grp = bmi_df[(bmi_df["age"] >= lo) & (bmi_df["age"] < hi)]["bmi"] + if len(grp) == 0: + continue + bmi_by_age_band.append(BmiByAge( + age_band=label, + p25=round(float(grp.quantile(0.25)), 2), + median=round(float(grp.median()), 2), + p75=round(float(grp.quantile(0.75)), 2), + n=int(len(grp)), + )) + + return InpatientClinicalResponse( + kpis=kpis, + los_histogram=los_histogram, + los_by_disease=los_by_disease, + outcome_counts=outcome_counts, + admission_route_counts=admission_route_counts, + bmi_by_age_band=bmi_by_age_band, + ) + + +@router.get("/inpatient-clinical", response_model=InpatientClinicalResponse, summary="住院临床统计") +async def inpatient_clinical(): + """住院临床概览:KPI、住院天数(LOS)分布、费用分布、转归、入院途径、BMI 分布。""" + try: + return await asyncio.to_thread(_compute_inpatient_clinical) + except Exception: + logger.exception("inpatient-clinical failed") + return _empty_inpatient_clinical() + + +# ============== Endpoint 2: symptoms ============== + + +def _compute_symptoms(top: int) -> SymptomsResponse: + df = get_outpatient_data() + if df.empty or "主诉" not in df.columns: + return SymptomsResponse(symptoms=[], revisit_ratio=0.0) + chief = df["主诉"].dropna().astype(str) + total = len(chief) + if total == 0: + return SymptomsResponse(symptoms=[], revisit_ratio=0.0) + + counts: list[SymptomItem] = [] + for kw in SYMPTOM_KEYWORDS: + c = int(chief.str.contains(kw, regex=False).sum()) + if c > 0: + counts.append(SymptomItem(keyword=kw, count=c)) + counts.sort(key=lambda x: x.count, reverse=True) + counts = counts[:top] + + revisit_mask = chief.str.contains("|".join(REVISIT_KEYWORDS), regex=True) + revisit_ratio = float(revisit_mask.sum()) / total if total else 0.0 + + return SymptomsResponse(symptoms=counts, revisit_ratio=round(revisit_ratio, 4)) + + +@router.get("/symptoms", response_model=SymptomsResponse, summary="门诊主诉症状词频") +async def symptoms(top: int = Query(20, ge=1, le=50, description="返回前 N 个症状词")): + """从门诊主诉中提取固定症状关键词的出现频次,并计算复诊比例。""" + try: + return await asyncio.to_thread(_compute_symptoms, top) + except Exception: + logger.exception("symptoms failed") + return SymptomsResponse(symptoms=[], revisit_ratio=0.0) + + +# ============== Endpoint 3: incidence rate ============== + + +def _compute_incidence() -> IncidenceResponse: + daily = load_cases_by_district_daily() + if daily.empty: + return IncidenceResponse(districts=[]) + case_totals = daily.groupby("district")["total_cases"].sum() + pop = _district_population() + + items: list[IncidenceItem] = [] + for district in case_totals.index: + total_cases = int(case_totals.get(district, 0)) + population = float(pop.get(district, 0.0)) + rate = (total_cases / population * 10000) if population > 0 else 0.0 + items.append(IncidenceItem( + district=str(district), + total_cases=total_cases, + population=round(population, 1), + rate_per_10k=round(rate, 2), + )) + items.sort(key=lambda x: x.rate_per_10k, reverse=True) + return IncidenceResponse(districts=items) + + +@router.get("/incidence-rate", response_model=IncidenceResponse, summary="各区发病率") +async def incidence_rate(): + """各区病例总数 / 区人口 * 10000,得到每万人发病率(13 区)。""" + try: + return await asyncio.to_thread(_compute_incidence) + except Exception: + logger.exception("incidence-rate failed") + return IncidenceResponse(districts=[]) + + +# ============== Endpoint 4: env correlation ============== + + +def _compute_env_correlation() -> EnvCorrelationResponse: + df = _feature_snapshots() + if df.empty or "total_cases" not in df.columns: + return EnvCorrelationResponse(correlation_matrix=[], scatter=[], pollutant_pairwise=[]) + + # 污染物 vs 病例 的 Pearson 相关(按格点,汇集所有快照) + correlation_matrix: list[CorrItem] = [] + cases = pd.to_numeric(df["total_cases"], errors="coerce") + for p in POLLUTANTS: + if p not in df.columns: + continue + series = pd.to_numeric(df[p], errors="coerce") + valid = series.notna() & cases.notna() + if valid.sum() < 2 or series[valid].std() == 0 or cases[valid].std() == 0: + corr = 0.0 + else: + corr = float(series[valid].corr(cases[valid])) + correlation_matrix.append(CorrItem(pollutant=p, corr_with_cases=_safe_float(corr))) + + # scatter: 采样 cases>0 的格点(up to 500) + scatter: list[ScatterPoint] = [] + has_cols = all(c in df.columns for c in ["PM25", "AQI", "total_cases"]) + if has_cols: + sdf = df[["PM25", "AQI", "total_cases"]].copy() + sdf = sdf[pd.to_numeric(sdf["total_cases"], errors="coerce") > 0].dropna() + if len(sdf) > 500: + sdf = sdf.sample(n=500, random_state=42) + for _, r in sdf.iterrows(): + scatter.append(ScatterPoint( + pm25=_safe_float(r["PM25"]), + aqi=_safe_float(r["AQI"]), + cases=_safe_float(r["total_cases"]), + )) + + # pollutant pairwise (upper triangle) + pollutant_pairwise: list[PairwiseCorr] = [] + present = [p for p in POLLUTANTS if p in df.columns] + pol_df = df[present].apply(pd.to_numeric, errors="coerce") + corr_mat = pol_df.corr() + for i, a in enumerate(present): + for b in present[i + 1:]: + try: + v = corr_mat.loc[a, b] + except KeyError: + v = 0.0 + pollutant_pairwise.append(PairwiseCorr(a=a, b=b, corr=_safe_float(v))) + + return EnvCorrelationResponse( + correlation_matrix=correlation_matrix, + scatter=scatter, + pollutant_pairwise=pollutant_pairwise, + ) + + +@router.get("/env-correlation", response_model=EnvCorrelationResponse, summary="环境-病例相关性") +async def env_correlation(): + """污染物与病例的相关矩阵、PM2.5/AQI 散点、污染物两两相关(热力图)。""" + try: + return await asyncio.to_thread(_compute_env_correlation) + except Exception: + logger.exception("env-correlation failed") + return EnvCorrelationResponse(correlation_matrix=[], scatter=[], pollutant_pairwise=[]) + + +# ============== Endpoint 5: temporal ============== + +_WEEKDAY_LABELS = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] + + +def _compute_temporal() -> TemporalResponse: + df = get_combined_data().copy() + if df.empty: + return TemporalResponse(weekday=[], month_year=[], yoy=[]) + df["date"] = pd.to_datetime(df["date"], errors="coerce") + df = df[df["date"].notna()] + if df.empty: + return TemporalResponse(weekday=[], month_year=[], yoy=[]) + + # weekday (0=周一..6=周日) + df["wd"] = df["date"].dt.weekday + weekday: list[WeekdayPoint] = [] + for wd in range(7): + sub = df[df["wd"] == wd] + out = int((sub["type"] == "outpatient").sum()) + inp = int((sub["type"] == "inpatient").sum()) + weekday.append(WeekdayPoint( + weekday=_WEEKDAY_LABELS[wd], outpatient=out, inpatient=inp, total=out + inp, + )) + + # month_year (seasonality grid) + df["year"] = df["date"].dt.year + df["month"] = df["date"].dt.month + my = df.groupby(["year", "month"]).size() + month_year = [ + MonthYearPoint(year=int(y), month=int(m), total=int(c)) + for (y, m), c in my.items() + ] + month_year.sort(key=lambda x: (x.year, x.month)) + + # yoy: monthly current vs same-month-prior-year (only if multiple years exist) + yoy: list[YoYPoint] = [] + years = sorted(df["year"].unique().tolist()) + if len(years) > 1: + monthly_totals = {(int(y), int(m)): int(c) for (y, m), c in my.items()} + for (y, m), cur in sorted(monthly_totals.items()): + prev = monthly_totals.get((y - 1, m)) + if prev is None: + continue + growth = ((cur - prev) / prev * 100) if prev else 0.0 + yoy.append(YoYPoint( + period=f"{y}-{m:02d}", + current=cur, + previous=prev, + growth_pct=round(growth, 2), + )) + + return TemporalResponse(weekday=weekday, month_year=month_year, yoy=yoy) + + +@router.get("/temporal", response_model=TemporalResponse, summary="时序统计") +async def temporal(): + """按星期、年-月(季节性网格)聚合,以及同比(YoY)增长(若有多年数据)。""" + try: + return await asyncio.to_thread(_compute_temporal) + except Exception: + logger.exception("temporal failed") + return TemporalResponse(weekday=[], month_year=[], yoy=[]) diff --git a/backend/tests/test_district_normalization.py b/backend/tests/test_district_normalization.py new file mode 100644 index 0000000..f30179c --- /dev/null +++ b/backend/tests/test_district_normalization.py @@ -0,0 +1,82 @@ +"""Tests for district label normalization at the case-loader boundary. + +The processed/cases_by_district_daily.parquet carries both bare ("武昌") and +区-suffixed ("武昌区") spellings of each district (26 labels = 13 districts × 2 +spellings), which double-counts in any roll-up. data.case_loader normalizes +these to the canonical 13 区-suffixed names and re-aggregates. These tests pin +that behavior. +""" +import sys +from pathlib import Path + +import pandas as pd +import pytest + +# Ensure the backend package root is importable at collection time (mirrors the +# sys.path handling other modules rely on once the app is imported). +BACKEND_ROOT = Path(__file__).parent.parent +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + +from data.case_loader import ( # noqa: E402 + CANONICAL_DISTRICTS, + normalize_district, + load_cases_by_district_daily, +) + +PROJECT_ROOT = Path(__file__).parent.parent.parent +RAW_PARQUET = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet" + + +def test_normalize_district_known_bare_forms(): + """Every known bare form maps to its canonical 区-suffixed name.""" + cases = { + "武昌": "武昌区", "汉阳": "汉阳区", "江岸": "江岸区", "硚口": "硚口区", + "青山": "青山区", "洪山": "洪山区", "东西湖": "东西湖区", "汉南": "汉南区", + "蔡甸": "蔡甸区", "江夏": "江夏区", "黄陂": "黄陂区", "新洲": "新洲区", + "江汉": "江汉区", + } + for bare, canonical in cases.items(): + assert normalize_district(bare) == canonical + + +def test_normalize_district_already_suffixed_passes_through(): + for d in CANONICAL_DISTRICTS: + assert normalize_district(d) == d + + +def test_canonical_set_is_exactly_thirteen(): + assert len(CANONICAL_DISTRICTS) == 13 + assert len(set(CANONICAL_DISTRICTS)) == 13 + + +@pytest.mark.skipif(not RAW_PARQUET.exists(), reason="case parquet not present") +def test_loader_collapses_to_thirteen_canonical_districts(): + df = load_cases_by_district_daily() + districts = set(df["district"].unique()) + + # (a) exactly 13 unique districts, all canonical + assert len(districts) == 13, f"expected 13 districts, got {len(districts)}: {sorted(districts)}" + assert districts == set(CANONICAL_DISTRICTS) + + # (b) no bare / unsuffixed duplicates remain + for name in districts: + assert name.endswith(("区", "县", "市")), f"unsuffixed district leaked: {name}" + + +@pytest.mark.skipif(not RAW_PARQUET.exists(), reason="case parquet not present") +def test_loader_preserves_totals_no_rows_dropped_or_double_counted(): + """Sum integrity: normalized total == raw parquet total.""" + raw = pd.read_parquet(RAW_PARQUET) + normalized = load_cases_by_district_daily() + + assert int(normalized["total_cases"].sum()) == int(raw["total_cases"].sum()) + assert int(normalized["outpatient_count"].sum()) == int(raw["outpatient_count"].sum()) + assert int(normalized["inpatient_count"].sum()) == int(raw["inpatient_count"].sum()) + + +@pytest.mark.skipif(not RAW_PARQUET.exists(), reason="case parquet not present") +def test_raw_parquet_actually_has_dirty_labels(): + """Sanity: the raw file really has the 26-label problem we are fixing.""" + raw = pd.read_parquet(RAW_PARQUET) + assert raw["district"].nunique() > 13 diff --git a/frontend/e2e/clinical.spec.ts b/frontend/e2e/clinical.spec.ts new file mode 100644 index 0000000..a04c1de --- /dev/null +++ b/frontend/e2e/clinical.spec.ts @@ -0,0 +1,90 @@ +/** + * 住院临床分析页(/analysis/clinical)验收测试。 + * 与 user-flows.spec.ts 一致的鉴权策略:addInitScript 注入 cbpoa_token, + * 用 page.route 拦截 /api/**,对 inpatient-clinical 返回合法小样本,其余返回 {}。 + */ +import { test, expect, Page } from '@playwright/test'; +import { TESTIDS } from '../src/utils/testids'; + +const CLINICAL_FIXTURE = { + kpis: { + total_admissions: 5822, + median_los_days: 4, + cure_rate: 0.991, + emergency_admit_ratio: 0.47, + }, + los_histogram: [ + { bin_label: '1-2', count: 1200 }, + { bin_label: '3-4', count: 2100 }, + { bin_label: '5-7', count: 1500 }, + ], + los_by_disease: [ + { diagnosis: '肺炎', p25: 3, median: 5, p75: 7, n: 800 }, + { diagnosis: '支气管炎', p25: 2, median: 4, p75: 6, n: 600 }, + ], + outcome_counts: [ + { outcome: '治愈', count: 3474 }, + { outcome: '好转', count: 2298 }, + { outcome: '其他', count: 35 }, + { outcome: '未愈', count: 12 }, + { outcome: '死亡', count: 3 }, + ], + admission_route_counts: [ + { route: '急诊', count: 2700 }, + { route: '门诊', count: 3122 }, + ], + bmi_by_age_band: [ + { age_band: '0-2', p25: 14, median: 16, p75: 18, n: 400 }, + { age_band: '3-6', p25: 15, median: 17, p75: 19, n: 500 }, + ], +}; + +async function seedAuthAndMockApi(page: Page) { + await page.addInitScript(() => { + localStorage.setItem('cbpoa_token', 'e2e-test-token'); + }); + + await page.route('/api/**', (route) => { + const url = route.request().url(); + + if (url.includes('/stats/inpatient-clinical')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(CLINICAL_FIXTURE), + }); + return; + } + + // 其余接口返回空对象,本页不依赖。 + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); +} + +test.describe('住院临床分析页', () => { + test.beforeEach(async ({ page }) => { + await seedAuthAndMockApi(page); + }); + + test('deep-link /analysis/clinical mounts page-clinical + clinical-kpis', async ({ page }) => { + await page.goto('/analysis/clinical'); + await expect(page.locator(`[data-testid="${TESTIDS.pageClinical}"]`)).toBeVisible(); + await expect(page.locator(`[data-testid="${TESTIDS.clinicalKpis}"]`)).toBeVisible(); + }); + + test('no horizontal scroll at 375px', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto('/analysis/clinical'); + await expect(page.locator(`[data-testid="${TESTIDS.pageClinical}"]`)).toBeVisible(); + await expect(page.locator(`[data-testid="${TESTIDS.clinicalKpis}"]`)).toBeVisible(); + + const noHorizontalScroll = await page.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth + ); + expect(noHorizontalScroll).toBe(true); + }); +}); diff --git a/frontend/e2e/doctor-view.spec.ts b/frontend/e2e/doctor-view.spec.ts new file mode 100644 index 0000000..ef1e3ec --- /dev/null +++ b/frontend/e2e/doctor-view.spec.ts @@ -0,0 +1,143 @@ +/** + * Phase-3 acceptance tests: role-aware 预警 (alerts) view. + * + * Two view-preset invariants (D2 — frontend presets, NOT access control): + * + * 1. PRIVACY INVARIANT (doctor / ?view=cluster): the doctor sees ONLY the aggregated + * density raster + the disease filter — ZERO individual patient/case point markers. + * The page mirrors every individual marker it would actually render into a hidden + * data-testid="patient-point" element (the live Leaflet CircleMarkers are canvas/SVG + * objects with no testid and can't be counted directly). In cluster mode the page + * forces showAlertMarkers=false, so that mirror set is empty → patient-point count 0. + * + * 2. 官员 (official) GRID-HIDE: the 100m 网格 is meaningless for leadership, so the grid + * toggle wrapper (data-testid="grid-layer-wrapper") is not rendered at all. + * + * Auth + API mocking mirror e2e/user-flows.spec.ts so the suite runs hermetically + * (no live :8000 backend). Role is seeded via localStorage['cbpoa_role']. + */ +import { test, expect, Page } from '@playwright/test'; +import { TESTIDS } from '../src/utils/testids'; + +/** + * Seed auth token (+ optional role) and mock all /api/** calls before page load. + * Response shapes copied from user-flows.spec.ts. + */ +async function seedAuthAndMockApi(page: Page, role?: string) { + await page.addInitScript((r) => { + localStorage.setItem('cbpoa_token', 'e2e-test-token'); + if (r) localStorage.setItem('cbpoa_role', r); + }, role ?? ''); + + await page.route('/api/**', (route) => { + const url = route.request().url(); + + if (url.includes('/alerts')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ alerts: [], total: 0 }), + }); + return; + } + if (url.includes('/history/aggregated')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ aggregations: [], total_records: 0 }), + }); + return; + } + if (url.includes('/grids')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ type: 'FeatureCollection', features: [] }), + }); + return; + } + if (url.includes('/cases/demographics')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + age_distribution: [], + gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } }, + age_diagnosis_matrix: [], + }), + }); + return; + } + if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }); + return; + } + if (url.includes('/diagnoses') || url.includes('/diagnosis-list')) { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }); + return; + } + if (url.includes('/cases/districts') || url.includes('/districts')) { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }); + return; + } + if (url.includes('/cases/trend') || url.includes('/cases')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [], total: 0 }), + }); + return; + } + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [], items: [], total: 0 }), + }); + }); +} + +test.describe('role-aware 预警 view (Phase 3)', () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test('医生 /alerts?view=cluster: cluster-view mounts, disease filter present, ZERO patient points', async ({ + page, + }) => { + await seedAuthAndMockApi(page, 'doctor'); + await page.goto('/alerts?view=cluster'); + + // Page + the aggregated density (cluster) map both mount. + await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible(); + await expect(page.locator(`[data-testid="${TESTIDS.clusterView}"]`)).toBeVisible(); + + // The disease filter is the doctor's core tool — it must be on the page. + await expect(page.getByText('按病种筛选')).toBeVisible(); + + // PRIVACY INVARIANT: not a single individual patient/case point may be rendered. + // Asserted at the data level (the mirrored DOM set), independent of Leaflet internals. + await expect(page.getByTestId(TESTIDS.patientPoint)).toHaveCount(0); + + // The 预警标记 toggle (which would turn individual markers on) must be absent, + // so there is no way for the doctor to opt out of the privacy invariant. + await expect(page.getByRole('button', { name: '预警标记' })).toHaveCount(0); + }); + + test('官员 /alerts: 100m grid hidden — grid-layer-wrapper not rendered', async ({ page }) => { + await seedAuthAndMockApi(page, 'official'); + await page.goto('/alerts'); + + await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible(); + + // The grid toggle wrapper must be entirely absent for leadership. + await expect(page.getByTestId(TESTIDS.gridLayerWrapper)).toHaveCount(0); + }); + + test('admin /alerts: full behavior — grid toggle present, no forced cluster view', async ({ page }) => { + await seedAuthAndMockApi(page, 'admin'); + await page.goto('/alerts'); + + await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible(); + // Admin keeps the grid toggle and is NOT forced into cluster view. + await expect(page.getByTestId(TESTIDS.gridLayerWrapper)).toHaveCount(1); + await expect(page.locator(`[data-testid="${TESTIDS.clusterView}"]`)).toHaveCount(0); + }); +}); diff --git a/frontend/e2e/granularity.spec.ts b/frontend/e2e/granularity.spec.ts new file mode 100644 index 0000000..933e266 --- /dev/null +++ b/frontend/e2e/granularity.spec.ts @@ -0,0 +1,78 @@ +import { test, expect, Page } from '@playwright/test'; +import { TESTIDS } from '../src/utils/testids'; + +// 与 user-flows.spec.ts 一致的鉴权策略:注入 token 绕过登录门,并 mock /api/**, +// 让用例脱离活的后端 hermetic 运行。 +async function seedAuthAndMockApi(page: Page) { + await page.addInitScript(() => { + localStorage.setItem('cbpoa_token', 'e2e-test-token'); + }); + await page.route('/api/**', (route) => { + const url = route.request().url(); + if (url.includes('/history/aggregated')) { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ aggregations: [], total_records: 0 }) }); + return; + } + if (url.includes('/grids')) { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ type: 'FeatureCollection', features: [] }) }); + return; + } + if (url.includes('/streets')) { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ streets: [] }) }); + return; + } + if (url.includes('/cases/districts') || url.includes('/districts')) { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }); + return; + } + if (url.includes('/cases/trend') || url.includes('/cases')) { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [], trend: [], total: 0 }) }); + return; + } + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [], items: [], total: 0 }) }); + }); +} + +// 粒度(granularity)以 URL query 参数为真相来源(source of truth)。 +// 监测页通过 useSearchParams 读取它,drilldownStore 单向派生。 +test.describe('monitoring granularity URL source-of-truth', () => { + test.beforeEach(async ({ page }) => { + await seedAuthAndMockApi(page); + }); + + test('deep-link granularity=street mounts page and reflects street in control', async ({ page }) => { + await page.goto('/monitoring?granularity=street'); + + await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible(); + + const control = page.getByTestId(TESTIDS.granularityControl); + await expect(control).toBeVisible(); + // 街道分段为激活态(Segmented 给激活按钮加 bg-primary)。 + const streetBtn = page.getByTestId(`${TESTIDS.granularityControl}-street`); + await expect(streetBtn).toHaveClass(/bg-primary/); + }); + + test('clicking a granularity control updates the URL granularity param', async ({ page }) => { + await page.goto('/monitoring?granularity=street'); + await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible(); + + // 切到「区域」应把 URL 写为 granularity=district。 + await page.getByTestId(`${TESTIDS.granularityControl}-district`).click(); + await expect(page).toHaveURL(/granularity=district/); + + // 切到「全市」应把 URL 写为 granularity=city。 + await page.getByTestId(`${TESTIDS.granularityControl}-city`).click(); + await expect(page).toHaveURL(/granularity=city/); + }); + + test('deep-link granularity=district survives a reload', async ({ page }) => { + await page.goto('/monitoring?granularity=district'); + await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible(); + await expect(page).toHaveURL(/granularity=district/); + + await page.reload(); + await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible(); + await expect(page).toHaveURL(/granularity=district/); + await expect(page.getByTestId(`${TESTIDS.granularityControl}-district`)).toHaveClass(/bg-primary/); + }); +}); diff --git a/frontend/e2e/overview.spec.ts b/frontend/e2e/overview.spec.ts new file mode 100644 index 0000000..66f3565 --- /dev/null +++ b/frontend/e2e/overview.spec.ts @@ -0,0 +1,119 @@ +/** + * 综合概览大屏 (/overview) 验收测试。 + * + * 与 user-flows.spec.ts 一致:用 addInitScript 注入 cbpoa_token 绕过登录门, + * page.route 拦截 /api/** 使套件 hermetic(无需 :8000)。/wuhan_districts.geojson + * 走真实静态资源(dev server 提供),由 Leaflet 取用。 + */ +import { test, expect, Page } from '@playwright/test'; +import { TESTIDS } from '../src/utils/testids'; + +/** 13 区里造两条数据,断言 choropleth 能着色、toggle 能切换。 */ +function districtPayload() { + return { + districts: [ + { district: '武昌区', outpatient: 120, inpatient: 30, total: 150, outpatient_ratio: 0.8, inpatient_ratio: 0.2 }, + { district: '江岸', outpatient: 60, inpatient: 10, total: 70, outpatient_ratio: 0.86, inpatient_ratio: 0.14 }, + ], + total: 220, + }; +} + +async function seedAuthAndMockApi(page: Page) { + await page.addInitScript(() => { + localStorage.setItem('cbpoa_token', 'e2e-test-token'); + }); + + await page.route('/api/**', (route) => { + const url = route.request().url(); + + const json = (body: unknown) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); + + if (url.includes('/alerts')) { + return json({ alerts: [], total: 0 }); + } + if (url.includes('/cases/stats')) { + return json({ + total_outpatient: 1000, + total_inpatient: 200, + date_range: { start: '2023-01-01', end: '2023-12-01' }, + top_districts: [], + top_diagnoses: [ + { diagnosis: '上呼吸道感染', outpatient: 300, inpatient: 40 }, + { diagnosis: '肺炎', outpatient: 120, inpatient: 80 }, + ], + }); + } + if (url.includes('/cases/trend')) { + return json({ + trend: [ + { date: '2023-11-01', outpatient: 10, inpatient: 2, total: 12 }, + { date: '2023-11-02', outpatient: 14, inpatient: 3, total: 17 }, + ], + summary: { + total_outpatient: 24, + total_inpatient: 5, + period_count: 2, + avg_daily_outpatient: 12, + avg_daily_inpatient: 2.5, + }, + }); + } + if (url.includes('/cases/districts')) { + return json(districtPayload()); + } + if (url.includes('/risk/stats')) { + return json({ high_risk_count: 7, total_grids: 100, avg_risk: 0.4 }); + } + if (url.includes('/environment/pollutants')) { + return json({ data: [{ date: '2023-11-01', AQI: 80 }, { date: '2023-11-02', AQI: 95 }] }); + } + + return json({ data: [], items: [], total: 0 }); + }); +} + +test.describe('Overview 大屏', () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test.beforeEach(async ({ page }) => { + await seedAuthAndMockApi(page); + }); + + test('renders kpi-row, choropleth, as-of badge and metric toggle', async ({ page }) => { + await page.goto('/overview'); + + await expect(page.locator(`[data-testid="${TESTIDS.pageOverview}"]`)).toBeVisible(); + await expect(page.locator(`[data-testid="${TESTIDS.kpiRow}"]`)).toBeVisible(); + await expect(page.locator(`[data-testid="${TESTIDS.choroplethWrapper}"]`)).toBeVisible(); + + // Literal honesty badge — exact text. + const badge = page.locator(`[data-testid="${TESTIDS.asofBadge}"]`); + await expect(badge).toBeVisible(); + await expect(badge).toHaveText('数据截至2023-12'); + }); + + test('门诊/住院 toggle switches active segment without error', async ({ page }) => { + await page.goto('/overview'); + await expect(page.locator(`[data-testid="${TESTIDS.choroplethWrapper}"]`)).toBeVisible(); + + const outBtn = page.locator(`[data-testid="${TESTIDS.outinpatientToggle}-outpatient"]`); + const inBtn = page.locator(`[data-testid="${TESTIDS.outinpatientToggle}-inpatient"]`); + const allBtn = page.locator(`[data-testid="${TESTIDS.outinpatientToggle}-all"]`); + + // Default: 全部 active (primary background). + await expect(allBtn).toHaveClass(/bg-primary/); + + await outBtn.click(); + await expect(outBtn).toHaveClass(/bg-primary/); + await expect(allBtn).not.toHaveClass(/bg-primary/); + + await inBtn.click(); + await expect(inBtn).toHaveClass(/bg-primary/); + await expect(outBtn).not.toHaveClass(/bg-primary/); + + // Wrapper still mounted after toggling — no render crash. + await expect(page.locator(`[data-testid="${TESTIDS.choroplethWrapper}"]`)).toBeVisible(); + }); +}); diff --git a/frontend/e2e/perf.spec.ts b/frontend/e2e/perf.spec.ts new file mode 100644 index 0000000..d35f1c8 --- /dev/null +++ b/frontend/e2e/perf.spec.ts @@ -0,0 +1,232 @@ +/** + * Performance-measurement harness for the leadership 大屏 (/overview). + * + * Runs ONLY in the dedicated `perf` Playwright project (see playwright.config.ts + * testMatch) so emulated network throttling never pollutes the functional suite. + * + * What it measures: + * 1. LCP (Largest Contentful Paint) of /overview under emulated Fast 3G. + * 2. Client-side route-transition time from /overview → /monitoring. + * + * Throttling model: /api/** is mocked to resolve INSTANTLY (see seedAuthAndMockApi), + * so the backend contributes ~0ms. That is deliberate — it isolates the realistic + * SPA cost on a slow link: the *static asset graph* (app JS/CSS bundle + the + * /wuhan_districts.geojson choropleth payload, which is served by the real dev + * server, not mocked). Fast 3G therefore shapes exactly the bytes a cold-cache + * leadership client must pull before first paint, which is what LCP should reflect. + * + * Assertion policy (per the UX-modernization plan): LCP and route-transition + * targets (2500ms LCP / 800ms transition) are REPORTED, not hard CI gates — a + * miss under throttle on a loaded CI box must not fail the build. We therefore + * record each number against its target as a test annotation + console line and + * let the test PASS regardless of the target. (Note: `expect.soft` would still + * mark the test failed at teardown, so it's the wrong tool for a report-only + * target — annotations are.) Hard assertions guard ONLY that the measurement + * machinery worked: LCP was observed (> 0) and the nav actually landed. + * + * Caveat on absolute values: this runs against the Vite DEV server (unbundled, + * unminified ESM with per-module requests). Dev LCP under Fast 3G is therefore + * far higher than a production build would be — these numbers are a relative + * regression signal for this harness, not a production SLA. + */ +import { test, expect, Page } from '@playwright/test'; +import { TESTIDS } from '../src/utils/testids'; + +// Reported (soft) targets — see file header. +const LCP_TARGET_MS = 2500; +const ROUTE_TRANSITION_TARGET_MS = 800; + +// Emulated "Fast 3G" network conditions (Chrome DevTools preset). +const FAST_3G = { + offline: false, + downloadThroughput: (1.6 * 1024 * 1024) / 8, // 1.6 Mbps + uploadThroughput: (750 * 1024) / 8, // 750 Kbps + latency: 150, // ms RTT +}; + +/** + * Seed auth + mock /api/** so the page renders hermetically. Mirrors the helper + * in user-flows.spec.ts, with one deliberate difference: /wuhan_districts.geojson + * is a real static asset and is NOT under /api, so page.route('/api/**') already + * lets it pass through to the dev server (the realistic, throttled payload). + */ +async function seedAuthAndMockApi(page: Page) { + await page.addInitScript(() => { + localStorage.setItem('cbpoa_token', 'e2e-test-token'); + }); + + // Mock backend responses instantly so Fast-3G shapes only the static asset + // graph (JS/CSS + geojson), not API latency. + await page.route('/api/**', (route) => { + const url = route.request().url(); + + if (url.includes('/alerts')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ alerts: [], total: 0 }), + }); + return; + } + + if (url.includes('/history/aggregated')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ aggregations: [], total_records: 0 }), + }); + return; + } + + if (url.includes('/grids')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ type: 'FeatureCollection', features: [] }), + }); + return; + } + + if (url.includes('/cases/demographics')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + age_distribution: [], + gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } }, + age_diagnosis_matrix: [], + }), + }); + return; + } + + if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }); + return; + } + + if (url.includes('/cases/districts') || url.includes('/districts')) { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }); + return; + } + + if (url.includes('/cases/trend') || url.includes('/cases')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [], total: 0 }), + }); + return; + } + + // Default fallback — safe empty shape. + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [], items: [], total: 0 }), + }); + }); +} + +test.describe('Performance — /overview under emulated Fast 3G', () => { + test('LCP and route-transition are measured and reported', async ({ page }, testInfo) => { + // Fast-3G throttling makes the cold asset-graph download slow; the default 30s + // test budget can be eaten by the initial /overview load alone. Give the whole + // measurement flow generous headroom — this bounds the harness, not the metrics. + test.setTimeout(120_000); + await seedAuthAndMockApi(page); + + // Install the LCP observer BEFORE any navigation so it captures the very + // first paint. buffered:true also replays entries emitted before observe(). + await page.addInitScript(() => { + (window as unknown as { __lcp: number }).__lcp = 0; + new PerformanceObserver((list) => { + const entries = list.getEntries(); + (window as unknown as { __lcp: number }).__lcp = entries[entries.length - 1].startTime; + }).observe({ type: 'largest-contentful-paint', buffered: true }); + }); + + // Apply Fast 3G throttling via CDP before navigating. + const client = await page.context().newCDPSession(page); + await client.send('Network.enable'); + await client.send('Network.emulateNetworkConditions', FAST_3G); + + // --- LCP measurement ----------------------------------------------------- + // Generous wait: under Fast 3G the throttled JS bundle download dominates, so + // first meaningful paint legitimately exceeds the 5s default expect timeout. + // The LCP NUMBER we read is the real measured value — this timeout only bounds + // how long we'll wait for the asset graph to arrive before failing the harness. + await page.goto('/overview'); + await expect(page.locator(`[data-testid="${TESTIDS.kpiRow}"]`)).toBeVisible({ timeout: 30_000 }); + + // LCP finalizes on the last contentful paint; give the observer a beat to flush + // the entry for the kpi-row we just saw before reading it. + await page.waitForTimeout(200); + const lcp = await page.evaluate(() => (window as unknown as { __lcp: number }).__lcp); + + // --- Route-transition measurement --------------------------------------- + // Expand the 监测 module if its NavLink is collapsed, then click it. + const railSel = `[data-testid="${TESTIDS.sidebarRail}"]`; + const navMonitoring = page.locator(`${railSel} [data-testid="${TESTIDS.navMonitoring}"]`); + if (!(await navMonitoring.isVisible())) { + await page.locator(`${railSel} button`).filter({ hasText: '监测' }).first().click(); + } + await expect(navMonitoring).toBeVisible(); + + const t0 = await page.evaluate(() => performance.now()); + await navMonitoring.click(); + await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible({ + timeout: 30_000, + }); + const t1 = await page.evaluate(() => performance.now()); + const routeTransitionMs = t1 - t0; + + // --- Report -------------------------------------------------------------- + // eslint-disable-next-line no-console + console.log(`[perf] /overview LCP (Fast 3G): ${lcp.toFixed(0)} ms (target < ${LCP_TARGET_MS})`); + // eslint-disable-next-line no-console + console.log( + `[perf] /overview → /monitoring route transition: ${routeTransitionMs.toFixed(0)} ms (target < ${ROUTE_TRANSITION_TARGET_MS})` + ); + await testInfo.attach('perf-metrics', { + contentType: 'application/json', + body: JSON.stringify( + { + lcpMs: Math.round(lcp), + lcpTargetMs: LCP_TARGET_MS, + routeTransitionMs: Math.round(routeTransitionMs), + routeTransitionTargetMs: ROUTE_TRANSITION_TARGET_MS, + network: 'Fast 3G (emulated via CDP)', + }, + null, + 2 + ), + }); + + // --- Reported targets (NOT gates) --------------------------------------- + // Record each metric vs. its target as a passing/over annotation. A miss is + // visible in the report and console but does NOT fail the test. + const lcpVerdict = lcp < LCP_TARGET_MS ? 'within' : 'over'; + const routeVerdict = routeTransitionMs < ROUTE_TRANSITION_TARGET_MS ? 'within' : 'over'; + testInfo.annotations.push({ + type: 'perf-lcp', + description: `${Math.round(lcp)}ms (target ${LCP_TARGET_MS}ms — ${lcpVerdict})`, + }); + testInfo.annotations.push({ + type: 'perf-route-transition', + description: `${Math.round(routeTransitionMs)}ms (target ${ROUTE_TRANSITION_TARGET_MS}ms — ${routeVerdict})`, + }); + if (lcpVerdict === 'over' || routeVerdict === 'over') { + // eslint-disable-next-line no-console + console.warn( + `[perf] target exceeded (LCP ${lcpVerdict}, route ${routeVerdict}) — reported, not gated (dev-server throttled run).` + ); + } + + // --- Hard assertions (gates) -------------------------------------------- + // Only the measurement machinery is gated: the observer fired and the nav + // landed (page-monitoring visibility is already hard-asserted above). + expect(lcp, 'LCP observer should have recorded a paint').toBeGreaterThan(0); + expect(routeTransitionMs, 'route transition should elapse measurable time').toBeGreaterThan(0); + }); +}); diff --git a/frontend/e2e/responsive.spec.ts b/frontend/e2e/responsive.spec.ts new file mode 100644 index 0000000..a73ccb4 --- /dev/null +++ b/frontend/e2e/responsive.spec.ts @@ -0,0 +1,135 @@ +/** + * Phase-4 responsive acceptance: every analysis page must be usable at 375px + * (the narrowest mobile viewport in D4) with NO horizontal scroll. + * + * Auth + backend mocking mirror e2e/user-flows.spec.ts (seedAuthAndMockApi): + * seed localStorage['cbpoa_token'] so the login gate is skipped, then mock all + * /api/** calls so the suite runs hermetically without a live :8000 backend. + */ +import { test, expect, Page } from '@playwright/test'; +import { TESTIDS } from '../src/utils/testids'; + +// Each analysis route paired with its page-* mount testid. +const ANALYSIS_PAGES: Array<{ route: string; testid: string }> = [ + { route: '/analysis/trend', testid: TESTIDS.pageTrend }, + { route: '/analysis/district', testid: TESTIDS.pageDistrict }, + { route: '/analysis/insights', testid: TESTIDS.pageInsights }, + { route: '/analysis/reports', testid: TESTIDS.pageReports }, + { route: '/analysis/demographics', testid: TESTIDS.pageDemographics }, + { route: '/analysis/disease', testid: TESTIDS.pageDisease }, + { route: '/analysis/environment', testid: TESTIDS.pageEnvironment }, +]; + +/** Seed auth token and mock all /api/** calls before each page load. */ +async function seedAuthAndMockApi(page: Page) { + await page.addInitScript(() => { + localStorage.setItem('cbpoa_token', 'e2e-test-token'); + }); + + // Mock backend responses so the suite is hermetic — no live :8000 required. + // Each response must match the TypeScript interface shape; returning {} causes + // pages to throw when accessing expected array properties. + await page.route('/api/**', (route) => { + const url = route.request().url(); + + if (url.includes('/alerts')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ alerts: [], total: 0 }), + }); + return; + } + + if (url.includes('/history/aggregated')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ aggregations: [], total_records: 0 }), + }); + return; + } + + if (url.includes('/grids')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ type: 'FeatureCollection', features: [] }), + }); + return; + } + + if (url.includes('/cases/demographics')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + age_distribution: [], + gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } }, + age_diagnosis_matrix: [], + }), + }); + return; + } + + if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([]), + }); + return; + } + + if (url.includes('/cases/districts') || url.includes('/districts')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([]), + }); + return; + } + + if (url.includes('/cases/trend') || url.includes('/cases')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [], total: 0 }), + }); + return; + } + + // Default fallback — return a safe empty object. + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [], items: [], total: 0 }), + }); + }); +} + +test.describe('Responsive — analysis pages @375px', () => { + test.use({ viewport: { width: 375, height: 812 } }); + + test.beforeEach(async ({ page }) => { + await seedAuthAndMockApi(page); + }); + + for (const { route, testid } of ANALYSIS_PAGES) { + test(`${route} mounts and has no horizontal scroll at 375px`, async ({ page }) => { + await page.goto(route); + + // Page must mount. + await expect(page.locator(`[data-testid="${testid}"]`)).toBeVisible(); + + // No horizontal overflow: scrollWidth must not exceed clientWidth (+1px slack + // for sub-pixel rounding). + const noHorizontalScroll = await page.evaluate( + () => + document.documentElement.scrollWidth <= + document.documentElement.clientWidth + 1 + ); + expect(noHorizontalScroll, `${route} overflows horizontally at 375px`).toBe(true); + }); + } +}); diff --git a/frontend/e2e/roles.spec.ts b/frontend/e2e/roles.spec.ts new file mode 100644 index 0000000..fd34003 --- /dev/null +++ b/frontend/e2e/roles.spec.ts @@ -0,0 +1,133 @@ +/** + * Phase-3 acceptance tests: 视角/perspective switcher (D2 — frontend view-presets only). + * + * Roles are NOT access control: the switcher only changes the default landing page + + * granularity/filter presets. This suite verifies the switcher renders, selecting a role + * navigates to that role's default landing URL (with its query params), and the choice + * survives a reload (persisted to localStorage['cbpoa_role']). + * + * Auth + API mocking mirror e2e/user-flows.spec.ts so the suite runs hermetically. + */ +import { test, expect, Page } from '@playwright/test'; +import { TESTIDS } from '../src/utils/testids'; + +/** Seed auth token and mock all /api/** calls (shapes copied from user-flows.spec.ts). */ +async function seedAuthAndMockApi(page: Page) { + await page.addInitScript(() => { + localStorage.setItem('cbpoa_token', 'e2e-test-token'); + }); + + await page.route('/api/**', (route) => { + const url = route.request().url(); + + if (url.includes('/alerts')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ alerts: [], total: 0 }), + }); + return; + } + if (url.includes('/history/aggregated')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ aggregations: [], total_records: 0 }), + }); + return; + } + if (url.includes('/grids')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ type: 'FeatureCollection', features: [] }), + }); + return; + } + if (url.includes('/cases/demographics')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + age_distribution: [], + gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } }, + age_diagnosis_matrix: [], + }), + }); + return; + } + if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }); + return; + } + if (url.includes('/cases/districts') || url.includes('/districts')) { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }); + return; + } + if (url.includes('/cases/trend') || url.includes('/cases')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [], total: 0 }), + }); + return; + } + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [], items: [], total: 0 }), + }); + }); +} + +test.describe('视角/perspective switcher (Phase 3)', () => { + // Use a desktop viewport so the top bar renders the switcher inline. + test.use({ viewport: { width: 1280, height: 800 } }); + + test.beforeEach(async ({ page }) => { + await seedAuthAndMockApi(page); + }); + + test('perspective-switcher is visible in the top bar', async ({ page }) => { + await page.goto('/monitoring'); + await expect(page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`)).toBeVisible(); + }); + + test('selecting 厅领导 (official) navigates to /overview?granularity=district', async ({ page }) => { + await page.goto('/monitoring'); + const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`); + await expect(switcher).toBeVisible(); + + await switcher.selectOption('official'); + + await expect(page).toHaveURL(/\/overview/); + await expect(page).toHaveURL(/granularity=district/); + }); + + test('selecting 医生 (doctor) navigates to /alerts?view=cluster', async ({ page }) => { + await page.goto('/monitoring'); + const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`); + await expect(switcher).toBeVisible(); + + await switcher.selectOption('doctor'); + + await expect(page).toHaveURL(/\/alerts/); + await expect(page).toHaveURL(/view=cluster/); + }); + + test('selected role persists across reload (localStorage cbpoa_role)', async ({ page }) => { + await page.goto('/monitoring'); + const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`); + await switcher.selectOption('doctor'); + await expect(page).toHaveURL(/\/alerts/); + + // localStorage should now hold the chosen role. + const stored = await page.evaluate(() => localStorage.getItem('cbpoa_role')); + expect(stored).toBe('doctor'); + + await page.reload(); + + // After reload the switcher reflects the persisted role. + await expect(page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`)).toHaveValue('doctor'); + }); +}); diff --git a/frontend/e2e/user-flows.spec.ts b/frontend/e2e/user-flows.spec.ts index 013cf50..da6b478 100644 --- a/frontend/e2e/user-flows.spec.ts +++ b/frontend/e2e/user-flows.spec.ts @@ -1,242 +1,291 @@ /** - * US-007 + US-008: E2E user flow and UI state tests. - * Simulates real user workflows through the CBPOA system. + * Phase-1 acceptance tests: URL-based navigation, responsive layout, and core user flows. + * Rewrites the previous click-nav suite for react-router v6 URL navigation. + * + * Auth strategy: seed localStorage['cbpoa_token'] via addInitScript (App.tsx gates on + * token presence only; no server validation). Backend is mocked via page.route so the + * suite runs hermetically without a live :8000 backend. */ -import { test, expect } from '@playwright/test'; +import { test, expect, Page } from '@playwright/test'; +import { TESTIDS } from '../src/utils/testids'; -const BASE_URL = 'http://localhost:3000'; +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- -test.describe('认证流程 (Authentication Flow)', () => { - test('显示登录页面', async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForTimeout(1000); - // Should see login form or app (if cached token) - const isLogin = await page.locator('input').count(); - const isApp = await page.locator('nav').count(); - expect(isLogin > 0 || isApp > 0).toBeTruthy(); +/** Seed auth token and mock all /api/** calls before each page load. */ +async function seedAuthAndMockApi(page: Page) { + // Prevent login gate from appearing. + await page.addInitScript(() => { + localStorage.setItem('cbpoa_token', 'e2e-test-token'); }); - test('登录表单可交互', async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForTimeout(1000); + // Mock backend responses so the suite is hermetic — no live :8000 required. + // Each response must match the TypeScript interface shape; returning {} causes + // pages to throw when accessing expected array properties. + await page.route('/api/**', (route) => { + const url = route.request().url(); - const inputs = page.locator('input'); - const count = await inputs.count(); - - if (count >= 2) { - // Login page is shown - await inputs.first().fill('admin'); - await inputs.nth(1).fill('admin123'); - - const loginBtn = page.locator('button[type="submit"], button:has-text("登录"), button:has-text("Login")'); - const btnCount = await loginBtn.count(); - if (btnCount > 0) { - await loginBtn.first().click(); - await page.waitForTimeout(2000); - } + if (url.includes('/alerts')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ alerts: [], total: 0 }), + }); + return; } - // If no inputs, user is already logged in (token in localStorage) - }); -}); -test.describe('监测面板 (Monitoring Dashboard)', () => { - test('面板加载并显示统计卡片', async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForTimeout(3000); - - // Should show monitoring page by default - const statCards = page.locator('[class*="stat"], [class*="card"], [class*="Stat"]'); - const cardsCount = await statCards.count(); - - // Should see some content - const bodyText = await page.textContent('body'); - expect(bodyText).toBeTruthy(); - }); - - test('时间线控件可交互', async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForTimeout(3000); - - // Look for timeline controls - const playButton = page.locator('button:has-text("播放"), button[title*="play" i], button[class*="play" i]'); - const prevButton = page.locator('button:has-text("前一天"), button[title*="prev" i]'); - const nextButton = page.locator('button:has-text("后一天"), button[title*="next" i]'); - - if (await playButton.count() > 0) { - await playButton.first().click(); - await page.waitForTimeout(1000); + if (url.includes('/history/aggregated')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ aggregations: [], total_records: 0 }), + }); + return; } - }); - test('疾病筛选器可用', async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForTimeout(3000); - - const selects = page.locator('select, [role="combobox"], [class*="select" i], [class*="filter" i]'); - const count = await selects.count(); - expect(count >= 0).toBeTruthy(); - }); -}); - -test.describe('预警面板 (Alerts Dashboard)', () => { - test('导航到预警面板', async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForTimeout(2000); - - // Navigate to alerts - click sidebar link - const alertsLink = page.locator('a[href*="alert" i], button:has-text("预警"), button:has-text("告警"), span:has-text("预警"), span:has-text("告警")'); - if (await alertsLink.count() > 0) { - await alertsLink.first().click(); - await page.waitForTimeout(2000); + if (url.includes('/grids')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ type: 'FeatureCollection', features: [] }), + }); + return; } - }); - test('预警列表加载', async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForTimeout(2000); - - const alertsLink = page.locator('a[href*="alert" i], button:has-text("预警"), span:has-text("预警")'); - if (await alertsLink.count() > 0) { - await alertsLink.first().click(); - await page.waitForTimeout(3000); - - const bodyText = await page.textContent('body'); - expect(bodyText).toBeTruthy(); + // DemographicsResponse — used by DemographicAnalysis page. + if (url.includes('/cases/demographics')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + age_distribution: [], + gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } }, + age_diagnosis_matrix: [], + }), + }); + return; } - }); -}); -test.describe('趋势分析 (Trend Analysis)', () => { - test('导航到趋势分析页面', async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForTimeout(2000); - - const trendLink = page.locator('button:has-text("趋势"), span:has-text("趋势"), a[href*="trend" i]'); - if (await trendLink.count() > 0) { - await trendLink.first().click(); - await page.waitForTimeout(2000); + // DiseaseAnalysis calls: diagnosis-distribution, seasonality, districts. + if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([]), + }); + return; } - }); - test('趋势图渲染', async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForTimeout(2000); - - const trendLink = page.locator('button:has-text("趋势"), span:has-text("趋势")'); - if (await trendLink.count() > 0) { - await trendLink.first().click(); - await page.waitForTimeout(3000); - - // Recharts renders SVG charts - const svgCharts = page.locator('svg.recharts-surface'); - const chartCount = await svgCharts.count(); - expect(chartCount >= 0).toBeTruthy(); + if (url.includes('/cases/districts') || url.includes('/districts')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([]), + }); + return; } - }); -}); -test.describe('区县对比 (District Comparison)', () => { - test('导航到区县对比页面', async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForTimeout(2000); - - const districtLink = page.locator('button:has-text("区县"), button:has-text("对比"), span:has-text("区县")'); - if (await districtLink.count() > 0) { - await districtLink.first().click(); - await page.waitForTimeout(2000); + // Trend / time-series endpoints. + if (url.includes('/cases/trend') || url.includes('/cases')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [], total: 0 }), + }); + return; } - }); -}); -test.describe('报告中心 (Reports Center)', () => { - test('导航到报告中心', async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForTimeout(2000); - - const reportsLink = page.locator('button:has-text("报告"), span:has-text("报告"), a[href*="report" i]'); - if (await reportsLink.count() > 0) { - await reportsLink.first().click(); - await page.waitForTimeout(2000); - } - }); - - test('报告列表加载', async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForTimeout(2000); - - const reportsLink = page.locator('button:has-text("报告"), span:has-text("报告")'); - if (await reportsLink.count() > 0) { - await reportsLink.first().click(); - await page.waitForTimeout(3000); - - const bodyText = await page.textContent('body'); - expect(bodyText).toBeTruthy(); - } - }); -}); - -test.describe('UI 状态与错误处理 (UI States & Error Handling)', () => { - test('页面加载显示加载指示器而非白屏', async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForTimeout(500); - - const bodyHTML = await page.innerHTML('body'); - // Should have some content, even during loading - expect(bodyHTML.length).toBeGreaterThan(0); - }); - - test('侧边栏导航切换页面正常', async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForTimeout(2000); - - const navLinks = page.locator('nav a, nav button, [class*="side" i] a, [class*="side" i] button'); - const count = await navLinks.count(); - - if (count >= 2) { - await navLinks.first().click(); - await page.waitForTimeout(1000); - await navLinks.nth(1).click(); - await page.waitForTimeout(1000); - } - }); - - test('未出现明显 console 报错', async ({ page }) => { - const errors: string[] = []; - page.on('console', (msg) => { - if (msg.type() === 'error') { - errors.push(msg.text()); - } - }); - page.on('pageerror', (err) => { - errors.push(err.message); + // Default fallback — return a safe empty object. + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [], items: [], total: 0 }), }); + }); +} - await page.goto(BASE_URL); - await page.waitForTimeout(3000); +// --------------------------------------------------------------------------- +// URL-based navigation (react-router v6) +// --------------------------------------------------------------------------- - const filtered = errors.filter( - (e) => !e.includes('favicon') && !e.includes('404') && !e.includes('OLMap') +test.describe('URL-based navigation', () => { + test.beforeEach(async ({ page }) => { + await seedAuthAndMockApi(page); + }); + + test('deep-link: /analysis/disease mounts page-disease directly', async ({ page }) => { + await page.goto('/analysis/disease'); + await expect(page.locator(`[data-testid="${TESTIDS.pageDisease}"]`)).toBeVisible(); + }); + + test('refresh preserves page: reload on /analysis/disease keeps URL and mounts page-disease', async ({ + page, + }) => { + await page.goto('/analysis/disease'); + await expect(page.locator(`[data-testid="${TESTIDS.pageDisease}"]`)).toBeVisible(); + + await page.reload(); + + await expect(page).toHaveURL(/\/analysis\/disease/); + await expect(page.locator(`[data-testid="${TESTIDS.pageDisease}"]`)).toBeVisible(); + }); + + test('browser back: from /analysis/trend back to /monitoring restores page-monitoring', async ({ + page, + }) => { + await page.goto('/monitoring'); + await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible(); + + await page.goto('/analysis/trend'); + await expect(page.locator(`[data-testid="${TESTIDS.pageTrend}"]`)).toBeVisible(); + + await page.goBack(); + + await expect(page).toHaveURL(/\/monitoring/); + await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible(); + }); + + test('NavLink click updates URL to /alerts and mounts page-alerts', async ({ page }) => { + // Start on /monitoring. The SideNav collapses all modules except the active one, + // so nav-alerts (inside the "预警" module) is hidden behind a collapsed section. + // We must expand the "预警" module first by clicking its header button. + await page.goto('/monitoring'); + await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible(); + + // Expand the "预警" module section so nav-alerts NavLink becomes visible. + // Both sidebar-rail and app-drawer render a SideNav; scope to sidebar-rail to avoid + // strict-mode ambiguity (the app-drawer's copy is also in the DOM but off-screen). + await page + .locator(`[data-testid="${TESTIDS.sidebarRail}"] button`) + .filter({ hasText: '预警' }) + .click(); + await expect( + page.locator(`[data-testid="${TESTIDS.sidebarRail}"] [data-testid="${TESTIDS.navAlerts}"]`) + ).toBeVisible(); + + await page + .locator(`[data-testid="${TESTIDS.sidebarRail}"] [data-testid="${TESTIDS.navAlerts}"]`) + .click(); + + await expect(page).toHaveURL(/\/alerts/); + await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible(); + }); + + test('root / redirects to /monitoring', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveURL(/\/monitoring/); + await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible(); + }); + + test('unknown path redirects to /monitoring', async ({ page }) => { + await page.goto('/does-not-exist'); + await expect(page).toHaveURL(/\/monitoring/); + await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// Responsive layout +// --------------------------------------------------------------------------- + +test.describe('Responsive layout — mobile @375px', () => { + test.use({ viewport: { width: 375, height: 812 } }); + + test.beforeEach(async ({ page }) => { + await seedAuthAndMockApi(page); + }); + + test('hamburger is visible and sidebar-rail is hidden at 375px', async ({ page }) => { + await page.goto('/monitoring'); + await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible(); + + await expect(page.locator(`[data-testid="${TESTIDS.hamburger}"]`)).toBeVisible(); + await expect(page.locator(`[data-testid="${TESTIDS.sidebarRail}"]`)).not.toBeVisible(); + }); + + test('tapping hamburger slides app-drawer into viewport', async ({ page }) => { + await page.goto('/monitoring'); + await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible(); + + // Drawer should be off-screen (translate-x-full) before toggle. + const drawer = page.locator(`[data-testid="${TESTIDS.appDrawer}"]`); + await expect(drawer).not.toBeInViewport(); + + await page.locator(`[data-testid="${TESTIDS.hamburger}"]`).click(); + + // After toggle, drawer slides in and becomes visible in viewport. + await expect(drawer).toBeInViewport(); + }); + + test('no horizontal scroll on default route at 375px', async ({ page }) => { + await page.goto('/monitoring'); + await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible(); + + const noHorizontalScroll = await page.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth ); - expect(filtered).toHaveLength(0); + expect(noHorizontalScroll).toBe(true); }); }); -test.describe('响应式布局 (Responsive Layout)', () => { - test('移动端视口下不崩溃', async ({ page }) => { - await page.setViewportSize({ width: 375, height: 812 }); - await page.goto(BASE_URL); - await page.waitForTimeout(2000); +test.describe('Responsive layout — desktop @1280px', () => { + test.use({ viewport: { width: 1280, height: 800 } }); - const bodyText = await page.textContent('body'); - expect(bodyText).toBeTruthy(); + test.beforeEach(async ({ page }) => { + await seedAuthAndMockApi(page); }); - test('平板视口下正常显示', async ({ page }) => { - await page.setViewportSize({ width: 768, height: 1024 }); - await page.goto(BASE_URL); - await page.waitForTimeout(2000); + test('sidebar-rail is visible and hamburger is hidden at 1280px', async ({ page }) => { + await page.goto('/monitoring'); + await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible(); - const bodyText = await page.textContent('body'); - expect(bodyText).toBeTruthy(); + await expect(page.locator(`[data-testid="${TESTIDS.sidebarRail}"]`)).toBeVisible(); + await expect(page.locator(`[data-testid="${TESTIDS.hamburger}"]`)).not.toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// Core page loading +// --------------------------------------------------------------------------- + +test.describe('Core pages load via URL nav', () => { + test.beforeEach(async ({ page }) => { + await seedAuthAndMockApi(page); + }); + + test('/monitoring loads page-monitoring', async ({ page }) => { + await page.goto('/monitoring'); + await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible(); + }); + + test('/alerts loads page-alerts', async ({ page }) => { + await page.goto('/alerts'); + await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible(); + }); + + test('/analysis/trend loads page-trend', async ({ page }) => { + await page.goto('/analysis/trend'); + await expect(page.locator(`[data-testid="${TESTIDS.pageTrend}"]`)).toBeVisible(); + }); + + test('/analysis/district loads page-district', async ({ page }) => { + await page.goto('/analysis/district'); + await expect(page.locator(`[data-testid="${TESTIDS.pageDistrict}"]`)).toBeVisible(); + }); + + test('/analysis/reports loads page-reports', async ({ page }) => { + await page.goto('/analysis/reports'); + await expect(page.locator(`[data-testid="${TESTIDS.pageReports}"]`)).toBeVisible(); + }); + + test('/analysis/demographics loads page-demographics', async ({ page }) => { + await page.goto('/analysis/demographics'); + await expect(page.locator(`[data-testid="${TESTIDS.pageDemographics}"]`)).toBeVisible(); + }); + + test('/analysis/environment loads page-environment', async ({ page }) => { + await page.goto('/analysis/environment'); + await expect(page.locator(`[data-testid="${TESTIDS.pageEnvironment}"]`)).toBeVisible(); }); }); diff --git a/frontend/package.json b/frontend/package.json index 3b3c401..a8194e3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-leaflet": "^4.2.1", + "react-router-dom": "^6.30.4", "recharts": "^2.12.0", "zustand": "^4.5.0" }, diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 759e23a..2de4010 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -14,6 +14,16 @@ export default defineConfig({ projects: [ { name: 'chromium', + // Functional suite. Exclude the throttled perf spec so emulated Fast-3G + // latency never bleeds into (or slows) the normal acceptance run. + testIgnore: /perf\.spec\.ts/, + use: { ...devices['Desktop Chrome'] }, + }, + { + // Dedicated perf project — only perf.spec.ts runs here, under CDP network + // throttling. Kept separate so functional and perf measurements don't mix. + name: 'perf', + testMatch: /perf\.spec\.ts/, use: { ...devices['Desktop Chrome'] }, }, ], diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index d52ecb1..065fa83 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: react-leaflet: specifier: ^4.2.1 version: 4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-router-dom: + specifier: ^6.30.4 + version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) recharts: specifier: ^2.12.0 version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -385,6 +388,10 @@ packages: react: ^18.0.0 react-dom: ^18.0.0 + '@remix-run/router@1.23.3': + resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==} + engines: {node: '>=14.0.0'} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -1504,6 +1511,19 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} + react-router-dom@6.30.4: + resolution: {integrity: sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-router@6.30.4: + resolution: {integrity: sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-smooth@4.0.4: resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} peerDependencies: @@ -2157,6 +2177,8 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + '@remix-run/router@1.23.3': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.60.2': @@ -3233,6 +3255,18 @@ snapshots: react-refresh@0.17.0: {} + react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.3 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 6.30.4(react@18.3.1) + + react-router@6.30.4(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.3 + react: 18.3.1 + react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: fast-equals: 5.4.0 diff --git a/frontend/public/wuhan_districts.geojson b/frontend/public/wuhan_districts.geojson new file mode 100644 index 0000000..67a507e --- /dev/null +++ b/frontend/public/wuhan_districts.geojson @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12cf8f99e45b7faabca53cea2ba9fab112815163e6475c38a07af358e906feac +size 80098 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2209371..62c0bfe 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,19 +1,9 @@ -import { useEffect, useState, Component, ReactNode, Suspense, lazy, useCallback } from 'react'; -import { TopNav } from '@/components/TopNav'; -import { SideNav } from '@/components/SideNav'; +import { useEffect, useState, Component, ReactNode, useCallback } from 'react'; +import { BrowserRouter, Routes, Route, useRoutes } from 'react-router-dom'; +import { AppShell } from '@/components/AppShell'; import { useRiskStore } from '@/stores'; import { Login } from '@/pages/Login'; - -const MonitoringDashboard = lazy(() => import('@/pages/MonitoringDashboard').then(m => ({ default: m.MonitoringDashboard }))); -const AlertsDashboard = lazy(() => import('@/pages/AlertsDashboard').then(m => ({ default: m.AlertsDashboard }))); -const TrendAnalysis = lazy(() => import('@/pages/TrendAnalysis').then(m => ({ default: m.TrendAnalysis }))); -const DistrictComparison = lazy(() => import('@/pages/DistrictComparison').then(m => ({ default: m.DistrictComparison }))); -const Insights = lazy(() => import('@/pages/Insights').then(m => ({ default: m.Insights }))); -const ReportsCenter = lazy(() => import('@/pages/ReportsCenter').then(m => ({ default: m.ReportsCenter }))); -const DemographicAnalysis = lazy(() => import('@/pages/DemographicAnalysis').then(m => ({ default: m.DemographicAnalysis }))); -const DiseaseAnalysis = lazy(() => import('@/pages/DiseaseAnalysis').then(m => ({ default: m.DiseaseAnalysis }))); -const EnvironmentalHealth = lazy(() => import('@/pages/EnvironmentalHealth').then(m => ({ default: m.EnvironmentalHealth }))); - +import { appRoutes } from '@/routes'; interface Props { children: ReactNode; @@ -55,28 +45,25 @@ class ErrorBoundary extends Component { } } -function PageLoader() { - return ( -
-
加载中...
-
- ); +// 已登录:AppShell 提供布局骨架,子路由表渲染到其 。 +function AuthedApp({ onLogout }: { onLogout: () => void }) { + const element = useRoutes([ + { + element: , + children: appRoutes, + }, + ]); + return element; } function App() { - const [activePage, setActivePage] = useState('monitoring'); const [token, setToken] = useState(() => localStorage.getItem('cbpoa_token')); - const alerts = useRiskStore((s) => s.alerts); const fetchAlerts = useRiskStore((s) => s.fetchAlerts); useEffect(() => { if (token) fetchAlerts(); }, [fetchAlerts, token]); - const handlePageChange = useCallback((page: string) => { - setActivePage(page); - }, []); - const handleLogin = useCallback((newToken: string) => { setToken(newToken); }, []); @@ -86,41 +73,18 @@ function App() { setToken(null); }, []); - if (!token) { - return ( - - - - ); - } - return ( -
- - -
- - -
- }> - {activePage === 'monitoring' && } - {activePage === 'alerts' && } - {activePage === 'trend-analysis' && } - {activePage === 'district-comparison' && } - {activePage === 'insights' && } - {activePage === 'reports' && } - {activePage === 'demographics' && } - {activePage === 'disease' && } - {activePage === 'environment' && } - -
-
-
+ + {token ? ( + + ) : ( + // 鉴权门:无 token 时所有路由都进入登录页。 + + } /> + + )} +
); } diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..8cbdbaa --- /dev/null +++ b/frontend/src/components/AppShell.tsx @@ -0,0 +1,141 @@ +import { useState, useCallback, useEffect, useRef } from 'react'; +import { Outlet } from 'react-router-dom'; +import { TopNav } from '@/components/TopNav'; +import { SideNav } from '@/components/SideNav'; +import { RouteErrorBoundary } from '@/components/RouteErrorBoundary'; +import { useRiskStore } from '@/stores'; +import { TESTIDS } from '@/utils/testids'; + +interface AppShellProps { + onLogout?: () => void; +} + +// 收集容器内当前可聚焦的元素,供初始聚焦与焦点循环陷阱使用。 +function getFocusable(container: HTMLElement): HTMLElement[] { + return Array.from( + container.querySelectorAll( + 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) + ).filter((el) => el.offsetParent !== null || el === document.activeElement); +} + +// 仅负责布局骨架(顶栏 / 侧栏 / 内容区),不涉及路由匹配与鉴权。 +export function AppShell({ onLogout }: AppShellProps) { + const alerts = useRiskStore((s) => s.alerts); + const [drawerOpen, setDrawerOpen] = useState(false); + // 提升手风琴展开态:导轨与抽屉两份 SideNav 共享,保持同步。 + const [expandedNav, setExpandedNav] = useState('monitoring'); + const drawerRef = useRef(null); + + const openDrawer = useCallback(() => setDrawerOpen(true), []); + const closeDrawer = useCallback(() => setDrawerOpen(false), []); + + // 抽屉作为模态:ESC 关闭、锁定 body 滚动、焦点移入并在关闭后归还给汉堡。 + useEffect(() => { + if (!drawerOpen) return; + + const opener = document.activeElement as HTMLElement | null; + + // 锁定 body 滚动,关闭时还原原值。 + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + // 焦点移入抽屉(优先第一个可聚焦元素,否则聚焦抽屉容器本身)。 + const drawer = drawerRef.current; + const focusables = drawer ? getFocusable(drawer) : []; + (focusables[0] ?? drawer)?.focus(); + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + closeDrawer(); + return; + } + // 焦点循环陷阱:Tab 在抽屉内首尾元素之间循环。 + if (e.key === 'Tab' && drawer) { + const items = getFocusable(drawer); + if (items.length === 0) { + e.preventDefault(); + drawer.focus(); + return; + } + const first = items[0]; + const last = items[items.length - 1]; + const active = document.activeElement; + if (e.shiftKey && (active === first || active === drawer)) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } + } + }; + + document.addEventListener('keydown', onKeyDown); + + return () => { + document.removeEventListener('keydown', onKeyDown); + document.body.style.overflow = prevOverflow; + // 关闭后把焦点还给打开抽屉的元素(汉堡按钮),回退到按 testid 查询。 + const restoreTarget = + opener ?? + document.querySelector(`[data-testid="${TESTIDS.hamburger}"]`); + restoreTarget?.focus(); + }; + }, [drawerOpen, closeDrawer]); + + return ( +
+ + +
+ {/* lg 及以上:持久侧栏导轨 */} + + + {/* lg 以下:离屏抽屉 + 遮罩 */} + {drawerOpen && ( + +
+ ); +} diff --git a/frontend/src/components/CaseLocationMap.tsx b/frontend/src/components/CaseLocationMap.tsx index 4bf7d70..648460a 100644 --- a/frontend/src/components/CaseLocationMap.tsx +++ b/frontend/src/components/CaseLocationMap.tsx @@ -1,18 +1,62 @@ import { useEffect, useRef, useState, memo } from 'react'; import L from 'leaflet'; import { geocodedApi } from '@/services/api'; +import { TESTIDS } from '@/utils/testids'; import type { GeocodedCase } from '@/types'; const WUHAN_CENTER: [number, number] = [30.59, 114.31]; +// 视图模式: +// 'points' —— 个体病例点(默认,非医生视角)。逐病例渲染 circleMarker, +// 并在 DOM 中输出隐藏的 patient-point 镜像供 e2e 计数。 +// 'density' —— 聚合密度(医生视角,隐私不变量)。仅按行政区/街道聚合的密度圆, +// 不渲染任何个体点,patient-point 数量必须为 0。 +type CaseMapMode = 'points' | 'density'; + interface CaseLocationMapProps { height?: string; district?: string | null; street?: string | null; date?: string | null; + mode?: CaseMapMode; } -function CaseLocationMapComponent({ height = '400px', district = null, street = null, date = null }: CaseLocationMapProps) { +// 聚合中心:按 street(无则 district)分组,取经纬度均值 + 计数。 +interface DensityCluster { + key: string; + label: string; + latitude: number; + longitude: number; + count: number; +} + +function aggregateClusters(cases: GeocodedCase[]): DensityCluster[] { + const groups: Record = {}; + for (const c of cases) { + if (!c.latitude || !c.longitude) continue; + const key = `${c.district}/${c.street || ''}`; + const label = c.street ? `${c.district} ${c.street}` : c.district; + if (!groups[key]) groups[key] = { latSum: 0, lonSum: 0, count: 0, label }; + groups[key].latSum += c.latitude; + groups[key].lonSum += c.longitude; + groups[key].count += 1; + } + return Object.entries(groups).map(([key, g]) => ({ + key, + label: g.label, + latitude: g.latSum / g.count, + longitude: g.lonSum / g.count, + count: g.count, + })); +} + +function CaseLocationMapComponent({ + height = '400px', + district = null, + street = null, + date = null, + mode = 'points', +}: CaseLocationMapProps) { const mapRef = useRef(null); const mapInstanceRef = useRef(null); const layerRef = useRef(null); @@ -20,6 +64,10 @@ function CaseLocationMapComponent({ height = '400px', district = null, street = const resizeObserverRef = useRef(null); const [isLoading, setIsLoading] = useState(true); const [caseCount, setCaseCount] = useState(0); + // points 模式下的隐藏 DOM 镜像(每病例一项,供 e2e 对 patient-point 计数)。 + // density 模式下保持为空数组 —— 隐私不变量:医生视角下 patient-point 必须为 0。 + const [pointKeys, setPointKeys] = useState([]); + const [clusterCount, setClusterCount] = useState(0); useEffect(() => { if (!mapRef.current || mapInstanceRef.current) return; @@ -79,6 +127,42 @@ function CaseLocationMapComponent({ height = '400px', district = null, street = if (cancelledRef.current) return; + if (mode === 'density') { + // 医生视角:仅渲染聚合密度圆(按街道/区聚合),不渲染任何个体点。 + const clusters = aggregateClusters(unique); + const maxCount = clusters.reduce((m, c) => Math.max(m, c.count), 1); + + for (const cl of clusters) { + // 半径随计数缩放(8–28px),明确表达「密度」而非个体位置。 + const radius = 8 + Math.round((cl.count / maxCount) * 20); + const marker = L.circleMarker([cl.latitude, cl.longitude], { + radius, + fillColor: '#7c3aed', + fillOpacity: 0.35, + color: '#7c3aed', + weight: 1.5, + }); + marker.bindTooltip( + `
${cl.label}
病例数: ${cl.count}
`, + { direction: 'top', offset: [0, -4] } + ); + marker.addTo(layer); + } + + setClusterCount(clusters.length); + setCaseCount(unique.length); + setPointKeys([]); // 隐私不变量:density 下无个体点镜像 + setIsLoading(false); + + if (clusters.length > 0) { + const bounds = L.latLngBounds(clusters.map((c) => [c.latitude, c.longitude])); + map.fitBounds(bounds, { padding: [30, 30] }); + } + return; + } + + // points 模式(默认):逐病例渲染个体 circleMarker。 + const keys: string[] = []; for (const c of unique) { if (!c.latitude || !c.longitude) continue; @@ -100,9 +184,12 @@ function CaseLocationMapComponent({ height = '400px', district = null, street = ); marker.addTo(layer); + keys.push(c.case_id); } + setClusterCount(0); setCaseCount(unique.length); + setPointKeys(keys); // 隐藏 DOM 镜像供 e2e 计数 setIsLoading(false); // Fit bounds to case locations @@ -124,23 +211,41 @@ function CaseLocationMapComponent({ height = '400px', district = null, street = map.remove(); mapInstanceRef.current = null; }; - }, [district, street, date]); + }, [district, street, date, mode]); + + const isDensity = mode === 'density'; return ( -
+
{isLoading && (
加载病例位置...
)} - {!isLoading && ( + {!isLoading && !isDensity && (
{caseCount.toLocaleString()} 个病例位置 ● 住院 ● 门诊
)} + {!isLoading && isDensity && ( +
+ {clusterCount.toLocaleString()} 个聚合区域 + 按区域聚合密度(隐私保护) +
+ )} + {/* + 隐藏 DOM 镜像:points 模式下每病例输出一个 patient-point 节点,使 e2e 能对 + Leaflet canvas 之外的真实 DOM 做计数断言。density 模式下 pointKeys 恒为空, + 因此医生视角下 [data-testid=patient-point] 数量必为 0(隐私不变量)。 + */} +
); } diff --git a/frontend/src/components/CaseMap.tsx b/frontend/src/components/CaseMap.tsx index 6c5c430..2bb0e39 100644 --- a/frontend/src/components/CaseMap.tsx +++ b/frontend/src/components/CaseMap.tsx @@ -1,4 +1,5 @@ import { memo, useEffect, useRef, useState, useCallback } from 'react'; +import { Skeleton } from '@/components/ui'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { geocodedApi } from '@/services/api'; @@ -340,7 +341,7 @@ function CaseMapComponent({ height = '480px' }: CaseMapProps) {
{isLoading ? ( - 数据加载中... + ) : error ? ( 加载失败: {error} ) : ( diff --git a/frontend/src/components/RoleRedirect.tsx b/frontend/src/components/RoleRedirect.tsx new file mode 100644 index 0000000..e13c02f --- /dev/null +++ b/frontend/src/components/RoleRedirect.tsx @@ -0,0 +1,13 @@ +import { Navigate } from 'react-router-dom'; +import { useSessionStore } from '@/stores'; +import { roleDefaultPath } from '@/utils/roleViews'; + +/** + * 根据当前视角(role)把裸路径 `/` 重定向到该视角的默认落地页。 + * D2:纯前端视图预设 —— role 只决定默认落地页,不是访问控制。 + * 角色来源被隔离在 sessionStore 的 getRoleSource() 接缝里。 + */ +export function RoleRedirect(): JSX.Element { + const role = useSessionStore((s) => s.role); + return ; +} diff --git a/frontend/src/components/RouteErrorBoundary.tsx b/frontend/src/components/RouteErrorBoundary.tsx new file mode 100644 index 0000000..cab69e5 --- /dev/null +++ b/frontend/src/components/RouteErrorBoundary.tsx @@ -0,0 +1,45 @@ +import { Component, ReactNode } from 'react'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; +} + +// 路由级错误边界:单个页面(含懒加载 chunk)崩溃时只降级内容区, +// 保留外层骨架(顶栏 + 侧栏),避免整页白屏。 +export class RouteErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError() { + return { hasError: true }; + } + + private reset = () => { + this.setState({ hasError: false }); + }; + + render() { + if (this.state.hasError) { + return ( +
+
+
此页面加载失败
+ +
+
+ ); + } + return this.props.children; + } +} diff --git a/frontend/src/components/SideNav.tsx b/frontend/src/components/SideNav.tsx index e071c23..081e92c 100644 --- a/frontend/src/components/SideNav.tsx +++ b/frontend/src/components/SideNav.tsx @@ -1,76 +1,97 @@ import { useState } from 'react'; +import { NavLink, useLocation } from 'react-router-dom'; +import { TESTIDS } from '@/utils/testids'; interface SideNavProps { - activePage: string; - onPageChange: (page: string) => void; alertCount?: number; + // 抽屉模式下点击导航项后关闭抽屉(持久侧栏可不传)。 + onNavigate?: () => void; + // 受控的展开手风琴分组:由 AppShell 提供时,导轨与抽屉两份实例保持同步。 + // 不传则回退到内部 state,向后兼容独立使用。 + expanded?: string | null; + onExpandedChange?: (moduleId: string | null) => void; } -const modules: { id: string; label: string; icon: React.ReactNode; items: { id: string; label: string }[] }[] = [ +interface NavItem { + to: string; + label: string; + testid: string; +} + +const modules: { id: string; label: string; icon: React.ReactNode; items: NavItem[] }[] = [ { id: 'monitoring', label: '监测', icon: ( - + ), - items: [ - { id: 'monitoring', label: '监测面板' }, - ], + items: [{ to: '/monitoring', label: '监测面板', testid: TESTIDS.navMonitoring }], }, { id: 'alert', label: '预警', icon: ( - + ), - items: [ - { id: 'alerts', label: '预警地图' }, - ], + items: [{ to: '/alerts', label: '预警地图', testid: TESTIDS.navAlerts }], }, { id: 'analysis', label: '分析', icon: ( - + ), items: [ - { id: 'trend-analysis', label: '趋势分析' }, - { id: 'district-comparison', label: '区域对比' }, - { id: 'insights', label: '智能洞察' }, - { id: 'reports', label: '报表中心' }, - { id: 'demographics', label: '人群分析' }, - { id: 'disease', label: '疾病分析' }, - { id: 'environment', label: '环境健康' }, + { to: '/overview', label: '总览', testid: TESTIDS.navOverview }, + { to: '/analysis/trend', label: '趋势分析', testid: TESTIDS.navTrend }, + { to: '/analysis/district', label: '区域对比', testid: TESTIDS.navDistrict }, + { to: '/analysis/insights', label: '智能洞察', testid: TESTIDS.navInsights }, + { to: '/analysis/reports', label: '报表中心', testid: TESTIDS.navReports }, + { to: '/analysis/demographics', label: '人群分析', testid: TESTIDS.navDemographics }, + { to: '/analysis/disease', label: '疾病分析', testid: TESTIDS.navDisease }, + { to: '/analysis/clinical', label: '临床分析', testid: TESTIDS.navClinical }, + { to: '/analysis/environment', label: '环境健康', testid: TESTIDS.navEnvironment }, ], }, ]; export function SideNav({ - activePage, - onPageChange, alertCount = 0, + onNavigate, + expanded: expandedProp, + onExpandedChange, }: SideNavProps) { - const [expanded, setExpanded] = useState('monitoring'); + const location = useLocation(); - const handleItemClick = (moduleId: string, itemId: string) => { - setExpanded(moduleId); - onPageChange(itemId); + // 当前路径命中的模块默认展开。 + const moduleForPath = (pathname: string) => + modules.find((m) => m.items.some((item) => pathname.startsWith(item.to)))?.id ?? 'monitoring'; + + // 受控/非受控双模式:父级传入 expanded 时由父级管理,否则回退内部 state。 + const [internalExpanded, setInternalExpanded] = useState(() => + moduleForPath(location.pathname) + ); + const isControlled = expandedProp !== undefined; + const expanded = isControlled ? expandedProp : internalExpanded; + const setExpanded = (next: string | null) => { + if (isControlled) onExpandedChange?.(next); + else setInternalExpanded(next); }; const isActiveModule = (moduleId: string) => { - const module = modules.find(m => m.id === moduleId); + const module = modules.find((m) => m.id === moduleId); if (!module) return false; - return module.items.some(item => item.id === activePage); + return module.items.some((item) => location.pathname.startsWith(item.to)); }; return ( -
))} - + ); } diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index d661a2d..a04ab48 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -1,7 +1,15 @@ import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { TESTIDS } from '@/utils/testids'; +import { useSessionStore, ROLES, type Role } from '@/stores/sessionStore'; +import { ROLE_LABELS, roleDefaultPath } from '@/utils/roleViews'; interface TopNavProps { onLogout?: () => void; + // 移动端汉堡按钮:切换侧栏抽屉。 + onToggleMenu?: () => void; + // 抽屉是否展开(用于汉堡按钮的 aria-expanded)。 + isMenuOpen?: boolean; } function Clock() { @@ -13,14 +21,61 @@ function Clock() { return {time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}; } -export function TopNav({ onLogout }: TopNavProps) { +// 视角切换器:纯前端视图预设(D2)。刻意标注「视角」而非「权限」——不是访问控制。 +// 切换时持久化角色并跳转到该视角的默认落地页。 +function PerspectiveSwitcher() { + const role = useSessionStore((s) => s.role); + const setRole = useSessionStore((s) => s.setRole); + const navigate = useNavigate(); + + const handleChange = (next: Role) => { + setRole(next); + navigate(roleDefaultPath(next)); + }; return ( -