Merge pull request 'feat/ux-modernization' (#1) from feat/ux-modernization into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -14,6 +14,8 @@ cache/
|
|||||||
logs/
|
logs/
|
||||||
mlruns/
|
mlruns/
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
frontend/playwright-report/
|
||||||
|
frontend/test-results/
|
||||||
*Zone.Identifier
|
*Zone.Identifier
|
||||||
# Transcription processing intermediates
|
# Transcription processing intermediates
|
||||||
Outputs/transcript/chunks/
|
Outputs/transcript/chunks/
|
||||||
|
|||||||
@@ -25,12 +25,42 @@ _cache: dict[str, Optional[pd.DataFrame | datetime]] = {
|
|||||||
"outpatient": None,
|
"outpatient": None,
|
||||||
"inpatient": None,
|
"inpatient": None,
|
||||||
"combined": None,
|
"combined": None,
|
||||||
|
"cases_by_district_daily": None,
|
||||||
"loaded_at": None,
|
"loaded_at": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Guards the lazy build so concurrent callers don't duplicate the load/concat.
|
# Guards the lazy build so concurrent callers don't duplicate the load/concat.
|
||||||
_load_lock = threading.RLock()
|
_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 district mapping
|
||||||
WUHAN_DISTRICTS = {
|
WUHAN_DISTRICTS = {
|
||||||
'江岸区': ['江岸'],
|
'江岸区': ['江岸'],
|
||||||
@@ -170,3 +200,40 @@ def get_inpatient_data() -> pd.DataFrame:
|
|||||||
"""Return the cached inpatient dataframe"""
|
"""Return the cached inpatient dataframe"""
|
||||||
load_data()
|
load_data()
|
||||||
return _cache["inpatient"] # type: ignore[return-value]
|
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()
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from logging_config import setup_logging
|
|||||||
from middleware.request_logger import RequestLoggerMiddleware
|
from middleware.request_logger import RequestLoggerMiddleware
|
||||||
from auth.router import router as auth_router
|
from auth.router import router as auth_router
|
||||||
from auth.service import seed_default_admin
|
from auth.service import seed_default_admin
|
||||||
from routers import risk, alerts, analysis, insights, reports, cases, geocoded, grid, chat, environment
|
from routers import risk, alerts, analysis, insights, reports, cases, geocoded, grid, chat, environment, statistics
|
||||||
|
|
||||||
|
|
||||||
setup_logging()
|
setup_logging()
|
||||||
@@ -59,6 +59,7 @@ app.include_router(geocoded.router)
|
|||||||
app.include_router(grid.router)
|
app.include_router(grid.router)
|
||||||
app.include_router(chat.router)
|
app.include_router(chat.router)
|
||||||
app.include_router(environment.router)
|
app.include_router(environment.router)
|
||||||
|
app.include_router(statistics.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import pandas as pd
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT, WUHAN_BOUNDS, LAT_STEP, LON_STEP
|
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.date_helpers import get_latest_date
|
||||||
from utils.geojson import parse_geojson_file, load_districts
|
from utils.geojson import parse_geojson_file, load_districts
|
||||||
from utils.geo import point_in_polygon
|
from utils.geo import point_in_polygon
|
||||||
@@ -179,18 +180,16 @@ def _district_avg_aqi() -> dict:
|
|||||||
def _district_total_cases() -> dict:
|
def _district_total_cases() -> dict:
|
||||||
"""Real total recorded cases per district from cases_by_district_daily.
|
"""Real total recorded cases per district from cases_by_district_daily.
|
||||||
|
|
||||||
District labels in the case file are inconsistent ("武昌" vs "武昌区"),
|
District labels are normalized to the canonical 13 区-suffixed names at the
|
||||||
so names are normalized by stripping the "区" suffix and summed, then
|
data-access boundary (data.case_loader), so this is a plain per-district
|
||||||
keyed by the canonical mapping name (with "区"). Returns {district: cases}.
|
sum. Returns {district: cases}.
|
||||||
"""
|
"""
|
||||||
path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
|
try:
|
||||||
if not path.exists():
|
df = load_cases_by_district_daily()
|
||||||
|
except FileNotFoundError:
|
||||||
return {}
|
return {}
|
||||||
df = pd.read_parquet(path, columns=["district", "total_cases"])
|
by_district = df.groupby("district")["total_cases"].sum()
|
||||||
df = df.copy()
|
return {str(d): int(v) for d, v in by_district.items()}
|
||||||
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()}
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=8)
|
@lru_cache(maxsize=8)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from models import (
|
|||||||
MultiDayPredictionRequest,
|
MultiDayPredictionRequest,
|
||||||
MultiDayPredictionResponse,
|
MultiDayPredictionResponse,
|
||||||
)
|
)
|
||||||
|
from data.case_loader import load_cases_by_district_daily
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["grid"])
|
router = APIRouter(prefix="/api", tags=["grid"])
|
||||||
|
|
||||||
@@ -43,14 +44,13 @@ def _compute_historical_aggregation(
|
|||||||
) -> HistoricalAggregationResponse:
|
) -> HistoricalAggregationResponse:
|
||||||
"""Run the full pandas aggregation pipeline (called in thread pool)."""
|
"""Run the full pandas aggregation pipeline (called in thread pool)."""
|
||||||
try:
|
try:
|
||||||
cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet")
|
cases_df = load_cases_by_district_daily()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return HistoricalAggregationResponse(
|
return HistoricalAggregationResponse(
|
||||||
aggregations=[], total_records=0,
|
aggregations=[], total_records=0,
|
||||||
date_range=(start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")),
|
date_range=(start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")),
|
||||||
timestamp=datetime.now().isoformat(),
|
timestamp=datetime.now().isoformat(),
|
||||||
)
|
)
|
||||||
cases_df = cases_df.copy()
|
|
||||||
cases_df['date'] = pd.to_datetime(cases_df['date'])
|
cases_df['date'] = pd.to_datetime(cases_df['date'])
|
||||||
|
|
||||||
filtered_cases = cases_df[
|
filtered_cases = cases_df[
|
||||||
@@ -196,7 +196,7 @@ def _grids_geojson_body(date: str, district: Optional[str], risk_level: Optional
|
|||||||
if district:
|
if district:
|
||||||
merged = merged[merged['district_name'].str.contains(district.replace('区', ''), na=False, regex=False)]
|
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['date'] = pd.to_datetime(cases_df['date']).dt.strftime('%Y-%m-%d')
|
||||||
cases_df = cases_df[cases_df['date'] == date]
|
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']
|
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'])
|
cases_df['date'] = pd.to_datetime(cases_df['date'])
|
||||||
|
|
||||||
end_date = datetime.now()
|
end_date = datetime.now()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from pydantic import BaseModel, Field
|
|||||||
from typing import Dict, List, Literal
|
from typing import Dict, List, Literal
|
||||||
|
|
||||||
from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT
|
from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT
|
||||||
|
from data.case_loader import load_cases_by_district_daily
|
||||||
from models import (
|
from models import (
|
||||||
InsightsResponse,
|
InsightsResponse,
|
||||||
InsightTrend,
|
InsightTrend,
|
||||||
@@ -491,22 +492,22 @@ async def get_insights_cards():
|
|||||||
|
|
||||||
cases_path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
|
cases_path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
|
||||||
if cases_path.exists():
|
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_case_date = cases_df["date"].max()
|
||||||
latest_cases = cases_df[cases_df["date"] == latest_case_date].copy()
|
latest_cases = cases_df[cases_df["date"] == latest_case_date]
|
||||||
latest_cases["base_district"] = latest_cases["district"].str.replace("区", "")
|
district_daily = latest_cases.groupby("district")["total_cases"].sum().sort_values(ascending=False)
|
||||||
district_daily = latest_cases.groupby("base_district")["total_cases"].sum().sort_values(ascending=False)
|
|
||||||
total_daily = int(district_daily.sum())
|
total_daily = int(district_daily.sum())
|
||||||
top_name = district_daily.index[0]
|
top_name = district_daily.index[0]
|
||||||
top_val = int(district_daily.iloc[0])
|
top_val = int(district_daily.iloc[0])
|
||||||
num_districts = len(district_daily)
|
num_districts = len(district_daily)
|
||||||
|
|
||||||
week_ago = latest_case_date - pd.Timedelta(days=6)
|
week_ago = latest_case_date - pd.Timedelta(days=6)
|
||||||
week_cases = cases_df[cases_df["date"] >= week_ago].copy()
|
week_cases = cases_df[cases_df["date"] >= week_ago]
|
||||||
week_cases["base_district"] = week_cases["district"].str.replace("区", "")
|
|
||||||
daily_totals = week_cases.groupby("date")["total_cases"].sum()
|
daily_totals = week_cases.groupby("date")["total_cases"].sum()
|
||||||
avg_daily = int(daily_totals.mean())
|
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])
|
week_top_val = int(week_district.iloc[0])
|
||||||
|
|
||||||
date_str = latest_case_date.strftime("%m月%d日")
|
date_str = latest_case_date.strftime("%m月%d日")
|
||||||
@@ -515,8 +516,8 @@ async def get_insights_cards():
|
|||||||
title=f"日病例统计 ({date_str})",
|
title=f"日病例统计 ({date_str})",
|
||||||
description=(
|
description=(
|
||||||
f"最近统计日({date_str})全市{num_districts}个区共记录{total_daily}例儿童呼吸道疾病病例,"
|
f"最近统计日({date_str})全市{num_districts}个区共记录{total_daily}例儿童呼吸道疾病病例,"
|
||||||
f"{top_name}区{top_val}例为当日最高。近7日日均{avg_daily}例,"
|
f"{top_name}{top_val}例为当日最高。近7日日均{avg_daily}例,"
|
||||||
f"{week_district.index[0]}区累计{week_top_val}例居首。"
|
f"{week_district.index[0]}累计{week_top_val}例居首。"
|
||||||
),
|
),
|
||||||
type="warning",
|
type="warning",
|
||||||
metric="日病例",
|
metric="日病例",
|
||||||
|
|||||||
566
backend/routers/statistics.py
Normal file
566
backend/routers/statistics.py
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
"""
|
||||||
|
统计分析 API 路由 (prefix /api/stats)
|
||||||
|
|
||||||
|
为前端统计仪表盘提供聚合后的临床、症状、发病率、环境相关性和时序数据。
|
||||||
|
所有数据从 processed/*.parquet 计算得出(文件型后端,无数据库)。
|
||||||
|
|
||||||
|
设计原则:
|
||||||
|
- 模块级缓存载入的 parquet(与其他路由一致)。
|
||||||
|
- 仅返回聚合结果,绝不直接 dump 原始行,保持 payload 小。
|
||||||
|
- 每个端点用 try/except 包裹,失败时返回合法的空结构(绝不让 UI 收到 500)。
|
||||||
|
- pandas 计算放入线程池 (asyncio.to_thread),避免阻塞事件循环。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import glob
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, cast
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from fastapi import APIRouter, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from data.case_loader import (
|
||||||
|
get_inpatient_data,
|
||||||
|
get_outpatient_data,
|
||||||
|
get_combined_data,
|
||||||
|
load_cases_by_district_daily,
|
||||||
|
normalize_district,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("cbpoa.statistics")
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/stats", tags=["statistics"])
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||||
|
PROCESSED_DIR = PROJECT_ROOT / "processed"
|
||||||
|
|
||||||
|
# ============== Module-level caches ==============
|
||||||
|
|
||||||
|
_cache: dict[str, object] = {}
|
||||||
|
_cache_lock = threading.RLock()
|
||||||
|
|
||||||
|
# 7 个污染物(与 feature snapshot 列名一致)
|
||||||
|
POLLUTANTS = ["AQI", "PM25", "PM10", "SO2", "NO2", "O3", "CO"]
|
||||||
|
|
||||||
|
# 主诉症状关键词(固定列表,子串匹配)。注意顺序:更具体的在前避免被宽泛词吞掉,
|
||||||
|
# 但因为是独立子串计数,顺序不影响结果,仅为可读性分组。
|
||||||
|
SYMPTOM_KEYWORDS = [
|
||||||
|
"发热", "咳嗽", "咳", "喘息", "喘", "流涕", "鼻塞", "咽痛", "咽喉",
|
||||||
|
"痰", "气促", "呼吸困难", "肺炎", "复诊", "随诊", "复查",
|
||||||
|
"腹泻", "呕吐", "头痛", "乏力", "胸闷", "鼻涕", "发烧", "感冒",
|
||||||
|
]
|
||||||
|
REVISIT_KEYWORDS = ["复诊", "随诊", "复查"]
|
||||||
|
|
||||||
|
|
||||||
|
def _district_population() -> pd.Series:
|
||||||
|
"""各区人口(population_density 求和,按 grid_district_mapping 归属)。
|
||||||
|
|
||||||
|
返回 index 为规范化区名(13)、值为人口的 Series。结果缓存。
|
||||||
|
"""
|
||||||
|
cached = _cache.get("district_population")
|
||||||
|
if cached is not None:
|
||||||
|
return cast(pd.Series, cached)
|
||||||
|
with _cache_lock:
|
||||||
|
cached = _cache.get("district_population")
|
||||||
|
if cached is not None:
|
||||||
|
return cast(pd.Series, cached)
|
||||||
|
mapping = pd.read_parquet(PROCESSED_DIR / "grid_district_mapping.parquet")
|
||||||
|
grid = pd.read_parquet(
|
||||||
|
PROCESSED_DIR / "grid_100m_with_dem_pop.parquet",
|
||||||
|
columns=["grid_id", "population_density"],
|
||||||
|
)
|
||||||
|
joined = mapping.merge(grid, on="grid_id", how="inner")
|
||||||
|
joined = joined.dropna(subset=["district_name"])
|
||||||
|
joined["district_name"] = joined["district_name"].map(normalize_district)
|
||||||
|
pop = joined.groupby("district_name")["population_density"].sum()
|
||||||
|
_cache["district_population"] = pop
|
||||||
|
return pop
|
||||||
|
|
||||||
|
|
||||||
|
def _feature_snapshots() -> pd.DataFrame:
|
||||||
|
"""合并所有可用的 features_*.parquet 快照(缓存)。
|
||||||
|
|
||||||
|
用于污染物 vs 病例的相关性分析。每个快照按格点给出污染物 + 病例计数 + 区。
|
||||||
|
"""
|
||||||
|
cached = _cache.get("features")
|
||||||
|
if cached is not None:
|
||||||
|
return cast(pd.DataFrame, cached)
|
||||||
|
with _cache_lock:
|
||||||
|
cached = _cache.get("features")
|
||||||
|
if cached is not None:
|
||||||
|
return cast(pd.DataFrame, cached)
|
||||||
|
paths = sorted(glob.glob(str(PROCESSED_DIR / "features_*.parquet")))
|
||||||
|
if not paths:
|
||||||
|
df = pd.DataFrame(
|
||||||
|
columns=POLLUTANTS + ["outpatient_count", "inpatient_count", "total_cases", "district"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
frames = [pd.read_parquet(p) for p in paths]
|
||||||
|
df = pd.concat(frames, ignore_index=True)
|
||||||
|
_cache["features"] = df
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Response Models ==============
|
||||||
|
|
||||||
|
|
||||||
|
class KeyValueCount(BaseModel):
|
||||||
|
bin_label: str
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class InpatientKpis(BaseModel):
|
||||||
|
total_admissions: int
|
||||||
|
median_los_days: float
|
||||||
|
cure_rate: float
|
||||||
|
emergency_admit_ratio: float
|
||||||
|
|
||||||
|
|
||||||
|
class LosByDisease(BaseModel):
|
||||||
|
diagnosis: str
|
||||||
|
p25: float
|
||||||
|
median: float
|
||||||
|
p75: float
|
||||||
|
n: int
|
||||||
|
|
||||||
|
|
||||||
|
class LabelCount(BaseModel):
|
||||||
|
outcome: Optional[str] = None
|
||||||
|
route: Optional[str] = None
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class OutcomeCount(BaseModel):
|
||||||
|
outcome: str
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class RouteCount(BaseModel):
|
||||||
|
route: str
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class BmiByAge(BaseModel):
|
||||||
|
age_band: str
|
||||||
|
p25: float
|
||||||
|
median: float
|
||||||
|
p75: float
|
||||||
|
n: int
|
||||||
|
|
||||||
|
|
||||||
|
class InpatientClinicalResponse(BaseModel):
|
||||||
|
kpis: InpatientKpis
|
||||||
|
los_histogram: list[KeyValueCount]
|
||||||
|
los_by_disease: list[LosByDisease]
|
||||||
|
outcome_counts: list[OutcomeCount]
|
||||||
|
admission_route_counts: list[RouteCount]
|
||||||
|
bmi_by_age_band: list[BmiByAge]
|
||||||
|
|
||||||
|
|
||||||
|
class SymptomItem(BaseModel):
|
||||||
|
keyword: str
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class SymptomsResponse(BaseModel):
|
||||||
|
symptoms: list[SymptomItem]
|
||||||
|
revisit_ratio: float
|
||||||
|
|
||||||
|
|
||||||
|
class IncidenceItem(BaseModel):
|
||||||
|
district: str
|
||||||
|
total_cases: int
|
||||||
|
population: float
|
||||||
|
rate_per_10k: float
|
||||||
|
|
||||||
|
|
||||||
|
class IncidenceResponse(BaseModel):
|
||||||
|
districts: list[IncidenceItem]
|
||||||
|
|
||||||
|
|
||||||
|
class CorrItem(BaseModel):
|
||||||
|
pollutant: str
|
||||||
|
corr_with_cases: float
|
||||||
|
|
||||||
|
|
||||||
|
class ScatterPoint(BaseModel):
|
||||||
|
pm25: float
|
||||||
|
aqi: float
|
||||||
|
cases: float
|
||||||
|
|
||||||
|
|
||||||
|
class PairwiseCorr(BaseModel):
|
||||||
|
a: str
|
||||||
|
b: str
|
||||||
|
corr: float
|
||||||
|
|
||||||
|
|
||||||
|
class EnvCorrelationResponse(BaseModel):
|
||||||
|
correlation_matrix: list[CorrItem]
|
||||||
|
scatter: list[ScatterPoint]
|
||||||
|
pollutant_pairwise: list[PairwiseCorr]
|
||||||
|
|
||||||
|
|
||||||
|
class WeekdayPoint(BaseModel):
|
||||||
|
weekday: str
|
||||||
|
outpatient: int
|
||||||
|
inpatient: int
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class MonthYearPoint(BaseModel):
|
||||||
|
year: int
|
||||||
|
month: int
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class YoYPoint(BaseModel):
|
||||||
|
period: str
|
||||||
|
current: int
|
||||||
|
previous: int
|
||||||
|
growth_pct: float
|
||||||
|
|
||||||
|
|
||||||
|
class TemporalResponse(BaseModel):
|
||||||
|
weekday: list[WeekdayPoint]
|
||||||
|
month_year: list[MonthYearPoint]
|
||||||
|
yoy: list[YoYPoint]
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Helpers ==============
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_inpatient_clinical() -> InpatientClinicalResponse:
|
||||||
|
return InpatientClinicalResponse(
|
||||||
|
kpis=InpatientKpis(
|
||||||
|
total_admissions=0, median_los_days=0.0,
|
||||||
|
cure_rate=0.0, emergency_admit_ratio=0.0,
|
||||||
|
),
|
||||||
|
los_histogram=[], los_by_disease=[], outcome_counts=[],
|
||||||
|
admission_route_counts=[], bmi_by_age_band=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_float(v) -> float:
|
||||||
|
try:
|
||||||
|
f = float(v)
|
||||||
|
if np.isnan(f) or np.isinf(f):
|
||||||
|
return 0.0
|
||||||
|
return round(f, 4)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Endpoint 1: inpatient clinical ==============
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_inpatient_clinical() -> InpatientClinicalResponse:
|
||||||
|
df = get_inpatient_data().copy()
|
||||||
|
if df.empty:
|
||||||
|
return _empty_inpatient_clinical()
|
||||||
|
|
||||||
|
# LOS = (出院日期 - 入院日期).days, valid 0-60
|
||||||
|
in_date = pd.to_datetime(df["入院日期"], errors="coerce")
|
||||||
|
out_date = pd.to_datetime(df["出院日期"], errors="coerce")
|
||||||
|
df["los"] = (out_date - in_date).dt.days
|
||||||
|
df_los = df[(df["los"] >= 0) & (df["los"] <= 60)]
|
||||||
|
|
||||||
|
total = len(df)
|
||||||
|
median_los = float(df_los["los"].median()) if len(df_los) else 0.0
|
||||||
|
|
||||||
|
outcome = df["出院情况"].fillna("未知")
|
||||||
|
cure_n = int(outcome.isin(["治愈", "好转"]).sum())
|
||||||
|
cure_rate = cure_n / total if total else 0.0
|
||||||
|
|
||||||
|
route = df["入院途径"].fillna("未知")
|
||||||
|
emerg_n = int((route == "急诊").sum())
|
||||||
|
emerg_ratio = emerg_n / total if total else 0.0
|
||||||
|
|
||||||
|
kpis = InpatientKpis(
|
||||||
|
total_admissions=total,
|
||||||
|
median_los_days=round(median_los, 2),
|
||||||
|
cure_rate=round(cure_rate, 4),
|
||||||
|
emergency_admit_ratio=round(emerg_ratio, 4),
|
||||||
|
)
|
||||||
|
|
||||||
|
# LOS histogram bins: 0,1,2,3,4,5,6,7,8-14,15+
|
||||||
|
los_histogram: list[KeyValueCount] = []
|
||||||
|
los_vals = df_los["los"]
|
||||||
|
for b in range(0, 8):
|
||||||
|
los_histogram.append(KeyValueCount(bin_label=str(b), count=int((los_vals == b).sum())))
|
||||||
|
los_histogram.append(KeyValueCount(bin_label="8-14", count=int(((los_vals >= 8) & (los_vals <= 14)).sum())))
|
||||||
|
los_histogram.append(KeyValueCount(bin_label="15+", count=int((los_vals >= 15).sum())))
|
||||||
|
|
||||||
|
# LOS by disease (top 8 diagnoses by n)
|
||||||
|
los_by_disease: list[LosByDisease] = []
|
||||||
|
if len(df_los):
|
||||||
|
top_diag = df_los["诊断名称"].value_counts().head(8).index.tolist()
|
||||||
|
for d in top_diag:
|
||||||
|
grp = df_los[df_los["诊断名称"] == d]["los"]
|
||||||
|
los_by_disease.append(LosByDisease(
|
||||||
|
diagnosis=str(d),
|
||||||
|
p25=round(float(grp.quantile(0.25)), 2),
|
||||||
|
median=round(float(grp.median()), 2),
|
||||||
|
p75=round(float(grp.quantile(0.75)), 2),
|
||||||
|
n=int(len(grp)),
|
||||||
|
))
|
||||||
|
|
||||||
|
# outcome counts
|
||||||
|
outcome_counts = [
|
||||||
|
OutcomeCount(outcome=str(k), count=int(v))
|
||||||
|
for k, v in outcome.value_counts().items()
|
||||||
|
]
|
||||||
|
|
||||||
|
# admission route counts
|
||||||
|
admission_route_counts = [
|
||||||
|
RouteCount(route=str(k), count=int(v))
|
||||||
|
for k, v in route.value_counts().items()
|
||||||
|
]
|
||||||
|
|
||||||
|
# BMI by age band. BMI = 体重kg / (身高m)^2; plausible 8-40.
|
||||||
|
bmi_by_age_band: list[BmiByAge] = []
|
||||||
|
h = pd.to_numeric(df["身高"], errors="coerce") # cm
|
||||||
|
w = pd.to_numeric(df["体重"], errors="coerce") # kg
|
||||||
|
age = pd.to_numeric(df["年龄"], errors="coerce")
|
||||||
|
bmi = w / ((h / 100.0) ** 2)
|
||||||
|
bmi_df = pd.DataFrame({"age": age, "bmi": bmi})
|
||||||
|
bmi_df = bmi_df[(bmi_df["bmi"] >= 8) & (bmi_df["bmi"] <= 40) & bmi_df["age"].notna()]
|
||||||
|
age_bands = [(0, 3, "0-2"), (3, 6, "3-5"), (6, 9, "6-8"),
|
||||||
|
(9, 12, "9-11"), (12, 15, "12-14"), (15, 19, "15-18")]
|
||||||
|
for lo, hi, label in age_bands:
|
||||||
|
grp = bmi_df[(bmi_df["age"] >= lo) & (bmi_df["age"] < hi)]["bmi"]
|
||||||
|
if len(grp) == 0:
|
||||||
|
continue
|
||||||
|
bmi_by_age_band.append(BmiByAge(
|
||||||
|
age_band=label,
|
||||||
|
p25=round(float(grp.quantile(0.25)), 2),
|
||||||
|
median=round(float(grp.median()), 2),
|
||||||
|
p75=round(float(grp.quantile(0.75)), 2),
|
||||||
|
n=int(len(grp)),
|
||||||
|
))
|
||||||
|
|
||||||
|
return InpatientClinicalResponse(
|
||||||
|
kpis=kpis,
|
||||||
|
los_histogram=los_histogram,
|
||||||
|
los_by_disease=los_by_disease,
|
||||||
|
outcome_counts=outcome_counts,
|
||||||
|
admission_route_counts=admission_route_counts,
|
||||||
|
bmi_by_age_band=bmi_by_age_band,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/inpatient-clinical", response_model=InpatientClinicalResponse, summary="住院临床统计")
|
||||||
|
async def inpatient_clinical():
|
||||||
|
"""住院临床概览:KPI、住院天数(LOS)分布、费用分布、转归、入院途径、BMI 分布。"""
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(_compute_inpatient_clinical)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("inpatient-clinical failed")
|
||||||
|
return _empty_inpatient_clinical()
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Endpoint 2: symptoms ==============
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_symptoms(top: int) -> SymptomsResponse:
|
||||||
|
df = get_outpatient_data()
|
||||||
|
if df.empty or "主诉" not in df.columns:
|
||||||
|
return SymptomsResponse(symptoms=[], revisit_ratio=0.0)
|
||||||
|
chief = df["主诉"].dropna().astype(str)
|
||||||
|
total = len(chief)
|
||||||
|
if total == 0:
|
||||||
|
return SymptomsResponse(symptoms=[], revisit_ratio=0.0)
|
||||||
|
|
||||||
|
counts: list[SymptomItem] = []
|
||||||
|
for kw in SYMPTOM_KEYWORDS:
|
||||||
|
c = int(chief.str.contains(kw, regex=False).sum())
|
||||||
|
if c > 0:
|
||||||
|
counts.append(SymptomItem(keyword=kw, count=c))
|
||||||
|
counts.sort(key=lambda x: x.count, reverse=True)
|
||||||
|
counts = counts[:top]
|
||||||
|
|
||||||
|
revisit_mask = chief.str.contains("|".join(REVISIT_KEYWORDS), regex=True)
|
||||||
|
revisit_ratio = float(revisit_mask.sum()) / total if total else 0.0
|
||||||
|
|
||||||
|
return SymptomsResponse(symptoms=counts, revisit_ratio=round(revisit_ratio, 4))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/symptoms", response_model=SymptomsResponse, summary="门诊主诉症状词频")
|
||||||
|
async def symptoms(top: int = Query(20, ge=1, le=50, description="返回前 N 个症状词")):
|
||||||
|
"""从门诊主诉中提取固定症状关键词的出现频次,并计算复诊比例。"""
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(_compute_symptoms, top)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("symptoms failed")
|
||||||
|
return SymptomsResponse(symptoms=[], revisit_ratio=0.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Endpoint 3: incidence rate ==============
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_incidence() -> IncidenceResponse:
|
||||||
|
daily = load_cases_by_district_daily()
|
||||||
|
if daily.empty:
|
||||||
|
return IncidenceResponse(districts=[])
|
||||||
|
case_totals = daily.groupby("district")["total_cases"].sum()
|
||||||
|
pop = _district_population()
|
||||||
|
|
||||||
|
items: list[IncidenceItem] = []
|
||||||
|
for district in case_totals.index:
|
||||||
|
total_cases = int(case_totals.get(district, 0))
|
||||||
|
population = float(pop.get(district, 0.0))
|
||||||
|
rate = (total_cases / population * 10000) if population > 0 else 0.0
|
||||||
|
items.append(IncidenceItem(
|
||||||
|
district=str(district),
|
||||||
|
total_cases=total_cases,
|
||||||
|
population=round(population, 1),
|
||||||
|
rate_per_10k=round(rate, 2),
|
||||||
|
))
|
||||||
|
items.sort(key=lambda x: x.rate_per_10k, reverse=True)
|
||||||
|
return IncidenceResponse(districts=items)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/incidence-rate", response_model=IncidenceResponse, summary="各区发病率")
|
||||||
|
async def incidence_rate():
|
||||||
|
"""各区病例总数 / 区人口 * 10000,得到每万人发病率(13 区)。"""
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(_compute_incidence)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("incidence-rate failed")
|
||||||
|
return IncidenceResponse(districts=[])
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Endpoint 4: env correlation ==============
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_env_correlation() -> EnvCorrelationResponse:
|
||||||
|
df = _feature_snapshots()
|
||||||
|
if df.empty or "total_cases" not in df.columns:
|
||||||
|
return EnvCorrelationResponse(correlation_matrix=[], scatter=[], pollutant_pairwise=[])
|
||||||
|
|
||||||
|
# 污染物 vs 病例 的 Pearson 相关(按格点,汇集所有快照)
|
||||||
|
correlation_matrix: list[CorrItem] = []
|
||||||
|
cases = pd.to_numeric(df["total_cases"], errors="coerce")
|
||||||
|
for p in POLLUTANTS:
|
||||||
|
if p not in df.columns:
|
||||||
|
continue
|
||||||
|
series = pd.to_numeric(df[p], errors="coerce")
|
||||||
|
valid = series.notna() & cases.notna()
|
||||||
|
if valid.sum() < 2 or series[valid].std() == 0 or cases[valid].std() == 0:
|
||||||
|
corr = 0.0
|
||||||
|
else:
|
||||||
|
corr = float(series[valid].corr(cases[valid]))
|
||||||
|
correlation_matrix.append(CorrItem(pollutant=p, corr_with_cases=_safe_float(corr)))
|
||||||
|
|
||||||
|
# scatter: 采样 cases>0 的格点(up to 500)
|
||||||
|
scatter: list[ScatterPoint] = []
|
||||||
|
has_cols = all(c in df.columns for c in ["PM25", "AQI", "total_cases"])
|
||||||
|
if has_cols:
|
||||||
|
sdf = df[["PM25", "AQI", "total_cases"]].copy()
|
||||||
|
sdf = sdf[pd.to_numeric(sdf["total_cases"], errors="coerce") > 0].dropna()
|
||||||
|
if len(sdf) > 500:
|
||||||
|
sdf = sdf.sample(n=500, random_state=42)
|
||||||
|
for _, r in sdf.iterrows():
|
||||||
|
scatter.append(ScatterPoint(
|
||||||
|
pm25=_safe_float(r["PM25"]),
|
||||||
|
aqi=_safe_float(r["AQI"]),
|
||||||
|
cases=_safe_float(r["total_cases"]),
|
||||||
|
))
|
||||||
|
|
||||||
|
# pollutant pairwise (upper triangle)
|
||||||
|
pollutant_pairwise: list[PairwiseCorr] = []
|
||||||
|
present = [p for p in POLLUTANTS if p in df.columns]
|
||||||
|
pol_df = df[present].apply(pd.to_numeric, errors="coerce")
|
||||||
|
corr_mat = pol_df.corr()
|
||||||
|
for i, a in enumerate(present):
|
||||||
|
for b in present[i + 1:]:
|
||||||
|
try:
|
||||||
|
v = corr_mat.loc[a, b]
|
||||||
|
except KeyError:
|
||||||
|
v = 0.0
|
||||||
|
pollutant_pairwise.append(PairwiseCorr(a=a, b=b, corr=_safe_float(v)))
|
||||||
|
|
||||||
|
return EnvCorrelationResponse(
|
||||||
|
correlation_matrix=correlation_matrix,
|
||||||
|
scatter=scatter,
|
||||||
|
pollutant_pairwise=pollutant_pairwise,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/env-correlation", response_model=EnvCorrelationResponse, summary="环境-病例相关性")
|
||||||
|
async def env_correlation():
|
||||||
|
"""污染物与病例的相关矩阵、PM2.5/AQI 散点、污染物两两相关(热力图)。"""
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(_compute_env_correlation)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("env-correlation failed")
|
||||||
|
return EnvCorrelationResponse(correlation_matrix=[], scatter=[], pollutant_pairwise=[])
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Endpoint 5: temporal ==============
|
||||||
|
|
||||||
|
_WEEKDAY_LABELS = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_temporal() -> TemporalResponse:
|
||||||
|
df = get_combined_data().copy()
|
||||||
|
if df.empty:
|
||||||
|
return TemporalResponse(weekday=[], month_year=[], yoy=[])
|
||||||
|
df["date"] = pd.to_datetime(df["date"], errors="coerce")
|
||||||
|
df = df[df["date"].notna()]
|
||||||
|
if df.empty:
|
||||||
|
return TemporalResponse(weekday=[], month_year=[], yoy=[])
|
||||||
|
|
||||||
|
# weekday (0=周一..6=周日)
|
||||||
|
df["wd"] = df["date"].dt.weekday
|
||||||
|
weekday: list[WeekdayPoint] = []
|
||||||
|
for wd in range(7):
|
||||||
|
sub = df[df["wd"] == wd]
|
||||||
|
out = int((sub["type"] == "outpatient").sum())
|
||||||
|
inp = int((sub["type"] == "inpatient").sum())
|
||||||
|
weekday.append(WeekdayPoint(
|
||||||
|
weekday=_WEEKDAY_LABELS[wd], outpatient=out, inpatient=inp, total=out + inp,
|
||||||
|
))
|
||||||
|
|
||||||
|
# month_year (seasonality grid)
|
||||||
|
df["year"] = df["date"].dt.year
|
||||||
|
df["month"] = df["date"].dt.month
|
||||||
|
my = df.groupby(["year", "month"]).size()
|
||||||
|
month_year = [
|
||||||
|
MonthYearPoint(year=int(y), month=int(m), total=int(c))
|
||||||
|
for (y, m), c in my.items()
|
||||||
|
]
|
||||||
|
month_year.sort(key=lambda x: (x.year, x.month))
|
||||||
|
|
||||||
|
# yoy: monthly current vs same-month-prior-year (only if multiple years exist)
|
||||||
|
yoy: list[YoYPoint] = []
|
||||||
|
years = sorted(df["year"].unique().tolist())
|
||||||
|
if len(years) > 1:
|
||||||
|
monthly_totals = {(int(y), int(m)): int(c) for (y, m), c in my.items()}
|
||||||
|
for (y, m), cur in sorted(monthly_totals.items()):
|
||||||
|
prev = monthly_totals.get((y - 1, m))
|
||||||
|
if prev is None:
|
||||||
|
continue
|
||||||
|
growth = ((cur - prev) / prev * 100) if prev else 0.0
|
||||||
|
yoy.append(YoYPoint(
|
||||||
|
period=f"{y}-{m:02d}",
|
||||||
|
current=cur,
|
||||||
|
previous=prev,
|
||||||
|
growth_pct=round(growth, 2),
|
||||||
|
))
|
||||||
|
|
||||||
|
return TemporalResponse(weekday=weekday, month_year=month_year, yoy=yoy)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/temporal", response_model=TemporalResponse, summary="时序统计")
|
||||||
|
async def temporal():
|
||||||
|
"""按星期、年-月(季节性网格)聚合,以及同比(YoY)增长(若有多年数据)。"""
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(_compute_temporal)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("temporal failed")
|
||||||
|
return TemporalResponse(weekday=[], month_year=[], yoy=[])
|
||||||
82
backend/tests/test_district_normalization.py
Normal file
82
backend/tests/test_district_normalization.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""Tests for district label normalization at the case-loader boundary.
|
||||||
|
|
||||||
|
The processed/cases_by_district_daily.parquet carries both bare ("武昌") and
|
||||||
|
区-suffixed ("武昌区") spellings of each district (26 labels = 13 districts × 2
|
||||||
|
spellings), which double-counts in any roll-up. data.case_loader normalizes
|
||||||
|
these to the canonical 13 区-suffixed names and re-aggregates. These tests pin
|
||||||
|
that behavior.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Ensure the backend package root is importable at collection time (mirrors the
|
||||||
|
# sys.path handling other modules rely on once the app is imported).
|
||||||
|
BACKEND_ROOT = Path(__file__).parent.parent
|
||||||
|
if str(BACKEND_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
from data.case_loader import ( # noqa: E402
|
||||||
|
CANONICAL_DISTRICTS,
|
||||||
|
normalize_district,
|
||||||
|
load_cases_by_district_daily,
|
||||||
|
)
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||||
|
RAW_PARQUET = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_district_known_bare_forms():
|
||||||
|
"""Every known bare form maps to its canonical 区-suffixed name."""
|
||||||
|
cases = {
|
||||||
|
"武昌": "武昌区", "汉阳": "汉阳区", "江岸": "江岸区", "硚口": "硚口区",
|
||||||
|
"青山": "青山区", "洪山": "洪山区", "东西湖": "东西湖区", "汉南": "汉南区",
|
||||||
|
"蔡甸": "蔡甸区", "江夏": "江夏区", "黄陂": "黄陂区", "新洲": "新洲区",
|
||||||
|
"江汉": "江汉区",
|
||||||
|
}
|
||||||
|
for bare, canonical in cases.items():
|
||||||
|
assert normalize_district(bare) == canonical
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_district_already_suffixed_passes_through():
|
||||||
|
for d in CANONICAL_DISTRICTS:
|
||||||
|
assert normalize_district(d) == d
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_set_is_exactly_thirteen():
|
||||||
|
assert len(CANONICAL_DISTRICTS) == 13
|
||||||
|
assert len(set(CANONICAL_DISTRICTS)) == 13
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not RAW_PARQUET.exists(), reason="case parquet not present")
|
||||||
|
def test_loader_collapses_to_thirteen_canonical_districts():
|
||||||
|
df = load_cases_by_district_daily()
|
||||||
|
districts = set(df["district"].unique())
|
||||||
|
|
||||||
|
# (a) exactly 13 unique districts, all canonical
|
||||||
|
assert len(districts) == 13, f"expected 13 districts, got {len(districts)}: {sorted(districts)}"
|
||||||
|
assert districts == set(CANONICAL_DISTRICTS)
|
||||||
|
|
||||||
|
# (b) no bare / unsuffixed duplicates remain
|
||||||
|
for name in districts:
|
||||||
|
assert name.endswith(("区", "县", "市")), f"unsuffixed district leaked: {name}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not RAW_PARQUET.exists(), reason="case parquet not present")
|
||||||
|
def test_loader_preserves_totals_no_rows_dropped_or_double_counted():
|
||||||
|
"""Sum integrity: normalized total == raw parquet total."""
|
||||||
|
raw = pd.read_parquet(RAW_PARQUET)
|
||||||
|
normalized = load_cases_by_district_daily()
|
||||||
|
|
||||||
|
assert int(normalized["total_cases"].sum()) == int(raw["total_cases"].sum())
|
||||||
|
assert int(normalized["outpatient_count"].sum()) == int(raw["outpatient_count"].sum())
|
||||||
|
assert int(normalized["inpatient_count"].sum()) == int(raw["inpatient_count"].sum())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not RAW_PARQUET.exists(), reason="case parquet not present")
|
||||||
|
def test_raw_parquet_actually_has_dirty_labels():
|
||||||
|
"""Sanity: the raw file really has the 26-label problem we are fixing."""
|
||||||
|
raw = pd.read_parquet(RAW_PARQUET)
|
||||||
|
assert raw["district"].nunique() > 13
|
||||||
90
frontend/e2e/clinical.spec.ts
Normal file
90
frontend/e2e/clinical.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* 住院临床分析页(/analysis/clinical)验收测试。
|
||||||
|
* 与 user-flows.spec.ts 一致的鉴权策略:addInitScript 注入 cbpoa_token,
|
||||||
|
* 用 page.route 拦截 /api/**,对 inpatient-clinical 返回合法小样本,其余返回 {}。
|
||||||
|
*/
|
||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
import { TESTIDS } from '../src/utils/testids';
|
||||||
|
|
||||||
|
const CLINICAL_FIXTURE = {
|
||||||
|
kpis: {
|
||||||
|
total_admissions: 5822,
|
||||||
|
median_los_days: 4,
|
||||||
|
cure_rate: 0.991,
|
||||||
|
emergency_admit_ratio: 0.47,
|
||||||
|
},
|
||||||
|
los_histogram: [
|
||||||
|
{ bin_label: '1-2', count: 1200 },
|
||||||
|
{ bin_label: '3-4', count: 2100 },
|
||||||
|
{ bin_label: '5-7', count: 1500 },
|
||||||
|
],
|
||||||
|
los_by_disease: [
|
||||||
|
{ diagnosis: '肺炎', p25: 3, median: 5, p75: 7, n: 800 },
|
||||||
|
{ diagnosis: '支气管炎', p25: 2, median: 4, p75: 6, n: 600 },
|
||||||
|
],
|
||||||
|
outcome_counts: [
|
||||||
|
{ outcome: '治愈', count: 3474 },
|
||||||
|
{ outcome: '好转', count: 2298 },
|
||||||
|
{ outcome: '其他', count: 35 },
|
||||||
|
{ outcome: '未愈', count: 12 },
|
||||||
|
{ outcome: '死亡', count: 3 },
|
||||||
|
],
|
||||||
|
admission_route_counts: [
|
||||||
|
{ route: '急诊', count: 2700 },
|
||||||
|
{ route: '门诊', count: 3122 },
|
||||||
|
],
|
||||||
|
bmi_by_age_band: [
|
||||||
|
{ age_band: '0-2', p25: 14, median: 16, p75: 18, n: 400 },
|
||||||
|
{ age_band: '3-6', p25: 15, median: 17, p75: 19, n: 500 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function seedAuthAndMockApi(page: Page) {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('cbpoa_token', 'e2e-test-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route('/api/**', (route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
|
||||||
|
if (url.includes('/stats/inpatient-clinical')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(CLINICAL_FIXTURE),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其余接口返回空对象,本页不依赖。
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('住院临床分析页', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthAndMockApi(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deep-link /analysis/clinical mounts page-clinical + clinical-kpis', async ({ page }) => {
|
||||||
|
await page.goto('/analysis/clinical');
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.pageClinical}"]`)).toBeVisible();
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.clinicalKpis}"]`)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no horizontal scroll at 375px', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 812 });
|
||||||
|
await page.goto('/analysis/clinical');
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.pageClinical}"]`)).toBeVisible();
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.clinicalKpis}"]`)).toBeVisible();
|
||||||
|
|
||||||
|
const noHorizontalScroll = await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth
|
||||||
|
);
|
||||||
|
expect(noHorizontalScroll).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
143
frontend/e2e/doctor-view.spec.ts
Normal file
143
frontend/e2e/doctor-view.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Phase-3 acceptance tests: role-aware 预警 (alerts) view.
|
||||||
|
*
|
||||||
|
* Two view-preset invariants (D2 — frontend presets, NOT access control):
|
||||||
|
*
|
||||||
|
* 1. PRIVACY INVARIANT (doctor / ?view=cluster): the doctor sees ONLY the aggregated
|
||||||
|
* density raster + the disease filter — ZERO individual patient/case point markers.
|
||||||
|
* The page mirrors every individual marker it would actually render into a hidden
|
||||||
|
* data-testid="patient-point" element (the live Leaflet CircleMarkers are canvas/SVG
|
||||||
|
* objects with no testid and can't be counted directly). In cluster mode the page
|
||||||
|
* forces showAlertMarkers=false, so that mirror set is empty → patient-point count 0.
|
||||||
|
*
|
||||||
|
* 2. 官员 (official) GRID-HIDE: the 100m 网格 is meaningless for leadership, so the grid
|
||||||
|
* toggle wrapper (data-testid="grid-layer-wrapper") is not rendered at all.
|
||||||
|
*
|
||||||
|
* Auth + API mocking mirror e2e/user-flows.spec.ts so the suite runs hermetically
|
||||||
|
* (no live :8000 backend). Role is seeded via localStorage['cbpoa_role'].
|
||||||
|
*/
|
||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
import { TESTIDS } from '../src/utils/testids';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed auth token (+ optional role) and mock all /api/** calls before page load.
|
||||||
|
* Response shapes copied from user-flows.spec.ts.
|
||||||
|
*/
|
||||||
|
async function seedAuthAndMockApi(page: Page, role?: string) {
|
||||||
|
await page.addInitScript((r) => {
|
||||||
|
localStorage.setItem('cbpoa_token', 'e2e-test-token');
|
||||||
|
if (r) localStorage.setItem('cbpoa_role', r);
|
||||||
|
}, role ?? '');
|
||||||
|
|
||||||
|
await page.route('/api/**', (route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
|
||||||
|
if (url.includes('/alerts')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ alerts: [], total: 0 }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/history/aggregated')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ aggregations: [], total_records: 0 }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/grids')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/cases/demographics')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
age_distribution: [],
|
||||||
|
gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
|
||||||
|
age_diagnosis_matrix: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/diagnoses') || url.includes('/diagnosis-list')) {
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/cases/districts') || url.includes('/districts')) {
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/cases/trend') || url.includes('/cases')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ data: [], total: 0 }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ data: [], items: [], total: 0 }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('role-aware 预警 view (Phase 3)', () => {
|
||||||
|
test.use({ viewport: { width: 1280, height: 800 } });
|
||||||
|
|
||||||
|
test('医生 /alerts?view=cluster: cluster-view mounts, disease filter present, ZERO patient points', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await seedAuthAndMockApi(page, 'doctor');
|
||||||
|
await page.goto('/alerts?view=cluster');
|
||||||
|
|
||||||
|
// Page + the aggregated density (cluster) map both mount.
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.clusterView}"]`)).toBeVisible();
|
||||||
|
|
||||||
|
// The disease filter is the doctor's core tool — it must be on the page.
|
||||||
|
await expect(page.getByText('按病种筛选')).toBeVisible();
|
||||||
|
|
||||||
|
// PRIVACY INVARIANT: not a single individual patient/case point may be rendered.
|
||||||
|
// Asserted at the data level (the mirrored DOM set), independent of Leaflet internals.
|
||||||
|
await expect(page.getByTestId(TESTIDS.patientPoint)).toHaveCount(0);
|
||||||
|
|
||||||
|
// The 预警标记 toggle (which would turn individual markers on) must be absent,
|
||||||
|
// so there is no way for the doctor to opt out of the privacy invariant.
|
||||||
|
await expect(page.getByRole('button', { name: '预警标记' })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('官员 /alerts: 100m grid hidden — grid-layer-wrapper not rendered', async ({ page }) => {
|
||||||
|
await seedAuthAndMockApi(page, 'official');
|
||||||
|
await page.goto('/alerts');
|
||||||
|
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
|
||||||
|
|
||||||
|
// The grid toggle wrapper must be entirely absent for leadership.
|
||||||
|
await expect(page.getByTestId(TESTIDS.gridLayerWrapper)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin /alerts: full behavior — grid toggle present, no forced cluster view', async ({ page }) => {
|
||||||
|
await seedAuthAndMockApi(page, 'admin');
|
||||||
|
await page.goto('/alerts');
|
||||||
|
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
|
||||||
|
// Admin keeps the grid toggle and is NOT forced into cluster view.
|
||||||
|
await expect(page.getByTestId(TESTIDS.gridLayerWrapper)).toHaveCount(1);
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.clusterView}"]`)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
78
frontend/e2e/granularity.spec.ts
Normal file
78
frontend/e2e/granularity.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
import { TESTIDS } from '../src/utils/testids';
|
||||||
|
|
||||||
|
// 与 user-flows.spec.ts 一致的鉴权策略:注入 token 绕过登录门,并 mock /api/**,
|
||||||
|
// 让用例脱离活的后端 hermetic 运行。
|
||||||
|
async function seedAuthAndMockApi(page: Page) {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('cbpoa_token', 'e2e-test-token');
|
||||||
|
});
|
||||||
|
await page.route('/api/**', (route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
if (url.includes('/history/aggregated')) {
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ aggregations: [], total_records: 0 }) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/grids')) {
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ type: 'FeatureCollection', features: [] }) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/streets')) {
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ streets: [] }) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/cases/districts') || url.includes('/districts')) {
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/cases/trend') || url.includes('/cases')) {
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [], trend: [], total: 0 }) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [], items: [], total: 0 }) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 粒度(granularity)以 URL query 参数为真相来源(source of truth)。
|
||||||
|
// 监测页通过 useSearchParams 读取它,drilldownStore 单向派生。
|
||||||
|
test.describe('monitoring granularity URL source-of-truth', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthAndMockApi(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deep-link granularity=street mounts page and reflects street in control', async ({ page }) => {
|
||||||
|
await page.goto('/monitoring?granularity=street');
|
||||||
|
|
||||||
|
await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible();
|
||||||
|
|
||||||
|
const control = page.getByTestId(TESTIDS.granularityControl);
|
||||||
|
await expect(control).toBeVisible();
|
||||||
|
// 街道分段为激活态(Segmented 给激活按钮加 bg-primary)。
|
||||||
|
const streetBtn = page.getByTestId(`${TESTIDS.granularityControl}-street`);
|
||||||
|
await expect(streetBtn).toHaveClass(/bg-primary/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking a granularity control updates the URL granularity param', async ({ page }) => {
|
||||||
|
await page.goto('/monitoring?granularity=street');
|
||||||
|
await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible();
|
||||||
|
|
||||||
|
// 切到「区域」应把 URL 写为 granularity=district。
|
||||||
|
await page.getByTestId(`${TESTIDS.granularityControl}-district`).click();
|
||||||
|
await expect(page).toHaveURL(/granularity=district/);
|
||||||
|
|
||||||
|
// 切到「全市」应把 URL 写为 granularity=city。
|
||||||
|
await page.getByTestId(`${TESTIDS.granularityControl}-city`).click();
|
||||||
|
await expect(page).toHaveURL(/granularity=city/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deep-link granularity=district survives a reload', async ({ page }) => {
|
||||||
|
await page.goto('/monitoring?granularity=district');
|
||||||
|
await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/granularity=district/);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/granularity=district/);
|
||||||
|
await expect(page.getByTestId(`${TESTIDS.granularityControl}-district`)).toHaveClass(/bg-primary/);
|
||||||
|
});
|
||||||
|
});
|
||||||
119
frontend/e2e/overview.spec.ts
Normal file
119
frontend/e2e/overview.spec.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* 综合概览大屏 (/overview) 验收测试。
|
||||||
|
*
|
||||||
|
* 与 user-flows.spec.ts 一致:用 addInitScript 注入 cbpoa_token 绕过登录门,
|
||||||
|
* page.route 拦截 /api/** 使套件 hermetic(无需 :8000)。/wuhan_districts.geojson
|
||||||
|
* 走真实静态资源(dev server 提供),由 Leaflet 取用。
|
||||||
|
*/
|
||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
import { TESTIDS } from '../src/utils/testids';
|
||||||
|
|
||||||
|
/** 13 区里造两条数据,断言 choropleth 能着色、toggle 能切换。 */
|
||||||
|
function districtPayload() {
|
||||||
|
return {
|
||||||
|
districts: [
|
||||||
|
{ district: '武昌区', outpatient: 120, inpatient: 30, total: 150, outpatient_ratio: 0.8, inpatient_ratio: 0.2 },
|
||||||
|
{ district: '江岸', outpatient: 60, inpatient: 10, total: 70, outpatient_ratio: 0.86, inpatient_ratio: 0.14 },
|
||||||
|
],
|
||||||
|
total: 220,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedAuthAndMockApi(page: Page) {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('cbpoa_token', 'e2e-test-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route('/api/**', (route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
|
||||||
|
const json = (body: unknown) =>
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
|
||||||
|
|
||||||
|
if (url.includes('/alerts')) {
|
||||||
|
return json({ alerts: [], total: 0 });
|
||||||
|
}
|
||||||
|
if (url.includes('/cases/stats')) {
|
||||||
|
return json({
|
||||||
|
total_outpatient: 1000,
|
||||||
|
total_inpatient: 200,
|
||||||
|
date_range: { start: '2023-01-01', end: '2023-12-01' },
|
||||||
|
top_districts: [],
|
||||||
|
top_diagnoses: [
|
||||||
|
{ diagnosis: '上呼吸道感染', outpatient: 300, inpatient: 40 },
|
||||||
|
{ diagnosis: '肺炎', outpatient: 120, inpatient: 80 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes('/cases/trend')) {
|
||||||
|
return json({
|
||||||
|
trend: [
|
||||||
|
{ date: '2023-11-01', outpatient: 10, inpatient: 2, total: 12 },
|
||||||
|
{ date: '2023-11-02', outpatient: 14, inpatient: 3, total: 17 },
|
||||||
|
],
|
||||||
|
summary: {
|
||||||
|
total_outpatient: 24,
|
||||||
|
total_inpatient: 5,
|
||||||
|
period_count: 2,
|
||||||
|
avg_daily_outpatient: 12,
|
||||||
|
avg_daily_inpatient: 2.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes('/cases/districts')) {
|
||||||
|
return json(districtPayload());
|
||||||
|
}
|
||||||
|
if (url.includes('/risk/stats')) {
|
||||||
|
return json({ high_risk_count: 7, total_grids: 100, avg_risk: 0.4 });
|
||||||
|
}
|
||||||
|
if (url.includes('/environment/pollutants')) {
|
||||||
|
return json({ data: [{ date: '2023-11-01', AQI: 80 }, { date: '2023-11-02', AQI: 95 }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ data: [], items: [], total: 0 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Overview 大屏', () => {
|
||||||
|
test.use({ viewport: { width: 1280, height: 900 } });
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthAndMockApi(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders kpi-row, choropleth, as-of badge and metric toggle', async ({ page }) => {
|
||||||
|
await page.goto('/overview');
|
||||||
|
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.pageOverview}"]`)).toBeVisible();
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.kpiRow}"]`)).toBeVisible();
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.choroplethWrapper}"]`)).toBeVisible();
|
||||||
|
|
||||||
|
// Literal honesty badge — exact text.
|
||||||
|
const badge = page.locator(`[data-testid="${TESTIDS.asofBadge}"]`);
|
||||||
|
await expect(badge).toBeVisible();
|
||||||
|
await expect(badge).toHaveText('数据截至2023-12');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('门诊/住院 toggle switches active segment without error', async ({ page }) => {
|
||||||
|
await page.goto('/overview');
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.choroplethWrapper}"]`)).toBeVisible();
|
||||||
|
|
||||||
|
const outBtn = page.locator(`[data-testid="${TESTIDS.outinpatientToggle}-outpatient"]`);
|
||||||
|
const inBtn = page.locator(`[data-testid="${TESTIDS.outinpatientToggle}-inpatient"]`);
|
||||||
|
const allBtn = page.locator(`[data-testid="${TESTIDS.outinpatientToggle}-all"]`);
|
||||||
|
|
||||||
|
// Default: 全部 active (primary background).
|
||||||
|
await expect(allBtn).toHaveClass(/bg-primary/);
|
||||||
|
|
||||||
|
await outBtn.click();
|
||||||
|
await expect(outBtn).toHaveClass(/bg-primary/);
|
||||||
|
await expect(allBtn).not.toHaveClass(/bg-primary/);
|
||||||
|
|
||||||
|
await inBtn.click();
|
||||||
|
await expect(inBtn).toHaveClass(/bg-primary/);
|
||||||
|
await expect(outBtn).not.toHaveClass(/bg-primary/);
|
||||||
|
|
||||||
|
// Wrapper still mounted after toggling — no render crash.
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.choroplethWrapper}"]`)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
232
frontend/e2e/perf.spec.ts
Normal file
232
frontend/e2e/perf.spec.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* Performance-measurement harness for the leadership 大屏 (/overview).
|
||||||
|
*
|
||||||
|
* Runs ONLY in the dedicated `perf` Playwright project (see playwright.config.ts
|
||||||
|
* testMatch) so emulated network throttling never pollutes the functional suite.
|
||||||
|
*
|
||||||
|
* What it measures:
|
||||||
|
* 1. LCP (Largest Contentful Paint) of /overview under emulated Fast 3G.
|
||||||
|
* 2. Client-side route-transition time from /overview → /monitoring.
|
||||||
|
*
|
||||||
|
* Throttling model: /api/** is mocked to resolve INSTANTLY (see seedAuthAndMockApi),
|
||||||
|
* so the backend contributes ~0ms. That is deliberate — it isolates the realistic
|
||||||
|
* SPA cost on a slow link: the *static asset graph* (app JS/CSS bundle + the
|
||||||
|
* /wuhan_districts.geojson choropleth payload, which is served by the real dev
|
||||||
|
* server, not mocked). Fast 3G therefore shapes exactly the bytes a cold-cache
|
||||||
|
* leadership client must pull before first paint, which is what LCP should reflect.
|
||||||
|
*
|
||||||
|
* Assertion policy (per the UX-modernization plan): LCP and route-transition
|
||||||
|
* targets (2500ms LCP / 800ms transition) are REPORTED, not hard CI gates — a
|
||||||
|
* miss under throttle on a loaded CI box must not fail the build. We therefore
|
||||||
|
* record each number against its target as a test annotation + console line and
|
||||||
|
* let the test PASS regardless of the target. (Note: `expect.soft` would still
|
||||||
|
* mark the test failed at teardown, so it's the wrong tool for a report-only
|
||||||
|
* target — annotations are.) Hard assertions guard ONLY that the measurement
|
||||||
|
* machinery worked: LCP was observed (> 0) and the nav actually landed.
|
||||||
|
*
|
||||||
|
* Caveat on absolute values: this runs against the Vite DEV server (unbundled,
|
||||||
|
* unminified ESM with per-module requests). Dev LCP under Fast 3G is therefore
|
||||||
|
* far higher than a production build would be — these numbers are a relative
|
||||||
|
* regression signal for this harness, not a production SLA.
|
||||||
|
*/
|
||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
import { TESTIDS } from '../src/utils/testids';
|
||||||
|
|
||||||
|
// Reported (soft) targets — see file header.
|
||||||
|
const LCP_TARGET_MS = 2500;
|
||||||
|
const ROUTE_TRANSITION_TARGET_MS = 800;
|
||||||
|
|
||||||
|
// Emulated "Fast 3G" network conditions (Chrome DevTools preset).
|
||||||
|
const FAST_3G = {
|
||||||
|
offline: false,
|
||||||
|
downloadThroughput: (1.6 * 1024 * 1024) / 8, // 1.6 Mbps
|
||||||
|
uploadThroughput: (750 * 1024) / 8, // 750 Kbps
|
||||||
|
latency: 150, // ms RTT
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed auth + mock /api/** so the page renders hermetically. Mirrors the helper
|
||||||
|
* in user-flows.spec.ts, with one deliberate difference: /wuhan_districts.geojson
|
||||||
|
* is a real static asset and is NOT under /api, so page.route('/api/**') already
|
||||||
|
* lets it pass through to the dev server (the realistic, throttled payload).
|
||||||
|
*/
|
||||||
|
async function seedAuthAndMockApi(page: Page) {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('cbpoa_token', 'e2e-test-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock backend responses instantly so Fast-3G shapes only the static asset
|
||||||
|
// graph (JS/CSS + geojson), not API latency.
|
||||||
|
await page.route('/api/**', (route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
|
||||||
|
if (url.includes('/alerts')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ alerts: [], total: 0 }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/history/aggregated')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ aggregations: [], total_records: 0 }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/grids')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/cases/demographics')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
age_distribution: [],
|
||||||
|
gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
|
||||||
|
age_diagnosis_matrix: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/cases/districts') || url.includes('/districts')) {
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/cases/trend') || url.includes('/cases')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ data: [], total: 0 }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default fallback — safe empty shape.
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ data: [], items: [], total: 0 }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Performance — /overview under emulated Fast 3G', () => {
|
||||||
|
test('LCP and route-transition are measured and reported', async ({ page }, testInfo) => {
|
||||||
|
// Fast-3G throttling makes the cold asset-graph download slow; the default 30s
|
||||||
|
// test budget can be eaten by the initial /overview load alone. Give the whole
|
||||||
|
// measurement flow generous headroom — this bounds the harness, not the metrics.
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
await seedAuthAndMockApi(page);
|
||||||
|
|
||||||
|
// Install the LCP observer BEFORE any navigation so it captures the very
|
||||||
|
// first paint. buffered:true also replays entries emitted before observe().
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
(window as unknown as { __lcp: number }).__lcp = 0;
|
||||||
|
new PerformanceObserver((list) => {
|
||||||
|
const entries = list.getEntries();
|
||||||
|
(window as unknown as { __lcp: number }).__lcp = entries[entries.length - 1].startTime;
|
||||||
|
}).observe({ type: 'largest-contentful-paint', buffered: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply Fast 3G throttling via CDP before navigating.
|
||||||
|
const client = await page.context().newCDPSession(page);
|
||||||
|
await client.send('Network.enable');
|
||||||
|
await client.send('Network.emulateNetworkConditions', FAST_3G);
|
||||||
|
|
||||||
|
// --- LCP measurement -----------------------------------------------------
|
||||||
|
// Generous wait: under Fast 3G the throttled JS bundle download dominates, so
|
||||||
|
// first meaningful paint legitimately exceeds the 5s default expect timeout.
|
||||||
|
// The LCP NUMBER we read is the real measured value — this timeout only bounds
|
||||||
|
// how long we'll wait for the asset graph to arrive before failing the harness.
|
||||||
|
await page.goto('/overview');
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.kpiRow}"]`)).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// LCP finalizes on the last contentful paint; give the observer a beat to flush
|
||||||
|
// the entry for the kpi-row we just saw before reading it.
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
const lcp = await page.evaluate(() => (window as unknown as { __lcp: number }).__lcp);
|
||||||
|
|
||||||
|
// --- Route-transition measurement ---------------------------------------
|
||||||
|
// Expand the 监测 module if its NavLink is collapsed, then click it.
|
||||||
|
const railSel = `[data-testid="${TESTIDS.sidebarRail}"]`;
|
||||||
|
const navMonitoring = page.locator(`${railSel} [data-testid="${TESTIDS.navMonitoring}"]`);
|
||||||
|
if (!(await navMonitoring.isVisible())) {
|
||||||
|
await page.locator(`${railSel} button`).filter({ hasText: '监测' }).first().click();
|
||||||
|
}
|
||||||
|
await expect(navMonitoring).toBeVisible();
|
||||||
|
|
||||||
|
const t0 = await page.evaluate(() => performance.now());
|
||||||
|
await navMonitoring.click();
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
const t1 = await page.evaluate(() => performance.now());
|
||||||
|
const routeTransitionMs = t1 - t0;
|
||||||
|
|
||||||
|
// --- Report --------------------------------------------------------------
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[perf] /overview LCP (Fast 3G): ${lcp.toFixed(0)} ms (target < ${LCP_TARGET_MS})`);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
`[perf] /overview → /monitoring route transition: ${routeTransitionMs.toFixed(0)} ms (target < ${ROUTE_TRANSITION_TARGET_MS})`
|
||||||
|
);
|
||||||
|
await testInfo.attach('perf-metrics', {
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(
|
||||||
|
{
|
||||||
|
lcpMs: Math.round(lcp),
|
||||||
|
lcpTargetMs: LCP_TARGET_MS,
|
||||||
|
routeTransitionMs: Math.round(routeTransitionMs),
|
||||||
|
routeTransitionTargetMs: ROUTE_TRANSITION_TARGET_MS,
|
||||||
|
network: 'Fast 3G (emulated via CDP)',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Reported targets (NOT gates) ---------------------------------------
|
||||||
|
// Record each metric vs. its target as a passing/over annotation. A miss is
|
||||||
|
// visible in the report and console but does NOT fail the test.
|
||||||
|
const lcpVerdict = lcp < LCP_TARGET_MS ? 'within' : 'over';
|
||||||
|
const routeVerdict = routeTransitionMs < ROUTE_TRANSITION_TARGET_MS ? 'within' : 'over';
|
||||||
|
testInfo.annotations.push({
|
||||||
|
type: 'perf-lcp',
|
||||||
|
description: `${Math.round(lcp)}ms (target ${LCP_TARGET_MS}ms — ${lcpVerdict})`,
|
||||||
|
});
|
||||||
|
testInfo.annotations.push({
|
||||||
|
type: 'perf-route-transition',
|
||||||
|
description: `${Math.round(routeTransitionMs)}ms (target ${ROUTE_TRANSITION_TARGET_MS}ms — ${routeVerdict})`,
|
||||||
|
});
|
||||||
|
if (lcpVerdict === 'over' || routeVerdict === 'over') {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(
|
||||||
|
`[perf] target exceeded (LCP ${lcpVerdict}, route ${routeVerdict}) — reported, not gated (dev-server throttled run).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Hard assertions (gates) --------------------------------------------
|
||||||
|
// Only the measurement machinery is gated: the observer fired and the nav
|
||||||
|
// landed (page-monitoring visibility is already hard-asserted above).
|
||||||
|
expect(lcp, 'LCP observer should have recorded a paint').toBeGreaterThan(0);
|
||||||
|
expect(routeTransitionMs, 'route transition should elapse measurable time').toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
135
frontend/e2e/responsive.spec.ts
Normal file
135
frontend/e2e/responsive.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* Phase-4 responsive acceptance: every analysis page must be usable at 375px
|
||||||
|
* (the narrowest mobile viewport in D4) with NO horizontal scroll.
|
||||||
|
*
|
||||||
|
* Auth + backend mocking mirror e2e/user-flows.spec.ts (seedAuthAndMockApi):
|
||||||
|
* seed localStorage['cbpoa_token'] so the login gate is skipped, then mock all
|
||||||
|
* /api/** calls so the suite runs hermetically without a live :8000 backend.
|
||||||
|
*/
|
||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
import { TESTIDS } from '../src/utils/testids';
|
||||||
|
|
||||||
|
// Each analysis route paired with its page-* mount testid.
|
||||||
|
const ANALYSIS_PAGES: Array<{ route: string; testid: string }> = [
|
||||||
|
{ route: '/analysis/trend', testid: TESTIDS.pageTrend },
|
||||||
|
{ route: '/analysis/district', testid: TESTIDS.pageDistrict },
|
||||||
|
{ route: '/analysis/insights', testid: TESTIDS.pageInsights },
|
||||||
|
{ route: '/analysis/reports', testid: TESTIDS.pageReports },
|
||||||
|
{ route: '/analysis/demographics', testid: TESTIDS.pageDemographics },
|
||||||
|
{ route: '/analysis/disease', testid: TESTIDS.pageDisease },
|
||||||
|
{ route: '/analysis/environment', testid: TESTIDS.pageEnvironment },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Seed auth token and mock all /api/** calls before each page load. */
|
||||||
|
async function seedAuthAndMockApi(page: Page) {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('cbpoa_token', 'e2e-test-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock backend responses so the suite is hermetic — no live :8000 required.
|
||||||
|
// Each response must match the TypeScript interface shape; returning {} causes
|
||||||
|
// pages to throw when accessing expected array properties.
|
||||||
|
await page.route('/api/**', (route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
|
||||||
|
if (url.includes('/alerts')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ alerts: [], total: 0 }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/history/aggregated')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ aggregations: [], total_records: 0 }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/grids')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/cases/demographics')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
age_distribution: [],
|
||||||
|
gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
|
||||||
|
age_diagnosis_matrix: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify([]),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/cases/districts') || url.includes('/districts')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify([]),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/cases/trend') || url.includes('/cases')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ data: [], total: 0 }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default fallback — return a safe empty object.
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ data: [], items: [], total: 0 }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Responsive — analysis pages @375px', () => {
|
||||||
|
test.use({ viewport: { width: 375, height: 812 } });
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthAndMockApi(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const { route, testid } of ANALYSIS_PAGES) {
|
||||||
|
test(`${route} mounts and has no horizontal scroll at 375px`, async ({ page }) => {
|
||||||
|
await page.goto(route);
|
||||||
|
|
||||||
|
// Page must mount.
|
||||||
|
await expect(page.locator(`[data-testid="${testid}"]`)).toBeVisible();
|
||||||
|
|
||||||
|
// No horizontal overflow: scrollWidth must not exceed clientWidth (+1px slack
|
||||||
|
// for sub-pixel rounding).
|
||||||
|
const noHorizontalScroll = await page.evaluate(
|
||||||
|
() =>
|
||||||
|
document.documentElement.scrollWidth <=
|
||||||
|
document.documentElement.clientWidth + 1
|
||||||
|
);
|
||||||
|
expect(noHorizontalScroll, `${route} overflows horizontally at 375px`).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
133
frontend/e2e/roles.spec.ts
Normal file
133
frontend/e2e/roles.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* Phase-3 acceptance tests: 视角/perspective switcher (D2 — frontend view-presets only).
|
||||||
|
*
|
||||||
|
* Roles are NOT access control: the switcher only changes the default landing page +
|
||||||
|
* granularity/filter presets. This suite verifies the switcher renders, selecting a role
|
||||||
|
* navigates to that role's default landing URL (with its query params), and the choice
|
||||||
|
* survives a reload (persisted to localStorage['cbpoa_role']).
|
||||||
|
*
|
||||||
|
* Auth + API mocking mirror e2e/user-flows.spec.ts so the suite runs hermetically.
|
||||||
|
*/
|
||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
import { TESTIDS } from '../src/utils/testids';
|
||||||
|
|
||||||
|
/** Seed auth token and mock all /api/** calls (shapes copied from user-flows.spec.ts). */
|
||||||
|
async function seedAuthAndMockApi(page: Page) {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('cbpoa_token', 'e2e-test-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route('/api/**', (route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
|
||||||
|
if (url.includes('/alerts')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ alerts: [], total: 0 }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/history/aggregated')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ aggregations: [], total_records: 0 }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/grids')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/cases/demographics')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
age_distribution: [],
|
||||||
|
gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
|
||||||
|
age_diagnosis_matrix: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/cases/districts') || url.includes('/districts')) {
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/cases/trend') || url.includes('/cases')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ data: [], total: 0 }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ data: [], items: [], total: 0 }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('视角/perspective switcher (Phase 3)', () => {
|
||||||
|
// Use a desktop viewport so the top bar renders the switcher inline.
|
||||||
|
test.use({ viewport: { width: 1280, height: 800 } });
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthAndMockApi(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('perspective-switcher is visible in the top bar', async ({ page }) => {
|
||||||
|
await page.goto('/monitoring');
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selecting 厅领导 (official) navigates to /overview?granularity=district', async ({ page }) => {
|
||||||
|
await page.goto('/monitoring');
|
||||||
|
const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`);
|
||||||
|
await expect(switcher).toBeVisible();
|
||||||
|
|
||||||
|
await switcher.selectOption('official');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/overview/);
|
||||||
|
await expect(page).toHaveURL(/granularity=district/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selecting 医生 (doctor) navigates to /alerts?view=cluster', async ({ page }) => {
|
||||||
|
await page.goto('/monitoring');
|
||||||
|
const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`);
|
||||||
|
await expect(switcher).toBeVisible();
|
||||||
|
|
||||||
|
await switcher.selectOption('doctor');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/alerts/);
|
||||||
|
await expect(page).toHaveURL(/view=cluster/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selected role persists across reload (localStorage cbpoa_role)', async ({ page }) => {
|
||||||
|
await page.goto('/monitoring');
|
||||||
|
const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`);
|
||||||
|
await switcher.selectOption('doctor');
|
||||||
|
await expect(page).toHaveURL(/\/alerts/);
|
||||||
|
|
||||||
|
// localStorage should now hold the chosen role.
|
||||||
|
const stored = await page.evaluate(() => localStorage.getItem('cbpoa_role'));
|
||||||
|
expect(stored).toBe('doctor');
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// After reload the switcher reflects the persisted role.
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`)).toHaveValue('doctor');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,242 +1,291 @@
|
|||||||
/**
|
/**
|
||||||
* US-007 + US-008: E2E user flow and UI state tests.
|
* Phase-1 acceptance tests: URL-based navigation, responsive layout, and core user flows.
|
||||||
* Simulates real user workflows through the CBPOA system.
|
* 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)', () => {
|
/** Seed auth token and mock all /api/** calls before each page load. */
|
||||||
test('显示登录页面', async ({ page }) => {
|
async function seedAuthAndMockApi(page: Page) {
|
||||||
await page.goto(BASE_URL);
|
// Prevent login gate from appearing.
|
||||||
await page.waitForTimeout(1000);
|
await page.addInitScript(() => {
|
||||||
// Should see login form or app (if cached token)
|
localStorage.setItem('cbpoa_token', 'e2e-test-token');
|
||||||
const isLogin = await page.locator('input').count();
|
|
||||||
const isApp = await page.locator('nav').count();
|
|
||||||
expect(isLogin > 0 || isApp > 0).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('登录表单可交互', async ({ page }) => {
|
// Mock backend responses so the suite is hermetic — no live :8000 required.
|
||||||
await page.goto(BASE_URL);
|
// Each response must match the TypeScript interface shape; returning {} causes
|
||||||
await page.waitForTimeout(1000);
|
// pages to throw when accessing expected array properties.
|
||||||
|
await page.route('/api/**', (route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
|
||||||
const inputs = page.locator('input');
|
if (url.includes('/alerts')) {
|
||||||
const count = await inputs.count();
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
if (count >= 2) {
|
contentType: 'application/json',
|
||||||
// Login page is shown
|
body: JSON.stringify({ alerts: [], total: 0 }),
|
||||||
await inputs.first().fill('admin');
|
});
|
||||||
await inputs.nth(1).fill('admin123');
|
return;
|
||||||
|
|
||||||
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 no inputs, user is already logged in (token in localStorage)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('监测面板 (Monitoring Dashboard)', () => {
|
if (url.includes('/history/aggregated')) {
|
||||||
test('面板加载并显示统计卡片', async ({ page }) => {
|
route.fulfill({
|
||||||
await page.goto(BASE_URL);
|
status: 200,
|
||||||
await page.waitForTimeout(3000);
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ aggregations: [], total_records: 0 }),
|
||||||
// Should show monitoring page by default
|
});
|
||||||
const statCards = page.locator('[class*="stat"], [class*="card"], [class*="Stat"]');
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
test('疾病筛选器可用', async ({ page }) => {
|
if (url.includes('/grids')) {
|
||||||
await page.goto(BASE_URL);
|
route.fulfill({
|
||||||
await page.waitForTimeout(3000);
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
const selects = page.locator('select, [role="combobox"], [class*="select" i], [class*="filter" i]');
|
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
|
||||||
const count = await selects.count();
|
});
|
||||||
expect(count >= 0).toBeTruthy();
|
return;
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
test('预警列表加载', async ({ page }) => {
|
// DemographicsResponse — used by DemographicAnalysis page.
|
||||||
await page.goto(BASE_URL);
|
if (url.includes('/cases/demographics')) {
|
||||||
await page.waitForTimeout(2000);
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
const alertsLink = page.locator('a[href*="alert" i], button:has-text("预警"), span:has-text("预警")');
|
contentType: 'application/json',
|
||||||
if (await alertsLink.count() > 0) {
|
body: JSON.stringify({
|
||||||
await alertsLink.first().click();
|
age_distribution: [],
|
||||||
await page.waitForTimeout(3000);
|
gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
|
||||||
|
age_diagnosis_matrix: [],
|
||||||
const bodyText = await page.textContent('body');
|
}),
|
||||||
expect(bodyText).toBeTruthy();
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('趋势分析 (Trend Analysis)', () => {
|
// DiseaseAnalysis calls: diagnosis-distribution, seasonality, districts.
|
||||||
test('导航到趋势分析页面', async ({ page }) => {
|
if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
|
||||||
await page.goto(BASE_URL);
|
route.fulfill({
|
||||||
await page.waitForTimeout(2000);
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
const trendLink = page.locator('button:has-text("趋势"), span:has-text("趋势"), a[href*="trend" i]');
|
body: JSON.stringify([]),
|
||||||
if (await trendLink.count() > 0) {
|
});
|
||||||
await trendLink.first().click();
|
return;
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
test('趋势图渲染', async ({ page }) => {
|
if (url.includes('/cases/districts') || url.includes('/districts')) {
|
||||||
await page.goto(BASE_URL);
|
route.fulfill({
|
||||||
await page.waitForTimeout(2000);
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
const trendLink = page.locator('button:has-text("趋势"), span:has-text("趋势")');
|
body: JSON.stringify([]),
|
||||||
if (await trendLink.count() > 0) {
|
});
|
||||||
await trendLink.first().click();
|
return;
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
// Recharts renders SVG charts
|
|
||||||
const svgCharts = page.locator('svg.recharts-surface');
|
|
||||||
const chartCount = await svgCharts.count();
|
|
||||||
expect(chartCount >= 0).toBeTruthy();
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('区县对比 (District Comparison)', () => {
|
// Trend / time-series endpoints.
|
||||||
test('导航到区县对比页面', async ({ page }) => {
|
if (url.includes('/cases/trend') || url.includes('/cases')) {
|
||||||
await page.goto(BASE_URL);
|
route.fulfill({
|
||||||
await page.waitForTimeout(2000);
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
const districtLink = page.locator('button:has-text("区县"), button:has-text("对比"), span:has-text("区县")');
|
body: JSON.stringify({ data: [], total: 0 }),
|
||||||
if (await districtLink.count() > 0) {
|
});
|
||||||
await districtLink.first().click();
|
return;
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('报告中心 (Reports Center)', () => {
|
// Default fallback — return a safe empty object.
|
||||||
test('导航到报告中心', async ({ page }) => {
|
route.fulfill({
|
||||||
await page.goto(BASE_URL);
|
status: 200,
|
||||||
await page.waitForTimeout(2000);
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ data: [], items: [], total: 0 }),
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await page.goto(BASE_URL);
|
// ---------------------------------------------------------------------------
|
||||||
await page.waitForTimeout(3000);
|
// URL-based navigation (react-router v6)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const filtered = errors.filter(
|
test.describe('URL-based navigation', () => {
|
||||||
(e) => !e.includes('favicon') && !e.includes('404') && !e.includes('OLMap')
|
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.describe('Responsive layout — desktop @1280px', () => {
|
||||||
test('移动端视口下不崩溃', async ({ page }) => {
|
test.use({ viewport: { width: 1280, height: 800 } });
|
||||||
await page.setViewportSize({ width: 375, height: 812 });
|
|
||||||
await page.goto(BASE_URL);
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
const bodyText = await page.textContent('body');
|
test.beforeEach(async ({ page }) => {
|
||||||
expect(bodyText).toBeTruthy();
|
await seedAuthAndMockApi(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('平板视口下正常显示', async ({ page }) => {
|
test('sidebar-rail is visible and hamburger is hidden at 1280px', async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 768, height: 1024 });
|
await page.goto('/monitoring');
|
||||||
await page.goto(BASE_URL);
|
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
const bodyText = await page.textContent('body');
|
await expect(page.locator(`[data-testid="${TESTIDS.sidebarRail}"]`)).toBeVisible();
|
||||||
expect(bodyText).toBeTruthy();
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
|
"react-router-dom": "^6.30.4",
|
||||||
"recharts": "^2.12.0",
|
"recharts": "^2.12.0",
|
||||||
"zustand": "^4.5.0"
|
"zustand": "^4.5.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ export default defineConfig({
|
|||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
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'] },
|
use: { ...devices['Desktop Chrome'] },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
34
frontend/pnpm-lock.yaml
generated
34
frontend/pnpm-lock.yaml
generated
@@ -26,6 +26,9 @@ importers:
|
|||||||
react-leaflet:
|
react-leaflet:
|
||||||
specifier: ^4.2.1
|
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)
|
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:
|
recharts:
|
||||||
specifier: ^2.12.0
|
specifier: ^2.12.0
|
||||||
version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
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: ^18.0.0
|
||||||
react-dom: ^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':
|
'@rolldown/pluginutils@1.0.0-beta.27':
|
||||||
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
||||||
|
|
||||||
@@ -1504,6 +1511,19 @@ packages:
|
|||||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
react-smooth@4.0.4:
|
||||||
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
|
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2157,6 +2177,8 @@ snapshots:
|
|||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(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': {}
|
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.60.2':
|
'@rollup/rollup-android-arm-eabi@4.60.2':
|
||||||
@@ -3233,6 +3255,18 @@ snapshots:
|
|||||||
|
|
||||||
react-refresh@0.17.0: {}
|
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):
|
react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-equals: 5.4.0
|
fast-equals: 5.4.0
|
||||||
|
|||||||
BIN
frontend/public/wuhan_districts.geojson
LFS
Normal file
BIN
frontend/public/wuhan_districts.geojson
LFS
Normal file
Binary file not shown.
@@ -1,19 +1,9 @@
|
|||||||
import { useEffect, useState, Component, ReactNode, Suspense, lazy, useCallback } from 'react';
|
import { useEffect, useState, Component, ReactNode, useCallback } from 'react';
|
||||||
import { TopNav } from '@/components/TopNav';
|
import { BrowserRouter, Routes, Route, useRoutes } from 'react-router-dom';
|
||||||
import { SideNav } from '@/components/SideNav';
|
import { AppShell } from '@/components/AppShell';
|
||||||
import { useRiskStore } from '@/stores';
|
import { useRiskStore } from '@/stores';
|
||||||
import { Login } from '@/pages/Login';
|
import { Login } from '@/pages/Login';
|
||||||
|
import { appRoutes } from '@/routes';
|
||||||
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 })));
|
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -55,28 +45,25 @@ class ErrorBoundary extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function PageLoader() {
|
// 已登录:AppShell 提供布局骨架,子路由表渲染到其 <Outlet/>。
|
||||||
return (
|
function AuthedApp({ onLogout }: { onLogout: () => void }) {
|
||||||
<div className="flex items-center justify-center h-[60vh]">
|
const element = useRoutes([
|
||||||
<div className="text-text-secondary text-[13px]">加载中...</div>
|
{
|
||||||
</div>
|
element: <AppShell onLogout={onLogout} />,
|
||||||
);
|
children: appRoutes,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [activePage, setActivePage] = useState('monitoring');
|
|
||||||
const [token, setToken] = useState<string | null>(() => localStorage.getItem('cbpoa_token'));
|
const [token, setToken] = useState<string | null>(() => localStorage.getItem('cbpoa_token'));
|
||||||
const alerts = useRiskStore((s) => s.alerts);
|
|
||||||
const fetchAlerts = useRiskStore((s) => s.fetchAlerts);
|
const fetchAlerts = useRiskStore((s) => s.fetchAlerts);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) fetchAlerts();
|
if (token) fetchAlerts();
|
||||||
}, [fetchAlerts, token]);
|
}, [fetchAlerts, token]);
|
||||||
|
|
||||||
const handlePageChange = useCallback((page: string) => {
|
|
||||||
setActivePage(page);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLogin = useCallback((newToken: string) => {
|
const handleLogin = useCallback((newToken: string) => {
|
||||||
setToken(newToken);
|
setToken(newToken);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -86,41 +73,18 @@ function App() {
|
|||||||
setToken(null);
|
setToken(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<Login onLogin={handleLogin} />
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<div className="min-h-screen bg-bg-page flex flex-col">
|
<BrowserRouter>
|
||||||
<TopNav onLogout={handleLogout} />
|
{token ? (
|
||||||
|
<AuthedApp onLogout={handleLogout} />
|
||||||
<div className="flex flex-1 pt-[52px]">
|
) : (
|
||||||
<SideNav
|
// 鉴权门:无 token 时所有路由都进入登录页。
|
||||||
activePage={activePage}
|
<Routes>
|
||||||
onPageChange={handlePageChange}
|
<Route path="*" element={<Login onLogin={handleLogin} />} />
|
||||||
alertCount={alerts.length}
|
</Routes>
|
||||||
/>
|
)}
|
||||||
|
</BrowserRouter>
|
||||||
<main className="flex-1 ml-[200px] p-5 min-w-0">
|
|
||||||
<Suspense fallback={<PageLoader />}>
|
|
||||||
{activePage === 'monitoring' && <MonitoringDashboard />}
|
|
||||||
{activePage === 'alerts' && <AlertsDashboard />}
|
|
||||||
{activePage === 'trend-analysis' && <TrendAnalysis />}
|
|
||||||
{activePage === 'district-comparison' && <DistrictComparison />}
|
|
||||||
{activePage === 'insights' && <Insights />}
|
|
||||||
{activePage === 'reports' && <ReportsCenter />}
|
|
||||||
{activePage === 'demographics' && <DemographicAnalysis />}
|
|
||||||
{activePage === 'disease' && <DiseaseAnalysis />}
|
|
||||||
{activePage === 'environment' && <EnvironmentalHealth />}
|
|
||||||
</Suspense>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
141
frontend/src/components/AppShell.tsx
Normal file
141
frontend/src/components/AppShell.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { TopNav } from '@/components/TopNav';
|
||||||
|
import { SideNav } from '@/components/SideNav';
|
||||||
|
import { RouteErrorBoundary } from '@/components/RouteErrorBoundary';
|
||||||
|
import { useRiskStore } from '@/stores';
|
||||||
|
import { TESTIDS } from '@/utils/testids';
|
||||||
|
|
||||||
|
interface AppShellProps {
|
||||||
|
onLogout?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集容器内当前可聚焦的元素,供初始聚焦与焦点循环陷阱使用。
|
||||||
|
function getFocusable(container: HTMLElement): HTMLElement[] {
|
||||||
|
return Array.from(
|
||||||
|
container.querySelectorAll<HTMLElement>(
|
||||||
|
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||||
|
)
|
||||||
|
).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅负责布局骨架(顶栏 / 侧栏 / 内容区),不涉及路由匹配与鉴权。
|
||||||
|
export function AppShell({ onLogout }: AppShellProps) {
|
||||||
|
const alerts = useRiskStore((s) => s.alerts);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
// 提升手风琴展开态:导轨与抽屉两份 SideNav 共享,保持同步。
|
||||||
|
const [expandedNav, setExpandedNav] = useState<string | null>('monitoring');
|
||||||
|
const drawerRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
const openDrawer = useCallback(() => setDrawerOpen(true), []);
|
||||||
|
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
|
||||||
|
|
||||||
|
// 抽屉作为模态:ESC 关闭、锁定 body 滚动、焦点移入并在关闭后归还给汉堡。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!drawerOpen) return;
|
||||||
|
|
||||||
|
const opener = document.activeElement as HTMLElement | null;
|
||||||
|
|
||||||
|
// 锁定 body 滚动,关闭时还原原值。
|
||||||
|
const prevOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
// 焦点移入抽屉(优先第一个可聚焦元素,否则聚焦抽屉容器本身)。
|
||||||
|
const drawer = drawerRef.current;
|
||||||
|
const focusables = drawer ? getFocusable(drawer) : [];
|
||||||
|
(focusables[0] ?? drawer)?.focus();
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
closeDrawer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 焦点循环陷阱:Tab 在抽屉内首尾元素之间循环。
|
||||||
|
if (e.key === 'Tab' && drawer) {
|
||||||
|
const items = getFocusable(drawer);
|
||||||
|
if (items.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
drawer.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = items[0];
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (e.shiftKey && (active === first || active === drawer)) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!e.shiftKey && active === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
document.body.style.overflow = prevOverflow;
|
||||||
|
// 关闭后把焦点还给打开抽屉的元素(汉堡按钮),回退到按 testid 查询。
|
||||||
|
const restoreTarget =
|
||||||
|
opener ??
|
||||||
|
document.querySelector<HTMLElement>(`[data-testid="${TESTIDS.hamburger}"]`);
|
||||||
|
restoreTarget?.focus();
|
||||||
|
};
|
||||||
|
}, [drawerOpen, closeDrawer]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid={TESTIDS.appShell} className="h-screen bg-bg-page flex flex-col overflow-hidden">
|
||||||
|
<TopNav onLogout={onLogout} onToggleMenu={openDrawer} isMenuOpen={drawerOpen} />
|
||||||
|
|
||||||
|
<div className="flex flex-1 min-h-0">
|
||||||
|
{/* lg 及以上:持久侧栏导轨 */}
|
||||||
|
<aside
|
||||||
|
data-testid={TESTIDS.sidebarRail}
|
||||||
|
className="hidden lg:block w-[200px] shrink-0 bg-bg-card border-r border-border"
|
||||||
|
>
|
||||||
|
<SideNav
|
||||||
|
alertCount={alerts.length}
|
||||||
|
expanded={expandedNav}
|
||||||
|
onExpandedChange={setExpandedNav}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* lg 以下:离屏抽屉 + 遮罩 */}
|
||||||
|
{drawerOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 bg-black/40 lg:hidden"
|
||||||
|
onClick={closeDrawer}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<aside
|
||||||
|
ref={drawerRef}
|
||||||
|
id="app-drawer"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="导航菜单"
|
||||||
|
tabIndex={-1}
|
||||||
|
data-testid={TESTIDS.appDrawer}
|
||||||
|
className={`fixed top-0 left-0 bottom-0 z-50 w-[260px] max-w-[80vw] bg-bg-card border-r border-border shadow-xl transition-transform duration-200 lg:hidden ${
|
||||||
|
drawerOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<SideNav
|
||||||
|
alertCount={alerts.length}
|
||||||
|
onNavigate={closeDrawer}
|
||||||
|
expanded={expandedNav}
|
||||||
|
onExpandedChange={setExpandedNav}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="flex-1 min-w-0 overflow-auto p-5">
|
||||||
|
<RouteErrorBoundary>
|
||||||
|
<Outlet />
|
||||||
|
</RouteErrorBoundary>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,62 @@
|
|||||||
import { useEffect, useRef, useState, memo } from 'react';
|
import { useEffect, useRef, useState, memo } from 'react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import { geocodedApi } from '@/services/api';
|
import { geocodedApi } from '@/services/api';
|
||||||
|
import { TESTIDS } from '@/utils/testids';
|
||||||
import type { GeocodedCase } from '@/types';
|
import type { GeocodedCase } from '@/types';
|
||||||
|
|
||||||
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
|
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 {
|
interface CaseLocationMapProps {
|
||||||
height?: string;
|
height?: string;
|
||||||
district?: string | null;
|
district?: string | null;
|
||||||
street?: string | null;
|
street?: string | null;
|
||||||
date?: string | null;
|
date?: string | null;
|
||||||
|
mode?: CaseMapMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CaseLocationMapComponent({ height = '400px', district = null, street = null, date = null }: CaseLocationMapProps) {
|
// 聚合中心:按 street(无则 district)分组,取经纬度均值 + 计数。
|
||||||
|
interface DensityCluster {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateClusters(cases: GeocodedCase[]): DensityCluster[] {
|
||||||
|
const groups: Record<string, { latSum: number; lonSum: number; count: number; label: string }> = {};
|
||||||
|
for (const c of cases) {
|
||||||
|
if (!c.latitude || !c.longitude) continue;
|
||||||
|
const key = `${c.district}/${c.street || ''}`;
|
||||||
|
const label = c.street ? `${c.district} ${c.street}` : c.district;
|
||||||
|
if (!groups[key]) groups[key] = { latSum: 0, lonSum: 0, count: 0, label };
|
||||||
|
groups[key].latSum += c.latitude;
|
||||||
|
groups[key].lonSum += c.longitude;
|
||||||
|
groups[key].count += 1;
|
||||||
|
}
|
||||||
|
return Object.entries(groups).map(([key, g]) => ({
|
||||||
|
key,
|
||||||
|
label: g.label,
|
||||||
|
latitude: g.latSum / g.count,
|
||||||
|
longitude: g.lonSum / g.count,
|
||||||
|
count: g.count,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function CaseLocationMapComponent({
|
||||||
|
height = '400px',
|
||||||
|
district = null,
|
||||||
|
street = null,
|
||||||
|
date = null,
|
||||||
|
mode = 'points',
|
||||||
|
}: CaseLocationMapProps) {
|
||||||
const mapRef = useRef<HTMLDivElement>(null);
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||||
const layerRef = useRef<L.LayerGroup | null>(null);
|
const layerRef = useRef<L.LayerGroup | null>(null);
|
||||||
@@ -20,6 +64,10 @@ function CaseLocationMapComponent({ height = '400px', district = null, street =
|
|||||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [caseCount, setCaseCount] = useState(0);
|
const [caseCount, setCaseCount] = useState(0);
|
||||||
|
// points 模式下的隐藏 DOM 镜像(每病例一项,供 e2e 对 patient-point 计数)。
|
||||||
|
// density 模式下保持为空数组 —— 隐私不变量:医生视角下 patient-point 必须为 0。
|
||||||
|
const [pointKeys, setPointKeys] = useState<string[]>([]);
|
||||||
|
const [clusterCount, setClusterCount] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapRef.current || mapInstanceRef.current) return;
|
if (!mapRef.current || mapInstanceRef.current) return;
|
||||||
@@ -79,6 +127,42 @@ function CaseLocationMapComponent({ height = '400px', district = null, street =
|
|||||||
|
|
||||||
if (cancelledRef.current) return;
|
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(
|
||||||
|
`<div style="font-size:12px"><strong>${cl.label}</strong><br/>病例数: ${cl.count}</div>`,
|
||||||
|
{ direction: 'top', offset: [0, -4] }
|
||||||
|
);
|
||||||
|
marker.addTo(layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
setClusterCount(clusters.length);
|
||||||
|
setCaseCount(unique.length);
|
||||||
|
setPointKeys([]); // 隐私不变量:density 下无个体点镜像
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
if (clusters.length > 0) {
|
||||||
|
const bounds = L.latLngBounds(clusters.map((c) => [c.latitude, c.longitude]));
|
||||||
|
map.fitBounds(bounds, { padding: [30, 30] });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// points 模式(默认):逐病例渲染个体 circleMarker。
|
||||||
|
const keys: string[] = [];
|
||||||
for (const c of unique) {
|
for (const c of unique) {
|
||||||
if (!c.latitude || !c.longitude) continue;
|
if (!c.latitude || !c.longitude) continue;
|
||||||
|
|
||||||
@@ -100,9 +184,12 @@ function CaseLocationMapComponent({ height = '400px', district = null, street =
|
|||||||
);
|
);
|
||||||
|
|
||||||
marker.addTo(layer);
|
marker.addTo(layer);
|
||||||
|
keys.push(c.case_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setClusterCount(0);
|
||||||
setCaseCount(unique.length);
|
setCaseCount(unique.length);
|
||||||
|
setPointKeys(keys); // 隐藏 DOM 镜像供 e2e 计数
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
// Fit bounds to case locations
|
// Fit bounds to case locations
|
||||||
@@ -124,23 +211,41 @@ function CaseLocationMapComponent({ height = '400px', district = null, street =
|
|||||||
map.remove();
|
map.remove();
|
||||||
mapInstanceRef.current = null;
|
mapInstanceRef.current = null;
|
||||||
};
|
};
|
||||||
}, [district, street, date]);
|
}, [district, street, date, mode]);
|
||||||
|
|
||||||
|
const isDensity = mode === 'density';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative" data-case-map-mode={mode}>
|
||||||
<div ref={mapRef} style={{ height, width: '100%', borderRadius: '8px' }} />
|
<div ref={mapRef} style={{ height, width: '100%', borderRadius: '8px' }} />
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-white/80 rounded-lg">
|
<div className="absolute inset-0 flex items-center justify-center bg-white/80 rounded-lg">
|
||||||
<div className="text-sm text-gray-500">加载病例位置...</div>
|
<div className="text-sm text-gray-500">加载病例位置...</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isLoading && (
|
{!isLoading && !isDensity && (
|
||||||
<div className="absolute top-2 right-2 bg-white/90 px-3 py-1.5 rounded shadow text-xs">
|
<div className="absolute top-2 right-2 bg-white/90 px-3 py-1.5 rounded shadow text-xs">
|
||||||
<span className="text-blue-600 font-semibold">{caseCount.toLocaleString()}</span> 个病例位置
|
<span className="text-blue-600 font-semibold">{caseCount.toLocaleString()}</span> 个病例位置
|
||||||
<span className="ml-2 text-red-500">● 住院</span>
|
<span className="ml-2 text-red-500">● 住院</span>
|
||||||
<span className="ml-1 text-blue-500">● 门诊</span>
|
<span className="ml-1 text-blue-500">● 门诊</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!isLoading && isDensity && (
|
||||||
|
<div className="absolute top-2 right-2 bg-white/90 px-3 py-1.5 rounded shadow text-xs">
|
||||||
|
<span className="text-purple-600 font-semibold">{clusterCount.toLocaleString()}</span> 个聚合区域
|
||||||
|
<span className="ml-2 text-gray-500">按区域聚合密度(隐私保护)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/*
|
||||||
|
隐藏 DOM 镜像:points 模式下每病例输出一个 patient-point 节点,使 e2e 能对
|
||||||
|
Leaflet canvas 之外的真实 DOM 做计数断言。density 模式下 pointKeys 恒为空,
|
||||||
|
因此医生视角下 [data-testid=patient-point] 数量必为 0(隐私不变量)。
|
||||||
|
*/}
|
||||||
|
<div className="hidden" aria-hidden="true">
|
||||||
|
{pointKeys.map((id) => (
|
||||||
|
<span key={id} data-testid={TESTIDS.patientPoint} data-case-id={id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { memo, useEffect, useRef, useState, useCallback } from 'react';
|
import { memo, useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import { Skeleton } from '@/components/ui';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { geocodedApi } from '@/services/api';
|
import { geocodedApi } from '@/services/api';
|
||||||
@@ -340,7 +341,7 @@ function CaseMapComponent({ height = '480px' }: CaseMapProps) {
|
|||||||
<div className="bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm px-3 py-2">
|
<div className="bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm px-3 py-2">
|
||||||
<div className="text-[11px] text-text-secondary">
|
<div className="text-[11px] text-text-secondary">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<span className="text-text-muted">数据加载中...</span>
|
<Skeleton className="h-3 w-20 inline-block align-middle" />
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<span className="text-danger">加载失败: {error}</span>
|
<span className="text-danger">加载失败: {error}</span>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
13
frontend/src/components/RoleRedirect.tsx
Normal file
13
frontend/src/components/RoleRedirect.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useSessionStore } from '@/stores';
|
||||||
|
import { roleDefaultPath } from '@/utils/roleViews';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据当前视角(role)把裸路径 `/` 重定向到该视角的默认落地页。
|
||||||
|
* D2:纯前端视图预设 —— role 只决定默认落地页,不是访问控制。
|
||||||
|
* 角色来源被隔离在 sessionStore 的 getRoleSource() 接缝里。
|
||||||
|
*/
|
||||||
|
export function RoleRedirect(): JSX.Element {
|
||||||
|
const role = useSessionStore((s) => s.role);
|
||||||
|
return <Navigate to={roleDefaultPath(role)} replace />;
|
||||||
|
}
|
||||||
45
frontend/src/components/RouteErrorBoundary.tsx
Normal file
45
frontend/src/components/RouteErrorBoundary.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Component, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路由级错误边界:单个页面(含懒加载 chunk)崩溃时只降级内容区,
|
||||||
|
// 保留外层骨架(顶栏 + 侧栏),避免整页白屏。
|
||||||
|
export class RouteErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError() {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private reset = () => {
|
||||||
|
this.setState({ hasError: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-danger text-base mb-2">此页面加载失败</div>
|
||||||
|
<button
|
||||||
|
onClick={this.reset}
|
||||||
|
className="mt-2 px-4 py-2 bg-primary text-white rounded text-sm"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,76 +1,97 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
|
import { TESTIDS } from '@/utils/testids';
|
||||||
|
|
||||||
interface SideNavProps {
|
interface SideNavProps {
|
||||||
activePage: string;
|
|
||||||
onPageChange: (page: string) => void;
|
|
||||||
alertCount?: number;
|
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',
|
id: 'monitoring',
|
||||||
label: '监测',
|
label: '监测',
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/>
|
<path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
items: [
|
items: [{ to: '/monitoring', label: '监测面板', testid: TESTIDS.navMonitoring }],
|
||||||
{ id: 'monitoring', label: '监测面板' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'alert',
|
id: 'alert',
|
||||||
label: '预警',
|
label: '预警',
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
|
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
items: [
|
items: [{ to: '/alerts', label: '预警地图', testid: TESTIDS.navAlerts }],
|
||||||
{ id: 'alerts', label: '预警地图' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'analysis',
|
id: 'analysis',
|
||||||
label: '分析',
|
label: '分析',
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"/>
|
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
items: [
|
items: [
|
||||||
{ id: 'trend-analysis', label: '趋势分析' },
|
{ to: '/overview', label: '总览', testid: TESTIDS.navOverview },
|
||||||
{ id: 'district-comparison', label: '区域对比' },
|
{ to: '/analysis/trend', label: '趋势分析', testid: TESTIDS.navTrend },
|
||||||
{ id: 'insights', label: '智能洞察' },
|
{ to: '/analysis/district', label: '区域对比', testid: TESTIDS.navDistrict },
|
||||||
{ id: 'reports', label: '报表中心' },
|
{ to: '/analysis/insights', label: '智能洞察', testid: TESTIDS.navInsights },
|
||||||
{ id: 'demographics', label: '人群分析' },
|
{ to: '/analysis/reports', label: '报表中心', testid: TESTIDS.navReports },
|
||||||
{ id: 'disease', label: '疾病分析' },
|
{ to: '/analysis/demographics', label: '人群分析', testid: TESTIDS.navDemographics },
|
||||||
{ id: 'environment', label: '环境健康' },
|
{ to: '/analysis/disease', label: '疾病分析', testid: TESTIDS.navDisease },
|
||||||
|
{ to: '/analysis/clinical', label: '临床分析', testid: TESTIDS.navClinical },
|
||||||
|
{ to: '/analysis/environment', label: '环境健康', testid: TESTIDS.navEnvironment },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function SideNav({
|
export function SideNav({
|
||||||
activePage,
|
|
||||||
onPageChange,
|
|
||||||
alertCount = 0,
|
alertCount = 0,
|
||||||
|
onNavigate,
|
||||||
|
expanded: expandedProp,
|
||||||
|
onExpandedChange,
|
||||||
}: SideNavProps) {
|
}: SideNavProps) {
|
||||||
const [expanded, setExpanded] = useState<string | null>('monitoring');
|
const location = useLocation();
|
||||||
|
|
||||||
const handleItemClick = (moduleId: string, itemId: string) => {
|
// 当前路径命中的模块默认展开。
|
||||||
setExpanded(moduleId);
|
const moduleForPath = (pathname: string) =>
|
||||||
onPageChange(itemId);
|
modules.find((m) => m.items.some((item) => pathname.startsWith(item.to)))?.id ?? 'monitoring';
|
||||||
|
|
||||||
|
// 受控/非受控双模式:父级传入 expanded 时由父级管理,否则回退内部 state。
|
||||||
|
const [internalExpanded, setInternalExpanded] = useState<string | null>(() =>
|
||||||
|
moduleForPath(location.pathname)
|
||||||
|
);
|
||||||
|
const isControlled = expandedProp !== undefined;
|
||||||
|
const expanded = isControlled ? expandedProp : internalExpanded;
|
||||||
|
const setExpanded = (next: string | null) => {
|
||||||
|
if (isControlled) onExpandedChange?.(next);
|
||||||
|
else setInternalExpanded(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isActiveModule = (moduleId: string) => {
|
const isActiveModule = (moduleId: string) => {
|
||||||
const module = modules.find(m => m.id === moduleId);
|
const module = modules.find((m) => m.id === moduleId);
|
||||||
if (!module) return false;
|
if (!module) return false;
|
||||||
return module.items.some(item => item.id === activePage);
|
return module.items.some((item) => location.pathname.startsWith(item.to));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-[200px] bg-bg-card border-r border-border fixed top-[52px] left-0 bottom-0 overflow-y-auto py-4 px-2">
|
<nav className="h-full overflow-y-auto py-4 px-2">
|
||||||
{modules.map((module) => (
|
{modules.map((module) => (
|
||||||
<div key={module.id} className="mb-4">
|
<div key={module.id} className="mb-4">
|
||||||
<button
|
<button
|
||||||
@@ -81,9 +102,7 @@ export function SideNav({
|
|||||||
: 'text-text-primary hover:bg-bg-hover'
|
: 'text-text-primary hover:bg-bg-hover'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="w-4 h-4 flex items-center justify-center">
|
<span className="w-4 h-4 flex items-center justify-center">{module.icon}</span>
|
||||||
{module.icon}
|
|
||||||
</span>
|
|
||||||
<span>{module.label}</span>
|
<span>{module.label}</span>
|
||||||
{module.id === 'alert' && alertCount > 0 && (
|
{module.id === 'alert' && alertCount > 0 && (
|
||||||
<span className="ml-auto bg-danger-light text-danger text-[10px] font-semibold px-[5px] py-[2px] rounded">
|
<span className="ml-auto bg-danger-light text-danger text-[10px] font-semibold px-[5px] py-[2px] rounded">
|
||||||
@@ -95,22 +114,26 @@ export function SideNav({
|
|||||||
{expanded === module.id && (
|
{expanded === module.id && (
|
||||||
<div className="mt-1 pl-7">
|
<div className="mt-1 pl-7">
|
||||||
{module.items.map((item) => (
|
{module.items.map((item) => (
|
||||||
<button
|
<NavLink
|
||||||
key={item.id}
|
key={item.to}
|
||||||
onClick={() => handleItemClick(module.id, item.id)}
|
to={item.to}
|
||||||
className={`w-full text-left px-3 py-[7px] rounded text-[13px] font-medium transition-colors ${
|
data-testid={item.testid}
|
||||||
activePage === item.id
|
onClick={onNavigate}
|
||||||
? 'bg-bg-active text-primary'
|
className={({ isActive }) =>
|
||||||
: 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
|
`block w-full text-left px-3 py-[7px] rounded text-[13px] font-medium transition-colors ${
|
||||||
}`}
|
isActive
|
||||||
|
? 'bg-bg-active text-primary'
|
||||||
|
: 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
|
||||||
|
}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</button>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</aside>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { useState, useEffect } from 'react';
|
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 {
|
interface TopNavProps {
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
|
// 移动端汉堡按钮:切换侧栏抽屉。
|
||||||
|
onToggleMenu?: () => void;
|
||||||
|
// 抽屉是否展开(用于汉堡按钮的 aria-expanded)。
|
||||||
|
isMenuOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Clock() {
|
function Clock() {
|
||||||
@@ -13,14 +21,61 @@ function Clock() {
|
|||||||
return <span>{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>;
|
return <span>{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopNav({ onLogout }: TopNavProps) {
|
// 视角切换器:纯前端视图预设(D2)。刻意标注「视角」而非「权限」——不是访问控制。
|
||||||
|
// 切换时持久化角色并跳转到该视角的默认落地页。
|
||||||
|
function PerspectiveSwitcher() {
|
||||||
|
const role = useSessionStore((s) => s.role);
|
||||||
|
const setRole = useSessionStore((s) => s.setRole);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleChange = (next: Role) => {
|
||||||
|
setRole(next);
|
||||||
|
navigate(roleDefaultPath(next));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="h-[52px] bg-bg-card border-b border-border flex items-center px-5 fixed top-0 left-0 right-0 z-50">
|
<label className="flex items-center gap-1.5 text-[13px] text-text-secondary">
|
||||||
|
<span className="text-text-muted hidden sm:inline">视角</span>
|
||||||
|
<select
|
||||||
|
data-testid={TESTIDS.perspectiveSwitcher}
|
||||||
|
value={role}
|
||||||
|
onChange={(e) => handleChange(e.target.value as Role)}
|
||||||
|
aria-label="切换视角"
|
||||||
|
className="bg-bg-card border border-border rounded-md px-2 py-1 text-[13px] text-text-primary hover:bg-bg-hover focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer"
|
||||||
|
>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<option key={r} value={r} data-testid={`${TESTIDS.perspectiveOption}-${r}`}>
|
||||||
|
{ROLE_LABELS[r]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopNav({ onLogout, onToggleMenu, isMenuOpen = false }: TopNavProps) {
|
||||||
|
return (
|
||||||
|
<nav className="h-[52px] bg-bg-card border-b border-border flex items-center px-5 z-50">
|
||||||
|
{onToggleMenu && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleMenu}
|
||||||
|
aria-label="打开菜单"
|
||||||
|
aria-expanded={isMenuOpen}
|
||||||
|
aria-controls="app-drawer"
|
||||||
|
data-testid={TESTIDS.hamburger}
|
||||||
|
className="lg:hidden mr-3 -ml-1 w-9 h-9 flex items-center justify-center rounded-md text-text-secondary hover:bg-bg-hover transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-7 h-7 bg-primary rounded-md flex items-center justify-center">
|
<div className="w-7 h-7 bg-primary rounded-md flex items-center justify-center">
|
||||||
<svg className="w-4 h-4 fill-white" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 fill-white" viewBox="0 0 24 24">
|
||||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 3c1.93 0 3.5 1.57 3.5 3.5S13.93 13 12 13s-3.5-1.57-3.5-3.5S10.07 6 12 6zm7 13H5v-.23c0-.62.28-1.2.76-1.58C7.47 15.82 9.64 15 12 15s4.53.82 6.24 2.19c.48.38.76.97.76 1.58V19z"/>
|
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 3c1.93 0 3.5 1.57 3.5 3.5S13.93 13 12 13s-3.5-1.57-3.5-3.5S10.07 6 12 6zm7 13H5v-.23c0-.62.28-1.2.76-1.58C7.47 15.82 9.64 15 12 15s4.53.82 6.24 2.19c.48.38.76.97.76 1.58V19z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-display font-semibold text-[15px] text-text-primary">
|
<span className="font-display font-semibold text-[15px] text-text-primary">
|
||||||
@@ -28,21 +83,21 @@ export function TopNav({ onLogout }: TopNavProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-px h-5 bg-border ml-4 mr-4" />
|
<div className="w-px h-5 bg-border ml-4 mr-4 hidden sm:block" />
|
||||||
|
|
||||||
<span className="text-[13px] text-text-secondary">
|
<span className="text-[13px] text-text-secondary hidden sm:inline">
|
||||||
儿童呼吸道疾病风险监测预警平台
|
儿童呼吸道疾病风险监测预警平台
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-5">
|
<div className="ml-auto flex items-center gap-5">
|
||||||
<span className="text-[12px] text-text-muted">
|
<span className="text-[12px] text-text-muted hidden sm:inline">
|
||||||
<Clock />
|
<Clock />
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2 text-[13px] text-text-secondary">
|
<div className="flex items-center gap-2 text-[13px] text-text-secondary">
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||||
</svg>
|
</svg>
|
||||||
admin
|
<PerspectiveSwitcher />
|
||||||
</div>
|
</div>
|
||||||
{onLogout && (
|
{onLogout && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
108
frontend/src/components/alerts/AlertDetailModal.tsx
Normal file
108
frontend/src/components/alerts/AlertDetailModal.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { CellInfo } from '@/components/AlertMap';
|
||||||
|
import { HORIZON_LABELS } from './types';
|
||||||
|
import type { ExtendedAlert } from './types';
|
||||||
|
|
||||||
|
interface CellInfoPanelProps {
|
||||||
|
cellInfo: CellInfo;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cell info panel - shown when clicking grid cell without alert
|
||||||
|
export const CellInfoPanel = React.memo(function CellInfoPanel({ cellInfo, onClose }: CellInfoPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-5 left-1/2 -translate-x-1/2 bg-bg-card rounded-lg border border-border-light shadow-lg z-50 px-5 py-4 min-w-[320px]">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-[14px] font-semibold text-text-primary">网格详情 (100m)</span>
|
||||||
|
<button onClick={onClose} className="text-text-muted hover:text-text-primary text-[18px] leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 text-[12px]">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-text-muted">网格</span>
|
||||||
|
<span className="font-mono text-text-primary">{cellInfo.grid_id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-text-muted">坐标</span>
|
||||||
|
<span className="font-mono text-text-primary">{cellInfo.lat.toFixed(4)}, {cellInfo.lon.toFixed(4)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-text-muted">当前风险</span>
|
||||||
|
<span className={`font-bold ${cellInfo.risk >= 0.8 ? 'text-danger' : cellInfo.risk >= 0.6 ? 'text-warning' : cellInfo.risk >= 0.4 ? 'text-primary' : 'text-success'}`}>
|
||||||
|
{(cellInfo.risk * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-1">
|
||||||
|
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
|
||||||
|
<div className="text-[10px] text-text-muted">1天</div>
|
||||||
|
<div className="font-bold text-[13px]">{(cellInfo.risk_1d * 100).toFixed(0)}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
|
||||||
|
<div className="text-[10px] text-text-muted">3天</div>
|
||||||
|
<div className="font-bold text-[13px]">{(cellInfo.risk_3d * 100).toFixed(0)}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
|
||||||
|
<div className="text-[10px] text-text-muted">7天</div>
|
||||||
|
<div className="font-bold text-[13px]">{(cellInfo.risk_7d * 100).toFixed(0)}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{cellInfo.nearestAlertId && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-text-muted">最近预警距离</span>
|
||||||
|
<span className="text-text-primary">{(cellInfo.nearestAlertDist * 111).toFixed(1)} km</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!cellInfo.nearestAlertId && (
|
||||||
|
<div className="text-[11px] text-text-muted mt-1 pt-2 border-t border-border">
|
||||||
|
该区域无预警
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface AlertDetailModalProps {
|
||||||
|
alert: ExtendedAlert;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alert detail modal
|
||||||
|
export const AlertDetailModal = React.memo(function AlertDetailModal({ alert, onClose }: AlertDetailModalProps) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center" onClick={onClose}>
|
||||||
|
<div className="bg-bg-card rounded-lg p-6 max-w-md w-full mx-4" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3 className="font-display text-[16px] font-semibold mb-3">预警详情</h3>
|
||||||
|
<div className="space-y-2 text-[13px]">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-text-muted">优先级</span>
|
||||||
|
<span className={`font-bold ${alert.priority === 'P1' ? 'text-danger' : 'text-warning'}`}>
|
||||||
|
{alert.priority}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-text-muted">风险值</span>
|
||||||
|
<span className="font-bold">{Math.round(alert.risk_value * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-text-muted">预测时效</span>
|
||||||
|
<span>{HORIZON_LABELS[alert.forecast_horizon]}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-text-muted">位置</span>
|
||||||
|
<span>{alert.region}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pt-2 border-t border-border">
|
||||||
|
<div className="text-text-muted mb-1">预警原因</div>
|
||||||
|
<div className="text-[12px]">{alert.reason}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="mt-4 w-full px-4 py-2 bg-primary text-white rounded hover:bg-primary/80 transition-colors text-[13px]"
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
194
frontend/src/components/alerts/AlertsFilterBar.tsx
Normal file
194
frontend/src/components/alerts/AlertsFilterBar.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { TESTIDS } from '@/utils/testids';
|
||||||
|
import { DiseaseFilter } from '@/components/DiseaseFilter';
|
||||||
|
import { HORIZON_LABELS } from './types';
|
||||||
|
|
||||||
|
interface AlertsFilterBarProps {
|
||||||
|
selectedHorizon: number | 'all';
|
||||||
|
onHorizonChange: (horizon: number | 'all') => void;
|
||||||
|
selectedPriority: 'all' | 'P1' | 'P2';
|
||||||
|
onPriorityChange: (priority: 'all' | 'P1' | 'P2') => void;
|
||||||
|
riskRange: [number, number];
|
||||||
|
onRiskRangeChange: (range: [number, number]) => void;
|
||||||
|
showMap: boolean;
|
||||||
|
onToggleMap: () => void;
|
||||||
|
showAlertMarkers: boolean;
|
||||||
|
onToggleAlertMarkers: () => void;
|
||||||
|
showGrid: boolean;
|
||||||
|
onToggleGrid: () => void;
|
||||||
|
sortBy: 'risk' | 'time';
|
||||||
|
onSortByChange: (sortBy: 'risk' | 'time') => void;
|
||||||
|
// 视角驱动的两条不变量(结果由 orchestrator 计算后下传):
|
||||||
|
isCluster: boolean; // 聚类(医生)视角:隐藏「预警标记」切换 + 挂载病种过滤
|
||||||
|
isOfficial: boolean; // 官员视角:隐藏网格切换
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toolbar Row 2: Filters (时效/优先级/风险值/图层切换/排序).
|
||||||
|
export const AlertsFilterBar = React.memo(function AlertsFilterBar({
|
||||||
|
selectedHorizon,
|
||||||
|
onHorizonChange,
|
||||||
|
selectedPriority,
|
||||||
|
onPriorityChange,
|
||||||
|
riskRange,
|
||||||
|
onRiskRangeChange,
|
||||||
|
showMap,
|
||||||
|
onToggleMap,
|
||||||
|
showAlertMarkers,
|
||||||
|
onToggleAlertMarkers,
|
||||||
|
showGrid,
|
||||||
|
onToggleGrid,
|
||||||
|
sortBy,
|
||||||
|
onSortByChange,
|
||||||
|
isCluster,
|
||||||
|
isOfficial,
|
||||||
|
}: AlertsFilterBarProps) {
|
||||||
|
return (
|
||||||
|
<div className="card p-3 mb-4">
|
||||||
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[12px] text-text-muted">预测时效:</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['all', 1, 3, 7] as const).map((horizon) => (
|
||||||
|
<button
|
||||||
|
key={horizon}
|
||||||
|
onClick={() => onHorizonChange(horizon)}
|
||||||
|
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
||||||
|
selectedHorizon === horizon
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{horizon === 'all' ? '全部' : HORIZON_LABELS[horizon]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-border" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[12px] text-text-muted">优先级:</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['all', 'P1', 'P2'] as const).map((priority) => (
|
||||||
|
<button
|
||||||
|
key={priority}
|
||||||
|
onClick={() => onPriorityChange(priority)}
|
||||||
|
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
||||||
|
selectedPriority === priority
|
||||||
|
? priority === 'P1'
|
||||||
|
? 'bg-danger text-white'
|
||||||
|
: priority === 'P2'
|
||||||
|
? 'bg-warning text-white'
|
||||||
|
: 'bg-primary text-white'
|
||||||
|
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{priority === 'all' ? '全部' : priority}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-border" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[12px] text-text-muted">风险值:</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
value={riskRange[0]}
|
||||||
|
onChange={(e) => onRiskRangeChange([parseFloat(e.target.value) || 0, riskRange[1]])}
|
||||||
|
className="w-16 px-2 py-1.5 text-[12px] border border-border rounded bg-bg-page text-text-primary focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
<span className="text-[12px] text-text-muted">-</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
value={riskRange[1]}
|
||||||
|
onChange={(e) => onRiskRangeChange([riskRange[0], parseFloat(e.target.value) || 1])}
|
||||||
|
className="w-16 px-2 py-1.5 text-[12px] border border-border rounded bg-bg-page text-text-primary focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-border" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={onToggleMap}
|
||||||
|
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
||||||
|
showMap
|
||||||
|
? 'bg-primary/10 text-primary border border-primary/30'
|
||||||
|
: 'bg-bg-page text-text-muted border border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
地图
|
||||||
|
</button>
|
||||||
|
{/* 预警标记切换:聚类(医生)视角隐藏整块——个体病例点不可开启(隐私不变量)。 */}
|
||||||
|
{!isCluster && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleAlertMarkers}
|
||||||
|
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
||||||
|
showAlertMarkers
|
||||||
|
? 'bg-primary/10 text-primary border border-primary/30'
|
||||||
|
: 'bg-bg-page text-text-muted border border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
预警标记
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* 网格切换:官员视角隐藏整块(100m 网格对其无意义/太超前)。 */}
|
||||||
|
{!isOfficial && (
|
||||||
|
<div data-testid={TESTIDS.gridLayerWrapper}>
|
||||||
|
<button
|
||||||
|
onClick={onToggleGrid}
|
||||||
|
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
||||||
|
showGrid
|
||||||
|
? 'bg-primary/10 text-primary border border-primary/30'
|
||||||
|
: 'bg-bg-page text-text-muted border border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
网格
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 聚类(医生)视角:病种过滤是其核心工具,挂载于此。 */}
|
||||||
|
{isCluster && <DiseaseFilter />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-border" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[12px] text-text-muted">排序:</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => onSortByChange('risk')}
|
||||||
|
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
||||||
|
sortBy === 'risk'
|
||||||
|
? 'bg-bg-card text-primary border border-primary'
|
||||||
|
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
风险值
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSortByChange('time')}
|
||||||
|
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
||||||
|
sortBy === 'time'
|
||||||
|
? 'bg-bg-card text-primary border border-primary'
|
||||||
|
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
时间
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
57
frontend/src/components/alerts/AlertsHeader.tsx
Normal file
57
frontend/src/components/alerts/AlertsHeader.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface AlertsHeaderProps {
|
||||||
|
total: number;
|
||||||
|
p1: number;
|
||||||
|
p2: number;
|
||||||
|
activeTab: 'list' | 'stats';
|
||||||
|
onTabChange: (tab: 'list' | 'stats') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页头(标题 + 计数)+ 页内 tab 切换条(不走 router)。
|
||||||
|
export const AlertsHeader = React.memo(function AlertsHeader({
|
||||||
|
total,
|
||||||
|
p1,
|
||||||
|
p2,
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
}: AlertsHeaderProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4 flex-wrap gap-x-4 gap-y-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="font-display text-[18px] font-semibold mb-1">风险预警</h1>
|
||||||
|
<p className="text-[12px] text-text-muted truncate">
|
||||||
|
100m网格风险预测 · 多时间尺度预警 · 病例-气象关联分析
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-[11px] shrink-0 flex-wrap">
|
||||||
|
<span className="text-text-muted">共 <span className="font-semibold text-text-primary">{total}</span> 条预警</span>
|
||||||
|
<span className="px-2 py-1 bg-danger/10 border border-danger/20 rounded text-danger font-semibold">P1: {p1}</span>
|
||||||
|
<span className="px-2 py-1 bg-warning/10 border border-warning/20 rounded text-warning font-semibold">P2: {p2}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab strip — in-page, no router */}
|
||||||
|
<div className="flex gap-1 mb-4 border-b border-border">
|
||||||
|
{([
|
||||||
|
{ key: 'list', label: '预警列表' },
|
||||||
|
{ key: 'stats', label: '风险统计' },
|
||||||
|
] as const).map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => onTabChange(tab.key)}
|
||||||
|
className={`px-4 py-2 text-[13px] font-medium -mb-px border-b-2 transition-colors ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'border-primary text-primary'
|
||||||
|
: 'border-transparent text-text-secondary hover:text-text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
138
frontend/src/components/alerts/AlertsList.tsx
Normal file
138
frontend/src/components/alerts/AlertsList.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { HORIZON_LABELS } from './types';
|
||||||
|
import type { ExtendedAlert, RiskStats } from './types';
|
||||||
|
|
||||||
|
interface RiskDistributionSummaryProps {
|
||||||
|
riskStats: RiskStats;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预警列表 tab 顶部的风险分布概要(4 卡)。
|
||||||
|
export const RiskDistributionSummary = React.memo(function RiskDistributionSummary({
|
||||||
|
riskStats,
|
||||||
|
total,
|
||||||
|
}: RiskDistributionSummaryProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-4 gap-3 mb-4">
|
||||||
|
<div className="card p-3">
|
||||||
|
<div className="text-[11px] text-text-muted mb-1">高风险 (≥0.8)</div>
|
||||||
|
<div className="text-xl font-bold text-danger">{riskStats.high}</div>
|
||||||
|
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-danger rounded-full" style={{ width: `${total > 0 ? (riskStats.high / total) * 100 : 0}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card p-3">
|
||||||
|
<div className="text-[11px] text-text-muted mb-1">中高风险 (0.6-0.8)</div>
|
||||||
|
<div className="text-xl font-bold text-warning">{riskStats.mediumHigh}</div>
|
||||||
|
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-warning rounded-full" style={{ width: `${total > 0 ? (riskStats.mediumHigh / total) * 100 : 0}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card p-3">
|
||||||
|
<div className="text-[11px] text-text-muted mb-1">中风险 (0.4-0.6)</div>
|
||||||
|
<div className="text-xl font-bold text-primary">{riskStats.medium}</div>
|
||||||
|
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-primary rounded-full" style={{ width: `${total > 0 ? (riskStats.medium / total) * 100 : 0}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card p-3">
|
||||||
|
<div className="text-[11px] text-text-muted mb-1">平均风险</div>
|
||||||
|
<div className="text-xl font-bold text-text-primary">{(riskStats.avgRisk * 100).toFixed(1)}%</div>
|
||||||
|
<div className="mt-1.5 text-[10px] text-text-muted">
|
||||||
|
高风险区域: {riskStats.topDistricts.slice(0, 2).map(([d, n]) => `${d}(${n})`).join(', ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface AlertCardProps {
|
||||||
|
alert: ExtendedAlert;
|
||||||
|
isSelected?: boolean;
|
||||||
|
alertId: string;
|
||||||
|
onCardClick: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AlertCard = React.memo(function AlertCard({ alert, isSelected, alertId, onCardClick }: AlertCardProps) {
|
||||||
|
const isP1 = alert.priority === 'P1';
|
||||||
|
const riskPercent = Math.round(alert.risk_value * 100);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
onCardClick(alertId);
|
||||||
|
}, [alertId, onCardClick]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`card overflow-hidden transition-colors cursor-pointer ${
|
||||||
|
isSelected ? 'border-primary ring-1 ring-primary' : 'hover:border-primary'
|
||||||
|
}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<div className={`px-4 py-3 border-b ${isP1 ? 'bg-danger/5 border-danger/20' : 'bg-warning/5 border-warning/20'}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${isP1 ? 'bg-danger' : 'bg-warning'}`} />
|
||||||
|
<span className={`text-[11px] font-bold ${isP1 ? 'text-danger' : 'text-warning'}`}>
|
||||||
|
{alert.priority}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-text-muted">
|
||||||
|
{HORIZON_LABELS[alert.forecast_horizon] || '未知'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className={`text-[18px] font-bold ${isP1 ? 'text-danger' : 'text-warning'}`}>
|
||||||
|
{riskPercent}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-[13px] font-semibold mb-1">
|
||||||
|
{alert.region} - {alert.street}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-text-muted">
|
||||||
|
网格:{alert.grid_id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`text-[12px] px-3 py-2 rounded mb-3 ${
|
||||||
|
isP1 ? 'bg-danger/10 text-danger' : 'bg-warning/10 text-warning'
|
||||||
|
}`}>
|
||||||
|
{alert.reason}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-text-muted">
|
||||||
|
<span>预测时间:{alert.forecast_time}</span>
|
||||||
|
<span>生成:{alert.timestamp}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface AlertsListProps {
|
||||||
|
filteredAlerts: ExtendedAlert[];
|
||||||
|
selectedAlert: string | null;
|
||||||
|
onCardClick: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlertsList = React.memo(function AlertsList({ filteredAlerts, selectedAlert, onCardClick }: AlertsListProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 max-h-[calc(100vh-280px)] overflow-y-auto">
|
||||||
|
{filteredAlerts.slice(0, 50).map((alert) => (
|
||||||
|
<AlertCard
|
||||||
|
key={alert.alert_id}
|
||||||
|
alert={alert}
|
||||||
|
isSelected={selectedAlert === alert.alert_id}
|
||||||
|
alertId={alert.alert_id}
|
||||||
|
onCardClick={onCardClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{filteredAlerts.length > 50 && (
|
||||||
|
<div className="text-center text-text-muted text-[12px] py-2">
|
||||||
|
还有 {filteredAlerts.length - 50} 条预警未显示
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
129
frontend/src/components/alerts/AlertsListTab.tsx
Normal file
129
frontend/src/components/alerts/AlertsListTab.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { LoadingState } from '@/components/ui';
|
||||||
|
import type { CellInfo } from '@/components/AlertMap';
|
||||||
|
import { AlertsToolbar } from './AlertsToolbar';
|
||||||
|
import { AlertsFilterBar } from './AlertsFilterBar';
|
||||||
|
import { AlertsMapPanel } from './AlertsMapPanel';
|
||||||
|
import { AlertsList, RiskDistributionSummary } from './AlertsList';
|
||||||
|
import type { ExtendedAlert, RiskStats } from './types';
|
||||||
|
|
||||||
|
interface AlertsListTabProps {
|
||||||
|
// toolbar
|
||||||
|
forecastDay: 1 | 3 | 7;
|
||||||
|
onForecastDayChange: (day: 1 | 3 | 7) => void;
|
||||||
|
isFullscreen: boolean;
|
||||||
|
onToggleFullscreen: () => void;
|
||||||
|
onExportCsv: () => void;
|
||||||
|
onExportJson: () => void;
|
||||||
|
// filter bar
|
||||||
|
selectedHorizon: number | 'all';
|
||||||
|
onHorizonChange: (horizon: number | 'all') => void;
|
||||||
|
selectedPriority: 'all' | 'P1' | 'P2';
|
||||||
|
onPriorityChange: (priority: 'all' | 'P1' | 'P2') => void;
|
||||||
|
riskRange: [number, number];
|
||||||
|
onRiskRangeChange: (range: [number, number]) => void;
|
||||||
|
showMap: boolean;
|
||||||
|
onToggleMap: () => void;
|
||||||
|
showAlertMarkers: boolean;
|
||||||
|
onToggleAlertMarkers: () => void;
|
||||||
|
showGrid: boolean;
|
||||||
|
onToggleGrid: () => void;
|
||||||
|
sortBy: 'risk' | 'time';
|
||||||
|
onSortByChange: (sortBy: 'risk' | 'time') => void;
|
||||||
|
// data
|
||||||
|
riskStats: RiskStats;
|
||||||
|
filteredAlerts: ExtendedAlert[];
|
||||||
|
isLoading: boolean;
|
||||||
|
selectedGridId: string | null;
|
||||||
|
selectedAlert: string | null;
|
||||||
|
onGridClick: (gridId: string) => void;
|
||||||
|
onCellInfo: (info: CellInfo) => void;
|
||||||
|
onCardClick: (id: string) => void;
|
||||||
|
// privacy/role results (computed by orchestrator)
|
||||||
|
effectiveShowAlertMarkers: boolean;
|
||||||
|
isCluster: boolean;
|
||||||
|
isOfficial: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlertsListTab = React.memo(function AlertsListTab(props: AlertsListTabProps) {
|
||||||
|
const {
|
||||||
|
filteredAlerts,
|
||||||
|
isLoading,
|
||||||
|
isCluster,
|
||||||
|
isFullscreen,
|
||||||
|
showMap,
|
||||||
|
riskStats,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AlertsToolbar
|
||||||
|
forecastDay={props.forecastDay}
|
||||||
|
onForecastDayChange={props.onForecastDayChange}
|
||||||
|
isFullscreen={isFullscreen}
|
||||||
|
onToggleFullscreen={props.onToggleFullscreen}
|
||||||
|
onExportCsv={props.onExportCsv}
|
||||||
|
onExportJson={props.onExportJson}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertsFilterBar
|
||||||
|
selectedHorizon={props.selectedHorizon}
|
||||||
|
onHorizonChange={props.onHorizonChange}
|
||||||
|
selectedPriority={props.selectedPriority}
|
||||||
|
onPriorityChange={props.onPriorityChange}
|
||||||
|
riskRange={props.riskRange}
|
||||||
|
onRiskRangeChange={props.onRiskRangeChange}
|
||||||
|
showMap={showMap}
|
||||||
|
onToggleMap={props.onToggleMap}
|
||||||
|
showAlertMarkers={props.showAlertMarkers}
|
||||||
|
onToggleAlertMarkers={props.onToggleAlertMarkers}
|
||||||
|
showGrid={props.showGrid}
|
||||||
|
onToggleGrid={props.onToggleGrid}
|
||||||
|
sortBy={props.sortBy}
|
||||||
|
onSortByChange={props.onSortByChange}
|
||||||
|
isCluster={isCluster}
|
||||||
|
isOfficial={props.isOfficial}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RiskDistributionSummary riskStats={riskStats} total={filteredAlerts.length} />
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="card p-8">
|
||||||
|
<LoadingState />
|
||||||
|
</div>
|
||||||
|
) : filteredAlerts.length === 0 && !isCluster ? (
|
||||||
|
// 聚类(医生)视角即使没有个体预警,也要展示聚合密度栅格——故不走空状态分支。
|
||||||
|
<div className="card p-8 text-center">
|
||||||
|
<svg className="w-12 h-12 mx-auto mb-3 text-text-muted opacity-50" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
|
||||||
|
</svg>
|
||||||
|
<div className="text-text-muted text-[13px]">暂无符合条件的预警</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`grid gap-4 ${isFullscreen ? 'grid-cols-1' : 'grid-cols-[1fr_400px]'}`}>
|
||||||
|
{showMap && (
|
||||||
|
<AlertsMapPanel
|
||||||
|
selectedGridId={props.selectedGridId}
|
||||||
|
onGridClick={props.onGridClick}
|
||||||
|
onCellInfo={props.onCellInfo}
|
||||||
|
forecastDay={props.forecastDay}
|
||||||
|
effectiveShowAlertMarkers={props.effectiveShowAlertMarkers}
|
||||||
|
showGrid={props.showGrid}
|
||||||
|
filteredAlerts={filteredAlerts}
|
||||||
|
isFullscreen={isFullscreen}
|
||||||
|
isCluster={isCluster}
|
||||||
|
isOfficial={props.isOfficial}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isFullscreen && (
|
||||||
|
<AlertsList
|
||||||
|
filteredAlerts={filteredAlerts}
|
||||||
|
selectedAlert={props.selectedAlert}
|
||||||
|
onCardClick={props.onCardClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
64
frontend/src/components/alerts/AlertsMapPanel.tsx
Normal file
64
frontend/src/components/alerts/AlertsMapPanel.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { TESTIDS } from '@/utils/testids';
|
||||||
|
import { AlertMap } from '@/components/AlertMap';
|
||||||
|
import type { CellInfo } from '@/components/AlertMap';
|
||||||
|
import type { ExtendedAlert } from './types';
|
||||||
|
|
||||||
|
interface AlertsMapPanelProps {
|
||||||
|
selectedGridId: string | null;
|
||||||
|
onGridClick: (gridId: string) => void;
|
||||||
|
onCellInfo: (info: CellInfo) => void;
|
||||||
|
forecastDay: 1 | 3 | 7;
|
||||||
|
// effectiveShowAlertMarkers:唯一真值,cluster 模式恒为 false(隐私不变量),由 orchestrator 计算。
|
||||||
|
effectiveShowAlertMarkers: boolean;
|
||||||
|
showGrid: boolean;
|
||||||
|
filteredAlerts: ExtendedAlert[];
|
||||||
|
isFullscreen: boolean;
|
||||||
|
isCluster: boolean;
|
||||||
|
isOfficial: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlertsMapPanel = React.memo(function AlertsMapPanel({
|
||||||
|
selectedGridId,
|
||||||
|
onGridClick,
|
||||||
|
onCellInfo,
|
||||||
|
forecastDay,
|
||||||
|
effectiveShowAlertMarkers,
|
||||||
|
showGrid,
|
||||||
|
filteredAlerts,
|
||||||
|
isFullscreen,
|
||||||
|
isCluster,
|
||||||
|
isOfficial,
|
||||||
|
}: AlertsMapPanelProps) {
|
||||||
|
return (
|
||||||
|
<div data-testid={isCluster ? TESTIDS.clusterView : undefined}>
|
||||||
|
<AlertMap
|
||||||
|
selectedGridId={selectedGridId}
|
||||||
|
onGridClick={onGridClick}
|
||||||
|
onCellInfo={onCellInfo}
|
||||||
|
forecastDay={forecastDay}
|
||||||
|
showAlertMarkers={effectiveShowAlertMarkers}
|
||||||
|
showGrid={isOfficial ? false : showGrid}
|
||||||
|
filteredAlerts={filteredAlerts}
|
||||||
|
isFullscreen={isFullscreen}
|
||||||
|
/>
|
||||||
|
{/*
|
||||||
|
隐私不变量的「数据级」可断言点:每渲染一个个体病例点标记,就在此输出一个
|
||||||
|
data-testid="patient-point" 的隐藏标记。Leaflet 的 CircleMarker 是 canvas/SVG
|
||||||
|
内部对象、不带 testid,无法被 e2e 直接计数;这里把「实际会显示的个体点集合」
|
||||||
|
镜像成 DOM,使测试可断言医生/聚类视角下 patient-point 计数恒为 0,
|
||||||
|
而无需窥探 Leaflet 内部。effectiveShowAlertMarkers 在 cluster 模式恒为 false,
|
||||||
|
故该集合为空。
|
||||||
|
*/}
|
||||||
|
{effectiveShowAlertMarkers &&
|
||||||
|
filteredAlerts.map((a) => (
|
||||||
|
<span
|
||||||
|
key={a.alert_id}
|
||||||
|
data-testid={TESTIDS.patientPoint}
|
||||||
|
className="hidden"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
130
frontend/src/components/alerts/AlertsRiskPanel.tsx
Normal file
130
frontend/src/components/alerts/AlertsRiskPanel.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { LoadingState } from '@/components/ui';
|
||||||
|
import { StatCard } from '@/components/StatCard';
|
||||||
|
import { StatisticalCharts } from '@/components/StatisticalCharts';
|
||||||
|
import { PieChart, Pie, Cell, Tooltip as RechartsTooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||||
|
import type { RiskStats } from './types';
|
||||||
|
|
||||||
|
interface AlertsRiskPanelProps {
|
||||||
|
riskStats: RiskStats;
|
||||||
|
trendData: Array<{ date: string; cases: number; risk: number }>;
|
||||||
|
trendLoading: boolean;
|
||||||
|
trendError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlertsRiskPanel = React.memo(function AlertsRiskPanel({
|
||||||
|
riskStats,
|
||||||
|
trendData,
|
||||||
|
trendLoading,
|
||||||
|
trendError,
|
||||||
|
}: AlertsRiskPanelProps) {
|
||||||
|
// Severity donut data (P1/P2)
|
||||||
|
const alertPie = useMemo(() => ([
|
||||||
|
{ name: 'P1 (紧急)', value: riskStats.p1, color: '#ef4444' },
|
||||||
|
{ name: 'P2 (关注)', value: riskStats.p2, color: '#f59e0b' },
|
||||||
|
]), [riskStats.p1, riskStats.p2]);
|
||||||
|
|
||||||
|
const topDistrictMax = useMemo(
|
||||||
|
() => riskStats.topDistricts.reduce((m, [, n]) => Math.max(m, n), 0),
|
||||||
|
[riskStats.topDistricts],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Risk distribution as StatCards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<StatCard label="高风险 (≥0.8)" value={riskStats.high} color="#ef4444" />
|
||||||
|
<StatCard label="中高风险 (0.6-0.8)" value={riskStats.mediumHigh} color="#f59e0b" />
|
||||||
|
<StatCard label="中风险 (0.4-0.6)" value={riskStats.medium} color="#3b82f6" />
|
||||||
|
<StatCard label="平均风险" value={`${(riskStats.avgRisk * 100).toFixed(1)}%`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Risk trend chart (real data from /api/analysis/trend) */}
|
||||||
|
{trendLoading ? (
|
||||||
|
<div className="card p-8"><LoadingState /></div>
|
||||||
|
) : trendError ? (
|
||||||
|
<div className="card p-8 text-center text-danger text-[13px]">{trendError}</div>
|
||||||
|
) : trendData.length === 0 ? (
|
||||||
|
<div className="card p-8 text-center text-text-muted text-[13px]">暂无风险趋势数据</div>
|
||||||
|
) : (
|
||||||
|
<StatisticalCharts
|
||||||
|
data={trendData}
|
||||||
|
showCases={false}
|
||||||
|
showRisk
|
||||||
|
height={280}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Top high-risk districts bar */}
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||||
|
高风险区域 Top 5
|
||||||
|
</div>
|
||||||
|
{riskStats.topDistricts.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{riskStats.topDistricts.map(([district, count]) => (
|
||||||
|
<div key={district}>
|
||||||
|
<div className="flex items-center justify-between text-[12px] mb-1">
|
||||||
|
<span className="text-text-primary font-medium">{district}</span>
|
||||||
|
<span className="text-text-muted">{count} 条</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-danger rounded-full"
|
||||||
|
style={{ width: `${topDistrictMax > 0 ? (count / topDistrictMax) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-text-muted text-[13px]">暂无区域数据</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alert severity donut (P1/P2) */}
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||||
|
预警严重度分布
|
||||||
|
</div>
|
||||||
|
{riskStats.p1 > 0 || riskStats.p2 > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={alertPie}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={50}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={4}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
>
|
||||||
|
{alertPie.map((entry) => (
|
||||||
|
<Cell key={entry.name} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<RechartsTooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
border: '1px solid #E2E8F0',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
formatter={(value: number, name: string) => [value, name]}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: '12px' }}
|
||||||
|
formatter={(value: string) => <span className="text-text-secondary">{value}</span>}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-text-muted text-[13px]">暂无预警数据</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
73
frontend/src/components/alerts/AlertsToolbar.tsx
Normal file
73
frontend/src/components/alerts/AlertsToolbar.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface AlertsToolbarProps {
|
||||||
|
forecastDay: 1 | 3 | 7;
|
||||||
|
onForecastDayChange: (day: 1 | 3 | 7) => void;
|
||||||
|
isFullscreen: boolean;
|
||||||
|
onToggleFullscreen: () => void;
|
||||||
|
onExportCsv: () => void;
|
||||||
|
onExportJson: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toolbar Row 1: 网格预测时效 + 全屏 + 导出.
|
||||||
|
export const AlertsToolbar = React.memo(function AlertsToolbar({
|
||||||
|
forecastDay,
|
||||||
|
onForecastDayChange,
|
||||||
|
isFullscreen,
|
||||||
|
onToggleFullscreen,
|
||||||
|
onExportCsv,
|
||||||
|
onExportJson,
|
||||||
|
}: AlertsToolbarProps) {
|
||||||
|
return (
|
||||||
|
<div className="card p-3 mb-3">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[12px] text-text-muted">网格预测:</span>
|
||||||
|
<div className="flex gap-0.5 bg-bg-page p-0.5 rounded">
|
||||||
|
{([1, 3, 7] as const).map((day) => (
|
||||||
|
<button
|
||||||
|
key={day}
|
||||||
|
onClick={() => onForecastDayChange(day)}
|
||||||
|
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
|
||||||
|
forecastDay === day
|
||||||
|
? 'bg-bg-card text-primary shadow-sm'
|
||||||
|
: 'text-text-secondary hover:text-text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{day}天
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-border" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
||||||
|
isFullscreen
|
||||||
|
? 'bg-bg-card text-primary border border-primary'
|
||||||
|
: 'bg-bg-page text-text-secondary border border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isFullscreen ? '退出全屏' : '全屏'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-border" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onExportCsv}
|
||||||
|
className="px-3 py-1.5 text-[12px] font-medium rounded bg-bg-page text-text-secondary border border-border hover:border-primary transition-colors"
|
||||||
|
>
|
||||||
|
导出CSV
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onExportJson}
|
||||||
|
className="px-3 py-1.5 text-[12px] font-medium rounded bg-bg-page text-text-secondary border border-border hover:border-primary transition-colors"
|
||||||
|
>
|
||||||
|
导出JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
32
frontend/src/components/alerts/types.ts
Normal file
32
frontend/src/components/alerts/types.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Shared types for the alerts dashboard subcomponents.
|
||||||
|
export interface ExtendedAlert {
|
||||||
|
alert_id: string;
|
||||||
|
grid_id: string;
|
||||||
|
region: string;
|
||||||
|
street: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
risk_value: number;
|
||||||
|
risk_level: 'high' | 'medium_high' | 'medium' | 'medium_low' | 'low';
|
||||||
|
priority: 'P1' | 'P2';
|
||||||
|
forecast_horizon: number;
|
||||||
|
forecast_time: string;
|
||||||
|
reason: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HORIZON_LABELS: Record<number, string> = {
|
||||||
|
1: '1 天后',
|
||||||
|
3: '3 天后',
|
||||||
|
7: '7 天后',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface RiskStats {
|
||||||
|
p1: number;
|
||||||
|
p2: number;
|
||||||
|
high: number;
|
||||||
|
mediumHigh: number;
|
||||||
|
medium: number;
|
||||||
|
avgRisk: number;
|
||||||
|
topDistricts: [string, number][];
|
||||||
|
}
|
||||||
163
frontend/src/components/alerts/useAlertsData.ts
Normal file
163
frontend/src/components/alerts/useAlertsData.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||||
|
import { useRiskStore } from '@/stores';
|
||||||
|
import { analysisApi } from '@/services/api';
|
||||||
|
import type { ExtendedAlert, RiskStats } from './types';
|
||||||
|
|
||||||
|
interface UseAlertsDataParams {
|
||||||
|
selectedHorizon: number | 'all';
|
||||||
|
selectedPriority: 'all' | 'P1' | 'P2';
|
||||||
|
sortBy: 'risk' | 'time';
|
||||||
|
debouncedRiskRange: [number, number];
|
||||||
|
activeTab: 'list' | 'stats';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrendPoint { date: string; cases: number; risk: number }
|
||||||
|
|
||||||
|
// 预警仪表盘的数据层:派生 extendedAlerts/filteredAlerts/riskStats、按需拉取风险趋势、
|
||||||
|
// 以及 CSV/JSON 导出辅助。角色/隐私计算保留在 orchestrator,不在此处。
|
||||||
|
export function useAlertsData({
|
||||||
|
selectedHorizon,
|
||||||
|
selectedPriority,
|
||||||
|
sortBy,
|
||||||
|
debouncedRiskRange,
|
||||||
|
activeTab,
|
||||||
|
}: UseAlertsDataParams) {
|
||||||
|
const alerts = useRiskStore((s) => s.alerts);
|
||||||
|
|
||||||
|
// Risk-trend data for the 风险统计 tab, fetched on demand
|
||||||
|
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
||||||
|
const [trendLoading, setTrendLoading] = useState(false);
|
||||||
|
const [trendError, setTrendError] = useState<string | null>(null);
|
||||||
|
const [trendLoaded, setTrendLoaded] = useState(false);
|
||||||
|
|
||||||
|
// Fetch real risk-trend data when the 风险统计 tab is first opened
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'stats' || trendLoaded) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setTrendLoading(true);
|
||||||
|
setTrendError(null);
|
||||||
|
analysisApi
|
||||||
|
.getTrend(14)
|
||||||
|
.then((res: { dates?: string[]; values?: number[] }) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const dates = res?.dates ?? [];
|
||||||
|
const values = res?.values ?? [];
|
||||||
|
setTrendData(dates.map((date, i) => ({ date, cases: 0, risk: values[i] ?? 0 })));
|
||||||
|
setTrendLoaded(true);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setTrendError(err instanceof Error ? err.message : '加载风险趋势失败');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setTrendLoading(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [activeTab, trendLoaded]);
|
||||||
|
|
||||||
|
const extendedAlerts: ExtendedAlert[] = useMemo(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
return (alerts || []).map((alert) => {
|
||||||
|
const forecastDate = new Date(alert.forecast_time);
|
||||||
|
const diffDays = Math.ceil((forecastDate.getTime() - now) / (1000 * 60 * 60 * 24));
|
||||||
|
const horizon = diffDays <= 1 ? 1 : diffDays <= 3 ? 3 : 7;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...alert,
|
||||||
|
latitude: alert.latitude || 0,
|
||||||
|
longitude: alert.longitude || 0,
|
||||||
|
forecast_horizon: horizon,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [alerts]);
|
||||||
|
|
||||||
|
const filteredAlerts = useMemo(() => {
|
||||||
|
return extendedAlerts
|
||||||
|
.filter((alert) => {
|
||||||
|
const horizonMatch = selectedHorizon === 'all' || alert.forecast_horizon === selectedHorizon;
|
||||||
|
const priorityMatch = selectedPriority === 'all' || alert.priority === selectedPriority;
|
||||||
|
const riskMatch = alert.risk_value >= debouncedRiskRange[0] && alert.risk_value <= debouncedRiskRange[1];
|
||||||
|
return horizonMatch && priorityMatch && riskMatch;
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (sortBy === 'risk') {
|
||||||
|
return b.risk_value - a.risk_value;
|
||||||
|
}
|
||||||
|
return new Date(b.forecast_time).getTime() - new Date(a.forecast_time).getTime();
|
||||||
|
});
|
||||||
|
}, [extendedAlerts, selectedHorizon, selectedPriority, sortBy, debouncedRiskRange]);
|
||||||
|
|
||||||
|
// Risk distribution stats (includes p1/p2 counts) — single pass over each array
|
||||||
|
const riskStats: RiskStats = useMemo(() => {
|
||||||
|
// p1/p2 reflect the full (unfiltered) alert set
|
||||||
|
let p1 = 0;
|
||||||
|
let p2 = 0;
|
||||||
|
for (const a of extendedAlerts) {
|
||||||
|
if (a.priority === 'P1') p1++;
|
||||||
|
else if (a.priority === 'P2') p2++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single pass over filteredAlerts: counters + sum + district map
|
||||||
|
let high = 0;
|
||||||
|
let mediumHigh = 0;
|
||||||
|
let medium = 0;
|
||||||
|
let sum = 0;
|
||||||
|
const byDistrict: Record<string, number> = {};
|
||||||
|
for (const a of filteredAlerts) {
|
||||||
|
const v = a.risk_value;
|
||||||
|
if (v >= 0.8) high++;
|
||||||
|
else if (v >= 0.6) mediumHigh++;
|
||||||
|
else if (v >= 0.4) medium++;
|
||||||
|
sum += v;
|
||||||
|
const d = a.region || '未知';
|
||||||
|
byDistrict[d] = (byDistrict[d] || 0) + 1;
|
||||||
|
}
|
||||||
|
const avgRisk = filteredAlerts.length > 0 ? sum / filteredAlerts.length : 0;
|
||||||
|
|
||||||
|
const topDistricts = Object.entries(byDistrict)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
return { p1, p2, high, mediumHigh, medium, avgRisk, topDistricts };
|
||||||
|
}, [extendedAlerts, filteredAlerts]);
|
||||||
|
|
||||||
|
// Export utilities
|
||||||
|
const exportToCsv = useCallback(() => {
|
||||||
|
const headers = ['alert_id', 'grid_id', 'region', 'street', 'latitude', 'longitude', 'risk_value', 'priority', 'forecast_horizon', 'reason', 'timestamp'];
|
||||||
|
const rows = filteredAlerts.map(a => [
|
||||||
|
a.alert_id, a.grid_id, a.region, a.street,
|
||||||
|
a.latitude, a.longitude, a.risk_value, a.priority,
|
||||||
|
a.forecast_horizon, `"${a.reason}"`, a.timestamp,
|
||||||
|
]);
|
||||||
|
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
|
||||||
|
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `alerts_${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, [filteredAlerts]);
|
||||||
|
|
||||||
|
const exportToJson = useCallback(() => {
|
||||||
|
const json = JSON.stringify(filteredAlerts, null, 2);
|
||||||
|
const blob = new Blob([json], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `alerts_${new Date().toISOString().split('T')[0]}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, [filteredAlerts]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
extendedAlerts,
|
||||||
|
filteredAlerts,
|
||||||
|
riskStats,
|
||||||
|
trendData,
|
||||||
|
trendLoading,
|
||||||
|
trendError,
|
||||||
|
exportToCsv,
|
||||||
|
exportToJson,
|
||||||
|
};
|
||||||
|
}
|
||||||
87
frontend/src/components/clinical/BoxPlotRows.tsx
Normal file
87
frontend/src/components/clinical/BoxPlotRows.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { CLINICAL_COLORS } from './chartColors';
|
||||||
|
|
||||||
|
export interface BoxRow {
|
||||||
|
label: string;
|
||||||
|
p25: number;
|
||||||
|
median: number;
|
||||||
|
p75: number;
|
||||||
|
n: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BoxPlotRowsProps {
|
||||||
|
rows: BoxRow[];
|
||||||
|
/** 数值单位后缀,如 "天" / ""。 */
|
||||||
|
unit?: string;
|
||||||
|
/** 标签列宽(px)。 */
|
||||||
|
labelWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 横向箱线图(p25–中位–p75)。Recharts 无原生 box plot,
|
||||||
|
* 故用纯 div 渲染:每行一条从 p25 到 p75 的横条,中位处一根竖向刻度。
|
||||||
|
* 复用于「各病种住院天数」与「年龄别BMI」。
|
||||||
|
*/
|
||||||
|
export const BoxPlotRows = memo(function BoxPlotRows({
|
||||||
|
rows,
|
||||||
|
unit = '',
|
||||||
|
labelWidth = 96,
|
||||||
|
}: BoxPlotRowsProps) {
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
|
return <div className="text-center py-8 text-text-muted text-sm">暂无数据</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一横轴域:覆盖所有行的 p25..p75,留一点边距。
|
||||||
|
const domainMin = Math.min(...rows.map((r) => r.p25));
|
||||||
|
const domainMax = Math.max(...rows.map((r) => r.p75));
|
||||||
|
const span = domainMax - domainMin || 1;
|
||||||
|
const pct = (v: number) => ((v - domainMin) / span) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{rows.map((r) => {
|
||||||
|
const left = pct(r.p25);
|
||||||
|
const right = pct(r.p75);
|
||||||
|
const width = Math.max(right - left, 0.5);
|
||||||
|
const medianLeft = pct(r.median);
|
||||||
|
return (
|
||||||
|
<div key={r.label} className="flex items-center gap-2 text-[11px]">
|
||||||
|
<div
|
||||||
|
className="shrink-0 truncate text-text-secondary text-right"
|
||||||
|
style={{ width: labelWidth }}
|
||||||
|
title={r.label}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</div>
|
||||||
|
<div className="relative flex-1 h-5 rounded bg-bg-hover">
|
||||||
|
{/* p25–p75 箱体 */}
|
||||||
|
<div
|
||||||
|
className="absolute top-1 bottom-1 rounded-sm"
|
||||||
|
style={{
|
||||||
|
left: `${left}%`,
|
||||||
|
width: `${width}%`,
|
||||||
|
backgroundColor: CLINICAL_COLORS.box,
|
||||||
|
opacity: 0.35,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* 中位刻度 */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0.5 bottom-0.5 w-[2px] rounded"
|
||||||
|
style={{
|
||||||
|
left: `${medianLeft}%`,
|
||||||
|
backgroundColor: CLINICAL_COLORS.boxMedian,
|
||||||
|
}}
|
||||||
|
title={`中位 ${r.median}${unit}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 w-28 text-text-muted tabular-nums">
|
||||||
|
{r.p25}–<span className="font-semibold text-text-secondary">{r.median}</span>–{r.p75}
|
||||||
|
{unit}
|
||||||
|
<span className="ml-1 text-[10px] text-text-muted">n={r.n}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
42
frontend/src/components/clinical/ClinicalKpiRow.tsx
Normal file
42
frontend/src/components/clinical/ClinicalKpiRow.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { Users, CalendarDays, HeartPulse, Siren } from 'lucide-react';
|
||||||
|
import { StatCard } from '@/components/StatCard';
|
||||||
|
import { TESTIDS } from '@/utils/testids';
|
||||||
|
import type { InpatientClinicalResponse } from '@/services/api';
|
||||||
|
|
||||||
|
interface ClinicalKpiRowProps {
|
||||||
|
kpis: InpatientClinicalResponse['kpis'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 住院临床 4 项核心指标。375px 下 2 列,sm 起 4 列。 */
|
||||||
|
export const ClinicalKpiRow = memo(function ClinicalKpiRow({ kpis }: ClinicalKpiRowProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={TESTIDS.clinicalKpis}
|
||||||
|
className="grid grid-cols-2 sm:grid-cols-4 gap-3"
|
||||||
|
>
|
||||||
|
<StatCard
|
||||||
|
icon={<Users className="w-4 h-4 text-primary" />}
|
||||||
|
label="住院总人次"
|
||||||
|
value={kpis.total_admissions.toLocaleString()}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<CalendarDays className="w-4 h-4 text-primary" />}
|
||||||
|
label="中位住院日"
|
||||||
|
value={`${kpis.median_los_days} 天`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<HeartPulse className="w-4 h-4 text-success" />}
|
||||||
|
label="治愈好转率"
|
||||||
|
value={`${(kpis.cure_rate * 100).toFixed(1)}%`}
|
||||||
|
color="#16A34A"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Siren className="w-4 h-4 text-warning" />}
|
||||||
|
label="急诊入院占比"
|
||||||
|
value={`${(kpis.emergency_admit_ratio * 100).toFixed(1)}%`}
|
||||||
|
color="#D97706"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
56
frontend/src/components/clinical/DonutChart.tsx
Normal file
56
frontend/src/components/clinical/DonutChart.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||||
|
import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
|
||||||
|
|
||||||
|
export interface DonutSlice {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DonutChartProps {
|
||||||
|
data: DonutSlice[];
|
||||||
|
/** name -> color。未命中时按 palette 顺序回退。 */
|
||||||
|
colorMap?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用环形图。复用于「出院结局构成」与「入院途径构成」。 */
|
||||||
|
export const DonutChart = memo(function DonutChart({ data, colorMap }: DonutChartProps) {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return <div className="text-center py-8 text-text-muted text-sm">暂无数据</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = data.reduce((s, d) => s + d.value, 0);
|
||||||
|
const colorFor = (name: string, idx: number) =>
|
||||||
|
colorMap?.[name] ??
|
||||||
|
CLINICAL_COLORS.routePalette[idx % CLINICAL_COLORS.routePalette.length] ??
|
||||||
|
CLINICAL_COLORS.outcomeFallback;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={56}
|
||||||
|
outerRadius={88}
|
||||||
|
paddingAngle={2}
|
||||||
|
>
|
||||||
|
{data.map((d, idx) => (
|
||||||
|
<Cell key={d.name} fill={colorFor(d.name, idx)} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={TOOLTIP_STYLE}
|
||||||
|
formatter={(v: number, name: string) => [
|
||||||
|
`${v.toLocaleString()}(${total > 0 ? ((v / total) * 100).toFixed(1) : '0'}%)`,
|
||||||
|
name,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
});
|
||||||
51
frontend/src/components/clinical/HistogramChart.tsx
Normal file
51
frontend/src/components/clinical/HistogramChart.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
|
||||||
|
|
||||||
|
interface HistogramChartProps {
|
||||||
|
data: { bin_label: string; count: number }[];
|
||||||
|
color?: string;
|
||||||
|
/** tooltip 中数量的标签,如 "住院天数"。 */
|
||||||
|
countLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用直方图。用于「住院天数分布」。 */
|
||||||
|
export const HistogramChart = memo(function HistogramChart({
|
||||||
|
data,
|
||||||
|
color = CLINICAL_COLORS.los,
|
||||||
|
countLabel = '人次',
|
||||||
|
}: HistogramChartProps) {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return <div className="text-center py-8 text-text-muted text-sm">暂无数据</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={data} margin={{ top: 5, right: 12, left: 0, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={CLINICAL_COLORS.grid} vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="bin_label"
|
||||||
|
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }}
|
||||||
|
interval={0}
|
||||||
|
angle={-30}
|
||||||
|
textAnchor="end"
|
||||||
|
height={50}
|
||||||
|
/>
|
||||||
|
<YAxis tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }} width={40} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={TOOLTIP_STYLE}
|
||||||
|
formatter={(v: number) => [`${v.toLocaleString()}`, countLabel]}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="count" fill={color} radius={[3, 3, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
});
|
||||||
34
frontend/src/components/clinical/chartColors.ts
Normal file
34
frontend/src/components/clinical/chartColors.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* 住院临床分析页图表字面色值集中处。
|
||||||
|
* Recharts 需要原始 hex,无法用 Tailwind class,故在此集中定义,避免散落 magic hex。
|
||||||
|
*/
|
||||||
|
export const CLINICAL_COLORS = {
|
||||||
|
primary: '#2563EB', // primary
|
||||||
|
los: '#2563EB',
|
||||||
|
box: '#3B82F6', // 箱体填充
|
||||||
|
boxMedian: '#1D4ED8', // 中位刻度
|
||||||
|
grid: '#E2E8F0',
|
||||||
|
axis: '#64748B',
|
||||||
|
axisLabel: '#374151',
|
||||||
|
tooltipBorder: '#E2E8F0',
|
||||||
|
tooltipText: '#1E293B',
|
||||||
|
// 出院结局按严重程度配色:治愈/好转偏绿,未愈/死亡偏红,其他中性
|
||||||
|
outcome: {
|
||||||
|
治愈: '#16A34A',
|
||||||
|
好转: '#4ADE80',
|
||||||
|
其他: '#94A3B8',
|
||||||
|
未愈: '#F97316',
|
||||||
|
死亡: '#DC2626',
|
||||||
|
} as Record<string, string>,
|
||||||
|
outcomeFallback: '#94A3B8',
|
||||||
|
// 入院途径 donut 顺序色板
|
||||||
|
routePalette: ['#2563EB', '#0891B2', '#7C3AED', '#D97706', '#16A34A', '#DC2626'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Recharts tooltip 通用样式。 */
|
||||||
|
export const TOOLTIP_STYLE = {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
border: `1px solid ${CLINICAL_COLORS.tooltipBorder}`,
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
} as const;
|
||||||
145
frontend/src/components/monitoring/CaseStatsTab.tsx
Normal file
145
frontend/src/components/monitoring/CaseStatsTab.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||||
|
import { CalendarHeatmap } from '@/components/CalendarHeatmap';
|
||||||
|
import type { TopDiagnosis } from './types';
|
||||||
|
|
||||||
|
function formatDateLabel(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CaseStatsTabProps {
|
||||||
|
loading: boolean;
|
||||||
|
loaded: boolean;
|
||||||
|
error: string | null;
|
||||||
|
currentDate: string;
|
||||||
|
topDiagnoses: TopDiagnosis[];
|
||||||
|
caseTrend: Array<{ date: string; cases: number; aqi: number }>;
|
||||||
|
heatmapData: Array<{ date: string; value: number }>;
|
||||||
|
heatmapYear: number | null;
|
||||||
|
onRetry: () => void;
|
||||||
|
onDismissError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 病例统计 tab —— 诊断分布 / 病例与AQI趋势 / 日历热力图。纯展示,数据由父级按需加载。
|
||||||
|
export const CaseStatsTab = memo(function CaseStatsTab({
|
||||||
|
loading,
|
||||||
|
loaded,
|
||||||
|
error,
|
||||||
|
currentDate,
|
||||||
|
topDiagnoses,
|
||||||
|
caseTrend,
|
||||||
|
heatmapData,
|
||||||
|
heatmapYear,
|
||||||
|
onRetry,
|
||||||
|
onDismissError,
|
||||||
|
}: CaseStatsTabProps) {
|
||||||
|
if (loading && !loaded) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<ErrorBanner
|
||||||
|
error={error}
|
||||||
|
onRetry={onRetry}
|
||||||
|
onDismiss={onDismissError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top 5 诊断分布 */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top 5 诊断分布</h3>
|
||||||
|
{topDiagnoses.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<BarChart
|
||||||
|
data={[...topDiagnoses].reverse()}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
||||||
|
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="diagnosis"
|
||||||
|
tick={{ fontSize: 11, fill: '#374151' }}
|
||||||
|
width={100}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '8px', fontSize: '12px' }}
|
||||||
|
formatter={(value: number) => [value.toLocaleString(), '病例数']}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
||||||
|
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={16} />
|
||||||
|
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={16} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 近30日病例与AQI趋势 (driven off Monitoring timeline currentDate) */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-1">病例与AQI趋势</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">截至 {currentDate} 的近30日窗口</p>
|
||||||
|
{caseTrend.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<LineChart data={caseTrend} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={formatDateLabel}
|
||||||
|
tick={{ fontSize: 10, fill: '#64748B' }}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
axisLine={{ stroke: '#E2E8F0' }}
|
||||||
|
/>
|
||||||
|
<YAxis yAxisId="left" tick={{ fontSize: 10, fill: '#64748B' }} axisLine={{ stroke: '#E2E8F0' }} />
|
||||||
|
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10, fill: '#F59E0B' }} axisLine={{ stroke: '#E2E8F0' }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '8px', fontSize: '12px' }}
|
||||||
|
labelStyle={{ color: '#1E293B', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
||||||
|
<Line yAxisId="left" type="monotone" dataKey="cases" name="病例数" stroke="#3B82F6" strokeWidth={2} dot={false} activeDot={{ r: 3 }} />
|
||||||
|
<Line yAxisId="right" type="monotone" dataKey="aqi" name="AQI" stroke="#F59E0B" strokeWidth={2} strokeDasharray="5 5" dot={false} activeDot={{ r: 3 }} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 日历热力图 (year derived from data) */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
{heatmapYear ? `${heatmapYear}年 ` : ''}每日病例日历
|
||||||
|
</h3>
|
||||||
|
{heatmapYear && heatmapData.length > 0 ? (
|
||||||
|
<CalendarHeatmap data={heatmapData} year={heatmapYear} />
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
66
frontend/src/components/monitoring/DistrictStatsTab.tsx
Normal file
66
frontend/src/components/monitoring/DistrictStatsTab.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||||
|
import { MetricHeatmapTable } from '@/components/MetricHeatmapTable';
|
||||||
|
|
||||||
|
interface DistrictStatsTabProps {
|
||||||
|
loading: boolean;
|
||||||
|
loaded: boolean;
|
||||||
|
error: string | null;
|
||||||
|
rows: string[];
|
||||||
|
data: Record<string, Record<string, number>>;
|
||||||
|
onRetry: () => void;
|
||||||
|
onDismissError: () => void;
|
||||||
|
onSort: (col: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 区域统计 tab —— 区域指标热力表。纯展示,排序键由父级持有。
|
||||||
|
export const DistrictStatsTab = memo(function DistrictStatsTab({
|
||||||
|
loading,
|
||||||
|
loaded,
|
||||||
|
error,
|
||||||
|
rows,
|
||||||
|
data,
|
||||||
|
onRetry,
|
||||||
|
onDismissError,
|
||||||
|
onSort,
|
||||||
|
}: DistrictStatsTabProps) {
|
||||||
|
if (loading && !loaded) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<ErrorBanner
|
||||||
|
error={error}
|
||||||
|
onRetry={onRetry}
|
||||||
|
onDismiss={onDismissError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-1">区域指标热力表</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">点击列标题排序</p>
|
||||||
|
{rows.length > 0 ? (
|
||||||
|
<MetricHeatmapTable
|
||||||
|
rows={rows}
|
||||||
|
columns={[
|
||||||
|
{ key: 'total', label: '病例' },
|
||||||
|
{ key: 'outpatient', label: '门诊' },
|
||||||
|
{ key: 'inpatient', label: '住院' },
|
||||||
|
{ key: 'inpatient_ratio', label: '住院占比%' },
|
||||||
|
]}
|
||||||
|
data={data}
|
||||||
|
onSort={onSort}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
56
frontend/src/components/monitoring/MonitoringStatsBar.tsx
Normal file
56
frontend/src/components/monitoring/MonitoringStatsBar.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { Calendar, Activity, TrendingUp, TrendingDown, Stethoscope, Zap, BarChart3 } from 'lucide-react';
|
||||||
|
import { StatCard } from '@/components/StatCard';
|
||||||
|
import type { MonitoringStats } from './types';
|
||||||
|
|
||||||
|
interface MonitoringStatsBarProps {
|
||||||
|
stats: MonitoringStats;
|
||||||
|
sparkline7d: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监测页顶部统计条 —— 纯展示,已自适应(grid-cols-2 sm:grid-cols-3 lg:grid-cols-6)。
|
||||||
|
export const MonitoringStatsBar = memo(function MonitoringStatsBar({ stats, sparkline7d }: MonitoringStatsBarProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-w-0 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||||
|
<StatCard
|
||||||
|
icon={<Calendar className="w-4 h-4 text-blue-600" />}
|
||||||
|
label="当日病例"
|
||||||
|
value={stats.todayCases !== null ? stats.todayCases.toLocaleString() : '--'}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Activity className="w-4 h-4 text-indigo-600" />}
|
||||||
|
label="7日均值"
|
||||||
|
value={stats.avg7d.toLocaleString()}
|
||||||
|
sparkline={sparkline7d.length >= 2 ? { data: sparkline7d, color: '#6366F1' } : undefined}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={
|
||||||
|
stats.trend === 'up' ? <TrendingUp className="w-4 h-4 text-red-500" /> :
|
||||||
|
stats.trend === 'down' ? <TrendingDown className="w-4 h-4 text-green-500" /> :
|
||||||
|
<Activity className="w-4 h-4 text-gray-400" />
|
||||||
|
}
|
||||||
|
label="趋势"
|
||||||
|
value={stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳'}
|
||||||
|
trend={{
|
||||||
|
direction: stats.trend === 'up' ? 'up' : stats.trend === 'down' ? 'down' : 'stable',
|
||||||
|
value: stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Zap className="w-4 h-4 text-amber-500" />}
|
||||||
|
label="峰值日"
|
||||||
|
value={`${stats.maxDay.cases.toLocaleString()} (${stats.maxDay.date.slice(5)})`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<BarChart3 className="w-4 h-4 text-purple-500" />}
|
||||||
|
label="标准差"
|
||||||
|
value={stats.stdDev.toLocaleString()}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Stethoscope className="w-4 h-4 text-orange-500" />}
|
||||||
|
label="门诊 / 住院"
|
||||||
|
value={`${stats.totalOutpatient.toLocaleString()} / ${stats.totalInpatient.toLocaleString()}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
142
frontend/src/components/monitoring/OverviewTab.tsx
Normal file
142
frontend/src/components/monitoring/OverviewTab.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { memo, useMemo, useCallback } from 'react';
|
||||||
|
import { StatisticalCharts } from '@/components/StatisticalCharts';
|
||||||
|
import { CaseLocationMap } from '@/components/CaseLocationMap';
|
||||||
|
import { Segmented } from '@/components/ui';
|
||||||
|
import { TESTIDS } from '@/utils/testids';
|
||||||
|
import type { Granularity, DistrictCaseRow } from './types';
|
||||||
|
|
||||||
|
interface OverviewTabProps {
|
||||||
|
isLoading: boolean;
|
||||||
|
chartData: Array<{ date: string; cases: number; aqi?: number }>;
|
||||||
|
districtCases: DistrictCaseRow[];
|
||||||
|
selectedDistrict: string | null;
|
||||||
|
selectedStreet: string | null;
|
||||||
|
currentDate: string;
|
||||||
|
granularity: Granularity;
|
||||||
|
onGranularityChange: (g: Granularity) => void;
|
||||||
|
onDistrictSelect: (district: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 概览 tab —— 病例分布地图 + 统计图表 + 区县 roll-up(粒度真相来源在父级 URL)。
|
||||||
|
export const OverviewTab = memo(function OverviewTab({
|
||||||
|
isLoading,
|
||||||
|
chartData,
|
||||||
|
districtCases,
|
||||||
|
selectedDistrict,
|
||||||
|
selectedStreet,
|
||||||
|
currentDate,
|
||||||
|
granularity,
|
||||||
|
onGranularityChange,
|
||||||
|
onDistrictSelect,
|
||||||
|
}: OverviewTabProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Case Location Map */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">病例分布地图</h3>
|
||||||
|
<CaseLocationMap height="400px" district={selectedDistrict} street={selectedStreet} date={currentDate} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistical Charts */}
|
||||||
|
<StatisticalCharts
|
||||||
|
data={chartData}
|
||||||
|
height={350}
|
||||||
|
showCases={true}
|
||||||
|
showAQI={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* District breakdown — 区域 roll-up(URL 粒度真相来源) */}
|
||||||
|
<div data-testid={TESTIDS.districtRollup} className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">区县病例分布</h3>
|
||||||
|
<Segmented<Granularity>
|
||||||
|
testid={TESTIDS.granularityControl}
|
||||||
|
size="sm"
|
||||||
|
options={[
|
||||||
|
{ value: 'city', label: '全市' },
|
||||||
|
{ value: 'district', label: '区域' },
|
||||||
|
{ value: 'street', label: '街道' },
|
||||||
|
]}
|
||||||
|
value={granularity}
|
||||||
|
onChange={onGranularityChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<DistrictBreakdown
|
||||||
|
districtCases={districtCases}
|
||||||
|
selectedDistrict={selectedDistrict}
|
||||||
|
onDistrictSelect={onDistrictSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 mt-3 pt-2 border-t border-gray-100">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||||
|
<span className="w-3 h-3 bg-orange-400 rounded-sm" />门诊
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||||
|
<span className="w-3 h-3 bg-red-400 rounded-sm" />住院
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface DistrictBreakdownProps {
|
||||||
|
districtCases: DistrictCaseRow[];
|
||||||
|
selectedDistrict: string | null;
|
||||||
|
// 点击区域条目时上抛——由父组件驱动 URL(粒度真相来源),不在此处 mutate store。
|
||||||
|
onDistrictSelect: (district: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DistrictBreakdown = memo(function DistrictBreakdown({ districtCases, selectedDistrict, onDistrictSelect }: DistrictBreakdownProps) {
|
||||||
|
const sortedCases = useMemo(() => [...districtCases].sort((a, b) => b.total - a.total), [districtCases]);
|
||||||
|
const maxTotal = useMemo(() => sortedCases.length > 0 ? sortedCases[0].total : 1, [sortedCases]);
|
||||||
|
|
||||||
|
const handleDistrictClick = useCallback((district: string) => {
|
||||||
|
onDistrictSelect(district);
|
||||||
|
}, [onDistrictSelect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{sortedCases.map((d) => {
|
||||||
|
const outPct = d.total > 0 ? (d.outpatient / d.total) * 100 : 0;
|
||||||
|
const inPct = d.total > 0 ? (d.inpatient / d.total) * 100 : 0;
|
||||||
|
const barWidth = (d.total / maxTotal) * 100;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={d.district}
|
||||||
|
className={`flex items-center gap-3 p-2 rounded cursor-pointer transition-colors ${
|
||||||
|
selectedDistrict === d.district ? 'bg-blue-50' : 'hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleDistrictClick(d.district)}
|
||||||
|
>
|
||||||
|
<div className="w-16 text-sm text-gray-700 text-right shrink-0">{d.district}</div>
|
||||||
|
<div className="flex-1 h-6 bg-gray-100 rounded overflow-hidden flex">
|
||||||
|
<div
|
||||||
|
className="bg-orange-400 h-full transition-all"
|
||||||
|
style={{ width: `${barWidth * outPct / 100}%` }}
|
||||||
|
title={`门诊: ${d.outpatient.toLocaleString()}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="bg-red-400 h-full transition-all"
|
||||||
|
style={{ width: `${barWidth * inPct / 100}%` }}
|
||||||
|
title={`住院: ${d.inpatient.toLocaleString()}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-20 text-right text-sm font-medium text-gray-900 shrink-0">
|
||||||
|
{d.total.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
38
frontend/src/components/monitoring/types.ts
Normal file
38
frontend/src/components/monitoring/types.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// 监测页内部共享类型。Granularity 的真相来源仍是 URL,由 MonitoringDashboard 拥有;
|
||||||
|
// 此处只暴露类型与子组件复用的 props 形状。
|
||||||
|
export type Granularity = 'city' | 'district' | 'street';
|
||||||
|
|
||||||
|
export const GRANULARITY_VALUES: readonly Granularity[] = ['city', 'district', 'street'] as const;
|
||||||
|
|
||||||
|
export function parseGranularity(raw: string | null): Granularity {
|
||||||
|
return GRANULARITY_VALUES.includes(raw as Granularity) ? (raw as Granularity) : 'city';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 概览 tab 区县条目所需的最小字段(来自 monitoringStore 的 districtCases)。
|
||||||
|
export interface DistrictCaseRow {
|
||||||
|
district: string;
|
||||||
|
total: number;
|
||||||
|
outpatient: number;
|
||||||
|
inpatient: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitoringStats {
|
||||||
|
totalCases: number;
|
||||||
|
avgCases: number;
|
||||||
|
maxDay: { date: string; cases: number };
|
||||||
|
minDay: { date: string; cases: number };
|
||||||
|
stdDev: number;
|
||||||
|
trend: 'up' | 'down' | 'stable';
|
||||||
|
totalOutpatient: number;
|
||||||
|
totalInpatient: number;
|
||||||
|
avg7d: number;
|
||||||
|
todayCases: number | null;
|
||||||
|
noData: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopDiagnosis {
|
||||||
|
diagnosis: string;
|
||||||
|
outpatient: number;
|
||||||
|
inpatient: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
317
frontend/src/components/monitoring/useMonitoringData.ts
Normal file
317
frontend/src/components/monitoring/useMonitoringData.ts
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||||
|
import { useMonitoringStore } from '@/stores';
|
||||||
|
import { useDiseaseStore } from '@/stores/diseaseStore';
|
||||||
|
import { gridApi, caseApi, envApi } from '@/services/api';
|
||||||
|
import type { DistrictCaseData } from '@/types';
|
||||||
|
import type { MonitoringStats, TopDiagnosis } from './types';
|
||||||
|
|
||||||
|
type MonitoringTab = 'overview' | 'cases' | 'districts';
|
||||||
|
|
||||||
|
interface UseMonitoringDataArgs {
|
||||||
|
activeTab: MonitoringTab;
|
||||||
|
currentDate: string;
|
||||||
|
selectedDistrict: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监测页数据层:图表 90 天窗口、病例统计/区域统计两个按需 tab 的加载与派生。
|
||||||
|
// 不触碰 URL/drilldown(粒度真相来源仍由 MonitoringDashboard 持有),只消费 currentDate 与
|
||||||
|
// selectedDistrict 作为入参,避免把 store-mutation 逻辑下沉到子组件。
|
||||||
|
export function useMonitoringData({ activeTab, currentDate, selectedDistrict }: UseMonitoringDataArgs) {
|
||||||
|
const [chartData, setChartData] = useState<Array<{ date: string; cases: number; aqi?: number }>>([]);
|
||||||
|
|
||||||
|
// --- 病例统计 tab state (fetched on demand) ---
|
||||||
|
const [topDiagnoses, setTopDiagnoses] = useState<TopDiagnosis[]>([]);
|
||||||
|
const [caseTrend, setCaseTrend] = useState<Array<{ date: string; cases: number; aqi: number }>>([]);
|
||||||
|
const [heatmapData, setHeatmapData] = useState<Array<{ date: string; value: number }>>([]);
|
||||||
|
const [heatmapYear, setHeatmapYear] = useState<number | null>(null);
|
||||||
|
const [casesTabLoaded, setCasesTabLoaded] = useState(false);
|
||||||
|
const [casesTabLoading, setCasesTabLoading] = useState(false);
|
||||||
|
const [casesTabError, setCasesTabError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// --- 区域统计 tab state (fetched on demand) ---
|
||||||
|
const [districtMetrics, setDistrictMetrics] = useState<DistrictCaseData[]>([]);
|
||||||
|
const [districtSortKey, setDistrictSortKey] = useState<string>('total');
|
||||||
|
const [districtTabLoaded, setDistrictTabLoaded] = useState(false);
|
||||||
|
const [districtTabLoading, setDistrictTabLoading] = useState(false);
|
||||||
|
const [districtTabError, setDistrictTabError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const districtCases = useMonitoringStore((s) => s.districtCases);
|
||||||
|
const fetchDistrictCases = useMonitoringStore((s) => s.fetchDistrictCases);
|
||||||
|
const { selectedDiagnoses } = useDiseaseStore();
|
||||||
|
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Load chart data for 90-day window ending at the given reference date
|
||||||
|
const loadChartData = useCallback((refDate: string, district?: string) => {
|
||||||
|
const end = new Date(refDate);
|
||||||
|
const start = new Date(refDate);
|
||||||
|
start.setDate(start.getDate() - 90);
|
||||||
|
const startStr = start.toISOString().split('T')[0];
|
||||||
|
const endStr = end.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
if (selectedDiagnoses.length > 0 && selectedDiagnoses.length <= 3) {
|
||||||
|
caseApi.getTrend({
|
||||||
|
start_date: startStr,
|
||||||
|
end_date: endStr,
|
||||||
|
group_by: 'day',
|
||||||
|
diagnosis: selectedDiagnoses.join(','),
|
||||||
|
}).then((data) => {
|
||||||
|
const trend = data.trend || [];
|
||||||
|
setChartData(
|
||||||
|
trend.map((t: { date: string; total: number }) => ({ date: t.date, cases: t.total }))
|
||||||
|
);
|
||||||
|
}).catch((e) => { console.error('Failed to load chart data:', e); });
|
||||||
|
} else {
|
||||||
|
gridApi.getHistoricalAggregated(startStr, endStr, 'daily', district)
|
||||||
|
.then((data) => {
|
||||||
|
const rows = data.aggregations || [];
|
||||||
|
const dailyCases: Record<string, number> = {};
|
||||||
|
rows.forEach((item: { date: string; total_cases: number }) => {
|
||||||
|
dailyCases[item.date] = (dailyCases[item.date] || 0) + item.total_cases;
|
||||||
|
});
|
||||||
|
setChartData(
|
||||||
|
Object.entries(dailyCases)
|
||||||
|
.map(([date, cases]) => ({ date, cases }))
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date))
|
||||||
|
);
|
||||||
|
}).catch((e) => { console.error('Failed to load chart data:', e); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch districtCases with date filter (single day = currentDate)
|
||||||
|
const diagnosisParam = selectedDiagnoses.length > 0 ? selectedDiagnoses.join(',') : undefined;
|
||||||
|
fetchDistrictCases(diagnosisParam, undefined, refDate);
|
||||||
|
}, [fetchDistrictCases, selectedDiagnoses]);
|
||||||
|
|
||||||
|
// 提供给外部(手动刷新 / 病种过滤)触发的去抖加载。
|
||||||
|
const debouncedLoadChart = useCallback(() => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
loadChartData(currentDate, selectedDistrict || undefined);
|
||||||
|
}, 300);
|
||||||
|
}, [loadChartData, currentDate, selectedDistrict]);
|
||||||
|
|
||||||
|
// Re-fetch when currentDate, district, or diagnoses change
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
loadChartData(currentDate, selectedDistrict || undefined);
|
||||||
|
}, 300);
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
}, [currentDate, selectedDistrict, loadChartData]);
|
||||||
|
|
||||||
|
// Enhanced stats: window stats + current-date snapshot
|
||||||
|
const stats = useMemo<MonitoringStats>(() => {
|
||||||
|
const noData = chartData.length === 0;
|
||||||
|
|
||||||
|
const totalCases = noData ? 0 : chartData.reduce((sum, d) => sum + d.cases, 0);
|
||||||
|
const avgCases = noData ? 0 : Math.round(totalCases / chartData.length);
|
||||||
|
|
||||||
|
let maxDay = { date: '--', cases: 0 };
|
||||||
|
let minDay = { date: '--', cases: 0 };
|
||||||
|
let stdDev = 0;
|
||||||
|
let trend: 'up' | 'down' | 'stable' = 'stable';
|
||||||
|
|
||||||
|
if (!noData) {
|
||||||
|
maxDay = chartData.reduce((max, d) => d.cases > max.cases ? d : max, chartData[0]);
|
||||||
|
minDay = chartData.reduce((min, d) => d.cases < min.cases ? d : min, chartData[0]);
|
||||||
|
const variance = chartData.reduce((sum, d) => sum + (d.cases - avgCases) ** 2, 0) / chartData.length;
|
||||||
|
stdDev = Math.round(Math.sqrt(variance));
|
||||||
|
|
||||||
|
const halfIdx = Math.floor(chartData.length / 2);
|
||||||
|
const firstHalf = chartData.slice(0, halfIdx);
|
||||||
|
const secondHalf = chartData.slice(halfIdx);
|
||||||
|
const firstAvg = firstHalf.reduce((s, d) => s + d.cases, 0) / firstHalf.length;
|
||||||
|
const secondAvg = secondHalf.reduce((s, d) => s + d.cases, 0) / secondHalf.length;
|
||||||
|
trend = secondAvg > firstAvg * 1.1 ? 'up' : secondAvg < firstAvg * 0.9 ? 'down' : 'stable';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7-day moving average (last 7 days of the window)
|
||||||
|
const last7 = chartData.slice(-7);
|
||||||
|
const avg7d = last7.length > 0 ? Math.round(last7.reduce((s, d) => s + d.cases, 0) / last7.length) : 0;
|
||||||
|
|
||||||
|
// Current date snapshot: find the data point matching currentDate
|
||||||
|
const todaySnapshot = chartData.find((d) => d.date === currentDate);
|
||||||
|
const todayCases = todaySnapshot?.cases ?? null;
|
||||||
|
|
||||||
|
// Case type breakdown from districtCases
|
||||||
|
const totalOutpatient = districtCases.reduce((s, d) => s + d.outpatient, 0);
|
||||||
|
const totalInpatient = districtCases.reduce((s, d) => s + d.inpatient, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalCases, avgCases, maxDay, minDay,
|
||||||
|
stdDev, trend, totalOutpatient, totalInpatient,
|
||||||
|
avg7d, todayCases, noData,
|
||||||
|
};
|
||||||
|
}, [chartData, districtCases, currentDate]);
|
||||||
|
|
||||||
|
// 7-day sparkline for the StatCard bar (last 7 days of the loaded window)
|
||||||
|
const sparkline7d = useMemo(() => chartData.slice(-7).map((d) => d.cases), [chartData]);
|
||||||
|
|
||||||
|
// --- On-demand loader: 病例统计 tab ---
|
||||||
|
// Drives the trend off the Monitoring timeline (30-day window ending at currentDate),
|
||||||
|
// NOT a fixed now-30d window. Year for the heatmap is derived from the data.
|
||||||
|
const loadCasesTab = useCallback(async (refDate: string) => {
|
||||||
|
setCasesTabLoading(true);
|
||||||
|
setCasesTabError(null);
|
||||||
|
|
||||||
|
const end = new Date(refDate);
|
||||||
|
const start = new Date(refDate);
|
||||||
|
start.setDate(start.getDate() - 30);
|
||||||
|
const startStr = start.toISOString().split('T')[0];
|
||||||
|
const endStr = end.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const yearStart = `${end.getFullYear()}-01-01`;
|
||||||
|
const yearEnd = `${end.getFullYear()}-12-31`;
|
||||||
|
|
||||||
|
const [statsR, trendR, pollutantsR, yearTrendR] = await Promise.allSettled([
|
||||||
|
caseApi.getStats(),
|
||||||
|
caseApi.getTrend({ start_date: startStr, end_date: endStr, group_by: 'day' }),
|
||||||
|
envApi.getPollutants(30),
|
||||||
|
caseApi.getTrend({ start_date: yearStart, end_date: yearEnd, group_by: 'day' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const errs: string[] = [];
|
||||||
|
|
||||||
|
if (statsR.status === 'fulfilled') {
|
||||||
|
const topDiag = statsR.value.top_diagnoses || [];
|
||||||
|
setTopDiagnoses(
|
||||||
|
topDiag.slice(0, 5).map((d) => ({
|
||||||
|
diagnosis: d.diagnosis,
|
||||||
|
outpatient: d.outpatient,
|
||||||
|
inpatient: d.inpatient,
|
||||||
|
total: d.outpatient + d.inpatient,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
errs.push('诊断分布加载失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const aqiMap: Record<string, number> = {};
|
||||||
|
if (pollutantsR.status === 'fulfilled') {
|
||||||
|
for (const p of pollutantsR.value.data || []) {
|
||||||
|
aqiMap[p.date] = p.AQI || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trendR.status === 'fulfilled') {
|
||||||
|
const trend = trendR.value.trend || [];
|
||||||
|
setCaseTrend(
|
||||||
|
trend.map((t) => ({ date: t.date, cases: t.total, aqi: aqiMap[t.date] || 0 }))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
errs.push('趋势数据加载失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendar heatmap: daily cases for the data's actual year (derived from trend data)
|
||||||
|
if (yearTrendR.status === 'fulfilled') {
|
||||||
|
const yearTrend = yearTrendR.value.trend || [];
|
||||||
|
if (yearTrend.length > 0) {
|
||||||
|
const derivedYear = new Date(yearTrend[0].date).getFullYear();
|
||||||
|
setHeatmapYear(derivedYear);
|
||||||
|
setHeatmapData(yearTrend.map((t) => ({ date: t.date, value: t.total })));
|
||||||
|
} else {
|
||||||
|
setHeatmapYear(end.getFullYear());
|
||||||
|
setHeatmapData([]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errs.push('日历热力图加载失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
setCasesTabError(errs.length > 0 ? errs.join(';') : null);
|
||||||
|
setCasesTabLoading(false);
|
||||||
|
setCasesTabLoaded(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// --- On-demand loader: 区域统计 tab ---
|
||||||
|
const loadDistrictTab = useCallback(async () => {
|
||||||
|
setDistrictTabLoading(true);
|
||||||
|
setDistrictTabError(null);
|
||||||
|
try {
|
||||||
|
const res = await caseApi.getDistricts();
|
||||||
|
setDistrictMetrics(res.districts || []);
|
||||||
|
setDistrictTabError(null);
|
||||||
|
} catch {
|
||||||
|
setDistrictTabError('区域统计加载失败');
|
||||||
|
} finally {
|
||||||
|
setDistrictTabLoading(false);
|
||||||
|
setDistrictTabLoaded(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch tab data the first time a tab is opened (avoids loading everything upfront)
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'cases' && !casesTabLoaded && !casesTabLoading) {
|
||||||
|
loadCasesTab(currentDate);
|
||||||
|
}
|
||||||
|
if (activeTab === 'districts' && !districtTabLoaded && !districtTabLoading) {
|
||||||
|
loadDistrictTab();
|
||||||
|
}
|
||||||
|
}, [activeTab, casesTabLoaded, casesTabLoading, districtTabLoaded, districtTabLoading, currentDate, loadCasesTab, loadDistrictTab]);
|
||||||
|
|
||||||
|
// When the timeline date moves, refresh an already-opened 病例统计 tab so its
|
||||||
|
// trend window tracks the Monitoring timeline rather than going stale.
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'cases' && casesTabLoaded) {
|
||||||
|
loadCasesTab(currentDate);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentDate]);
|
||||||
|
|
||||||
|
// 区域统计 table: sortable district rows + heatmap columns
|
||||||
|
const districtTableRows = useMemo(() => {
|
||||||
|
const sorted = [...districtMetrics].sort((a, b) => {
|
||||||
|
switch (districtSortKey) {
|
||||||
|
case 'outpatient': return b.outpatient - a.outpatient;
|
||||||
|
case 'inpatient': return b.inpatient - a.inpatient;
|
||||||
|
case 'inpatient_ratio': return (b.inpatient_ratio ?? 0) - (a.inpatient_ratio ?? 0);
|
||||||
|
default: return b.total - a.total;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return sorted.map((d) => d.district);
|
||||||
|
}, [districtMetrics, districtSortKey]);
|
||||||
|
|
||||||
|
const districtTableData = useMemo(() => {
|
||||||
|
const map: Record<string, Record<string, number>> = {};
|
||||||
|
for (const d of districtMetrics) {
|
||||||
|
map[d.district] = {
|
||||||
|
total: d.total,
|
||||||
|
outpatient: d.outpatient,
|
||||||
|
inpatient: d.inpatient,
|
||||||
|
inpatient_ratio: Math.round((d.inpatient_ratio ?? 0) * 1000) / 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [districtMetrics]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 概览
|
||||||
|
chartData,
|
||||||
|
stats,
|
||||||
|
sparkline7d,
|
||||||
|
districtCases,
|
||||||
|
// 病例统计
|
||||||
|
topDiagnoses,
|
||||||
|
caseTrend,
|
||||||
|
heatmapData,
|
||||||
|
heatmapYear,
|
||||||
|
casesTabLoaded,
|
||||||
|
casesTabLoading,
|
||||||
|
casesTabError,
|
||||||
|
setCasesTabError,
|
||||||
|
loadCasesTab,
|
||||||
|
// 区域统计
|
||||||
|
districtTableRows,
|
||||||
|
districtTableData,
|
||||||
|
districtTabLoaded,
|
||||||
|
districtTabLoading,
|
||||||
|
districtTabError,
|
||||||
|
setDistrictTabError,
|
||||||
|
setDistrictSortKey,
|
||||||
|
loadDistrictTab,
|
||||||
|
// 图表手动加载(错误重试 / 病种过滤)
|
||||||
|
loadChartData,
|
||||||
|
debouncedLoadChart,
|
||||||
|
};
|
||||||
|
}
|
||||||
67
frontend/src/components/overview/AlertSeverityDonut.tsx
Normal file
67
frontend/src/components/overview/AlertSeverityDonut.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||||
|
import { EmptyState } from '@/components/ui';
|
||||||
|
import { CHART_COLORS } from './chartColors';
|
||||||
|
|
||||||
|
export interface AlertSlice {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertSeverityDonutProps {
|
||||||
|
data: AlertSlice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipStyle = {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
};
|
||||||
|
|
||||||
|
function AlertSeverityDonutComponent({ data }: AlertSeverityDonutProps) {
|
||||||
|
const hasData = data.some((d) => d.value > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
|
||||||
|
预警严重度分布
|
||||||
|
</div>
|
||||||
|
{hasData ? (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={50}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={4}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
>
|
||||||
|
{data.map((entry) => (
|
||||||
|
<Cell key={entry.name} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={tooltipStyle}
|
||||||
|
formatter={(value: number, name: string) => [value, name]}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: '12px' }}
|
||||||
|
formatter={(value: string) => <span className="text-text-primary">{value}</span>}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="暂无预警数据" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlertSeverityDonut = memo(AlertSeverityDonutComponent);
|
||||||
100
frontend/src/components/overview/CaseAqiTrend.tsx
Normal file
100
frontend/src/components/overview/CaseAqiTrend.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import { EmptyState } from '@/components/ui';
|
||||||
|
import { CHART_COLORS } from './chartColors';
|
||||||
|
|
||||||
|
export interface MergedTrendItem {
|
||||||
|
date: string;
|
||||||
|
cases: number;
|
||||||
|
aqi: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CaseAqiTrendProps {
|
||||||
|
data: MergedTrendItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateLabel(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipStyle = {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
};
|
||||||
|
|
||||||
|
function CaseAqiTrendComponent({ data }: CaseAqiTrendProps) {
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
|
||||||
|
近30日病例与AQI趋势
|
||||||
|
</div>
|
||||||
|
{data.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<LineChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={formatDateLabel}
|
||||||
|
tick={{ fontSize: 10, fill: CHART_COLORS.axis }}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
axisLine={{ stroke: CHART_COLORS.grid }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="left"
|
||||||
|
tick={{ fontSize: 10, fill: CHART_COLORS.axis }}
|
||||||
|
axisLine={{ stroke: CHART_COLORS.grid }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="right"
|
||||||
|
orientation="right"
|
||||||
|
tick={{ fontSize: 10, fill: CHART_COLORS.aqi }}
|
||||||
|
axisLine={{ stroke: CHART_COLORS.grid }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={tooltipStyle}
|
||||||
|
labelStyle={{ color: CHART_COLORS.tooltipText, fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
||||||
|
<Line
|
||||||
|
yAxisId="left"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="cases"
|
||||||
|
name="病例数"
|
||||||
|
stroke={CHART_COLORS.cases}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 3 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
yAxisId="right"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="aqi"
|
||||||
|
name="AQI"
|
||||||
|
stroke={CHART_COLORS.aqi}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 3 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="暂无数据" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CaseAqiTrend = memo(CaseAqiTrendComponent);
|
||||||
168
frontend/src/components/overview/DistrictChoropleth.tsx
Normal file
168
frontend/src/components/overview/DistrictChoropleth.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { memo, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { CHART_COLORS } from './chartColors';
|
||||||
|
|
||||||
|
interface DistrictChoroplethProps {
|
||||||
|
/** 区名(规范,带「区」) → 当前 metric 标量值 的查表。 */
|
||||||
|
metricLookup: Record<string, number>;
|
||||||
|
/** 当前指标的中文标签,用于 tooltip(如「门诊病例」)。 */
|
||||||
|
metricLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WUHAN_CENTER: [number, number] = [30.59, 114.3];
|
||||||
|
|
||||||
|
/** 把值映射到 7 档顺序色阶;高值 → 深色。 */
|
||||||
|
function colorForValue(value: number, max: number): string {
|
||||||
|
const scale = CHART_COLORS.choropleth;
|
||||||
|
if (max <= 0 || value <= 0) return CHART_COLORS.choroplethEmpty;
|
||||||
|
const ratio = value / max;
|
||||||
|
const idx = Math.min(scale.length - 1, Math.floor(ratio * scale.length));
|
||||||
|
return scale[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WuhanFeatureProps {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @types/geojson 随 @types/leaflet 一并提供 GeoJSON 全局命名空间。
|
||||||
|
type WuhanFeatureCollection = GeoJSON.FeatureCollection;
|
||||||
|
|
||||||
|
function DistrictChoroplethComponent({ metricLookup, metricLabel }: DistrictChoroplethProps) {
|
||||||
|
const mapDivRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
|
const geoLayerRef = useRef<L.GeoJSON | null>(null);
|
||||||
|
const geoDataRef = useRef<WuhanFeatureCollection | null>(null);
|
||||||
|
|
||||||
|
const maxValue = useMemo(() => {
|
||||||
|
const vals = Object.values(metricLookup);
|
||||||
|
return vals.length ? Math.max(...vals) : 0;
|
||||||
|
}, [metricLookup]);
|
||||||
|
|
||||||
|
// 创建地图 + 加载 geojson 一次。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapDivRef.current || mapRef.current) return;
|
||||||
|
|
||||||
|
const map = L.map(mapDivRef.current, {
|
||||||
|
center: WUHAN_CENTER,
|
||||||
|
zoom: 9,
|
||||||
|
zoomControl: true,
|
||||||
|
attributionControl: false,
|
||||||
|
scrollWheelZoom: false,
|
||||||
|
});
|
||||||
|
mapRef.current = map;
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||||
|
maxZoom: 18,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
fetch('/wuhan_districts.geojson')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: WuhanFeatureCollection) => {
|
||||||
|
if (cancelled || !mapRef.current) return;
|
||||||
|
geoDataRef.current = data;
|
||||||
|
renderLayer();
|
||||||
|
try {
|
||||||
|
const tmp = L.geoJSON(data);
|
||||||
|
map.fitBounds(tmp.getBounds(), { padding: [12, 12] });
|
||||||
|
} catch {
|
||||||
|
/* keep default center if bounds fail */
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* network/mock failure — wrapper still renders for tests */
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (mapRef.current) {
|
||||||
|
mapRef.current.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
}
|
||||||
|
geoLayerRef.current = null;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 当 metric 变化时重绘填色。
|
||||||
|
useEffect(() => {
|
||||||
|
renderLayer();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [metricLookup, maxValue, metricLabel]);
|
||||||
|
|
||||||
|
function renderLayer() {
|
||||||
|
const map = mapRef.current;
|
||||||
|
const data = geoDataRef.current;
|
||||||
|
if (!map || !data) return;
|
||||||
|
|
||||||
|
if (geoLayerRef.current) {
|
||||||
|
geoLayerRef.current.remove();
|
||||||
|
geoLayerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
geoLayerRef.current = L.geoJSON(data, {
|
||||||
|
style: (feature) => {
|
||||||
|
const name = (feature?.properties as WuhanFeatureProps | undefined)?.name ?? '';
|
||||||
|
const value = metricLookup[name] ?? 0;
|
||||||
|
return {
|
||||||
|
fillColor: colorForValue(value, maxValue),
|
||||||
|
fillOpacity: 0.78,
|
||||||
|
color: CHART_COLORS.choroplethStroke,
|
||||||
|
weight: 1.2,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onEachFeature: (feature, layer) => {
|
||||||
|
const name = (feature.properties as WuhanFeatureProps).name ?? '未知';
|
||||||
|
const value = metricLookup[name] ?? 0;
|
||||||
|
layer.bindTooltip(
|
||||||
|
`<div style="font-size:12px"><b>${name}</b><br/>${metricLabel}:${value.toLocaleString()}</div>`,
|
||||||
|
{ sticky: true }
|
||||||
|
);
|
||||||
|
layer.on({
|
||||||
|
mouseover: (e) => {
|
||||||
|
(e.target as L.Path).setStyle({ weight: 2.4, color: CHART_COLORS.cases });
|
||||||
|
},
|
||||||
|
mouseout: (e) => {
|
||||||
|
(e.target as L.Path).setStyle({ weight: 1.2, color: CHART_COLORS.choroplethStroke });
|
||||||
|
},
|
||||||
|
click: (e) => {
|
||||||
|
map.fitBounds((e.target as L.GeoJSON).getBounds(), { padding: [40, 40] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}).addTo(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图例的 5 档分界值。
|
||||||
|
const legendStops = useMemo(() => {
|
||||||
|
const scale = CHART_COLORS.choropleth;
|
||||||
|
return scale.map((color, i) => ({
|
||||||
|
color,
|
||||||
|
label: maxValue > 0 ? Math.round((maxValue * (i + 1)) / scale.length).toLocaleString() : '0',
|
||||||
|
}));
|
||||||
|
}, [maxValue]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="choropleth-wrapper" className="relative">
|
||||||
|
<div ref={mapDivRef} className="w-full rounded-lg overflow-hidden" style={{ height: 420 }} />
|
||||||
|
|
||||||
|
<div className="absolute bottom-3 right-3 z-[1000] bg-bg-card/95 px-3 py-2 rounded-lg border border-border shadow-sm">
|
||||||
|
<div className="text-[11px] font-semibold text-text-secondary mb-1.5">{metricLabel}</div>
|
||||||
|
<div className="flex items-center gap-0">
|
||||||
|
{legendStops.map((s) => (
|
||||||
|
<div key={s.color} className="flex flex-col items-center">
|
||||||
|
<div className="w-7 h-3" style={{ backgroundColor: s.color }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-1 text-[10px] text-text-muted">
|
||||||
|
<span>低</span>
|
||||||
|
<span>高</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DistrictChoropleth = memo(DistrictChoroplethComponent);
|
||||||
84
frontend/src/components/overview/KpiRow.tsx
Normal file
84
frontend/src/components/overview/KpiRow.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
AlertTriangle,
|
||||||
|
Droplets,
|
||||||
|
Building2,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { StatCard } from '@/components/StatCard';
|
||||||
|
import { TESTIDS } from '@/utils/testids';
|
||||||
|
import { CHART_COLORS } from './chartColors';
|
||||||
|
|
||||||
|
export interface KpiData {
|
||||||
|
totalCases: number;
|
||||||
|
todayCases: number;
|
||||||
|
changeRatio: number | null;
|
||||||
|
activeAlerts: number;
|
||||||
|
highRiskGrids: number;
|
||||||
|
avgAQI: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KpiRowProps {
|
||||||
|
kpi: KpiData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeTrendOf(ratio: number | null | undefined) {
|
||||||
|
if (ratio == null) return undefined;
|
||||||
|
if (ratio > 0) return { direction: 'up' as const, value: `${ratio.toFixed(1)}%` };
|
||||||
|
if (ratio < 0) return { direction: 'down' as const, value: `${Math.abs(ratio).toFixed(1)}%` };
|
||||||
|
return { direction: 'stable' as const, value: '0%' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiRowComponent({ kpi }: KpiRowProps) {
|
||||||
|
const changeTrend = changeTrendOf(kpi?.changeRatio);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={TESTIDS.kpiRow}
|
||||||
|
className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3"
|
||||||
|
>
|
||||||
|
<StatCard
|
||||||
|
icon={<Users className="w-4 h-4 text-primary" />}
|
||||||
|
label="累计病例总数"
|
||||||
|
value={kpi?.totalCases?.toLocaleString() ?? '--'}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Activity className="w-4 h-4 text-success" />}
|
||||||
|
label="今日病例"
|
||||||
|
value={kpi?.todayCases?.toLocaleString() ?? '--'}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={
|
||||||
|
(changeTrend?.direction === 'up' && <TrendingUp className="w-4 h-4 text-danger" />) ||
|
||||||
|
(changeTrend?.direction === 'down' && (
|
||||||
|
<TrendingDown className="w-4 h-4 text-success" />
|
||||||
|
)) || <Activity className="w-4 h-4 text-text-muted" />
|
||||||
|
}
|
||||||
|
label="7日变化率"
|
||||||
|
value={changeTrend ? changeTrend.value : '--'}
|
||||||
|
trend={changeTrend}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<AlertTriangle className="w-4 h-4 text-warning" />}
|
||||||
|
label="活跃预警数"
|
||||||
|
value={kpi?.activeAlerts?.toLocaleString() ?? '--'}
|
||||||
|
color={kpi && kpi.activeAlerts > 0 ? CHART_COLORS.alertP1 : undefined}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Building2 className="w-4 h-4 text-danger" />}
|
||||||
|
label="高风险网格"
|
||||||
|
value={kpi?.highRiskGrids?.toLocaleString() ?? '--'}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Droplets className="w-4 h-4 text-primary-light" />}
|
||||||
|
label="平均AQI"
|
||||||
|
value={kpi?.avgAQI?.toLocaleString() ?? '--'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KpiRow = memo(KpiRowComponent);
|
||||||
64
frontend/src/components/overview/TopDiagnosesBar.tsx
Normal file
64
frontend/src/components/overview/TopDiagnosesBar.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import { EmptyState } from '@/components/ui';
|
||||||
|
import type { DiagnosisBreakdown } from '@/types';
|
||||||
|
import { CHART_COLORS } from './chartColors';
|
||||||
|
|
||||||
|
interface TopDiagnosesBarProps {
|
||||||
|
diagnoses: DiagnosisBreakdown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipStyle = {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
};
|
||||||
|
|
||||||
|
function TopDiagnosesBarComponent({ diagnoses }: TopDiagnosesBarProps) {
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
|
||||||
|
Top 5 诊断分布
|
||||||
|
</div>
|
||||||
|
{diagnoses.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<BarChart
|
||||||
|
data={[...diagnoses].reverse()}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} horizontal={false} />
|
||||||
|
<XAxis type="number" tick={{ fontSize: 10, fill: CHART_COLORS.axis }} />
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="diagnosis"
|
||||||
|
tick={{ fontSize: 11, fill: CHART_COLORS.axisLabel }}
|
||||||
|
width={100}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={tooltipStyle}
|
||||||
|
formatter={(value: number, name: string) => [value.toLocaleString(), name]}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="outpatient" stackId="a" fill={CHART_COLORS.outpatient} name="门诊" barSize={16} />
|
||||||
|
<Bar dataKey="inpatient" stackId="a" fill={CHART_COLORS.inpatient} name="住院" barSize={16} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="暂无数据" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TopDiagnosesBar = memo(TopDiagnosesBarComponent);
|
||||||
91
frontend/src/components/overview/TopDistrictsBar.tsx
Normal file
91
frontend/src/components/overview/TopDistrictsBar.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { memo, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import { EmptyState } from '@/components/ui';
|
||||||
|
import { CHART_COLORS } from './chartColors';
|
||||||
|
import { metricValue, type DistrictMetric, type MetricKey } from './districtNormalize';
|
||||||
|
|
||||||
|
interface TopDistrictsBarProps {
|
||||||
|
/** 已归一并聚合到 13 区的指标数据。 */
|
||||||
|
districts: DistrictMetric[];
|
||||||
|
metric: MetricKey;
|
||||||
|
metricLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipStyle = {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
};
|
||||||
|
|
||||||
|
function TopDistrictsBarComponent({ districts, metric, metricLabel }: TopDistrictsBarProps) {
|
||||||
|
// 按当前 metric 排序取 Top5;横向条形图需 reverse 使最大值在顶部。
|
||||||
|
const top5 = useMemo(() => {
|
||||||
|
return [...districts]
|
||||||
|
.sort((a, b) => metricValue(b, metric) - metricValue(a, metric))
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((d) => ({
|
||||||
|
district: d.district,
|
||||||
|
outpatient: d.outpatient,
|
||||||
|
inpatient: d.inpatient,
|
||||||
|
value: metricValue(d, metric),
|
||||||
|
}))
|
||||||
|
.reverse();
|
||||||
|
}, [districts, metric]);
|
||||||
|
|
||||||
|
const hasData = top5.some((d) => d.value > 0);
|
||||||
|
const showStack = metric === 'all';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
|
||||||
|
Top 5 区县{metricLabel}分布
|
||||||
|
</div>
|
||||||
|
{hasData ? (
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<BarChart data={top5} layout="vertical" margin={{ top: 0, right: 10, left: 30, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} horizontal={false} />
|
||||||
|
<XAxis type="number" tick={{ fontSize: 10, fill: CHART_COLORS.axis }} />
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="district"
|
||||||
|
tick={{ fontSize: 11, fill: CHART_COLORS.axisLabel }}
|
||||||
|
width={64}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={tooltipStyle}
|
||||||
|
formatter={(value: number, name: string) => [value.toLocaleString(), name]}
|
||||||
|
/>
|
||||||
|
{showStack ? (
|
||||||
|
<>
|
||||||
|
<Bar dataKey="outpatient" stackId="a" fill={CHART_COLORS.outpatient} name="门诊" barSize={20} />
|
||||||
|
<Bar dataKey="inpatient" stackId="a" fill={CHART_COLORS.inpatient} name="住院" barSize={20} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Bar
|
||||||
|
dataKey="value"
|
||||||
|
fill={metric === 'inpatient' ? CHART_COLORS.inpatient : CHART_COLORS.outpatient}
|
||||||
|
name={metricLabel}
|
||||||
|
barSize={20}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="暂无数据" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TopDistrictsBar = memo(TopDistrictsBarComponent);
|
||||||
22
frontend/src/components/overview/chartColors.ts
Normal file
22
frontend/src/components/overview/chartColors.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* 概览大屏图表与地图使用的字面色值集中处。
|
||||||
|
* Recharts / Leaflet 需要原始 hex,无法用 Tailwind class,故在此集中定义,
|
||||||
|
* 避免页面里散落 magic hex。
|
||||||
|
*/
|
||||||
|
export const CHART_COLORS = {
|
||||||
|
outpatient: '#2563EB', // 门诊(primary)
|
||||||
|
inpatient: '#DC2626', // 住院(danger)
|
||||||
|
cases: '#2563EB',
|
||||||
|
aqi: '#D97706', // warning
|
||||||
|
grid: '#E2E8F0', // border
|
||||||
|
axis: '#64748B', // text-secondary
|
||||||
|
axisLabel: '#374151',
|
||||||
|
tooltipBorder: '#E2E8F0',
|
||||||
|
tooltipText: '#1E293B',
|
||||||
|
alertP1: '#DC2626',
|
||||||
|
alertP2: '#D97706',
|
||||||
|
// choropleth 顺序色阶(浅 → 深),高值高亮
|
||||||
|
choropleth: ['#DBEAFE', '#BFDBFE', '#93C5FD', '#60A5FA', '#3B82F6', '#2563EB', '#1D4ED8'],
|
||||||
|
choroplethEmpty: '#F1F5F9', // 无数据区填充
|
||||||
|
choroplethStroke: '#FFFFFF',
|
||||||
|
} as const;
|
||||||
111
frontend/src/components/overview/districtNormalize.test.ts
Normal file
111
frontend/src/components/overview/districtNormalize.test.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
WUHAN_DISTRICTS,
|
||||||
|
normalizeDistrictName,
|
||||||
|
joinDistrictCases,
|
||||||
|
buildMetricLookup,
|
||||||
|
} from './districtNormalize';
|
||||||
|
import type { DistrictCaseData } from '@/types';
|
||||||
|
|
||||||
|
function mk(district: string, outpatient: number, inpatient: number): DistrictCaseData {
|
||||||
|
return {
|
||||||
|
district,
|
||||||
|
outpatient,
|
||||||
|
inpatient,
|
||||||
|
total: outpatient + inpatient,
|
||||||
|
outpatient_ratio: 0,
|
||||||
|
inpatient_ratio: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('normalizeDistrictName', () => {
|
||||||
|
it('maps every bare form to its canonical 区-name', () => {
|
||||||
|
for (const canonical of WUHAN_DISTRICTS) {
|
||||||
|
const bare = canonical.replace(/区$/, '');
|
||||||
|
expect(normalizeDistrictName(bare)).toBe(canonical);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes canonical names through unchanged', () => {
|
||||||
|
for (const canonical of WUHAN_DISTRICTS) {
|
||||||
|
expect(normalizeDistrictName(canonical)).toBe(canonical);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims whitespace and returns null for unknown/empty', () => {
|
||||||
|
expect(normalizeDistrictName(' 武昌 ')).toBe('武昌区');
|
||||||
|
expect(normalizeDistrictName('')).toBeNull();
|
||||||
|
expect(normalizeDistrictName(null)).toBeNull();
|
||||||
|
expect(normalizeDistrictName('火星区')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('joinDistrictCases', () => {
|
||||||
|
it('always yields exactly the 13 canonical districts in canonical order', () => {
|
||||||
|
const joined = joinDistrictCases([mk('武昌', 5, 1)]);
|
||||||
|
expect(joined).toHaveLength(13);
|
||||||
|
expect(joined.map((d) => d.district)).toEqual([...WUHAN_DISTRICTS]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses 武昌 + 武昌区 into ONE summed district (no double-count)', () => {
|
||||||
|
const cases = [mk('武昌', 10, 2), mk('武昌区', 4, 3)];
|
||||||
|
const joined = joinDistrictCases(cases);
|
||||||
|
const wuchang = joined.find((d) => d.district === '武昌区')!;
|
||||||
|
expect(wuchang.outpatient).toBe(14);
|
||||||
|
expect(wuchang.inpatient).toBe(5);
|
||||||
|
expect(wuchang.total).toBe(19);
|
||||||
|
// exactly 13 entries — the duplicate did not create a 14th row
|
||||||
|
expect(joined).toHaveLength(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves sum integrity: sum(joined) == sum(input) for the 13 known districts', () => {
|
||||||
|
const cases: DistrictCaseData[] = [
|
||||||
|
mk('武昌', 10, 2),
|
||||||
|
mk('武昌区', 4, 3),
|
||||||
|
mk('江岸', 7, 1),
|
||||||
|
mk('江岸区', 2, 0),
|
||||||
|
mk('洪山区', 9, 4),
|
||||||
|
mk('黄陂', 3, 1),
|
||||||
|
];
|
||||||
|
const inputOut = cases.reduce((s, c) => s + c.outpatient, 0);
|
||||||
|
const inputIn = cases.reduce((s, c) => s + c.inpatient, 0);
|
||||||
|
const inputTotal = cases.reduce((s, c) => s + c.total, 0);
|
||||||
|
|
||||||
|
const joined = joinDistrictCases(cases);
|
||||||
|
const joinedOut = joined.reduce((s, d) => s + d.outpatient, 0);
|
||||||
|
const joinedIn = joined.reduce((s, d) => s + d.inpatient, 0);
|
||||||
|
const joinedTotal = joined.reduce((s, d) => s + d.total, 0);
|
||||||
|
|
||||||
|
expect(joinedOut).toBe(inputOut);
|
||||||
|
expect(joinedIn).toBe(inputIn);
|
||||||
|
expect(joinedTotal).toBe(inputTotal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores records outside the 13 districts (no leakage into the sum)', () => {
|
||||||
|
const cases = [mk('武昌区', 5, 0), mk('火星区', 99, 99)];
|
||||||
|
const joined = joinDistrictCases(cases);
|
||||||
|
expect(joined.reduce((s, d) => s + d.total, 0)).toBe(5);
|
||||||
|
expect(joined).toHaveLength(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills unseen districts with zeros', () => {
|
||||||
|
const joined = joinDistrictCases([mk('武昌区', 5, 1)]);
|
||||||
|
const jiangan = joined.find((d) => d.district === '江岸区')!;
|
||||||
|
expect(jiangan.total).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildMetricLookup', () => {
|
||||||
|
const joined = joinDistrictCases([mk('武昌', 10, 2), mk('江岸区', 3, 4)]);
|
||||||
|
|
||||||
|
it('keys by canonical name for the selected metric', () => {
|
||||||
|
expect(buildMetricLookup(joined, 'all')['武昌区']).toBe(12);
|
||||||
|
expect(buildMetricLookup(joined, 'outpatient')['武昌区']).toBe(10);
|
||||||
|
expect(buildMetricLookup(joined, 'inpatient')['武昌区']).toBe(2);
|
||||||
|
expect(buildMetricLookup(joined, 'all')['江岸区']).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a lookup covering all 13 districts', () => {
|
||||||
|
expect(Object.keys(buildMetricLookup(joined, 'all'))).toHaveLength(13);
|
||||||
|
});
|
||||||
|
});
|
||||||
112
frontend/src/components/overview/districtNormalize.ts
Normal file
112
frontend/src/components/overview/districtNormalize.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* 区县名称归一化与 case 数据聚合。
|
||||||
|
*
|
||||||
|
* 武汉市 geojson 的 `name` 属性是带「区」后缀的规范名(武昌区、江岸区…)。
|
||||||
|
* 后端 case 数据可能返回裸名(武昌)或带后缀名(武昌区),甚至两者并存。
|
||||||
|
* 这里把所有形式归一到 13 个规范名,并把同一区的门诊/住院/总数求和,
|
||||||
|
* 保证 join 后恰好 13 个区、无重复计数、求和守恒。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DistrictCaseData } from '@/types';
|
||||||
|
|
||||||
|
/** geojson 中武汉市的 13 个区(带「区」后缀),即规范名集合。 */
|
||||||
|
export const WUHAN_DISTRICTS = [
|
||||||
|
'江岸区',
|
||||||
|
'江汉区',
|
||||||
|
'硚口区',
|
||||||
|
'汉阳区',
|
||||||
|
'武昌区',
|
||||||
|
'青山区',
|
||||||
|
'洪山区',
|
||||||
|
'东西湖区',
|
||||||
|
'汉南区',
|
||||||
|
'蔡甸区',
|
||||||
|
'江夏区',
|
||||||
|
'黄陂区',
|
||||||
|
'新洲区',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type CanonicalDistrict = (typeof WUHAN_DISTRICTS)[number];
|
||||||
|
|
||||||
|
/** 规范名去掉「区」后缀的裸名 → 规范名 的映射,用于把裸名补全。 */
|
||||||
|
const BARE_TO_CANONICAL: Record<string, CanonicalDistrict> = WUHAN_DISTRICTS.reduce(
|
||||||
|
(acc, name) => {
|
||||||
|
acc[name.replace(/区$/, '')] = name;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, CanonicalDistrict>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把任意形式的区名归一到规范名(带「区」后缀)。
|
||||||
|
* - 已是规范名 → 原样返回
|
||||||
|
* - 裸名(武昌)→ 补「区」(武昌区)
|
||||||
|
* - 不在 13 区内 → 返回 null(调用方应忽略,避免污染 join)
|
||||||
|
*/
|
||||||
|
export function normalizeDistrictName(raw: string | null | undefined): CanonicalDistrict | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
// 已带后缀且在规范集合内
|
||||||
|
if ((WUHAN_DISTRICTS as readonly string[]).includes(trimmed)) {
|
||||||
|
return trimmed as CanonicalDistrict;
|
||||||
|
}
|
||||||
|
// 裸名补全
|
||||||
|
const bare = trimmed.replace(/区$/, '');
|
||||||
|
return BARE_TO_CANONICAL[bare] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** join 后每个区的指标值(按当前 metric 取出的标量)。 */
|
||||||
|
export interface DistrictMetric {
|
||||||
|
district: CanonicalDistrict;
|
||||||
|
outpatient: number;
|
||||||
|
inpatient: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MetricKey = 'all' | 'outpatient' | 'inpatient';
|
||||||
|
|
||||||
|
/** 取出某条聚合记录在当前 metric 下用于着色/排序的标量值。 */
|
||||||
|
export function metricValue(d: DistrictMetric, metric: MetricKey): number {
|
||||||
|
if (metric === 'outpatient') return d.outpatient;
|
||||||
|
if (metric === 'inpatient') return d.inpatient;
|
||||||
|
return d.total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 case 数组按区名归一后聚合到 13 个规范区。
|
||||||
|
* 同名区(武昌 + 武昌区)会被折叠并对各字段求和,绝不重复计数。
|
||||||
|
* 返回固定 13 项(未出现的区补 0),顺序与 WUHAN_DISTRICTS 一致,
|
||||||
|
* 便于与 geojson 稳定 join。
|
||||||
|
*/
|
||||||
|
export function joinDistrictCases(cases: readonly DistrictCaseData[]): DistrictMetric[] {
|
||||||
|
const acc = new Map<CanonicalDistrict, DistrictMetric>();
|
||||||
|
for (const name of WUHAN_DISTRICTS) {
|
||||||
|
acc.set(name, { district: name, outpatient: 0, inpatient: 0, total: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const c of cases) {
|
||||||
|
const canonical = normalizeDistrictName(c.district);
|
||||||
|
if (!canonical) continue; // 非 13 区的记录忽略
|
||||||
|
const entry = acc.get(canonical)!;
|
||||||
|
entry.outpatient += c.outpatient || 0;
|
||||||
|
entry.inpatient += c.inpatient || 0;
|
||||||
|
entry.total += c.total || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return WUHAN_DISTRICTS.map((name) => acc.get(name)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 区名(规范) → 指标标量 的查表,供 geojson 着色按 name 直接索引。
|
||||||
|
*/
|
||||||
|
export function buildMetricLookup(
|
||||||
|
joined: readonly DistrictMetric[],
|
||||||
|
metric: MetricKey
|
||||||
|
): Record<string, number> {
|
||||||
|
const lookup: Record<string, number> = {};
|
||||||
|
for (const d of joined) {
|
||||||
|
lookup[d.district] = metricValue(d, metric);
|
||||||
|
}
|
||||||
|
return lookup;
|
||||||
|
}
|
||||||
41
frontend/src/components/ui/Card.tsx
Normal file
41
frontend/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
|
export const Card = memo(function Card({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
title,
|
||||||
|
actions,
|
||||||
|
testid,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
title?: React.ReactNode;
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
testid?: string;
|
||||||
|
}): JSX.Element {
|
||||||
|
const hasHeader = title != null || actions != null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={testid}
|
||||||
|
className={[
|
||||||
|
'bg-bg-card rounded-lg border border-border',
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
>
|
||||||
|
{hasHeader && (
|
||||||
|
<div className="flex items-center justify-between gap-2 px-4 py-3 border-b border-border-light">
|
||||||
|
{title != null && (
|
||||||
|
<div className="text-sm font-medium text-text-primary">{title}</div>
|
||||||
|
)}
|
||||||
|
{actions != null && (
|
||||||
|
<div className="flex items-center gap-2 shrink-0">{actions}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
29
frontend/src/components/ui/EmptyState.tsx
Normal file
29
frontend/src/components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
|
export const EmptyState = memo(function EmptyState({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="empty-state"
|
||||||
|
className="flex flex-col items-center justify-center gap-3 py-12 px-6 text-center"
|
||||||
|
>
|
||||||
|
{icon && (
|
||||||
|
<div className="text-text-muted text-4xl">{icon}</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm font-medium text-text-secondary">{title}</p>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs text-text-muted max-w-xs">{description}</p>
|
||||||
|
)}
|
||||||
|
{action && <div className="mt-2">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
30
frontend/src/components/ui/LoadingState.tsx
Normal file
30
frontend/src/components/ui/LoadingState.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Skeleton } from './Skeleton';
|
||||||
|
|
||||||
|
export function LoadingState({
|
||||||
|
label,
|
||||||
|
testid,
|
||||||
|
lines = 3,
|
||||||
|
}: {
|
||||||
|
label?: string;
|
||||||
|
testid?: string;
|
||||||
|
lines?: number;
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={testid ?? 'loading-state'}
|
||||||
|
className="flex flex-col items-center justify-center gap-3 p-6 w-full"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2 w-full max-w-sm">
|
||||||
|
{Array.from({ length: lines }).map((_, i) => (
|
||||||
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
className={`h-4 ${i === lines - 1 ? 'w-2/3' : 'w-full'}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{label && (
|
||||||
|
<p className="text-xs text-text-muted">{label}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
frontend/src/components/ui/Panel.tsx
Normal file
22
frontend/src/components/ui/Panel.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
|
export const Panel = memo(function Panel({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
testid,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
testid?: string;
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={testid}
|
||||||
|
className={['bg-bg-hover/50 rounded-md p-3', className]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
53
frontend/src/components/ui/Segmented.tsx
Normal file
53
frontend/src/components/ui/Segmented.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
export const Segmented = memo(function Segmented<T extends string>({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
size = 'md',
|
||||||
|
testid,
|
||||||
|
}: {
|
||||||
|
options: { value: T; label: string }[];
|
||||||
|
value: T;
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
testid?: string;
|
||||||
|
}): JSX.Element {
|
||||||
|
const sizeClasses = size === 'sm'
|
||||||
|
? 'px-2.5 py-0.5 text-xs'
|
||||||
|
: 'px-3.5 py-1 text-[13px]';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={testid}
|
||||||
|
className="inline-flex items-center gap-0.5 rounded-full bg-bg-hover p-0.5"
|
||||||
|
>
|
||||||
|
{options.map((opt) => {
|
||||||
|
const isActive = opt.value === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
data-testid={testid ? `${testid}-${opt.value}` : undefined}
|
||||||
|
onClick={() => onChange(opt.value)}
|
||||||
|
className={[
|
||||||
|
'rounded-full font-medium transition-colors',
|
||||||
|
sizeClasses,
|
||||||
|
isActive
|
||||||
|
? 'bg-primary text-white shadow-sm'
|
||||||
|
: 'text-text-secondary hover:bg-bg-active',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}) as <T extends string>(props: {
|
||||||
|
options: { value: T; label: string }[];
|
||||||
|
value: T;
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
testid?: string;
|
||||||
|
}) => JSX.Element;
|
||||||
22
frontend/src/components/ui/Skeleton.tsx
Normal file
22
frontend/src/components/ui/Skeleton.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
export const Skeleton = memo(function Skeleton({
|
||||||
|
className,
|
||||||
|
rounded,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
rounded?: boolean;
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="skeleton"
|
||||||
|
className={[
|
||||||
|
'animate-pulse bg-bg-hover',
|
||||||
|
rounded ? 'rounded-full' : 'rounded',
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
6
frontend/src/components/ui/index.ts
Normal file
6
frontend/src/components/ui/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { Skeleton } from './Skeleton';
|
||||||
|
export { LoadingState } from './LoadingState';
|
||||||
|
export { EmptyState } from './EmptyState';
|
||||||
|
export { Card } from './Card';
|
||||||
|
export { Panel } from './Panel';
|
||||||
|
export { Segmented } from './Segmented';
|
||||||
@@ -1,37 +1,26 @@
|
|||||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||||
import React from 'react';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { useRiskStore } from '@/stores';
|
import { useRiskStore, useSessionStore } from '@/stores';
|
||||||
import { AlertMap } from '@/components/AlertMap';
|
import { TESTIDS } from '@/utils/testids';
|
||||||
import type { CellInfo } from '@/components/AlertMap';
|
import type { CellInfo } from '@/components/AlertMap';
|
||||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||||
import { StatCard } from '@/components/StatCard';
|
|
||||||
import { StatisticalCharts } from '@/components/StatisticalCharts';
|
|
||||||
import { analysisApi } from '@/services/api';
|
import { analysisApi } from '@/services/api';
|
||||||
import { PieChart, Pie, Cell, Tooltip as RechartsTooltip, Legend, ResponsiveContainer } from 'recharts';
|
import { AlertsHeader } from '@/components/alerts/AlertsHeader';
|
||||||
|
import { AlertsListTab } from '@/components/alerts/AlertsListTab';
|
||||||
interface ExtendedAlert {
|
import { AlertsRiskPanel } from '@/components/alerts/AlertsRiskPanel';
|
||||||
alert_id: string;
|
import { AlertDetailModal, CellInfoPanel } from '@/components/alerts/AlertDetailModal';
|
||||||
grid_id: string;
|
import type { ExtendedAlert, RiskStats } from '@/components/alerts/types';
|
||||||
region: string;
|
|
||||||
street: string;
|
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
risk_value: number;
|
|
||||||
risk_level: 'high' | 'medium_high' | 'medium' | 'medium_low' | 'low';
|
|
||||||
priority: 'P1' | 'P2';
|
|
||||||
forecast_horizon: number;
|
|
||||||
forecast_time: string;
|
|
||||||
reason: string;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const HORIZON_LABELS: Record<number, string> = {
|
|
||||||
1: '1 天后',
|
|
||||||
3: '3 天后',
|
|
||||||
7: '7 天后',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AlertsDashboard() {
|
export function AlertsDashboard() {
|
||||||
|
// 视角驱动的两条不变量(D2:纯前端视图预设,非访问控制):
|
||||||
|
// 1. 官员(厅领导)不展示 100m 网格(「对他没意义/太超前」)——强制 showGrid=false 且隐藏网格切换。
|
||||||
|
// 2. 医生(或 ?view=cluster)= 聚类/密度视角:只看聚合栅格密度 + 病种过滤,
|
||||||
|
// 绝不渲染任何个体病例点(隐私不变量)——强制 showAlertMarkers=false 且隐藏「预警标记」切换。
|
||||||
|
const role = useSessionStore((s) => s.role);
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const view = searchParams.get('view');
|
||||||
|
const isOfficial = role === 'official';
|
||||||
|
const isCluster = role === 'doctor' || view === 'cluster';
|
||||||
const alerts = useRiskStore((s) => s.alerts);
|
const alerts = useRiskStore((s) => s.alerts);
|
||||||
const isLoading = useRiskStore((s) => s.isLoading);
|
const isLoading = useRiskStore((s) => s.isLoading);
|
||||||
const error = useRiskStore((s) => s.error);
|
const error = useRiskStore((s) => s.error);
|
||||||
@@ -48,9 +37,15 @@ export function AlertsDashboard() {
|
|||||||
const [debouncedRiskRange, setDebouncedRiskRange] = useState<[number, number]>([0.6, 1.0]);
|
const [debouncedRiskRange, setDebouncedRiskRange] = useState<[number, number]>([0.6, 1.0]);
|
||||||
const [forecastDay, setForecastDay] = useState<1 | 3 | 7>(1);
|
const [forecastDay, setForecastDay] = useState<1 | 3 | 7>(1);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [showGrid, setShowGrid] = useState(true);
|
// 官员视角默认隐藏网格(见上);其余角色默认显示。
|
||||||
|
const [showGrid, setShowGrid] = useState(!isOfficial);
|
||||||
const [cellInfo, setCellInfo] = useState<CellInfo | null>(null);
|
const [cellInfo, setCellInfo] = useState<CellInfo | null>(null);
|
||||||
|
|
||||||
|
// 隐私不变量:聚类(医生)视角下,个体病例点标记永远关闭,且无法被打开。
|
||||||
|
// 这里把「用户意图的开关状态」与「实际生效的状态」分开:effectiveShowAlertMarkers
|
||||||
|
// 是唯一传给地图/渲染的真值,cluster 模式恒为 false,与用户点击无关。
|
||||||
|
const effectiveShowAlertMarkers = isCluster ? false : showAlertMarkers;
|
||||||
|
|
||||||
// In-page tab strip (no router) — matches existing activePage pattern
|
// In-page tab strip (no router) — matches existing activePage pattern
|
||||||
const [activeTab, setActiveTab] = useState<'list' | 'stats'>('list');
|
const [activeTab, setActiveTab] = useState<'list' | 'stats'>('list');
|
||||||
|
|
||||||
@@ -130,7 +125,7 @@ export function AlertsDashboard() {
|
|||||||
}, [extendedAlerts, selectedHorizon, selectedPriority, sortBy, debouncedRiskRange]);
|
}, [extendedAlerts, selectedHorizon, selectedPriority, sortBy, debouncedRiskRange]);
|
||||||
|
|
||||||
// Risk distribution stats (includes p1/p2 counts) — single pass over each array
|
// Risk distribution stats (includes p1/p2 counts) — single pass over each array
|
||||||
const riskStats = useMemo(() => {
|
const riskStats: RiskStats = useMemo(() => {
|
||||||
// p1/p2 reflect the full (unfiltered) alert set
|
// p1/p2 reflect the full (unfiltered) alert set
|
||||||
let p1 = 0;
|
let p1 = 0;
|
||||||
let p2 = 0;
|
let p2 = 0;
|
||||||
@@ -167,17 +162,6 @@ export function AlertsDashboard() {
|
|||||||
return filteredAlerts.find(a => a.alert_id === selectedAlert);
|
return filteredAlerts.find(a => a.alert_id === selectedAlert);
|
||||||
}, [filteredAlerts, selectedAlert]);
|
}, [filteredAlerts, selectedAlert]);
|
||||||
|
|
||||||
// Severity donut data (P1/P2) for the 风险统计 tab
|
|
||||||
const alertPie = useMemo(() => ([
|
|
||||||
{ name: 'P1 (紧急)', value: riskStats.p1, color: '#ef4444' },
|
|
||||||
{ name: 'P2 (关注)', value: riskStats.p2, color: '#f59e0b' },
|
|
||||||
]), [riskStats.p1, riskStats.p2]);
|
|
||||||
|
|
||||||
const topDistrictMax = useMemo(
|
|
||||||
() => riskStats.topDistricts.reduce((m, [, n]) => Math.max(m, n), 0),
|
|
||||||
[riskStats.topDistricts],
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedGridId = useMemo(() => {
|
const selectedGridId = useMemo(() => {
|
||||||
if (!selectedAlert) return null;
|
if (!selectedAlert) return null;
|
||||||
const alert = filteredAlerts.find(a => a.alert_id === selectedAlert);
|
const alert = filteredAlerts.find(a => a.alert_id === selectedAlert);
|
||||||
@@ -220,7 +204,7 @@ export function AlertsDashboard() {
|
|||||||
a.forecast_horizon, `"${a.reason}"`, a.timestamp,
|
a.forecast_horizon, `"${a.reason}"`, a.timestamp,
|
||||||
]);
|
]);
|
||||||
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
|
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
|
||||||
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
@@ -241,7 +225,7 @@ export function AlertsDashboard() {
|
|||||||
}, [filteredAlerts]);
|
}, [filteredAlerts]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={isFullscreen ? 'fixed inset-0 z-40 bg-bg-page pt-[52px] p-5' : 'p-5'}>
|
<div data-testid={TESTIDS.pageAlerts} className={isFullscreen ? 'fixed inset-0 z-40 bg-bg-page pt-[52px] p-5' : 'p-5'}>
|
||||||
{error && (
|
{error && (
|
||||||
<ErrorBanner
|
<ErrorBanner
|
||||||
error={error}
|
error={error}
|
||||||
@@ -250,566 +234,68 @@ export function AlertsDashboard() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
<AlertsHeader
|
||||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-x-4 gap-y-2">
|
total={filteredAlerts.length}
|
||||||
<div className="min-w-0">
|
p1={riskStats.p1}
|
||||||
<h1 className="font-display text-[18px] font-semibold mb-1">风险预警</h1>
|
p2={riskStats.p2}
|
||||||
<p className="text-[12px] text-text-muted truncate">
|
activeTab={activeTab}
|
||||||
100m网格风险预测 · 多时间尺度预警 · 病例-气象关联分析
|
onTabChange={setActiveTab}
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 text-[11px] shrink-0 flex-wrap">
|
|
||||||
<span className="text-text-muted">共 <span className="font-semibold text-text-primary">{filteredAlerts.length}</span> 条预警</span>
|
|
||||||
<span className="px-2 py-1 bg-danger/10 border border-danger/20 rounded text-danger font-semibold">P1: {riskStats.p1}</span>
|
|
||||||
<span className="px-2 py-1 bg-warning/10 border border-warning/20 rounded text-warning font-semibold">P2: {riskStats.p2}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab strip — in-page, no router */}
|
|
||||||
<div className="flex gap-1 mb-4 border-b border-border">
|
|
||||||
{([
|
|
||||||
{ key: 'list', label: '预警列表' },
|
|
||||||
{ key: 'stats', label: '风险统计' },
|
|
||||||
] as const).map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.key}
|
|
||||||
onClick={() => setActiveTab(tab.key)}
|
|
||||||
className={`px-4 py-2 text-[13px] font-medium -mb-px border-b-2 transition-colors ${
|
|
||||||
activeTab === tab.key
|
|
||||||
? 'border-primary text-primary'
|
|
||||||
: 'border-transparent text-text-secondary hover:text-text-primary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === 'list' && (
|
{activeTab === 'list' && (
|
||||||
<>
|
<AlertsListTab
|
||||||
{/* Toolbar Row 1: Forecast + Fullscreen + Export */}
|
forecastDay={forecastDay}
|
||||||
<div className="card p-3 mb-3">
|
onForecastDayChange={setForecastDay}
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
isFullscreen={isFullscreen}
|
||||||
<div className="flex items-center gap-2">
|
onToggleFullscreen={() => setIsFullscreen(!isFullscreen)}
|
||||||
<span className="text-[12px] text-text-muted">网格预测:</span>
|
onExportCsv={exportToCsv}
|
||||||
<div className="flex gap-0.5 bg-bg-page p-0.5 rounded">
|
onExportJson={exportToJson}
|
||||||
{([1, 3, 7] as const).map((day) => (
|
selectedHorizon={selectedHorizon}
|
||||||
<button
|
onHorizonChange={setSelectedHorizon}
|
||||||
key={day}
|
selectedPriority={selectedPriority}
|
||||||
onClick={() => setForecastDay(day)}
|
onPriorityChange={setSelectedPriority}
|
||||||
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
|
riskRange={riskRange}
|
||||||
forecastDay === day
|
onRiskRangeChange={setRiskRange}
|
||||||
? 'bg-bg-card text-primary shadow-sm'
|
showMap={showMap}
|
||||||
: 'text-text-secondary hover:text-text-primary'
|
onToggleMap={() => setShowMap(!showMap)}
|
||||||
}`}
|
showAlertMarkers={showAlertMarkers}
|
||||||
>
|
onToggleAlertMarkers={() => setShowAlertMarkers(!showAlertMarkers)}
|
||||||
{day}天
|
showGrid={showGrid}
|
||||||
</button>
|
onToggleGrid={() => setShowGrid(!showGrid)}
|
||||||
))}
|
sortBy={sortBy}
|
||||||
</div>
|
onSortByChange={setSortBy}
|
||||||
</div>
|
riskStats={riskStats}
|
||||||
|
filteredAlerts={filteredAlerts}
|
||||||
<div className="w-px h-6 bg-border" />
|
isLoading={isLoading}
|
||||||
|
selectedGridId={selectedGridId}
|
||||||
<button
|
selectedAlert={selectedAlert}
|
||||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
onGridClick={handleGridClick}
|
||||||
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
onCellInfo={handleCellInfo}
|
||||||
isFullscreen
|
onCardClick={handleAlertCardClick}
|
||||||
? 'bg-bg-card text-primary border border-primary'
|
effectiveShowAlertMarkers={effectiveShowAlertMarkers}
|
||||||
: 'bg-bg-page text-text-secondary border border-border'
|
isCluster={isCluster}
|
||||||
}`}
|
isOfficial={isOfficial}
|
||||||
>
|
/>
|
||||||
{isFullscreen ? '退出全屏' : '全屏'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="w-px h-6 bg-border" />
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={exportToCsv}
|
|
||||||
className="px-3 py-1.5 text-[12px] font-medium rounded bg-bg-page text-text-secondary border border-border hover:border-primary transition-colors"
|
|
||||||
>
|
|
||||||
导出CSV
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={exportToJson}
|
|
||||||
className="px-3 py-1.5 text-[12px] font-medium rounded bg-bg-page text-text-secondary border border-border hover:border-primary transition-colors"
|
|
||||||
>
|
|
||||||
导出JSON
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Toolbar Row 2: Filters */}
|
|
||||||
<div className="card p-3 mb-4">
|
|
||||||
<div className="flex items-center gap-4 flex-wrap">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-[12px] text-text-muted">预测时效:</span>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{(['all', 1, 3, 7] as const).map((horizon) => (
|
|
||||||
<button
|
|
||||||
key={horizon}
|
|
||||||
onClick={() => setSelectedHorizon(horizon)}
|
|
||||||
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
|
||||||
selectedHorizon === horizon
|
|
||||||
? 'bg-primary text-white'
|
|
||||||
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{horizon === 'all' ? '全部' : HORIZON_LABELS[horizon]}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-px h-6 bg-border" />
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-[12px] text-text-muted">优先级:</span>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{(['all', 'P1', 'P2'] as const).map((priority) => (
|
|
||||||
<button
|
|
||||||
key={priority}
|
|
||||||
onClick={() => setSelectedPriority(priority)}
|
|
||||||
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
|
||||||
selectedPriority === priority
|
|
||||||
? priority === 'P1'
|
|
||||||
? 'bg-danger text-white'
|
|
||||||
: priority === 'P2'
|
|
||||||
? 'bg-warning text-white'
|
|
||||||
: 'bg-primary text-white'
|
|
||||||
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{priority === 'all' ? '全部' : priority}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-px h-6 bg-border" />
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-[12px] text-text-muted">风险值:</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.05}
|
|
||||||
value={riskRange[0]}
|
|
||||||
onChange={(e) => setRiskRange([parseFloat(e.target.value) || 0, riskRange[1]])}
|
|
||||||
className="w-16 px-2 py-1.5 text-[12px] border border-border rounded bg-bg-page text-text-primary focus:outline-none focus:border-primary"
|
|
||||||
/>
|
|
||||||
<span className="text-[12px] text-text-muted">-</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.05}
|
|
||||||
value={riskRange[1]}
|
|
||||||
onChange={(e) => setRiskRange([riskRange[0], parseFloat(e.target.value) || 1])}
|
|
||||||
className="w-16 px-2 py-1.5 text-[12px] border border-border rounded bg-bg-page text-text-primary focus:outline-none focus:border-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-px h-6 bg-border" />
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowMap(!showMap)}
|
|
||||||
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
|
||||||
showMap
|
|
||||||
? 'bg-primary/10 text-primary border border-primary/30'
|
|
||||||
: 'bg-bg-page text-text-muted border border-border'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
地图
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAlertMarkers(!showAlertMarkers)}
|
|
||||||
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
|
||||||
showAlertMarkers
|
|
||||||
? 'bg-primary/10 text-primary border border-primary/30'
|
|
||||||
: 'bg-bg-page text-text-muted border border-border'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
预警标记
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowGrid(!showGrid)}
|
|
||||||
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
|
||||||
showGrid
|
|
||||||
? 'bg-primary/10 text-primary border border-primary/30'
|
|
||||||
: 'bg-bg-page text-text-muted border border-border'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
网格
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-px h-6 bg-border" />
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-[12px] text-text-muted">排序:</span>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => setSortBy('risk')}
|
|
||||||
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
|
||||||
sortBy === 'risk'
|
|
||||||
? 'bg-bg-card text-primary border border-primary'
|
|
||||||
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
风险值
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setSortBy('time')}
|
|
||||||
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
|
||||||
sortBy === 'time'
|
|
||||||
? 'bg-bg-card text-primary border border-primary'
|
|
||||||
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
时间
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Risk distribution summary */}
|
|
||||||
<div className="grid grid-cols-4 gap-3 mb-4">
|
|
||||||
<div className="card p-3">
|
|
||||||
<div className="text-[11px] text-text-muted mb-1">高风险 (≥0.8)</div>
|
|
||||||
<div className="text-xl font-bold text-danger">{riskStats.high}</div>
|
|
||||||
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
|
||||||
<div className="h-full bg-danger rounded-full" style={{ width: `${filteredAlerts.length > 0 ? (riskStats.high / filteredAlerts.length) * 100 : 0}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="card p-3">
|
|
||||||
<div className="text-[11px] text-text-muted mb-1">中高风险 (0.6-0.8)</div>
|
|
||||||
<div className="text-xl font-bold text-warning">{riskStats.mediumHigh}</div>
|
|
||||||
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
|
||||||
<div className="h-full bg-warning rounded-full" style={{ width: `${filteredAlerts.length > 0 ? (riskStats.mediumHigh / filteredAlerts.length) * 100 : 0}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="card p-3">
|
|
||||||
<div className="text-[11px] text-text-muted mb-1">中风险 (0.4-0.6)</div>
|
|
||||||
<div className="text-xl font-bold text-primary">{riskStats.medium}</div>
|
|
||||||
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
|
||||||
<div className="h-full bg-primary rounded-full" style={{ width: `${filteredAlerts.length > 0 ? (riskStats.medium / filteredAlerts.length) * 100 : 0}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="card p-3">
|
|
||||||
<div className="text-[11px] text-text-muted mb-1">平均风险</div>
|
|
||||||
<div className="text-xl font-bold text-text-primary">{(riskStats.avgRisk * 100).toFixed(1)}%</div>
|
|
||||||
<div className="mt-1.5 text-[10px] text-text-muted">
|
|
||||||
高风险区域: {riskStats.topDistricts.slice(0, 2).map(([d, n]) => `${d}(${n})`).join(', ')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="card p-8 text-center">
|
|
||||||
<div className="text-text-secondary text-[13px]">加载中...</div>
|
|
||||||
</div>
|
|
||||||
) : filteredAlerts.length === 0 ? (
|
|
||||||
<div className="card p-8 text-center">
|
|
||||||
<svg className="w-12 h-12 mx-auto mb-3 text-text-muted opacity-50" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
|
|
||||||
</svg>
|
|
||||||
<div className="text-text-muted text-[13px]">暂无符合条件的预警</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={`grid gap-4 ${isFullscreen ? 'grid-cols-1' : 'grid-cols-[1fr_400px]'}`}>
|
|
||||||
{showMap && (
|
|
||||||
<AlertMap
|
|
||||||
selectedGridId={selectedGridId}
|
|
||||||
onGridClick={handleGridClick}
|
|
||||||
onCellInfo={handleCellInfo}
|
|
||||||
forecastDay={forecastDay}
|
|
||||||
showAlertMarkers={showAlertMarkers}
|
|
||||||
showGrid={showGrid}
|
|
||||||
filteredAlerts={filteredAlerts}
|
|
||||||
isFullscreen={isFullscreen}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!isFullscreen && (
|
|
||||||
<div className="space-y-3 max-h-[calc(100vh-280px)] overflow-y-auto">
|
|
||||||
{filteredAlerts.slice(0, 50).map((alert) => (
|
|
||||||
<AlertCard
|
|
||||||
key={alert.alert_id}
|
|
||||||
alert={alert}
|
|
||||||
isSelected={selectedAlert === alert.alert_id}
|
|
||||||
alertId={alert.alert_id}
|
|
||||||
onCardClick={handleAlertCardClick}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{filteredAlerts.length > 50 && (
|
|
||||||
<div className="text-center text-text-muted text-[12px] py-2">
|
|
||||||
还有 {filteredAlerts.length - 50} 条预警未显示
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'stats' && (
|
{activeTab === 'stats' && (
|
||||||
<div className="space-y-4">
|
<AlertsRiskPanel
|
||||||
{/* Risk distribution as StatCards */}
|
riskStats={riskStats}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
trendData={trendData}
|
||||||
<StatCard label="高风险 (≥0.8)" value={riskStats.high} color="#ef4444" />
|
trendLoading={trendLoading}
|
||||||
<StatCard label="中高风险 (0.6-0.8)" value={riskStats.mediumHigh} color="#f59e0b" />
|
trendError={trendError}
|
||||||
<StatCard label="中风险 (0.4-0.6)" value={riskStats.medium} color="#3b82f6" />
|
/>
|
||||||
<StatCard label="平均风险" value={`${(riskStats.avgRisk * 100).toFixed(1)}%`} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Risk trend chart (real data from /api/analysis/trend) */}
|
|
||||||
{trendLoading ? (
|
|
||||||
<div className="card p-8 text-center text-text-secondary text-[13px]">趋势加载中...</div>
|
|
||||||
) : trendError ? (
|
|
||||||
<div className="card p-8 text-center text-danger text-[13px]">{trendError}</div>
|
|
||||||
) : trendData.length === 0 ? (
|
|
||||||
<div className="card p-8 text-center text-text-muted text-[13px]">暂无风险趋势数据</div>
|
|
||||||
) : (
|
|
||||||
<StatisticalCharts
|
|
||||||
data={trendData}
|
|
||||||
showCases={false}
|
|
||||||
showRisk
|
|
||||||
height={280}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
{/* Top high-risk districts bar */}
|
|
||||||
<div className="card p-4">
|
|
||||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
|
||||||
高风险区域 Top 5
|
|
||||||
</div>
|
|
||||||
{riskStats.topDistricts.length > 0 ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{riskStats.topDistricts.map(([district, count]) => (
|
|
||||||
<div key={district}>
|
|
||||||
<div className="flex items-center justify-between text-[12px] mb-1">
|
|
||||||
<span className="text-text-primary font-medium">{district}</span>
|
|
||||||
<span className="text-text-muted">{count} 条</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-danger rounded-full"
|
|
||||||
style={{ width: `${topDistrictMax > 0 ? (count / topDistrictMax) * 100 : 0}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-text-muted text-[13px]">暂无区域数据</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Alert severity donut (P1/P2) */}
|
|
||||||
<div className="card p-4">
|
|
||||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
|
||||||
预警严重度分布
|
|
||||||
</div>
|
|
||||||
{riskStats.p1 > 0 || riskStats.p2 > 0 ? (
|
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={alertPie}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius={50}
|
|
||||||
outerRadius={80}
|
|
||||||
paddingAngle={4}
|
|
||||||
dataKey="value"
|
|
||||||
nameKey="name"
|
|
||||||
>
|
|
||||||
{alertPie.map((entry) => (
|
|
||||||
<Cell key={entry.name} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<RechartsTooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
border: '1px solid #E2E8F0',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontSize: '12px',
|
|
||||||
}}
|
|
||||||
formatter={(value: number, name: string) => [value, name]}
|
|
||||||
/>
|
|
||||||
<Legend
|
|
||||||
wrapperStyle={{ fontSize: '12px' }}
|
|
||||||
formatter={(value: string) => <span className="text-text-secondary">{value}</span>}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-text-muted text-[13px]">暂无预警数据</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cell info panel - shown when clicking grid cell without alert */}
|
{/* Cell info panel - shown when clicking grid cell without alert */}
|
||||||
{cellInfo && !selectedAlertData && (
|
{cellInfo && !selectedAlertData && (
|
||||||
<div className="fixed bottom-5 left-1/2 -translate-x-1/2 bg-bg-card rounded-lg border border-border-light shadow-lg z-50 px-5 py-4 min-w-[320px]">
|
<CellInfoPanel cellInfo={cellInfo} onClose={clearCellInfo} />
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<span className="text-[14px] font-semibold text-text-primary">网格详情 (100m)</span>
|
|
||||||
<button onClick={clearCellInfo} className="text-text-muted hover:text-text-primary text-[18px] leading-none">×</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 text-[12px]">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-text-muted">网格</span>
|
|
||||||
<span className="font-mono text-text-primary">{cellInfo.grid_id}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-text-muted">坐标</span>
|
|
||||||
<span className="font-mono text-text-primary">{cellInfo.lat.toFixed(4)}, {cellInfo.lon.toFixed(4)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-text-muted">当前风险</span>
|
|
||||||
<span className={`font-bold ${cellInfo.risk >= 0.8 ? 'text-danger' : cellInfo.risk >= 0.6 ? 'text-warning' : cellInfo.risk >= 0.4 ? 'text-primary' : 'text-success'}`}>
|
|
||||||
{(cellInfo.risk * 100).toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 pt-1">
|
|
||||||
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
|
|
||||||
<div className="text-[10px] text-text-muted">1天</div>
|
|
||||||
<div className="font-bold text-[13px]">{(cellInfo.risk_1d * 100).toFixed(0)}%</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
|
|
||||||
<div className="text-[10px] text-text-muted">3天</div>
|
|
||||||
<div className="font-bold text-[13px]">{(cellInfo.risk_3d * 100).toFixed(0)}%</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
|
|
||||||
<div className="text-[10px] text-text-muted">7天</div>
|
|
||||||
<div className="font-bold text-[13px]">{(cellInfo.risk_7d * 100).toFixed(0)}%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{cellInfo.nearestAlertId && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-text-muted">最近预警距离</span>
|
|
||||||
<span className="text-text-primary">{(cellInfo.nearestAlertDist * 111).toFixed(1)} km</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!cellInfo.nearestAlertId && (
|
|
||||||
<div className="text-[11px] text-text-muted mt-1 pt-2 border-t border-border">
|
|
||||||
该区域无预警
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Alert detail modal */}
|
{/* Alert detail modal */}
|
||||||
{selectedAlertData && (
|
{selectedAlertData && (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center" onClick={clearSelectedAlert}>
|
<AlertDetailModal alert={selectedAlertData} onClose={clearSelectedAlert} />
|
||||||
<div className="bg-bg-card rounded-lg p-6 max-w-md w-full mx-4" onClick={e => e.stopPropagation()}>
|
|
||||||
<h3 className="font-display text-[16px] font-semibold mb-3">预警详情</h3>
|
|
||||||
<div className="space-y-2 text-[13px]">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-text-muted">优先级</span>
|
|
||||||
<span className={`font-bold ${selectedAlertData.priority === 'P1' ? 'text-danger' : 'text-warning'}`}>
|
|
||||||
{selectedAlertData.priority}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-text-muted">风险值</span>
|
|
||||||
<span className="font-bold">{Math.round(selectedAlertData.risk_value * 100)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-text-muted">预测时效</span>
|
|
||||||
<span>{HORIZON_LABELS[selectedAlertData.forecast_horizon]}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-text-muted">位置</span>
|
|
||||||
<span>{selectedAlertData.region}</span>
|
|
||||||
</div>
|
|
||||||
<div className="pt-2 border-t border-border">
|
|
||||||
<div className="text-text-muted mb-1">预警原因</div>
|
|
||||||
<div className="text-[12px]">{selectedAlertData.reason}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={clearSelectedAlert}
|
|
||||||
className="mt-4 w-full px-4 py-2 bg-primary text-white rounded hover:bg-primary/80 transition-colors text-[13px]"
|
|
||||||
>
|
|
||||||
关闭
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AlertCardProps {
|
|
||||||
alert: ExtendedAlert;
|
|
||||||
isSelected?: boolean;
|
|
||||||
alertId: string;
|
|
||||||
onCardClick: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AlertCard = React.memo(function AlertCard({ alert, isSelected, alertId, onCardClick }: AlertCardProps) {
|
|
||||||
const isP1 = alert.priority === 'P1';
|
|
||||||
const riskPercent = Math.round(alert.risk_value * 100);
|
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
onCardClick(alertId);
|
|
||||||
}, [alertId, onCardClick]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`card overflow-hidden transition-colors cursor-pointer ${
|
|
||||||
isSelected ? 'border-primary ring-1 ring-primary' : 'hover:border-primary'
|
|
||||||
}`}
|
|
||||||
onClick={handleClick}
|
|
||||||
>
|
|
||||||
<div className={`px-4 py-3 border-b ${isP1 ? 'bg-danger/5 border-danger/20' : 'bg-warning/5 border-warning/20'}`}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className={`w-2 h-2 rounded-full ${isP1 ? 'bg-danger' : 'bg-warning'}`} />
|
|
||||||
<span className={`text-[11px] font-bold ${isP1 ? 'text-danger' : 'text-warning'}`}>
|
|
||||||
{alert.priority}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-text-muted">
|
|
||||||
{HORIZON_LABELS[alert.forecast_horizon] || '未知'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className={`text-[18px] font-bold ${isP1 ? 'text-danger' : 'text-warning'}`}>
|
|
||||||
{riskPercent}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="mb-3">
|
|
||||||
<div className="text-[13px] font-semibold mb-1">
|
|
||||||
{alert.region} - {alert.street}
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-text-muted">
|
|
||||||
网格:{alert.grid_id}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`text-[12px] px-3 py-2 rounded mb-3 ${
|
|
||||||
isP1 ? 'bg-danger/10 text-danger' : 'bg-warning/10 text-warning'
|
|
||||||
}`}>
|
|
||||||
{alert.reason}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-[11px] text-text-muted">
|
|
||||||
<span>预测时间:{alert.forecast_time}</span>
|
|
||||||
<span>生成:{alert.timestamp}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
158
frontend/src/pages/ClinicalAnalysis.tsx
Normal file
158
frontend/src/pages/ClinicalAnalysis.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Activity } from 'lucide-react';
|
||||||
|
import { statsApi, type InpatientClinicalResponse } from '@/services/api';
|
||||||
|
import { Card, LoadingState, EmptyState } from '@/components/ui';
|
||||||
|
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||||
|
import { TESTIDS } from '@/utils/testids';
|
||||||
|
import { ClinicalKpiRow } from '@/components/clinical/ClinicalKpiRow';
|
||||||
|
import { HistogramChart } from '@/components/clinical/HistogramChart';
|
||||||
|
import { BoxPlotRows, type BoxRow } from '@/components/clinical/BoxPlotRows';
|
||||||
|
import { DonutChart, type DonutSlice } from '@/components/clinical/DonutChart';
|
||||||
|
import { CLINICAL_COLORS } from '@/components/clinical/chartColors';
|
||||||
|
|
||||||
|
/** 数据是否完全为空(KPI 0 人次且各序列均空)。 */
|
||||||
|
function isEmpty(d: InpatientClinicalResponse): boolean {
|
||||||
|
return (
|
||||||
|
(!d.kpis || d.kpis.total_admissions === 0) &&
|
||||||
|
(d.los_histogram?.length ?? 0) === 0 &&
|
||||||
|
(d.outcome_counts?.length ?? 0) === 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClinicalAnalysis() {
|
||||||
|
const [data, setData] = useState<InpatientClinicalResponse | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await statsApi.getInpatientClinical();
|
||||||
|
if (cancelled) return;
|
||||||
|
setData(res);
|
||||||
|
} catch {
|
||||||
|
if (cancelled) return;
|
||||||
|
setError('住院临床数据加载失败');
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
|
||||||
|
<Activity className="w-5 h-5 text-primary" />
|
||||||
|
住院临床分析
|
||||||
|
</h1>
|
||||||
|
<p className="text-[12px] text-text-secondary">
|
||||||
|
住院天数、出院结局、入院途径与年龄别 BMI 等临床特征分析
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div data-testid={TESTIDS.pageClinical} className="flex flex-col h-full overflow-auto p-6 space-y-6">
|
||||||
|
{header}
|
||||||
|
<LoadingState label="正在加载住院临床数据…" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div data-testid={TESTIDS.pageClinical} className="flex flex-col h-full overflow-auto p-6 space-y-6">
|
||||||
|
{header}
|
||||||
|
<ErrorBanner
|
||||||
|
error={error ?? '住院临床数据加载失败'}
|
||||||
|
onRetry={() => window.location.reload()}
|
||||||
|
onDismiss={() => setError(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmpty(data)) {
|
||||||
|
return (
|
||||||
|
<div data-testid={TESTIDS.pageClinical} className="flex flex-col h-full overflow-auto p-6 space-y-6">
|
||||||
|
{header}
|
||||||
|
<EmptyState title="暂无住院临床数据" description="当前筛选范围内没有可用的住院记录。" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 出院结局:治愈/好转在前(按严重程度排序展示更直观)。
|
||||||
|
const outcomeSlices: DonutSlice[] = (data.outcome_counts ?? []).map((o) => ({
|
||||||
|
name: o.outcome,
|
||||||
|
value: o.count,
|
||||||
|
}));
|
||||||
|
const routeSlices: DonutSlice[] = (data.admission_route_counts ?? []).map((r) => ({
|
||||||
|
name: r.route,
|
||||||
|
value: r.count,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const losBox: BoxRow[] = (data.los_by_disease ?? []).map((d) => ({
|
||||||
|
label: d.diagnosis,
|
||||||
|
p25: d.p25,
|
||||||
|
median: d.median,
|
||||||
|
p75: d.p75,
|
||||||
|
n: d.n,
|
||||||
|
}));
|
||||||
|
const bmiBox: BoxRow[] = (data.bmi_by_age_band ?? []).map((d) => ({
|
||||||
|
label: d.age_band,
|
||||||
|
p25: d.p25,
|
||||||
|
median: d.median,
|
||||||
|
p75: d.p75,
|
||||||
|
n: d.n,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={TESTIDS.pageClinical}
|
||||||
|
className="flex flex-col h-full overflow-auto"
|
||||||
|
>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{header}
|
||||||
|
|
||||||
|
{/* KPI 行 */}
|
||||||
|
<ClinicalKpiRow kpis={data.kpis} />
|
||||||
|
|
||||||
|
{/* 住院天数:分布 + 各病种箱线 */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<Card title="住院天数分布">
|
||||||
|
<HistogramChart data={data.los_histogram ?? []} color={CLINICAL_COLORS.los} countLabel="人次" />
|
||||||
|
</Card>
|
||||||
|
<Card title="各病种住院天数(P25–中位–P75)">
|
||||||
|
<BoxPlotRows rows={losBox} unit="天" />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 出院结局 + 入院途径 双环 */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<Card title="出院结局构成">
|
||||||
|
<DonutChart data={outcomeSlices} colorMap={CLINICAL_COLORS.outcome} />
|
||||||
|
</Card>
|
||||||
|
<Card title="入院途径构成">
|
||||||
|
<DonutChart data={routeSlices} />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 年龄别 BMI 箱线 */}
|
||||||
|
<Card title="年龄别 BMI(P25–中位–P75)">
|
||||||
|
<BoxPlotRows rows={bmiBox} />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
import { Users, Activity } from 'lucide-react';
|
import { Users, Activity } from 'lucide-react';
|
||||||
import { caseApi } from '@/services/api';
|
import { caseApi } from '@/services/api';
|
||||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||||
|
import { LoadingState } from '@/components/ui';
|
||||||
|
import { TESTIDS } from '@/utils/testids';
|
||||||
import type { DemographicsResponse, AgeBin, AgeDiagnosisMatrixItem } from '@/types';
|
import type { DemographicsResponse, AgeBin, AgeDiagnosisMatrixItem } from '@/types';
|
||||||
|
|
||||||
// --- Chart 3 helpers ---
|
// --- Chart 3 helpers ---
|
||||||
@@ -108,9 +110,11 @@ export function DemographicAnalysis() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// --- Derived data ---
|
// --- Derived data ---
|
||||||
|
// 防御性归一化:后端返回意外形状(缺字段/类型不符)时降级为空,避免 .map 抛错
|
||||||
|
// 冒泡到根 ErrorBoundary 把整页白屏(与 DiseaseAnalysis/EnvironmentalHealth 的处理一致)。
|
||||||
const ageData: AgeBin[] = useMemo(() => {
|
const ageData: AgeBin[] = useMemo(() => {
|
||||||
if (!data) return [];
|
const bins = Array.isArray(data?.age_distribution) ? data!.age_distribution : [];
|
||||||
return data.age_distribution.map((d) => ({
|
return bins.map((d) => ({
|
||||||
age_bin: d.age_bin,
|
age_bin: d.age_bin,
|
||||||
outpatient: d.outpatient,
|
outpatient: d.outpatient,
|
||||||
inpatient: d.inpatient,
|
inpatient: d.inpatient,
|
||||||
@@ -119,8 +123,8 @@ export function DemographicAnalysis() {
|
|||||||
|
|
||||||
const genderData = useMemo(() => {
|
const genderData = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
const male = data.gender_split.male.inpatient;
|
const male = data.gender_split?.male?.inpatient ?? 0;
|
||||||
const female = data.gender_split.female.inpatient;
|
const female = data.gender_split?.female?.inpatient ?? 0;
|
||||||
return [
|
return [
|
||||||
{ name: '男性', value: male, color: '#3B82F6' },
|
{ name: '男性', value: male, color: '#3B82F6' },
|
||||||
{ name: '女性', value: female, color: '#EC4899' },
|
{ name: '女性', value: female, color: '#EC4899' },
|
||||||
@@ -132,8 +136,8 @@ export function DemographicAnalysis() {
|
|||||||
}, [genderData]);
|
}, [genderData]);
|
||||||
|
|
||||||
const heatmapData = useMemo(() => {
|
const heatmapData = useMemo(() => {
|
||||||
if (!data) return { diagnoses: [], matrix: [], totals: [] };
|
const matrix = Array.isArray(data?.age_diagnosis_matrix) ? data!.age_diagnosis_matrix : [];
|
||||||
return buildHeatmapMatrix(data.age_diagnosis_matrix);
|
return buildHeatmapMatrix(matrix);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const heatmapMax = useMemo(() => {
|
const heatmapMax = useMemo(() => {
|
||||||
@@ -148,11 +152,7 @@ export function DemographicAnalysis() {
|
|||||||
|
|
||||||
// --- Loading state ---
|
// --- Loading state ---
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <LoadingState testid={TESTIDS.pageLoading} />;
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isEmpty =
|
const isEmpty =
|
||||||
@@ -162,7 +162,7 @@ export function DemographicAnalysis() {
|
|||||||
heatmapData.matrix.length === 0);
|
heatmapData.matrix.length === 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-auto">
|
<div data-testid="page-demographics" className="flex flex-col h-full overflow-auto">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="px-6 pt-4">
|
<div className="px-6 pt-4">
|
||||||
<ErrorBanner
|
<ErrorBanner
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import {
|
|||||||
Cell,
|
Cell,
|
||||||
ReferenceLine,
|
ReferenceLine,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { Stethoscope, Activity } from 'lucide-react';
|
import { Stethoscope, Activity, MessageSquareText } from 'lucide-react';
|
||||||
import { caseApi } from '@/services/api';
|
import { caseApi, statsApi } from '@/services/api';
|
||||||
|
import type { SymptomsResponse } from '@/services/api';
|
||||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||||
import type {
|
import type {
|
||||||
DiagnosisDistributionItem,
|
DiagnosisDistributionItem,
|
||||||
@@ -154,8 +155,9 @@ function SeasonalityHeatmap({ data }: { data: DiseaseSeasonalityPoint[] }) {
|
|||||||
当前数据仅覆盖单月,完整季节性分析需要全年数据
|
当前数据仅覆盖单月,完整季节性分析需要全年数据
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<div
|
<div
|
||||||
className="grid gap-px bg-gray-200 border border-gray-200 rounded overflow-hidden"
|
className="grid gap-px bg-gray-200 border border-gray-200 rounded overflow-hidden min-w-[480px]"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `minmax(90px, auto) repeat(${uniqueMonths > 0 ? uniqueMonths : 12}, 1fr)`,
|
gridTemplateColumns: `minmax(90px, auto) repeat(${uniqueMonths > 0 ? uniqueMonths : 12}, 1fr)`,
|
||||||
}}
|
}}
|
||||||
@@ -198,6 +200,7 @@ function SeasonalityHeatmap({ data }: { data: DiseaseSeasonalityPoint[] }) {
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -372,12 +375,64 @@ function DiagnosisSummaryTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Chart 5: Outpatient Symptom Keyword Frequency ---
|
||||||
|
|
||||||
|
function SymptomFrequencyChart({ data }: { data: SymptomsResponse['symptoms'] }) {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return <div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by count desc; chart renders bottom-up so reverse for top-at-top display.
|
||||||
|
const sorted = [...data].sort((a, b) => b.count - a.count);
|
||||||
|
const chartData = sorted.map((d) => ({
|
||||||
|
...d,
|
||||||
|
displayName: truncate(d.keyword, 8),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={Math.max(320, chartData.length * 22)}>
|
||||||
|
<BarChart
|
||||||
|
data={[...chartData].reverse()}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 5, right: 20, left: 40, bottom: 5 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
tick={{ fontSize: 10, fill: '#64748B' }}
|
||||||
|
tickFormatter={(v) => v.toLocaleString()}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="displayName"
|
||||||
|
tick={{ fontSize: 10, fill: '#374151' }}
|
||||||
|
width={70}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
border: '1px solid #E2E8F0',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
formatter={(value: number) => [value.toLocaleString(), '出现次数']}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="count" name="count" fill="#3B82F6" barSize={14} radius={[0, 3, 3, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Page Component ---
|
// --- Page Component ---
|
||||||
|
|
||||||
export function DiseaseAnalysis() {
|
export function DiseaseAnalysis() {
|
||||||
const [diagDistribution, setDiagDistribution] = useState<DiagnosisDistributionItem[]>([]);
|
const [diagDistribution, setDiagDistribution] = useState<DiagnosisDistributionItem[]>([]);
|
||||||
const [seasonality, setSeasonality] = useState<DiseaseSeasonalityPoint[]>([]);
|
const [seasonality, setSeasonality] = useState<DiseaseSeasonalityPoint[]>([]);
|
||||||
const [districts, setDistricts] = useState<DistrictCaseData[]>([]);
|
const [districts, setDistricts] = useState<DistrictCaseData[]>([]);
|
||||||
|
const [symptoms, setSymptoms] = useState<SymptomsResponse['symptoms']>([]);
|
||||||
|
const [revisitRatio, setRevisitRatio] = useState<number | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [errors, setErrors] = useState<string[]>([]);
|
const [errors, setErrors] = useState<string[]>([]);
|
||||||
|
|
||||||
@@ -388,10 +443,11 @@ export function DiseaseAnalysis() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setErrors([]);
|
setErrors([]);
|
||||||
|
|
||||||
const [distR, seasonR, districtR] = await Promise.allSettled([
|
const [distR, seasonR, districtR, symptomR] = await Promise.allSettled([
|
||||||
caseApi.getDiagnosisDistribution(15),
|
caseApi.getDiagnosisDistribution(15),
|
||||||
caseApi.getDiseaseSeasonality(),
|
caseApi.getDiseaseSeasonality(),
|
||||||
caseApi.getDistricts(),
|
caseApi.getDistricts(),
|
||||||
|
statsApi.getSymptoms(20),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -416,6 +472,15 @@ export function DiseaseAnalysis() {
|
|||||||
newErrors.push('区县数据加载失败');
|
newErrors.push('区县数据加载失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (symptomR.status === 'fulfilled') {
|
||||||
|
setSymptoms(symptomR.value.symptoms || []);
|
||||||
|
setRevisitRatio(
|
||||||
|
typeof symptomR.value.revisit_ratio === 'number' ? symptomR.value.revisit_ratio : null,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
newErrors.push('症状词频数据加载失败');
|
||||||
|
}
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
@@ -442,7 +507,7 @@ export function DiseaseAnalysis() {
|
|||||||
const avgOIRatio = totalIn > 0 ? totalOut / totalIn : 0;
|
const avgOIRatio = totalIn > 0 ? totalOut / totalIn : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-auto">
|
<div data-testid="page-disease" className="flex flex-col h-full overflow-auto">
|
||||||
{errors.length > 0 && (
|
{errors.length > 0 && (
|
||||||
<div className="px-6 pt-4">
|
<div className="px-6 pt-4">
|
||||||
<ErrorBanner
|
<ErrorBanner
|
||||||
@@ -495,6 +560,26 @@ export function DiseaseAnalysis() {
|
|||||||
</div>
|
</div>
|
||||||
<DiagnosisSummaryTable diagnoses={diagDistribution} districtsData={districts} />
|
<DiagnosisSummaryTable diagnoses={diagDistribution} districtsData={districts} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Chart 5: Outpatient Symptom Keyword Frequency */}
|
||||||
|
<div data-testid="symptom-freq" className="card p-4">
|
||||||
|
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-1 flex items-center gap-2">
|
||||||
|
<MessageSquareText className="w-3.5 h-3.5 text-gray-400" />
|
||||||
|
门诊主诉症状词频
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-gray-500 mb-4">
|
||||||
|
主诉文本高频词(含复诊/随诊等就诊类型词)
|
||||||
|
{revisitRatio !== null && (
|
||||||
|
<span className="ml-2">
|
||||||
|
复诊占比:
|
||||||
|
<span className="font-semibold text-gray-700">
|
||||||
|
{(revisitRatio * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<SymptomFrequencyChart data={symptoms} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { LoadingState, Segmented } from '@/components/ui';
|
||||||
import {
|
import {
|
||||||
BarChart,
|
BarChart,
|
||||||
Bar,
|
Bar,
|
||||||
@@ -11,7 +12,8 @@ import {
|
|||||||
ReferenceLine,
|
ReferenceLine,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { useAnalysisStore } from '@/stores/analysisStore';
|
import { useAnalysisStore } from '@/stores/analysisStore';
|
||||||
import { caseApi } from '@/services/api';
|
import { caseApi, statsApi } from '@/services/api';
|
||||||
|
import type { IncidenceRateResponse } from '@/services/api';
|
||||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||||
import { MetricHeatmapTable } from '@/components/MetricHeatmapTable';
|
import { MetricHeatmapTable } from '@/components/MetricHeatmapTable';
|
||||||
import { BarChart3, MapPin, Users, Shield } from 'lucide-react';
|
import { BarChart3, MapPin, Users, Shield } from 'lucide-react';
|
||||||
@@ -35,6 +37,10 @@ export function DistrictComparison() {
|
|||||||
const [caseDistrictData, setCaseDistrictData] = useState<DistrictCaseData[]>([]);
|
const [caseDistrictData, setCaseDistrictData] = useState<DistrictCaseData[]>([]);
|
||||||
const [caseDataLoading, setCaseDataLoading] = useState(false);
|
const [caseDataLoading, setCaseDataLoading] = useState(false);
|
||||||
const [caseDataError, setCaseDataError] = useState<string | null>(null);
|
const [caseDataError, setCaseDataError] = useState<string | null>(null);
|
||||||
|
const [incidence, setIncidence] = useState<IncidenceRateResponse['districts']>([]);
|
||||||
|
const [incidenceLoading, setIncidenceLoading] = useState(false);
|
||||||
|
const [incidenceError, setIncidenceError] = useState<string | null>(null);
|
||||||
|
const [incidenceMetric, setIncidenceMetric] = useState<'rate' | 'count'>('rate');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDistricts();
|
fetchDistricts();
|
||||||
@@ -60,6 +66,26 @@ export function DistrictComparison() {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setIncidenceLoading(true);
|
||||||
|
setIncidenceError(null);
|
||||||
|
statsApi.getIncidenceRate()
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIncidence(res.districts || []);
|
||||||
|
setIncidenceLoading(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIncidenceError((e as Error).message || '加载发病率数据失败');
|
||||||
|
setIncidenceLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const metricConfig = {
|
const metricConfig = {
|
||||||
avg_aqi: { label: '平均AQI', color: '#2563EB', unit: '' },
|
avg_aqi: { label: '平均AQI', color: '#2563EB', unit: '' },
|
||||||
avg_risk: { label: '平均风险', color: '#DC2626', unit: '' },
|
avg_risk: { label: '平均风险', color: '#DC2626', unit: '' },
|
||||||
@@ -103,6 +129,12 @@ export function DistrictComparison() {
|
|||||||
return { data, cityOIAvg };
|
return { data, cityOIAvg };
|
||||||
}, [caseDistrictData]);
|
}, [caseDistrictData]);
|
||||||
|
|
||||||
|
const incidenceChart = useMemo(() => {
|
||||||
|
const valueKey = incidenceMetric === 'rate' ? 'rate_per_10k' : 'total_cases';
|
||||||
|
const data = [...incidence].sort((a, b) => b[valueKey] - a[valueKey]);
|
||||||
|
return { data, valueKey };
|
||||||
|
}, [incidence, incidenceMetric]);
|
||||||
|
|
||||||
const heatmapMetrics = useMemo(() => {
|
const heatmapMetrics = useMemo(() => {
|
||||||
const caseMap = new Map(caseDistrictData.map((d) => [d.district, d]));
|
const caseMap = new Map(caseDistrictData.map((d) => [d.district, d]));
|
||||||
const rows: string[] = [];
|
const rows: string[] = [];
|
||||||
@@ -127,7 +159,7 @@ export function DistrictComparison() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div data-testid="page-district" className="overflow-x-hidden">
|
||||||
{error && (
|
{error && (
|
||||||
<ErrorBanner
|
<ErrorBanner
|
||||||
error={error}
|
error={error}
|
||||||
@@ -154,6 +186,25 @@ export function DistrictComparison() {
|
|||||||
onDismiss={() => setCaseDataError(null)}
|
onDismiss={() => setCaseDataError(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{incidenceError && (
|
||||||
|
<ErrorBanner
|
||||||
|
error={incidenceError}
|
||||||
|
onRetry={() => {
|
||||||
|
setIncidenceError(null);
|
||||||
|
setIncidenceLoading(true);
|
||||||
|
statsApi.getIncidenceRate()
|
||||||
|
.then((res) => {
|
||||||
|
setIncidence(res.districts || []);
|
||||||
|
setIncidenceLoading(false);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setIncidenceError((e as Error).message || '加载发病率数据失败');
|
||||||
|
setIncidenceLoading(false);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onDismiss={() => setIncidenceError(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
|
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
|
||||||
<BarChart3 className="w-5 h-5 text-primary" />
|
<BarChart3 className="w-5 h-5 text-primary" />
|
||||||
@@ -184,8 +235,8 @@ export function DistrictComparison() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="mb-4 text-center py-8 bg-bg-card rounded-lg border border-border">
|
<div className="mb-4 bg-bg-card rounded-lg border border-border">
|
||||||
<span className="text-text-secondary">数据加载中...</span>
|
<LoadingState />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -244,7 +295,7 @@ export function DistrictComparison() {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
{sortedData.map((district, index) => (
|
{sortedData.map((district, index) => (
|
||||||
<div key={district.district} className="card p-4">
|
<div key={district.district} className="card p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
@@ -379,13 +430,86 @@ export function DistrictComparison() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Standardized Incidence Rate (per 10k) with count/rate toggle */}
|
||||||
|
<div data-testid="incidence-rate" className="card p-4 mb-4">
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-4 flex-wrap">
|
||||||
|
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide">
|
||||||
|
{incidenceMetric === 'rate' ? '标化发病率(每万人)区域排名' : '病例数 区域排名'}
|
||||||
|
</div>
|
||||||
|
<Segmented
|
||||||
|
testid="incidence-rate-toggle"
|
||||||
|
size="sm"
|
||||||
|
value={incidenceMetric}
|
||||||
|
onChange={setIncidenceMetric}
|
||||||
|
options={[
|
||||||
|
{ value: 'count', label: '病例数' },
|
||||||
|
{ value: 'rate', label: '每万人发病率' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-text-muted mb-4">
|
||||||
|
标化发病率按人口归一化,可避免人口规模差异造成的误读(原始病例数会高估人口大区)。
|
||||||
|
</p>
|
||||||
|
{incidenceLoading && <LoadingState />}
|
||||||
|
{!incidenceLoading && incidenceChart.data.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={380}>
|
||||||
|
<BarChart
|
||||||
|
data={incidenceChart.data}
|
||||||
|
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
|
||||||
|
layout="vertical"
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
tick={{ fontSize: 12, fill: '#64748B' }}
|
||||||
|
axisLine={{ stroke: '#E2E8F0' }}
|
||||||
|
tickFormatter={(v: number) => v.toLocaleString()}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="district"
|
||||||
|
tick={{ fontSize: 12, fill: '#1E293B', fontWeight: 500 }}
|
||||||
|
axisLine={{ stroke: '#E2E8F0' }}
|
||||||
|
width={80}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
border: '1px solid #E2E8F0',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
formatter={(_value: number, _name, item) => {
|
||||||
|
const p = item?.payload as IncidenceRateResponse['districts'][number];
|
||||||
|
return [
|
||||||
|
`病例数 ${p.total_cases.toLocaleString()} · 人口 ${p.population.toLocaleString()} · 每万人 ${p.rate_per_10k.toLocaleString()}`,
|
||||||
|
incidenceMetric === 'rate' ? '标化发病率' : '病例数',
|
||||||
|
];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey={incidenceChart.valueKey}
|
||||||
|
name={incidenceMetric === 'rate' ? '每万人发病率' : '病例数'}
|
||||||
|
radius={[0, 4, 4, 0]}
|
||||||
|
maxBarSize={32}
|
||||||
|
fill={incidenceMetric === 'rate' ? '#DC2626' : '#2563EB'}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
!incidenceLoading && (
|
||||||
|
<div className="text-center py-8 text-text-secondary">无发病率数据</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* O/I Ratio Comparison Bar Chart */}
|
{/* O/I Ratio Comparison Bar Chart */}
|
||||||
<div className="card p-4 mb-4">
|
<div className="card p-4 mb-4">
|
||||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||||
门诊/住院比 (O/I Ratio) 区域排名
|
门诊/住院比 (O/I Ratio) 区域排名
|
||||||
</div>
|
</div>
|
||||||
{caseDataLoading && (
|
{caseDataLoading && (
|
||||||
<div className="text-center py-8 text-text-secondary">病例数据加载中...</div>
|
<LoadingState />
|
||||||
)}
|
)}
|
||||||
{!caseDataLoading && oiRatioData.data.length > 0 ? (
|
{!caseDataLoading && oiRatioData.data.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={350}>
|
<ResponsiveContainer width="100%" height={350}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useEffect, useState, useMemo, Fragment } from 'react';
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
@@ -12,9 +12,13 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
ReferenceLine,
|
ReferenceLine,
|
||||||
Cell,
|
Cell,
|
||||||
|
ScatterChart,
|
||||||
|
Scatter,
|
||||||
|
ZAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { Wind } from 'lucide-react';
|
import { Wind } from 'lucide-react';
|
||||||
import { envApi, caseApi } from '@/services/api';
|
import { envApi, caseApi, statsApi } from '@/services/api';
|
||||||
|
import type { EnvCorrelationResponse } from '@/services/api';
|
||||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||||
import { CalendarHeatmap } from '@/components/CalendarHeatmap';
|
import { CalendarHeatmap } from '@/components/CalendarHeatmap';
|
||||||
import type {
|
import type {
|
||||||
@@ -58,6 +62,55 @@ function getAQICategory(aqi: number): typeof AQI_CATEGORIES[number] {
|
|||||||
return AQI_CATEGORIES[AQI_CATEGORIES.length - 1];
|
return AQI_CATEGORIES[AQI_CATEGORIES.length - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Canonical pollutant order for the 7×7 correlation matrix.
|
||||||
|
const CORR_POLLUTANTS = ['AQI', 'PM25', 'PM10', 'SO2', 'NO2', 'O3', 'CO'];
|
||||||
|
|
||||||
|
function pollutantLabel(key: string): string {
|
||||||
|
switch (key) {
|
||||||
|
case 'PM25':
|
||||||
|
return 'PM2.5';
|
||||||
|
case 'SO2':
|
||||||
|
return 'SO₂';
|
||||||
|
case 'NO2':
|
||||||
|
return 'NO₂';
|
||||||
|
case 'O3':
|
||||||
|
return 'O₃';
|
||||||
|
default:
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diverging color scale for a correlation value in [-1, 1].
|
||||||
|
// Blue (negative) → white (0) → red (positive); opacity scales with |corr|.
|
||||||
|
function corrColor(corr: number): string {
|
||||||
|
const v = Math.max(-1, Math.min(1, corr));
|
||||||
|
if (v >= 0) return `rgba(220, 38, 38, ${0.12 + 0.88 * v})`;
|
||||||
|
return `rgba(37, 99, 235, ${0.12 + 0.88 * -v})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Least-squares linear regression: returns slope/intercept over (x, y) points.
|
||||||
|
function linearRegression(
|
||||||
|
points: { x: number; y: number }[],
|
||||||
|
): { slope: number; intercept: number } | null {
|
||||||
|
const n = points.length;
|
||||||
|
if (n < 2) return null;
|
||||||
|
let sx = 0;
|
||||||
|
let sy = 0;
|
||||||
|
let sxx = 0;
|
||||||
|
let sxy = 0;
|
||||||
|
for (const p of points) {
|
||||||
|
sx += p.x;
|
||||||
|
sy += p.y;
|
||||||
|
sxx += p.x * p.x;
|
||||||
|
sxy += p.x * p.y;
|
||||||
|
}
|
||||||
|
const denom = n * sxx - sx * sx;
|
||||||
|
if (Math.abs(denom) < 1e-9) return null;
|
||||||
|
const slope = (n * sxy - sx * sy) / denom;
|
||||||
|
const intercept = (sy - slope * sx) / n;
|
||||||
|
return { slope, intercept };
|
||||||
|
}
|
||||||
|
|
||||||
function findMaxLag(correlations: LagCorrelationItem[], pollutant: string): number | null {
|
function findMaxLag(correlations: LagCorrelationItem[], pollutant: string): number | null {
|
||||||
if (correlations.length === 0) return null;
|
if (correlations.length === 0) return null;
|
||||||
const pollData = correlations.filter(
|
const pollData = correlations.filter(
|
||||||
@@ -83,6 +136,7 @@ export function EnvironmentalHealth() {
|
|||||||
const [pollutants365, setPollutants365] = useState<PollutantPoint[]>([]);
|
const [pollutants365, setPollutants365] = useState<PollutantPoint[]>([]);
|
||||||
const [pollutants30, setPollutants30] = useState<PollutantPoint[]>([]);
|
const [pollutants30, setPollutants30] = useState<PollutantPoint[]>([]);
|
||||||
const [caseTrend, setCaseTrend] = useState<CaseTrendPoint[]>([]);
|
const [caseTrend, setCaseTrend] = useState<CaseTrendPoint[]>([]);
|
||||||
|
const [envCorr, setEnvCorr] = useState<EnvCorrelationResponse | null>(null);
|
||||||
|
|
||||||
// UI states
|
// UI states
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -104,11 +158,12 @@ export function EnvironmentalHealth() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setErrors([]);
|
setErrors([]);
|
||||||
|
|
||||||
const [lagR, p365R, p30R, caseTrendR] = await Promise.allSettled([
|
const [lagR, p365R, p30R, caseTrendR, envCorrR] = await Promise.allSettled([
|
||||||
envApi.getLagCorrelations(),
|
envApi.getLagCorrelations(),
|
||||||
envApi.getPollutants(365),
|
envApi.getPollutants(365),
|
||||||
envApi.getPollutants(30),
|
envApi.getPollutants(30),
|
||||||
caseApi.getTrend({ group_by: 'day' }),
|
caseApi.getTrend({ group_by: 'day' }),
|
||||||
|
statsApi.getEnvCorrelation(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -145,6 +200,12 @@ export function EnvironmentalHealth() {
|
|||||||
newErrors.push('病例趋势数据加载失败');
|
newErrors.push('病例趋势数据加载失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (envCorrR.status === 'fulfilled') {
|
||||||
|
setEnvCorr(envCorrR.value);
|
||||||
|
} else {
|
||||||
|
newErrors.push('污染物关联分析数据加载失败');
|
||||||
|
}
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
@@ -266,6 +327,61 @@ export function EnvironmentalHealth() {
|
|||||||
}));
|
}));
|
||||||
}, [pollutants30]);
|
}, [pollutants30]);
|
||||||
|
|
||||||
|
// --- Correlation: pollutant × cases (grid-level Pearson) ---
|
||||||
|
const corrWithCases = useMemo(() => {
|
||||||
|
const matrix = envCorr?.correlation_matrix ?? [];
|
||||||
|
return matrix
|
||||||
|
.map((m) => ({
|
||||||
|
pollutant: m.pollutant,
|
||||||
|
label: pollutantLabel(m.pollutant),
|
||||||
|
corr: m.corr_with_cases,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => Math.abs(b.corr) - Math.abs(a.corr));
|
||||||
|
}, [envCorr]);
|
||||||
|
|
||||||
|
// --- Correlation: pollutant pairwise 7×7 heatmap ---
|
||||||
|
// pollutant_pairwise carries the upper triangle (21 pairs); we mirror it into
|
||||||
|
// a symmetric lookup and fill the diagonal with 1.
|
||||||
|
const pairwiseGrid = useMemo(() => {
|
||||||
|
const pairs = envCorr?.pollutant_pairwise ?? [];
|
||||||
|
if (pairs.length === 0) return null;
|
||||||
|
const lookup = new Map<string, number>();
|
||||||
|
for (const p of pairs) {
|
||||||
|
lookup.set(`${p.a}|${p.b}`, p.corr);
|
||||||
|
lookup.set(`${p.b}|${p.a}`, p.corr);
|
||||||
|
}
|
||||||
|
return CORR_POLLUTANTS.map((rowKey) =>
|
||||||
|
CORR_POLLUTANTS.map((colKey) => {
|
||||||
|
if (rowKey === colKey) return 1;
|
||||||
|
return lookup.get(`${rowKey}|${colKey}`) ?? null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [envCorr]);
|
||||||
|
|
||||||
|
// --- Scatter: PM2.5 × cases + least-squares regression line ---
|
||||||
|
const scatterPoints = useMemo(() => {
|
||||||
|
return (envCorr?.scatter ?? []).map((s) => ({ pm25: s.pm25, cases: s.cases }));
|
||||||
|
}, [envCorr]);
|
||||||
|
|
||||||
|
const regression = useMemo(() => {
|
||||||
|
return linearRegression(scatterPoints.map((p) => ({ x: p.pm25, y: p.cases })));
|
||||||
|
}, [scatterPoints]);
|
||||||
|
|
||||||
|
// Two endpoints across the observed PM2.5 range to draw the trend line.
|
||||||
|
const regressionLine = useMemo(() => {
|
||||||
|
if (!regression || scatterPoints.length === 0) return [];
|
||||||
|
let minX = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
for (const p of scatterPoints) {
|
||||||
|
if (p.pm25 < minX) minX = p.pm25;
|
||||||
|
if (p.pm25 > maxX) maxX = p.pm25;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ pm25: minX, cases: regression.slope * minX + regression.intercept },
|
||||||
|
{ pm25: maxX, cases: regression.slope * maxX + regression.intercept },
|
||||||
|
];
|
||||||
|
}, [regression, scatterPoints]);
|
||||||
|
|
||||||
// --- Loading state ---
|
// --- Loading state ---
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -276,7 +392,7 @@ export function EnvironmentalHealth() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-auto">
|
<div data-testid="page-environment" className="flex flex-col h-full overflow-auto">
|
||||||
{/* Error banner */}
|
{/* Error banner */}
|
||||||
{errors.length > 0 && (
|
{errors.length > 0 && (
|
||||||
<div className="px-6 pt-4">
|
<div className="px-6 pt-4">
|
||||||
@@ -706,6 +822,229 @@ export function EnvironmentalHealth() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* === 污染物-病例关联分析 === */}
|
||||||
|
<div data-testid="env-correlation" className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-[15px] font-semibold mb-1">
|
||||||
|
污染物-病例关联分析
|
||||||
|
</h2>
|
||||||
|
<p className="text-[11px] text-text-muted">
|
||||||
|
基于网格级 Pearson 相关系数(grid-level)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* (a) Pollutant × Cases correlation */}
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||||
|
污染物 × 病例相关性
|
||||||
|
</div>
|
||||||
|
{corrWithCases.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart
|
||||||
|
data={corrWithCases}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 5, right: 20, left: 50, bottom: 5 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
stroke="#E2E8F0"
|
||||||
|
horizontal={false}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
domain={[-1, 1]}
|
||||||
|
tick={{ fontSize: 11, fill: '#64748B' }}
|
||||||
|
axisLine={{ stroke: '#E2E8F0' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="label"
|
||||||
|
tick={{ fontSize: 11, fill: '#374151' }}
|
||||||
|
width={50}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
border: '1px solid #E2E8F0',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
formatter={(value: number) => [value.toFixed(3), '相关系数']}
|
||||||
|
/>
|
||||||
|
<ReferenceLine x={0} stroke="#94A3B8" strokeWidth={1} />
|
||||||
|
<Bar dataKey="corr" barSize={20} radius={[0, 4, 4, 0]}>
|
||||||
|
{corrWithCases.map((entry, idx) => (
|
||||||
|
<Cell key={idx} fill={corrColor(entry.corr)} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<p className="text-[10px] text-text-muted mt-3 text-center">
|
||||||
|
网格级 Pearson 相关系数(红=正相关,蓝=负相关,颜色深浅表示强度)
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-text-muted text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* (b) Pollutant pairwise correlation heatmap */}
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||||
|
污染物两两相关热力图
|
||||||
|
</div>
|
||||||
|
{pairwiseGrid ? (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div
|
||||||
|
className="grid gap-px min-w-[320px]"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `48px repeat(${CORR_POLLUTANTS.length}, minmax(0, 1fr))`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header row */}
|
||||||
|
<div />
|
||||||
|
{CORR_POLLUTANTS.map((key) => (
|
||||||
|
<div
|
||||||
|
key={`h-${key}`}
|
||||||
|
className="text-[10px] font-medium text-text-muted text-center py-1"
|
||||||
|
>
|
||||||
|
{pollutantLabel(key)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Body rows */}
|
||||||
|
{pairwiseGrid.map((row, ri) => (
|
||||||
|
<Fragment key={`r-${CORR_POLLUTANTS[ri]}`}>
|
||||||
|
<div className="text-[10px] font-medium text-text-muted flex items-center justify-end pr-2">
|
||||||
|
{pollutantLabel(CORR_POLLUTANTS[ri])}
|
||||||
|
</div>
|
||||||
|
{row.map((val, ci) => (
|
||||||
|
<div
|
||||||
|
key={`c-${ri}-${ci}`}
|
||||||
|
className="aspect-square flex items-center justify-center text-[9px] font-medium rounded-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
val === null ? '#F1F5F9' : corrColor(val),
|
||||||
|
color:
|
||||||
|
val !== null && Math.abs(val) > 0.55
|
||||||
|
? '#FFFFFF'
|
||||||
|
: '#475569',
|
||||||
|
}}
|
||||||
|
title={`${pollutantLabel(CORR_POLLUTANTS[ri])} × ${pollutantLabel(
|
||||||
|
CORR_POLLUTANTS[ci],
|
||||||
|
)}: ${val === null ? 'N/A' : val.toFixed(2)}`}
|
||||||
|
>
|
||||||
|
{val === null ? '-' : val.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-text-muted mt-3 text-center">
|
||||||
|
对角线为自相关(=1.00);红=正相关,蓝=负相关
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-text-muted text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* (c) PM2.5 × cases scatter + regression line */}
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||||
|
PM2.5 × 病例 散点 + 回归线
|
||||||
|
</div>
|
||||||
|
{scatterPoints.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<ScatterChart margin={{ top: 5, right: 20, left: 10, bottom: 15 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
dataKey="pm25"
|
||||||
|
name="PM2.5"
|
||||||
|
unit="μg/m³"
|
||||||
|
tick={{ fontSize: 11, fill: '#64748B' }}
|
||||||
|
axisLine={{ stroke: '#E2E8F0' }}
|
||||||
|
label={{
|
||||||
|
value: 'PM2.5 (μg/m³)',
|
||||||
|
position: 'insideBottom',
|
||||||
|
offset: -8,
|
||||||
|
fontSize: 11,
|
||||||
|
fill: '#64748B',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="number"
|
||||||
|
dataKey="cases"
|
||||||
|
name="病例数"
|
||||||
|
tick={{ fontSize: 11, fill: '#64748B' }}
|
||||||
|
axisLine={{ stroke: '#E2E8F0' }}
|
||||||
|
label={{
|
||||||
|
value: '病例数',
|
||||||
|
angle: -90,
|
||||||
|
position: 'insideLeft',
|
||||||
|
offset: 0,
|
||||||
|
fontSize: 11,
|
||||||
|
fill: '#64748B',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ZAxis range={[30, 30]} />
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ strokeDasharray: '3 3' }}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
border: '1px solid #E2E8F0',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
formatter={(value: number, name: string) => [
|
||||||
|
Math.round(value).toLocaleString(),
|
||||||
|
name,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Scatter
|
||||||
|
name="网格点"
|
||||||
|
data={scatterPoints}
|
||||||
|
fill="#7C3AED"
|
||||||
|
fillOpacity={0.4}
|
||||||
|
/>
|
||||||
|
{regressionLine.length === 2 && (
|
||||||
|
<Scatter
|
||||||
|
name="回归线"
|
||||||
|
data={regressionLine}
|
||||||
|
line={{ stroke: '#DC2626', strokeWidth: 2 }}
|
||||||
|
lineType="joint"
|
||||||
|
fill="#DC2626"
|
||||||
|
shape={() => <g />}
|
||||||
|
legendType="none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ScatterChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
{regression && (
|
||||||
|
<p className="text-[10px] text-text-muted mt-3 text-center">
|
||||||
|
最小二乘回归:病例 ≈ {regression.slope.toFixed(2)} × PM2.5 +{' '}
|
||||||
|
{regression.intercept.toFixed(1)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-text-muted text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { LoadingState } from '@/components/ui';
|
||||||
import { useAnalysisStore } from '@/stores/analysisStore';
|
import { useAnalysisStore } from '@/stores/analysisStore';
|
||||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||||
import { ChatBot } from '@/components/ChatBot';
|
import { ChatBot } from '@/components/ChatBot';
|
||||||
@@ -186,7 +187,7 @@ export function Insights() {
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div data-testid="page-insights" className="overflow-x-hidden">
|
||||||
{error && (
|
{error && (
|
||||||
<ErrorBanner
|
<ErrorBanner
|
||||||
error={error}
|
error={error}
|
||||||
@@ -205,13 +206,13 @@ export function Insights() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="mb-4 text-center py-8 bg-bg-card rounded-lg border border-border">
|
<div className="mb-4 bg-bg-card rounded-lg border border-border">
|
||||||
<span className="text-text-secondary">数据加载中...</span>
|
<LoadingState />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{insights && (
|
{insights && (
|
||||||
<div className="grid grid-cols-4 gap-4 mb-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-4">
|
||||||
{stats.map((stat) => (
|
{stats.map((stat) => (
|
||||||
<div key={stat.label} className="card p-4">
|
<div key={stat.label} className="card p-4">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
@@ -231,7 +232,7 @@ export function Insights() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{insights && (
|
{insights && (
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
{(insights.cards || []).map((card) => {
|
{(insights.cards || []).map((card) => {
|
||||||
const config = TYPE_CONFIG[card.type];
|
const config = TYPE_CONFIG[card.type];
|
||||||
const Icon = config.icon;
|
const Icon = config.icon;
|
||||||
@@ -307,8 +308,8 @@ export function Insights() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{anomalyLoading && (
|
{anomalyLoading && (
|
||||||
<div className="card p-8 text-center">
|
<div className="card p-8">
|
||||||
<span className="text-text-secondary">异常检测数据加载中...</span>
|
<LoadingState />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useState, FormEvent } from 'react';
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import api from '@/services/api';
|
import api from '@/services/api';
|
||||||
|
import { TESTIDS } from '@/utils/testids';
|
||||||
|
|
||||||
interface LoginProps {
|
interface LoginProps {
|
||||||
onLogin: (token: string) => void;
|
onLogin: (token: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Login({ onLogin }: LoginProps) {
|
export function Login({ onLogin }: LoginProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -20,6 +23,7 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
const token = res.data.access_token;
|
const token = res.data.access_token;
|
||||||
localStorage.setItem('cbpoa_token', token);
|
localStorage.setItem('cbpoa_token', token);
|
||||||
onLogin(token);
|
onLogin(token);
|
||||||
|
navigate('/monitoring');
|
||||||
} catch {
|
} catch {
|
||||||
setError('用户名或密码错误');
|
setError('用户名或密码错误');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -28,7 +32,7 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-bg-page flex items-center justify-center">
|
<div data-testid={TESTIDS.pageLogin} className="min-h-screen bg-bg-page flex items-center justify-center">
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="bg-white rounded-lg shadow-md p-8 w-full max-w-sm"
|
className="bg-white rounded-lg shadow-md p-8 w-full max-w-sm"
|
||||||
@@ -68,6 +72,7 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
data-testid={TESTIDS.loginSubmit}
|
||||||
className="w-full py-2 bg-primary text-white rounded text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
|
className="w-full py-2 bg-primary text-white rounded text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? '登录中...' : '登录'}
|
{loading ? '登录中...' : '登录'}
|
||||||
|
|||||||
@@ -1,46 +1,21 @@
|
|||||||
import { useEffect, useState, useMemo, useRef, useCallback, memo } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import {
|
import { useSearchParams } from 'react-router-dom';
|
||||||
LineChart,
|
|
||||||
Line,
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
ResponsiveContainer,
|
|
||||||
} from 'recharts';
|
|
||||||
import { Calendar, Activity, TrendingUp, TrendingDown, Stethoscope, Zap, BarChart3 } from 'lucide-react';
|
|
||||||
import { useTimelineStore, useMonitoringStore } from '@/stores';
|
import { useTimelineStore, useMonitoringStore } from '@/stores';
|
||||||
import { useDiseaseStore } from '@/stores/diseaseStore';
|
|
||||||
import { useDrilldownStore } from '@/stores/drilldownStore';
|
import { useDrilldownStore } from '@/stores/drilldownStore';
|
||||||
import { gridApi, caseApi, envApi } from '@/services/api';
|
|
||||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||||
import { TimelinePlayer } from '@/components/TimelinePlayer';
|
import { TimelinePlayer } from '@/components/TimelinePlayer';
|
||||||
import { StatisticalCharts } from '@/components/StatisticalCharts';
|
|
||||||
import { CaseLocationMap } from '@/components/CaseLocationMap';
|
|
||||||
import { DiseaseFilter } from '@/components/DiseaseFilter';
|
import { DiseaseFilter } from '@/components/DiseaseFilter';
|
||||||
import { AdminBreadcrumb } from '@/components/AdminBreadcrumb';
|
import { AdminBreadcrumb } from '@/components/AdminBreadcrumb';
|
||||||
import { StatCard } from '@/components/StatCard';
|
import { MonitoringStatsBar } from '@/components/monitoring/MonitoringStatsBar';
|
||||||
import { CalendarHeatmap } from '@/components/CalendarHeatmap';
|
import { OverviewTab } from '@/components/monitoring/OverviewTab';
|
||||||
import { MetricHeatmapTable } from '@/components/MetricHeatmapTable';
|
import { CaseStatsTab } from '@/components/monitoring/CaseStatsTab';
|
||||||
import type { DistrictCaseData } from '@/types';
|
import { DistrictStatsTab } from '@/components/monitoring/DistrictStatsTab';
|
||||||
|
import { useMonitoringData } from '@/components/monitoring/useMonitoringData';
|
||||||
|
import { parseGranularity } from '@/components/monitoring/types';
|
||||||
|
import type { Granularity } from '@/components/monitoring/types';
|
||||||
|
|
||||||
type MonitoringTab = 'overview' | 'cases' | 'districts';
|
type MonitoringTab = 'overview' | 'cases' | 'districts';
|
||||||
|
|
||||||
interface TopDiagnosis {
|
|
||||||
diagnosis: string;
|
|
||||||
outpatient: number;
|
|
||||||
inpatient: number;
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateLabel(dateStr: string): string {
|
|
||||||
const d = new Date(dateStr);
|
|
||||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MonitoringDashboardProps {
|
interface MonitoringDashboardProps {
|
||||||
defaultStartDate?: string;
|
defaultStartDate?: string;
|
||||||
defaultEndDate?: string;
|
defaultEndDate?: string;
|
||||||
@@ -50,27 +25,9 @@ export function MonitoringDashboard({
|
|||||||
defaultStartDate = '2022-12-01',
|
defaultStartDate = '2022-12-01',
|
||||||
defaultEndDate = '2024-12-30',
|
defaultEndDate = '2024-12-30',
|
||||||
}: MonitoringDashboardProps) {
|
}: MonitoringDashboardProps) {
|
||||||
const [chartData, setChartData] = useState<Array<{ date: string; cases: number; aqi?: number }>>([]);
|
|
||||||
|
|
||||||
// In-page tab strip (local state, no router — mirrors the existing activePage pattern)
|
// In-page tab strip (local state, no router — mirrors the existing activePage pattern)
|
||||||
const [activeTab, setActiveTab] = useState<MonitoringTab>('overview');
|
const [activeTab, setActiveTab] = useState<MonitoringTab>('overview');
|
||||||
|
|
||||||
// --- 病例统计 tab state (fetched on demand) ---
|
|
||||||
const [topDiagnoses, setTopDiagnoses] = useState<TopDiagnosis[]>([]);
|
|
||||||
const [caseTrend, setCaseTrend] = useState<Array<{ date: string; cases: number; aqi: number }>>([]);
|
|
||||||
const [heatmapData, setHeatmapData] = useState<Array<{ date: string; value: number }>>([]);
|
|
||||||
const [heatmapYear, setHeatmapYear] = useState<number | null>(null);
|
|
||||||
const [casesTabLoaded, setCasesTabLoaded] = useState(false);
|
|
||||||
const [casesTabLoading, setCasesTabLoading] = useState(false);
|
|
||||||
const [casesTabError, setCasesTabError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// --- 区域统计 tab state (fetched on demand) ---
|
|
||||||
const [districtMetrics, setDistrictMetrics] = useState<DistrictCaseData[]>([]);
|
|
||||||
const [districtSortKey, setDistrictSortKey] = useState<string>('total');
|
|
||||||
const [districtTabLoaded, setDistrictTabLoaded] = useState(false);
|
|
||||||
const [districtTabLoading, setDistrictTabLoading] = useState(false);
|
|
||||||
const [districtTabError, setDistrictTabError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
currentDate,
|
currentDate,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
@@ -81,257 +38,83 @@ export function MonitoringDashboard({
|
|||||||
setDateRange,
|
setDateRange,
|
||||||
} = useTimelineStore();
|
} = useTimelineStore();
|
||||||
|
|
||||||
const districtCases = useMonitoringStore((s) => s.districtCases);
|
|
||||||
const error = useMonitoringStore((s) => s.error);
|
const error = useMonitoringStore((s) => s.error);
|
||||||
const clearError = useMonitoringStore((s) => s.clearError);
|
const clearError = useMonitoringStore((s) => s.clearError);
|
||||||
const fetchDistrictCases = useMonitoringStore((s) => s.fetchDistrictCases);
|
|
||||||
const isLoading = useMonitoringStore((s) => s.isLoading);
|
const isLoading = useMonitoringStore((s) => s.isLoading);
|
||||||
|
|
||||||
const { selectedDistrict, selectedStreet } = useDrilldownStore();
|
const { selectedDistrict, selectedStreet } = useDrilldownStore();
|
||||||
const { selectedDiagnoses } = useDiseaseStore();
|
const drillDown = useDrilldownStore((s) => s.drillDown);
|
||||||
|
const resetDrillDown = useDrilldownStore((s) => s.resetDrillDown);
|
||||||
|
|
||||||
|
// --- URL 是粒度的真相来源;drilldownStore 由 URL 派生 ---
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const granularity = parseGranularity(searchParams.get('granularity'));
|
||||||
|
const districtParam = searchParams.get('district');
|
||||||
|
const streetParam = searchParams.get('street');
|
||||||
|
|
||||||
|
// 把 URL 写入:粒度控件、面包屑、区域点击都通过它驱动 URL,再由下方 effect 同步 store。
|
||||||
|
const updateUrl = useCallback(
|
||||||
|
(next: { granularity: Granularity; district?: string | null; street?: string | null }) => {
|
||||||
|
setSearchParams(
|
||||||
|
(prev) => {
|
||||||
|
const sp = new URLSearchParams(prev);
|
||||||
|
sp.set('granularity', next.granularity);
|
||||||
|
if (next.district) sp.set('district', next.district);
|
||||||
|
else sp.delete('district');
|
||||||
|
if (next.street) sp.set('street', next.street);
|
||||||
|
else sp.delete('street');
|
||||||
|
return sp;
|
||||||
|
},
|
||||||
|
{ replace: false }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setSearchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 粒度控件回调:切换粒度即写 URL(深链可分享)。
|
||||||
|
const handleGranularityChange = useCallback(
|
||||||
|
(g: Granularity) => {
|
||||||
|
if (g === 'city') updateUrl({ granularity: 'city' });
|
||||||
|
else if (g === 'district') updateUrl({ granularity: 'district', district: selectedDistrict });
|
||||||
|
else updateUrl({ granularity: 'street', district: selectedDistrict, street: selectedStreet });
|
||||||
|
},
|
||||||
|
[updateUrl, selectedDistrict, selectedStreet]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 区域 roll-up 点击回调:驱动 URL 而非直接 mutate store(消除命令式 desync)。
|
||||||
|
const handleDistrictSelect = useCallback(
|
||||||
|
(district: string) => {
|
||||||
|
if (selectedDistrict === district) updateUrl({ granularity: 'city' });
|
||||||
|
else updateUrl({ granularity: 'district', district });
|
||||||
|
},
|
||||||
|
[updateUrl, selectedDistrict]
|
||||||
|
);
|
||||||
|
|
||||||
|
// store 从 URL 同步(URL 派生 store,单向)。mount 与 param 变化时执行。
|
||||||
|
useEffect(() => {
|
||||||
|
if (granularity === 'city') {
|
||||||
|
if (selectedDistrict !== null || selectedStreet !== null) resetDrillDown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (granularity === 'district') {
|
||||||
|
if (districtParam && selectedDistrict !== districtParam) drillDown('district', districtParam);
|
||||||
|
else if (!districtParam && selectedDistrict !== null) resetDrillDown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// granularity === 'street'
|
||||||
|
if (districtParam && selectedDistrict !== districtParam) drillDown('district', districtParam);
|
||||||
|
if (streetParam && selectedStreet !== streetParam) drillDown('street', streetParam);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [granularity, districtParam, streetParam]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDateRange(defaultStartDate, defaultEndDate);
|
setDateRange(defaultStartDate, defaultEndDate);
|
||||||
setCurrentDate(defaultEndDate);
|
setCurrentDate(defaultEndDate);
|
||||||
}, [defaultStartDate, defaultEndDate, setDateRange, setCurrentDate]);
|
}, [defaultStartDate, defaultEndDate, setDateRange, setCurrentDate]);
|
||||||
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
// 数据层:图表窗口 + 病例统计/区域统计两个按需 tab 的加载与派生。
|
||||||
|
// 不持有 URL/drilldown 真相来源,只消费 currentDate 与 selectedDistrict。
|
||||||
// Load chart data for 90-day window ending at the given reference date
|
const data = useMonitoringData({ activeTab, currentDate, selectedDistrict });
|
||||||
const loadChartData = useCallback((refDate: string, district?: string) => {
|
|
||||||
const end = new Date(refDate);
|
|
||||||
const start = new Date(refDate);
|
|
||||||
start.setDate(start.getDate() - 90);
|
|
||||||
const startStr = start.toISOString().split('T')[0];
|
|
||||||
const endStr = end.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
if (selectedDiagnoses.length > 0 && selectedDiagnoses.length <= 3) {
|
|
||||||
caseApi.getTrend({
|
|
||||||
start_date: startStr,
|
|
||||||
end_date: endStr,
|
|
||||||
group_by: 'day',
|
|
||||||
diagnosis: selectedDiagnoses.join(','),
|
|
||||||
}).then((data) => {
|
|
||||||
const trend = data.trend || [];
|
|
||||||
setChartData(
|
|
||||||
trend.map((t: { date: string; total: number }) => ({ date: t.date, cases: t.total }))
|
|
||||||
);
|
|
||||||
}).catch((e) => { console.error('Failed to load chart data:', e); });
|
|
||||||
} else {
|
|
||||||
gridApi.getHistoricalAggregated(startStr, endStr, 'daily', district)
|
|
||||||
.then((data) => {
|
|
||||||
const rows = data.aggregations || [];
|
|
||||||
const dailyCases: Record<string, number> = {};
|
|
||||||
rows.forEach((item: { date: string; total_cases: number }) => {
|
|
||||||
dailyCases[item.date] = (dailyCases[item.date] || 0) + item.total_cases;
|
|
||||||
});
|
|
||||||
setChartData(
|
|
||||||
Object.entries(dailyCases)
|
|
||||||
.map(([date, cases]) => ({ date, cases }))
|
|
||||||
.sort((a, b) => a.date.localeCompare(b.date))
|
|
||||||
);
|
|
||||||
}).catch((e) => { console.error('Failed to load chart data:', e); });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch districtCases with date filter (single day = currentDate)
|
|
||||||
const diagnosisParam = selectedDiagnoses.length > 0 ? selectedDiagnoses.join(',') : undefined;
|
|
||||||
fetchDistrictCases(diagnosisParam, undefined, refDate);
|
|
||||||
}, [fetchDistrictCases, selectedDiagnoses]);
|
|
||||||
|
|
||||||
// Re-fetch when currentDate, district, or diagnoses change
|
|
||||||
useEffect(() => {
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
debounceRef.current = setTimeout(() => {
|
|
||||||
loadChartData(currentDate, selectedDistrict || undefined);
|
|
||||||
}, 300);
|
|
||||||
return () => {
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
};
|
|
||||||
}, [currentDate, selectedDistrict, loadChartData]);
|
|
||||||
|
|
||||||
// Enhanced stats: window stats + current-date snapshot
|
|
||||||
const stats = useMemo(() => {
|
|
||||||
const noData = chartData.length === 0;
|
|
||||||
|
|
||||||
const totalCases = noData ? 0 : chartData.reduce((sum, d) => sum + d.cases, 0);
|
|
||||||
const avgCases = noData ? 0 : Math.round(totalCases / chartData.length);
|
|
||||||
|
|
||||||
let maxDay = { date: '--', cases: 0 };
|
|
||||||
let minDay = { date: '--', cases: 0 };
|
|
||||||
let stdDev = 0;
|
|
||||||
let trend: 'up' | 'down' | 'stable' = 'stable';
|
|
||||||
|
|
||||||
if (!noData) {
|
|
||||||
maxDay = chartData.reduce((max, d) => d.cases > max.cases ? d : max, chartData[0]);
|
|
||||||
minDay = chartData.reduce((min, d) => d.cases < min.cases ? d : min, chartData[0]);
|
|
||||||
const variance = chartData.reduce((sum, d) => sum + (d.cases - avgCases) ** 2, 0) / chartData.length;
|
|
||||||
stdDev = Math.round(Math.sqrt(variance));
|
|
||||||
|
|
||||||
const halfIdx = Math.floor(chartData.length / 2);
|
|
||||||
const firstHalf = chartData.slice(0, halfIdx);
|
|
||||||
const secondHalf = chartData.slice(halfIdx);
|
|
||||||
const firstAvg = firstHalf.reduce((s, d) => s + d.cases, 0) / firstHalf.length;
|
|
||||||
const secondAvg = secondHalf.reduce((s, d) => s + d.cases, 0) / secondHalf.length;
|
|
||||||
trend = secondAvg > firstAvg * 1.1 ? 'up' : secondAvg < firstAvg * 0.9 ? 'down' : 'stable';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7-day moving average (last 7 days of the window)
|
|
||||||
const last7 = chartData.slice(-7);
|
|
||||||
const avg7d = last7.length > 0 ? Math.round(last7.reduce((s, d) => s + d.cases, 0) / last7.length) : 0;
|
|
||||||
|
|
||||||
// Current date snapshot: find the data point matching currentDate
|
|
||||||
const todaySnapshot = chartData.find((d) => d.date === currentDate);
|
|
||||||
const todayCases = todaySnapshot?.cases ?? null;
|
|
||||||
|
|
||||||
// Case type breakdown from districtCases
|
|
||||||
const totalOutpatient = districtCases.reduce((s, d) => s + d.outpatient, 0);
|
|
||||||
const totalInpatient = districtCases.reduce((s, d) => s + d.inpatient, 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalCases, avgCases, maxDay, minDay,
|
|
||||||
stdDev, trend, totalOutpatient, totalInpatient,
|
|
||||||
avg7d, todayCases, noData,
|
|
||||||
};
|
|
||||||
}, [chartData, districtCases, currentDate]);
|
|
||||||
|
|
||||||
// 7-day sparkline for the StatCard bar (last 7 days of the loaded window)
|
|
||||||
const sparkline7d = useMemo(() => chartData.slice(-7).map((d) => d.cases), [chartData]);
|
|
||||||
|
|
||||||
// --- On-demand loader: 病例统计 tab ---
|
|
||||||
// Drives the trend off the Monitoring timeline (30-day window ending at currentDate),
|
|
||||||
// NOT a fixed now-30d window. Year for the heatmap is derived from the data.
|
|
||||||
const loadCasesTab = useCallback(async (refDate: string) => {
|
|
||||||
setCasesTabLoading(true);
|
|
||||||
setCasesTabError(null);
|
|
||||||
|
|
||||||
const end = new Date(refDate);
|
|
||||||
const start = new Date(refDate);
|
|
||||||
start.setDate(start.getDate() - 30);
|
|
||||||
const startStr = start.toISOString().split('T')[0];
|
|
||||||
const endStr = end.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
const yearStart = `${end.getFullYear()}-01-01`;
|
|
||||||
const yearEnd = `${end.getFullYear()}-12-31`;
|
|
||||||
|
|
||||||
const [statsR, trendR, pollutantsR, yearTrendR] = await Promise.allSettled([
|
|
||||||
caseApi.getStats(),
|
|
||||||
caseApi.getTrend({ start_date: startStr, end_date: endStr, group_by: 'day' }),
|
|
||||||
envApi.getPollutants(30),
|
|
||||||
caseApi.getTrend({ start_date: yearStart, end_date: yearEnd, group_by: 'day' }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const errs: string[] = [];
|
|
||||||
|
|
||||||
if (statsR.status === 'fulfilled') {
|
|
||||||
const topDiag = statsR.value.top_diagnoses || [];
|
|
||||||
setTopDiagnoses(
|
|
||||||
topDiag.slice(0, 5).map((d) => ({
|
|
||||||
diagnosis: d.diagnosis,
|
|
||||||
outpatient: d.outpatient,
|
|
||||||
inpatient: d.inpatient,
|
|
||||||
total: d.outpatient + d.inpatient,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
errs.push('诊断分布加载失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
const aqiMap: Record<string, number> = {};
|
|
||||||
if (pollutantsR.status === 'fulfilled') {
|
|
||||||
for (const p of pollutantsR.value.data || []) {
|
|
||||||
aqiMap[p.date] = p.AQI || 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trendR.status === 'fulfilled') {
|
|
||||||
const trend = trendR.value.trend || [];
|
|
||||||
setCaseTrend(
|
|
||||||
trend.map((t) => ({ date: t.date, cases: t.total, aqi: aqiMap[t.date] || 0 }))
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
errs.push('趋势数据加载失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calendar heatmap: daily cases for the data's actual year (derived from trend data)
|
|
||||||
if (yearTrendR.status === 'fulfilled') {
|
|
||||||
const yearTrend = yearTrendR.value.trend || [];
|
|
||||||
if (yearTrend.length > 0) {
|
|
||||||
const derivedYear = new Date(yearTrend[0].date).getFullYear();
|
|
||||||
setHeatmapYear(derivedYear);
|
|
||||||
setHeatmapData(yearTrend.map((t) => ({ date: t.date, value: t.total })));
|
|
||||||
} else {
|
|
||||||
setHeatmapYear(end.getFullYear());
|
|
||||||
setHeatmapData([]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
errs.push('日历热力图加载失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
setCasesTabError(errs.length > 0 ? errs.join(';') : null);
|
|
||||||
setCasesTabLoading(false);
|
|
||||||
setCasesTabLoaded(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// --- On-demand loader: 区域统计 tab ---
|
|
||||||
const loadDistrictTab = useCallback(async () => {
|
|
||||||
setDistrictTabLoading(true);
|
|
||||||
setDistrictTabError(null);
|
|
||||||
try {
|
|
||||||
const res = await caseApi.getDistricts();
|
|
||||||
setDistrictMetrics(res.districts || []);
|
|
||||||
setDistrictTabError(null);
|
|
||||||
} catch {
|
|
||||||
setDistrictTabError('区域统计加载失败');
|
|
||||||
} finally {
|
|
||||||
setDistrictTabLoading(false);
|
|
||||||
setDistrictTabLoaded(true);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch tab data the first time a tab is opened (avoids loading everything upfront)
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab === 'cases' && !casesTabLoaded && !casesTabLoading) {
|
|
||||||
loadCasesTab(currentDate);
|
|
||||||
}
|
|
||||||
if (activeTab === 'districts' && !districtTabLoaded && !districtTabLoading) {
|
|
||||||
loadDistrictTab();
|
|
||||||
}
|
|
||||||
}, [activeTab, casesTabLoaded, casesTabLoading, districtTabLoaded, districtTabLoading, currentDate, loadCasesTab, loadDistrictTab]);
|
|
||||||
|
|
||||||
// When the timeline date moves, refresh an already-opened 病例统计 tab so its
|
|
||||||
// trend window tracks the Monitoring timeline rather than going stale.
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab === 'cases' && casesTabLoaded) {
|
|
||||||
loadCasesTab(currentDate);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [currentDate]);
|
|
||||||
|
|
||||||
// 区域统计 table: sortable district rows + heatmap columns
|
|
||||||
const districtTableRows = useMemo(() => {
|
|
||||||
const sorted = [...districtMetrics].sort((a, b) => {
|
|
||||||
switch (districtSortKey) {
|
|
||||||
case 'outpatient': return b.outpatient - a.outpatient;
|
|
||||||
case 'inpatient': return b.inpatient - a.inpatient;
|
|
||||||
case 'inpatient_ratio': return (b.inpatient_ratio ?? 0) - (a.inpatient_ratio ?? 0);
|
|
||||||
default: return b.total - a.total;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return sorted.map((d) => d.district);
|
|
||||||
}, [districtMetrics, districtSortKey]);
|
|
||||||
|
|
||||||
const districtTableData = useMemo(() => {
|
|
||||||
const map: Record<string, Record<string, number>> = {};
|
|
||||||
for (const d of districtMetrics) {
|
|
||||||
map[d.district] = {
|
|
||||||
total: d.total,
|
|
||||||
outpatient: d.outpatient,
|
|
||||||
inpatient: d.inpatient,
|
|
||||||
inpatient_ratio: Math.round((d.inpatient_ratio ?? 0) * 1000) / 10,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [districtMetrics]);
|
|
||||||
|
|
||||||
const handleDateChange = useCallback((date: string) => {
|
const handleDateChange = useCallback((date: string) => {
|
||||||
setCurrentDate(date);
|
setCurrentDate(date);
|
||||||
@@ -342,14 +125,14 @@ export function MonitoringDashboard({
|
|||||||
}, [setPlaying]);
|
}, [setPlaying]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div data-testid="page-monitoring" className="flex flex-col h-full">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="px-6 pt-4">
|
<div className="px-6 pt-4">
|
||||||
<ErrorBanner
|
<ErrorBanner
|
||||||
error={error}
|
error={error}
|
||||||
onRetry={() => {
|
onRetry={() => {
|
||||||
clearError();
|
clearError();
|
||||||
loadChartData(currentDate, selectedDistrict || undefined);
|
data.loadChartData(currentDate, selectedDistrict || undefined);
|
||||||
}}
|
}}
|
||||||
onDismiss={clearError}
|
onDismiss={clearError}
|
||||||
/>
|
/>
|
||||||
@@ -358,59 +141,14 @@ export function MonitoringDashboard({
|
|||||||
{/* Top stats bar — standardized with StatCard */}
|
{/* Top stats bar — standardized with StatCard */}
|
||||||
<div className="bg-white border-b border-gray-200 px-6 py-4 shrink-0">
|
<div className="bg-white border-b border-gray-200 px-6 py-4 shrink-0">
|
||||||
<div className="flex items-start justify-between flex-wrap gap-x-4 gap-y-3">
|
<div className="flex items-start justify-between flex-wrap gap-x-4 gap-y-3">
|
||||||
<div className="flex-1 min-w-0 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
<MonitoringStatsBar stats={data.stats} sparkline7d={data.sparkline7d} />
|
||||||
<StatCard
|
|
||||||
icon={<Calendar className="w-4 h-4 text-blue-600" />}
|
|
||||||
label="当日病例"
|
|
||||||
value={stats.todayCases !== null ? stats.todayCases.toLocaleString() : '--'}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={<Activity className="w-4 h-4 text-indigo-600" />}
|
|
||||||
label="7日均值"
|
|
||||||
value={stats.avg7d.toLocaleString()}
|
|
||||||
sparkline={sparkline7d.length >= 2 ? { data: sparkline7d, color: '#6366F1' } : undefined}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={
|
|
||||||
stats.trend === 'up' ? <TrendingUp className="w-4 h-4 text-red-500" /> :
|
|
||||||
stats.trend === 'down' ? <TrendingDown className="w-4 h-4 text-green-500" /> :
|
|
||||||
<Activity className="w-4 h-4 text-gray-400" />
|
|
||||||
}
|
|
||||||
label="趋势"
|
|
||||||
value={stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳'}
|
|
||||||
trend={{
|
|
||||||
direction: stats.trend === 'up' ? 'up' : stats.trend === 'down' ? 'down' : 'stable',
|
|
||||||
value: stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={<Zap className="w-4 h-4 text-amber-500" />}
|
|
||||||
label="峰值日"
|
|
||||||
value={`${stats.maxDay.cases.toLocaleString()} (${stats.maxDay.date.slice(5)})`}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={<BarChart3 className="w-4 h-4 text-purple-500" />}
|
|
||||||
label="标准差"
|
|
||||||
value={stats.stdDev.toLocaleString()}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={<Stethoscope className="w-4 h-4 text-orange-500" />}
|
|
||||||
label="门诊 / 住院"
|
|
||||||
value={`${stats.totalOutpatient.toLocaleString()} / ${stats.totalInpatient.toLocaleString()}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Disease filter */}
|
{/* Disease filter */}
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
{stats.noData && (
|
{data.stats.noData && (
|
||||||
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded">该时段暂无数据</span>
|
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded">该时段暂无数据</span>
|
||||||
)}
|
)}
|
||||||
<DiseaseFilter onFilterChange={() => {
|
<DiseaseFilter onFilterChange={data.debouncedLoadChart} />
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
debounceRef.current = setTimeout(() => {
|
|
||||||
loadChartData(currentDate, selectedDistrict || undefined);
|
|
||||||
}, 300);
|
|
||||||
}} />
|
|
||||||
<AdminBreadcrumb />
|
<AdminBreadcrumb />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -441,178 +179,47 @@ export function MonitoringDashboard({
|
|||||||
<div className="flex-1 overflow-auto p-6 pb-24">
|
<div className="flex-1 overflow-auto p-6 pb-24">
|
||||||
{/* 概览 tab — unchanged Monitoring content */}
|
{/* 概览 tab — unchanged Monitoring content */}
|
||||||
{activeTab === 'overview' && (
|
{activeTab === 'overview' && (
|
||||||
isLoading ? (
|
<OverviewTab
|
||||||
<div className="flex items-center justify-center h-64">
|
isLoading={isLoading}
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
chartData={data.chartData}
|
||||||
</div>
|
districtCases={data.districtCases}
|
||||||
) : (
|
selectedDistrict={selectedDistrict}
|
||||||
<div className="space-y-6">
|
selectedStreet={selectedStreet}
|
||||||
{/* Case Location Map */}
|
currentDate={currentDate}
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
granularity={granularity}
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">病例分布地图</h3>
|
onGranularityChange={handleGranularityChange}
|
||||||
<CaseLocationMap height="400px" district={selectedDistrict} street={selectedStreet} date={currentDate} />
|
onDistrictSelect={handleDistrictSelect}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{/* Statistical Charts */}
|
|
||||||
<StatisticalCharts
|
|
||||||
data={chartData}
|
|
||||||
height={350}
|
|
||||||
showCases={true}
|
|
||||||
showAQI={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* District breakdown */}
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">区县病例分布</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<DistrictBreakdown districtCases={districtCases} selectedDistrict={selectedDistrict} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 mt-3 pt-2 border-t border-gray-100">
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
|
||||||
<span className="w-3 h-3 bg-orange-400 rounded-sm" />门诊
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
|
||||||
<span className="w-3 h-3 bg-red-400 rounded-sm" />住院
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 病例统计 tab */}
|
{/* 病例统计 tab */}
|
||||||
{activeTab === 'cases' && (
|
{activeTab === 'cases' && (
|
||||||
casesTabLoading && !casesTabLoaded ? (
|
<CaseStatsTab
|
||||||
<div className="flex items-center justify-center h-64">
|
loading={data.casesTabLoading}
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
loaded={data.casesTabLoaded}
|
||||||
</div>
|
error={data.casesTabError}
|
||||||
) : (
|
currentDate={currentDate}
|
||||||
<div className="space-y-6">
|
topDiagnoses={data.topDiagnoses}
|
||||||
{casesTabError && (
|
caseTrend={data.caseTrend}
|
||||||
<ErrorBanner
|
heatmapData={data.heatmapData}
|
||||||
error={casesTabError}
|
heatmapYear={data.heatmapYear}
|
||||||
onRetry={() => loadCasesTab(currentDate)}
|
onRetry={() => data.loadCasesTab(currentDate)}
|
||||||
onDismiss={() => setCasesTabError(null)}
|
onDismissError={() => data.setCasesTabError(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Top 5 诊断分布 */}
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top 5 诊断分布</h3>
|
|
||||||
{topDiagnoses.length > 0 ? (
|
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
|
||||||
<BarChart
|
|
||||||
data={[...topDiagnoses].reverse()}
|
|
||||||
layout="vertical"
|
|
||||||
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
|
||||||
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
|
|
||||||
<YAxis
|
|
||||||
type="category"
|
|
||||||
dataKey="diagnosis"
|
|
||||||
tick={{ fontSize: 11, fill: '#374151' }}
|
|
||||||
width={100}
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{ backgroundColor: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '8px', fontSize: '12px' }}
|
|
||||||
formatter={(value: number) => [value.toLocaleString(), '病例数']}
|
|
||||||
/>
|
|
||||||
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
|
||||||
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={16} />
|
|
||||||
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={16} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 近30日病例与AQI趋势 (driven off Monitoring timeline currentDate) */}
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">病例与AQI趋势</h3>
|
|
||||||
<p className="text-xs text-gray-500 mb-4">截至 {currentDate} 的近30日窗口</p>
|
|
||||||
{caseTrend.length > 0 ? (
|
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
|
||||||
<LineChart data={caseTrend} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="date"
|
|
||||||
tickFormatter={formatDateLabel}
|
|
||||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
|
||||||
interval="preserveStartEnd"
|
|
||||||
axisLine={{ stroke: '#E2E8F0' }}
|
|
||||||
/>
|
|
||||||
<YAxis yAxisId="left" tick={{ fontSize: 10, fill: '#64748B' }} axisLine={{ stroke: '#E2E8F0' }} />
|
|
||||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10, fill: '#F59E0B' }} axisLine={{ stroke: '#E2E8F0' }} />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{ backgroundColor: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '8px', fontSize: '12px' }}
|
|
||||||
labelStyle={{ color: '#1E293B', fontWeight: 600 }}
|
|
||||||
/>
|
|
||||||
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
|
||||||
<Line yAxisId="left" type="monotone" dataKey="cases" name="病例数" stroke="#3B82F6" strokeWidth={2} dot={false} activeDot={{ r: 3 }} />
|
|
||||||
<Line yAxisId="right" type="monotone" dataKey="aqi" name="AQI" stroke="#F59E0B" strokeWidth={2} strokeDasharray="5 5" dot={false} activeDot={{ r: 3 }} />
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 日历热力图 (year derived from data) */}
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
|
||||||
{heatmapYear ? `${heatmapYear}年 ` : ''}每日病例日历
|
|
||||||
</h3>
|
|
||||||
{heatmapYear && heatmapData.length > 0 ? (
|
|
||||||
<CalendarHeatmap data={heatmapData} year={heatmapYear} />
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 区域统计 tab */}
|
{/* 区域统计 tab */}
|
||||||
{activeTab === 'districts' && (
|
{activeTab === 'districts' && (
|
||||||
districtTabLoading && !districtTabLoaded ? (
|
<DistrictStatsTab
|
||||||
<div className="flex items-center justify-center h-64">
|
loading={data.districtTabLoading}
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
loaded={data.districtTabLoaded}
|
||||||
</div>
|
error={data.districtTabError}
|
||||||
) : (
|
rows={data.districtTableRows}
|
||||||
<div className="space-y-6">
|
data={data.districtTableData}
|
||||||
{districtTabError && (
|
onRetry={() => data.loadDistrictTab()}
|
||||||
<ErrorBanner
|
onDismissError={() => data.setDistrictTabError(null)}
|
||||||
error={districtTabError}
|
onSort={(col) => data.setDistrictSortKey(col)}
|
||||||
onRetry={() => loadDistrictTab()}
|
/>
|
||||||
onDismiss={() => setDistrictTabError(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">区域指标热力表</h3>
|
|
||||||
<p className="text-xs text-gray-500 mb-4">点击列标题排序</p>
|
|
||||||
{districtTableRows.length > 0 ? (
|
|
||||||
<MetricHeatmapTable
|
|
||||||
rows={districtTableRows}
|
|
||||||
columns={[
|
|
||||||
{ key: 'total', label: '病例' },
|
|
||||||
{ key: 'outpatient', label: '门诊' },
|
|
||||||
{ key: 'inpatient', label: '住院' },
|
|
||||||
{ key: 'inpatient_ratio', label: '住院占比%' },
|
|
||||||
]}
|
|
||||||
data={districtTableData}
|
|
||||||
onSort={(col) => setDistrictSortKey(col)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -630,57 +237,3 @@ export function MonitoringDashboard({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DistrictBreakdownProps {
|
|
||||||
districtCases: Array<{ district: string; total: number; outpatient: number; inpatient: number }>;
|
|
||||||
selectedDistrict: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DistrictBreakdown = memo(function DistrictBreakdown({ districtCases, selectedDistrict }: DistrictBreakdownProps) {
|
|
||||||
const sortedCases = useMemo(() => [...districtCases].sort((a, b) => b.total - a.total), [districtCases]);
|
|
||||||
const maxTotal = useMemo(() => sortedCases.length > 0 ? sortedCases[0].total : 1, [sortedCases]);
|
|
||||||
|
|
||||||
const handleDistrictClick = useCallback((district: string) => {
|
|
||||||
if (selectedDistrict === district) {
|
|
||||||
useDrilldownStore.getState().drillUp();
|
|
||||||
} else {
|
|
||||||
useDrilldownStore.getState().drillDown('district', district);
|
|
||||||
}
|
|
||||||
}, [selectedDistrict]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{sortedCases.map((d) => {
|
|
||||||
const outPct = d.total > 0 ? (d.outpatient / d.total) * 100 : 0;
|
|
||||||
const inPct = d.total > 0 ? (d.inpatient / d.total) * 100 : 0;
|
|
||||||
const barWidth = (d.total / maxTotal) * 100;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={d.district}
|
|
||||||
className={`flex items-center gap-3 p-2 rounded cursor-pointer transition-colors ${
|
|
||||||
selectedDistrict === d.district ? 'bg-blue-50' : 'hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
onClick={() => handleDistrictClick(d.district)}
|
|
||||||
>
|
|
||||||
<div className="w-16 text-sm text-gray-700 text-right shrink-0">{d.district}</div>
|
|
||||||
<div className="flex-1 h-6 bg-gray-100 rounded overflow-hidden flex">
|
|
||||||
<div
|
|
||||||
className="bg-orange-400 h-full transition-all"
|
|
||||||
style={{ width: `${barWidth * outPct / 100}%` }}
|
|
||||||
title={`门诊: ${d.outpatient.toLocaleString()}`}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="bg-red-400 h-full transition-all"
|
|
||||||
style={{ width: `${barWidth * inPct / 100}%` }}
|
|
||||||
title={`住院: ${d.inpatient.toLocaleString()}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-20 text-right text-sm font-medium text-gray-900 shrink-0">
|
|
||||||
{d.total.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,31 +1,9 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import {
|
import { Activity } from 'lucide-react';
|
||||||
LineChart,
|
|
||||||
Line,
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
PieChart,
|
|
||||||
Pie,
|
|
||||||
Cell,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
ResponsiveContainer,
|
|
||||||
} from 'recharts';
|
|
||||||
import {
|
|
||||||
Activity,
|
|
||||||
AlertTriangle,
|
|
||||||
Droplets,
|
|
||||||
Building2,
|
|
||||||
TrendingUp,
|
|
||||||
TrendingDown,
|
|
||||||
Users,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { caseApi, riskApi, alertApi, envApi } from '@/services/api';
|
import { caseApi, riskApi, alertApi, envApi } from '@/services/api';
|
||||||
import { StatCard } from '@/components/StatCard';
|
|
||||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||||
|
import { LoadingState, Segmented } from '@/components/ui';
|
||||||
|
import { TESTIDS } from '@/utils/testids';
|
||||||
import type {
|
import type {
|
||||||
CaseTrendPoint,
|
CaseTrendPoint,
|
||||||
DistrictCaseData,
|
DistrictCaseData,
|
||||||
@@ -33,27 +11,30 @@ import type {
|
|||||||
DiagnosisBreakdown,
|
DiagnosisBreakdown,
|
||||||
Alert,
|
Alert,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
import { KpiRow, type KpiData } from '@/components/overview/KpiRow';
|
||||||
|
import { CaseAqiTrend, type MergedTrendItem } from '@/components/overview/CaseAqiTrend';
|
||||||
|
import { DistrictChoropleth } from '@/components/overview/DistrictChoropleth';
|
||||||
|
import { TopDistrictsBar } from '@/components/overview/TopDistrictsBar';
|
||||||
|
import { TopDiagnosesBar } from '@/components/overview/TopDiagnosesBar';
|
||||||
|
import { AlertSeverityDonut, type AlertSlice } from '@/components/overview/AlertSeverityDonut';
|
||||||
|
import { CHART_COLORS } from '@/components/overview/chartColors';
|
||||||
|
import {
|
||||||
|
joinDistrictCases,
|
||||||
|
buildMetricLookup,
|
||||||
|
type MetricKey,
|
||||||
|
} from '@/components/overview/districtNormalize';
|
||||||
|
|
||||||
// --- Types for fetched data ---
|
const METRIC_OPTIONS: { value: MetricKey; label: string }[] = [
|
||||||
interface KpiData {
|
{ value: 'all', label: '全部' },
|
||||||
totalCases: number;
|
{ value: 'outpatient', label: '门诊' },
|
||||||
todayCases: number;
|
{ value: 'inpatient', label: '住院' },
|
||||||
changeRatio: number | null;
|
];
|
||||||
activeAlerts: number;
|
|
||||||
highRiskGrids: number;
|
|
||||||
avgAQI: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MergedTrendItem {
|
const METRIC_LABEL: Record<MetricKey, string> = {
|
||||||
date: string;
|
all: '病例',
|
||||||
cases: number;
|
outpatient: '门诊',
|
||||||
aqi: number;
|
inpatient: '住院',
|
||||||
}
|
};
|
||||||
|
|
||||||
function formatDateLabel(dateStr: string): string {
|
|
||||||
const d = new Date(dateStr);
|
|
||||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeChangeRatio(trend: CaseTrendPoint[]): number | null {
|
function computeChangeRatio(trend: CaseTrendPoint[]): number | null {
|
||||||
if (trend.length < 8) return null;
|
if (trend.length < 8) return null;
|
||||||
@@ -66,12 +47,15 @@ function computeChangeRatio(trend: CaseTrendPoint[]): number | null {
|
|||||||
export function OverviewDashboard() {
|
export function OverviewDashboard() {
|
||||||
const [kpi, setKpi] = useState<KpiData | null>(null);
|
const [kpi, setKpi] = useState<KpiData | null>(null);
|
||||||
const [mergedTrend, setMergedTrend] = useState<MergedTrendItem[]>([]);
|
const [mergedTrend, setMergedTrend] = useState<MergedTrendItem[]>([]);
|
||||||
const [topDistricts, setTopDistricts] = useState<DistrictCaseData[]>([]);
|
const [districts, setDistricts] = useState<DistrictCaseData[]>([]);
|
||||||
const [topDiagnoses, setTopDiagnoses] = useState<DiagnosisBreakdown[]>([]);
|
const [topDiagnoses, setTopDiagnoses] = useState<DiagnosisBreakdown[]>([]);
|
||||||
const [alertPie, setAlertPie] = useState<{ name: string; value: number; color: string }[]>([]);
|
const [alertPie, setAlertPie] = useState<AlertSlice[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [errors, setErrors] = useState<string[]>([]);
|
const [errors, setErrors] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 门诊/住院/全部 — 同时驱动 choropleth 与 Top5 区县条形图。
|
||||||
|
const [metric, setMetric] = useState<MetricKey>('all');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -88,7 +72,7 @@ export function OverviewDashboard() {
|
|||||||
start30.setDate(start30.getDate() - 30);
|
start30.setDate(start30.getDate() - 30);
|
||||||
const start30Str = start30.toISOString().split('T')[0];
|
const start30Str = start30.toISOString().split('T')[0];
|
||||||
|
|
||||||
// KPI sources — Promise.allSettled to survive individual failures
|
// KPI sources — Promise.allSettled to survive individual failures.
|
||||||
const [statsR, trend14R, alertsR, riskStatsR, pollutantsR] = await Promise.allSettled([
|
const [statsR, trend14R, alertsR, riskStatsR, pollutantsR] = await Promise.allSettled([
|
||||||
caseApi.getStats(),
|
caseApi.getStats(),
|
||||||
caseApi.getTrend({ start_date: start14Str, end_date: endStr, group_by: 'day' }),
|
caseApi.getTrend({ start_date: start14Str, end_date: endStr, group_by: 'day' }),
|
||||||
@@ -97,7 +81,7 @@ export function OverviewDashboard() {
|
|||||||
envApi.getPollutants(7),
|
envApi.getPollutants(7),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Trend sources
|
// Trend + district sources.
|
||||||
const [trend30R, districtsR, diagStatsR] = await Promise.allSettled([
|
const [trend30R, districtsR, diagStatsR] = await Promise.allSettled([
|
||||||
caseApi.getTrend({ start_date: start30Str, end_date: endStr, group_by: 'day' }),
|
caseApi.getTrend({ start_date: start30Str, end_date: endStr, group_by: 'day' }),
|
||||||
caseApi.getDistricts(),
|
caseApi.getDistricts(),
|
||||||
@@ -108,7 +92,7 @@ export function OverviewDashboard() {
|
|||||||
|
|
||||||
const newErrors: string[] = [];
|
const newErrors: string[] = [];
|
||||||
|
|
||||||
// --- Build KPI ---
|
// --- KPI ---
|
||||||
let totalCases = 0;
|
let totalCases = 0;
|
||||||
if (statsR.status === 'fulfilled') {
|
if (statsR.status === 'fulfilled') {
|
||||||
const s = statsR.value;
|
const s = statsR.value;
|
||||||
@@ -121,9 +105,7 @@ export function OverviewDashboard() {
|
|||||||
let changeRatio: number | null = null;
|
let changeRatio: number | null = null;
|
||||||
if (trend14R.status === 'fulfilled') {
|
if (trend14R.status === 'fulfilled') {
|
||||||
const trend = trend14R.value.trend || [];
|
const trend = trend14R.value.trend || [];
|
||||||
if (trend.length > 0) {
|
if (trend.length > 0) todayCases = trend[trend.length - 1].total;
|
||||||
todayCases = trend[trend.length - 1].total;
|
|
||||||
}
|
|
||||||
changeRatio = computeChangeRatio(trend);
|
changeRatio = computeChangeRatio(trend);
|
||||||
} else {
|
} else {
|
||||||
newErrors.push('今日病例数据加载失败');
|
newErrors.push('今日病例数据加载失败');
|
||||||
@@ -158,33 +140,24 @@ export function OverviewDashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setKpi({ totalCases, todayCases, changeRatio, activeAlerts, highRiskGrids, avgAQI });
|
setKpi({ totalCases, todayCases, changeRatio, activeAlerts, highRiskGrids, avgAQI });
|
||||||
setErrors(newErrors);
|
|
||||||
|
|
||||||
// --- Merge case trend + AQI ---
|
// --- Merge case trend + AQI ---
|
||||||
if (trend30R.status === 'fulfilled') {
|
if (trend30R.status === 'fulfilled') {
|
||||||
const trend30 = trend30R.value.trend || [];
|
const trend30 = trend30R.value.trend || [];
|
||||||
const aqiMap: Record<string, number> = {};
|
const aqiMap: Record<string, number> = {};
|
||||||
if (pollutantsR.status === 'fulfilled') {
|
for (const p of pollutantData) aqiMap[p.date] = p.AQI || 0;
|
||||||
for (const p of pollutantData) {
|
setMergedTrend(
|
||||||
aqiMap[p.date] = p.AQI || 0;
|
trend30.map((t) => ({ date: t.date, cases: t.total, aqi: aqiMap[t.date] || 0 }))
|
||||||
}
|
);
|
||||||
}
|
|
||||||
// Only use data from the last 30 days for display
|
|
||||||
const merged: MergedTrendItem[] = trend30.map((t) => ({
|
|
||||||
date: t.date,
|
|
||||||
cases: t.total,
|
|
||||||
aqi: aqiMap[t.date] || 0,
|
|
||||||
}));
|
|
||||||
setMergedTrend(merged);
|
|
||||||
} else if (!newErrors.includes('今日病例数据加载失败')) {
|
} else if (!newErrors.includes('今日病例数据加载失败')) {
|
||||||
newErrors.push('趋势数据加载失败');
|
newErrors.push('趋势数据加载失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Top 5 Districts ---
|
// --- Districts (feeds choropleth + Top5 via normalize/join) ---
|
||||||
if (districtsR.status === 'fulfilled') {
|
if (districtsR.status === 'fulfilled') {
|
||||||
const districts = districtsR.value.districts || [];
|
setDistricts(districtsR.value.districts || []);
|
||||||
const sorted = [...districts].sort((a, b) => b.total - a.total);
|
} else {
|
||||||
setTopDistricts(sorted.slice(0, 5));
|
newErrors.push('区县数据加载失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Top 5 Diagnoses ---
|
// --- Top 5 Diagnoses ---
|
||||||
@@ -204,43 +177,33 @@ export function OverviewDashboard() {
|
|||||||
const p1 = alertList.filter((a) => a.priority === 'P1').length;
|
const p1 = alertList.filter((a) => a.priority === 'P1').length;
|
||||||
const p2 = alertList.filter((a) => a.priority === 'P2').length;
|
const p2 = alertList.filter((a) => a.priority === 'P2').length;
|
||||||
setAlertPie([
|
setAlertPie([
|
||||||
{ name: 'P1 紧急', value: p1, color: '#EF4444' },
|
{ name: 'P1 紧急', value: p1, color: CHART_COLORS.alertP1 },
|
||||||
{ name: 'P2 关注', value: p2, color: '#F59E0B' },
|
{ name: 'P2 关注', value: p2, color: CHART_COLORS.alertP2 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchAll();
|
fetchAll();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const changeTrend = useMemo(() => {
|
// 归一并聚合到 13 区一次,供 choropleth 与 Top5 共享。
|
||||||
if (kpi?.changeRatio == null) return undefined;
|
const joinedDistricts = useMemo(() => joinDistrictCases(districts), [districts]);
|
||||||
if (kpi.changeRatio > 0) {
|
const metricLookup = useMemo(
|
||||||
return { direction: 'up' as const, value: `${kpi.changeRatio.toFixed(1)}%` };
|
() => buildMetricLookup(joinedDistricts, metric),
|
||||||
}
|
[joinedDistricts, metric]
|
||||||
if (kpi.changeRatio < 0) {
|
);
|
||||||
return { direction: 'down' as const, value: `${Math.abs(kpi.changeRatio).toFixed(1)}%` };
|
|
||||||
}
|
|
||||||
return { direction: 'stable' as const, value: '0%' };
|
|
||||||
}, [kpi?.changeRatio]);
|
|
||||||
|
|
||||||
// --- Loading state ---
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <LoadingState label="加载概览数据…" testid={TESTIDS.pageLoading} />;
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-auto">
|
<div data-testid={TESTIDS.pageOverview} className="flex flex-col h-full overflow-auto">
|
||||||
{/* Error banner */}
|
|
||||||
{errors.length > 0 && (
|
{errors.length > 0 && (
|
||||||
<div className="px-6 pt-4">
|
<div className="px-6 pt-4">
|
||||||
<ErrorBanner
|
<ErrorBanner
|
||||||
@@ -252,249 +215,58 @@ export function OverviewDashboard() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* Page header */}
|
{/* Page header + honesty badge + metric toggle */}
|
||||||
<div>
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
|
<div>
|
||||||
<Activity className="w-5 h-5 text-primary" />
|
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
|
||||||
综合概览
|
<Activity className="w-5 h-5 text-primary" />
|
||||||
</h1>
|
综合概览
|
||||||
<p className="text-[12px] text-gray-500">病例、环境与预警关键指标总览</p>
|
<span
|
||||||
</div>
|
data-testid={TESTIDS.asofBadge}
|
||||||
|
className="ml-1 inline-flex items-center rounded-full bg-bg-hover px-2 py-0.5 text-[11px] font-medium text-text-secondary border border-border"
|
||||||
{/* Section 1: KPI Row */}
|
>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
数据截至2023-12
|
||||||
<StatCard
|
</span>
|
||||||
icon={<Users className="w-4 h-4 text-blue-600" />}
|
</h1>
|
||||||
label="累计病例总数"
|
<p className="text-[12px] text-text-secondary">病例、环境与预警关键指标总览</p>
|
||||||
value={kpi?.totalCases?.toLocaleString() ?? '--'}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={<Activity className="w-4 h-4 text-green-600" />}
|
|
||||||
label="今日病例"
|
|
||||||
value={kpi?.todayCases?.toLocaleString() ?? '--'}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={
|
|
||||||
(changeTrend?.direction === 'up' && <TrendingUp className="w-4 h-4 text-red-500" />) ||
|
|
||||||
(changeTrend?.direction === 'down' && <TrendingDown className="w-4 h-4 text-green-500" />) || (
|
|
||||||
<Activity className="w-4 h-4 text-gray-400" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
label="7日变化率"
|
|
||||||
value={changeTrend ? changeTrend.value : '--'}
|
|
||||||
trend={changeTrend}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={<AlertTriangle className="w-4 h-4 text-orange-500" />}
|
|
||||||
label="活跃预警数"
|
|
||||||
value={kpi?.activeAlerts?.toLocaleString() ?? '--'}
|
|
||||||
color={kpi && kpi.activeAlerts > 0 ? '#EF4444' : undefined}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={<Building2 className="w-4 h-4 text-red-500" />}
|
|
||||||
label="高风险网格"
|
|
||||||
value={kpi?.highRiskGrids?.toLocaleString() ?? '--'}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={<Droplets className="w-4 h-4 text-cyan-500" />}
|
|
||||||
label="平均AQI"
|
|
||||||
value={kpi?.avgAQI?.toLocaleString() ?? '--'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section 2: Case + AQI Mini Trend */}
|
|
||||||
<div className="card p-4">
|
|
||||||
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
|
|
||||||
近30日病例与AQI趋势
|
|
||||||
</div>
|
</div>
|
||||||
{mergedTrend.length > 0 ? (
|
<Segmented
|
||||||
<ResponsiveContainer width="100%" height={200}>
|
options={METRIC_OPTIONS}
|
||||||
<LineChart data={mergedTrend} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
value={metric}
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
onChange={setMetric}
|
||||||
<XAxis
|
testid={TESTIDS.outinpatientToggle}
|
||||||
dataKey="date"
|
/>
|
||||||
tickFormatter={formatDateLabel}
|
|
||||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
|
||||||
interval="preserveStartEnd"
|
|
||||||
axisLine={{ stroke: '#E2E8F0' }}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
yAxisId="left"
|
|
||||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
|
||||||
axisLine={{ stroke: '#E2E8F0' }}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
yAxisId="right"
|
|
||||||
orientation="right"
|
|
||||||
tick={{ fontSize: 10, fill: '#F59E0B' }}
|
|
||||||
axisLine={{ stroke: '#E2E8F0' }}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
border: '1px solid #E2E8F0',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontSize: '12px',
|
|
||||||
}}
|
|
||||||
labelStyle={{ color: '#1E293B', fontWeight: 600 }}
|
|
||||||
/>
|
|
||||||
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
|
||||||
<Line
|
|
||||||
yAxisId="left"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="cases"
|
|
||||||
name="病例数"
|
|
||||||
stroke="#3B82F6"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
activeDot={{ r: 3 }}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
yAxisId="right"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="aqi"
|
|
||||||
name="AQI"
|
|
||||||
stroke="#F59E0B"
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeDasharray="5 5"
|
|
||||||
dot={false}
|
|
||||||
activeDot={{ r: 3 }}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 3 + 4: Top Districts + Top Diagnoses side by side */}
|
{/* KPI Row */}
|
||||||
|
<KpiRow kpi={kpi} />
|
||||||
|
|
||||||
|
{/* Headline: Wuhan 13-district choropleth */}
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-3">
|
||||||
|
武汉市13区{METRIC_LABEL[metric]}分布(高风险高亮)
|
||||||
|
</div>
|
||||||
|
<DistrictChoropleth
|
||||||
|
metricLookup={metricLookup}
|
||||||
|
metricLabel={`${METRIC_LABEL[metric]}数`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Case + AQI trend */}
|
||||||
|
<CaseAqiTrend data={mergedTrend} />
|
||||||
|
|
||||||
|
{/* Top districts (metric-driven) + Top diagnoses */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{/* Section 3: Top 5 Districts */}
|
<TopDistrictsBar
|
||||||
<div className="card p-4">
|
districts={joinedDistricts}
|
||||||
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
|
metric={metric}
|
||||||
Top 5 区县病例分布
|
metricLabel={METRIC_LABEL[metric]}
|
||||||
</div>
|
/>
|
||||||
{topDistricts.length > 0 ? (
|
<TopDiagnosesBar diagnoses={topDiagnoses} />
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
|
||||||
<BarChart
|
|
||||||
data={[...topDistricts].reverse()}
|
|
||||||
layout="vertical"
|
|
||||||
margin={{ top: 0, right: 10, left: 30, bottom: 0 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
|
||||||
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
|
|
||||||
<YAxis
|
|
||||||
type="category"
|
|
||||||
dataKey="district"
|
|
||||||
tick={{ fontSize: 11, fill: '#374151' }}
|
|
||||||
width={60}
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
border: '1px solid #E2E8F0',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontSize: '12px',
|
|
||||||
}}
|
|
||||||
formatter={(value: number) => [value.toLocaleString(), '病例数']}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={20} />
|
|
||||||
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={20} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section 4: Top 5 Diagnoses */}
|
|
||||||
<div className="card p-4">
|
|
||||||
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
|
|
||||||
Top 5 诊断分布
|
|
||||||
</div>
|
|
||||||
{topDiagnoses.length > 0 ? (
|
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
|
||||||
<BarChart
|
|
||||||
data={[...topDiagnoses].reverse()}
|
|
||||||
layout="vertical"
|
|
||||||
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
|
||||||
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
|
|
||||||
<YAxis
|
|
||||||
type="category"
|
|
||||||
dataKey="diagnosis"
|
|
||||||
tick={{ fontSize: 11, fill: '#374151' }}
|
|
||||||
width={100}
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
border: '1px solid #E2E8F0',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontSize: '12px',
|
|
||||||
}}
|
|
||||||
formatter={(value: number) => [value.toLocaleString(), '病例数']}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={16} />
|
|
||||||
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={16} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 5: Alert Severity Donut */}
|
{/* Alert severity donut */}
|
||||||
<div className="card p-4">
|
<AlertSeverityDonut data={alertPie} />
|
||||||
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
|
|
||||||
预警严重度分布
|
|
||||||
</div>
|
|
||||||
{alertPie[0].value > 0 || alertPie[1].value > 0 ? (
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={alertPie}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius={50}
|
|
||||||
outerRadius={80}
|
|
||||||
paddingAngle={4}
|
|
||||||
dataKey="value"
|
|
||||||
nameKey="name"
|
|
||||||
>
|
|
||||||
{alertPie.map((entry, idx) => (
|
|
||||||
<Cell key={idx} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
border: '1px solid #E2E8F0',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontSize: '12px',
|
|
||||||
}}
|
|
||||||
formatter={(value: number, name: string) => [value, name]}
|
|
||||||
/>
|
|
||||||
<Legend
|
|
||||||
wrapperStyle={{ fontSize: '12px' }}
|
|
||||||
formatter={(value: string) => (
|
|
||||||
<span className="text-gray-700">{value}</span>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-gray-400 text-sm">暂无预警数据</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { LoadingState } from '@/components/ui';
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import { FileText, Download, Activity, TrendingUp, TrendingDown, AlertTriangle, ChevronLeft, RefreshCw } from 'lucide-react';
|
import { FileText, Download, Activity, TrendingUp, TrendingDown, AlertTriangle, ChevronLeft, RefreshCw } from 'lucide-react';
|
||||||
import { useReportsStore } from '@/stores/reportsStore';
|
import { useReportsStore } from '@/stores/reportsStore';
|
||||||
@@ -94,7 +95,7 @@ function ReportList({ onSelect }: { onSelect: (id: string) => void }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8 text-sm text-gray-500">加载中...</div>
|
<LoadingState />
|
||||||
) : reports.length === 0 ? (
|
) : reports.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<FileText className="w-12 h-12 text-gray-300 mx-auto mb-3" />
|
<FileText className="w-12 h-12 text-gray-300 mx-auto mb-3" />
|
||||||
@@ -102,6 +103,7 @@ function ReportList({ onSelect }: { onSelect: (id: string) => void }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -134,6 +136,7 @@ function ReportList({ onSelect }: { onSelect: (id: string) => void }) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +173,7 @@ function ReportDetail({ reportId, onBack }: { reportId: string; onBack: () => vo
|
|||||||
<ChevronLeft className="w-4 h-4" /> 返回列表
|
<ChevronLeft className="w-4 h-4" /> 返回列表
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{metadata.title}</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{metadata.title}</h2>
|
||||||
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500">
|
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500">
|
||||||
@@ -207,7 +210,7 @@ function ReportDetail({ reportId, onBack }: { reportId: string; onBack: () => vo
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary cards */}
|
{/* Summary cards */}
|
||||||
<div className="grid grid-cols-5 gap-3 mb-6">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 mb-6">
|
||||||
{[
|
{[
|
||||||
{ label: '总病例数', value: summary.total_cases.toLocaleString(), icon: Activity, color: 'text-blue-600', bg: 'bg-blue-50' },
|
{ label: '总病例数', value: summary.total_cases.toLocaleString(), icon: Activity, color: 'text-blue-600', bg: 'bg-blue-50' },
|
||||||
{ label: '平均风险', value: (summary.avg_risk * 100).toFixed(1) + '%', icon: AlertTriangle, color: 'text-orange-600', bg: 'bg-orange-50' },
|
{ label: '平均风险', value: (summary.avg_risk * 100).toFixed(1) + '%', icon: AlertTriangle, color: 'text-orange-600', bg: 'bg-orange-50' },
|
||||||
@@ -233,7 +236,7 @@ function ReportDetail({ reportId, onBack }: { reportId: string; onBack: () => vo
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sections and charts in 2-column layout */}
|
{/* Sections and charts in 2-column layout */}
|
||||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||||
{sections.map((section, idx) => (
|
{sections.map((section, idx) => (
|
||||||
<div key={idx} className="bg-white rounded-lg border border-gray-200 p-4">
|
<div key={idx} className="bg-white rounded-lg border border-gray-200 p-4">
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">{section.title}</h3>
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">{section.title}</h3>
|
||||||
@@ -329,7 +332,7 @@ export function ReportsCenter() {
|
|||||||
}, [fetchReportsList]);
|
}, [fetchReportsList]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div data-testid="page-reports" className="overflow-x-hidden">
|
||||||
{error && view === 'list' && (
|
{error && view === 'list' && (
|
||||||
<ErrorBanner error={error} onRetry={() => { clearError(); fetchReportsList(); }} onDismiss={clearError} />
|
<ErrorBanner error={error} onRetry={() => { clearError(); fetchReportsList(); }} onDismiss={clearError} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { LoadingState } from '@/components/ui';
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
@@ -15,7 +16,8 @@ import {
|
|||||||
ReferenceLine,
|
ReferenceLine,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { useAnalysisStore } from '@/stores/analysisStore';
|
import { useAnalysisStore } from '@/stores/analysisStore';
|
||||||
import { caseApi } from '@/services/api';
|
import { caseApi, statsApi } from '@/services/api';
|
||||||
|
import type { TemporalResponse } from '@/services/api';
|
||||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||||
import { TrendingUp, Calendar, Activity } from 'lucide-react';
|
import { TrendingUp, Calendar, Activity } from 'lucide-react';
|
||||||
import type { CaseTrendPoint } from '@/types';
|
import type { CaseTrendPoint } from '@/types';
|
||||||
@@ -47,6 +49,7 @@ export function TrendAnalysis() {
|
|||||||
const [selectedPollutants, setSelectedPollutants] = useState<string[]>(['aqi', 'pm25']);
|
const [selectedPollutants, setSelectedPollutants] = useState<string[]>(['aqi', 'pm25']);
|
||||||
const [multiYearData, setMultiYearData] = useState<Record<string, CaseTrendPoint[]>>({});
|
const [multiYearData, setMultiYearData] = useState<Record<string, CaseTrendPoint[]>>({});
|
||||||
const [multiYearLoading, setMultiYearLoading] = useState(false);
|
const [multiYearLoading, setMultiYearLoading] = useState(false);
|
||||||
|
const [weekdayData, setWeekdayData] = useState<TemporalResponse['weekday']>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTrend(selectedDays);
|
fetchTrend(selectedDays);
|
||||||
@@ -81,6 +84,19 @@ export function TrendAnalysis() {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
statsApi
|
||||||
|
.getTemporal()
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled) setWeekdayData(res.weekday || []);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// weekday distribution is supplementary — fail silently
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Merge multi-year data by month (data is monthly) over a fixed 1..12 sequence
|
// Merge multi-year data by month (data is monthly) over a fixed 1..12 sequence
|
||||||
const mergedMultiYearData = (() => {
|
const mergedMultiYearData = (() => {
|
||||||
const yearColors: Record<string, string> = { '2022': '#94A3B8', '2023': '#3B82F6', '2024': '#EF4444' };
|
const yearColors: Record<string, string> = { '2022': '#94A3B8', '2023': '#3B82F6', '2024': '#EF4444' };
|
||||||
@@ -148,7 +164,7 @@ export function TrendAnalysis() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div data-testid="page-trend" className="overflow-x-hidden">
|
||||||
{error && (
|
{error && (
|
||||||
<ErrorBanner
|
<ErrorBanner
|
||||||
error={error}
|
error={error}
|
||||||
@@ -211,8 +227,8 @@ export function TrendAnalysis() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="mb-4 text-center py-8 bg-bg-card rounded-lg border border-border">
|
<div className="mb-4 bg-bg-card rounded-lg border border-border">
|
||||||
<span className="text-text-secondary">数据加载中...</span>
|
<LoadingState />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -310,7 +326,7 @@ export function TrendAnalysis() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{latestData && (
|
{latestData && (
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
{POLLUTANT_OPTIONS.filter((p) => selectedPollutants.includes(p.key)).slice(0, 4).map((p) => {
|
{POLLUTANT_OPTIONS.filter((p) => selectedPollutants.includes(p.key)).slice(0, 4).map((p) => {
|
||||||
const value = latestData[p.key as keyof typeof latestData] as number;
|
const value = latestData[p.key as keyof typeof latestData] as number;
|
||||||
const change = getChange(p.key);
|
const change = getChange(p.key);
|
||||||
@@ -350,7 +366,7 @@ export function TrendAnalysis() {
|
|||||||
多年度病例对比
|
多年度病例对比
|
||||||
</div>
|
</div>
|
||||||
{multiYearLoading ? (
|
{multiYearLoading ? (
|
||||||
<div className="text-center py-8 text-text-secondary text-sm">数据加载中...</div>
|
<LoadingState />
|
||||||
) : Object.keys(multiYearData).length > 0 ? (
|
) : Object.keys(multiYearData).length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<LineChart
|
<LineChart
|
||||||
@@ -443,6 +459,52 @@ export function TrendAnalysis() {
|
|||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Weekday case distribution (门诊/住院) */}
|
||||||
|
<div data-testid="weekday-dist" className="card p-4 mb-4">
|
||||||
|
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||||
|
星期就诊分布
|
||||||
|
</div>
|
||||||
|
{weekdayData.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<BarChart
|
||||||
|
data={weekdayData}
|
||||||
|
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="weekday"
|
||||||
|
tick={{ fontSize: 12, fill: '#64748B' }}
|
||||||
|
axisLine={{ stroke: '#E2E8F0' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12, fill: '#64748B' }}
|
||||||
|
axisLine={{ stroke: '#E2E8F0' }}
|
||||||
|
label={{ value: '就诊量', angle: -90, position: 'insideLeft', offset: 0, fontSize: 11, fill: '#64748B' }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
border: '1px solid #E2E8F0',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
formatter={(value: number, name: string) => [value.toLocaleString(), name]}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: '12px', paddingTop: '12px' }} />
|
||||||
|
<Bar dataKey="outpatient" name="门诊" stackId="visits" fill="#3B82F6" radius={[0, 0, 0, 0]} />
|
||||||
|
<Bar dataKey="inpatient" name="住院" stackId="visits" fill="#EF4444" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<p className="text-[10px] text-text-muted mt-3 text-center">
|
||||||
|
注:现有病例数据集中在12月,季节性/同比分析待更多月份数据
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-text-muted text-sm">暂无就诊分布数据</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
65
frontend/src/routes.tsx
Normal file
65
frontend/src/routes.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { lazy, Suspense, ComponentType } from 'react';
|
||||||
|
import { Navigate, RouteObject } from 'react-router-dom';
|
||||||
|
import { LoadingState } from '@/components/ui/LoadingState';
|
||||||
|
import { RoleRedirect } from '@/components/RoleRedirect';
|
||||||
|
import { TESTIDS } from '@/utils/testids';
|
||||||
|
|
||||||
|
// 按页面懒加载,路由层负责代码分割(原先位于 App.tsx)。
|
||||||
|
const MonitoringDashboard = lazy(() =>
|
||||||
|
import('@/pages/MonitoringDashboard').then((m) => ({ default: m.MonitoringDashboard }))
|
||||||
|
);
|
||||||
|
const AlertsDashboard = lazy(() =>
|
||||||
|
import('@/pages/AlertsDashboard').then((m) => ({ default: m.AlertsDashboard }))
|
||||||
|
);
|
||||||
|
const OverviewDashboard = lazy(() =>
|
||||||
|
import('@/pages/OverviewDashboard').then((m) => ({ default: m.OverviewDashboard }))
|
||||||
|
);
|
||||||
|
const TrendAnalysis = lazy(() =>
|
||||||
|
import('@/pages/TrendAnalysis').then((m) => ({ default: m.TrendAnalysis }))
|
||||||
|
);
|
||||||
|
const DistrictComparison = lazy(() =>
|
||||||
|
import('@/pages/DistrictComparison').then((m) => ({ default: m.DistrictComparison }))
|
||||||
|
);
|
||||||
|
const Insights = lazy(() => import('@/pages/Insights').then((m) => ({ default: m.Insights })));
|
||||||
|
const ReportsCenter = lazy(() =>
|
||||||
|
import('@/pages/ReportsCenter').then((m) => ({ default: m.ReportsCenter }))
|
||||||
|
);
|
||||||
|
const DemographicAnalysis = lazy(() =>
|
||||||
|
import('@/pages/DemographicAnalysis').then((m) => ({ default: m.DemographicAnalysis }))
|
||||||
|
);
|
||||||
|
const DiseaseAnalysis = lazy(() =>
|
||||||
|
import('@/pages/DiseaseAnalysis').then((m) => ({ default: m.DiseaseAnalysis }))
|
||||||
|
);
|
||||||
|
const EnvironmentalHealth = lazy(() =>
|
||||||
|
import('@/pages/EnvironmentalHealth').then((m) => ({ default: m.EnvironmentalHealth }))
|
||||||
|
);
|
||||||
|
const ClinicalAnalysis = lazy(() =>
|
||||||
|
import('@/pages/ClinicalAnalysis').then((m) => ({ default: m.ClinicalAnalysis }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// 用 Suspense 包裹懒加载页面,统一加载态。
|
||||||
|
function lazyElement(Page: ComponentType): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingState testid={TESTIDS.pageLoading} />}>
|
||||||
|
<Page />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppShell 的子路由表(在 App.tsx 中作为 <Outlet/> 的内容渲染)。
|
||||||
|
export const appRoutes: RouteObject[] = [
|
||||||
|
{ index: true, element: <RoleRedirect /> },
|
||||||
|
{ path: 'overview', element: lazyElement(OverviewDashboard) },
|
||||||
|
{ path: 'monitoring', element: lazyElement(MonitoringDashboard) },
|
||||||
|
{ path: 'alerts', element: lazyElement(AlertsDashboard) },
|
||||||
|
{ path: 'analysis/trend', element: lazyElement(TrendAnalysis) },
|
||||||
|
{ path: 'analysis/district', element: lazyElement(DistrictComparison) },
|
||||||
|
{ path: 'analysis/insights', element: lazyElement(Insights) },
|
||||||
|
{ path: 'analysis/reports', element: lazyElement(ReportsCenter) },
|
||||||
|
{ path: 'analysis/demographics', element: lazyElement(DemographicAnalysis) },
|
||||||
|
{ path: 'analysis/disease', element: lazyElement(DiseaseAnalysis) },
|
||||||
|
{ path: 'analysis/environment', element: lazyElement(EnvironmentalHealth) },
|
||||||
|
{ path: 'analysis/clinical', element: lazyElement(ClinicalAnalysis) },
|
||||||
|
// 未知路径回退到监测面板。
|
||||||
|
{ path: '*', element: <Navigate to="/monitoring" replace /> },
|
||||||
|
];
|
||||||
@@ -343,4 +343,44 @@ export const reportApi = {
|
|||||||
getLatestSummary: (): Promise<ReportSummary> => cachedGet('/reports/summary/latest'),
|
getLatestSummary: (): Promise<ReportSummary> => cachedGet('/reports/summary/latest'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ===== 深度统计分析(/api/stats)=====
|
||||||
|
export interface InpatientClinicalResponse {
|
||||||
|
kpis: {
|
||||||
|
total_admissions: number;
|
||||||
|
median_los_days: number;
|
||||||
|
cure_rate: number;
|
||||||
|
emergency_admit_ratio: number;
|
||||||
|
};
|
||||||
|
los_histogram: { bin_label: string; count: number }[];
|
||||||
|
los_by_disease: { diagnosis: string; p25: number; median: number; p75: number; n: number }[];
|
||||||
|
outcome_counts: { outcome: string; count: number }[];
|
||||||
|
admission_route_counts: { route: string; count: number }[];
|
||||||
|
bmi_by_age_band: { age_band: string; p25: number; median: number; p75: number; n: number }[];
|
||||||
|
}
|
||||||
|
export interface SymptomsResponse {
|
||||||
|
symptoms: { keyword: string; count: number }[];
|
||||||
|
revisit_ratio: number;
|
||||||
|
}
|
||||||
|
export interface IncidenceRateResponse {
|
||||||
|
districts: { district: string; total_cases: number; population: number; rate_per_10k: number }[];
|
||||||
|
}
|
||||||
|
export interface EnvCorrelationResponse {
|
||||||
|
correlation_matrix: { pollutant: string; corr_with_cases: number }[];
|
||||||
|
scatter: { pm25: number; aqi: number; cases: number }[];
|
||||||
|
pollutant_pairwise: { a: string; b: string; corr: number }[];
|
||||||
|
}
|
||||||
|
export interface TemporalResponse {
|
||||||
|
weekday: { weekday: string; outpatient: number; inpatient: number; total: number }[];
|
||||||
|
month_year: { year: number; month: number; total: number }[];
|
||||||
|
yoy: { period: string; current: number; previous: number; growth_pct: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const statsApi = {
|
||||||
|
getInpatientClinical: (): Promise<InpatientClinicalResponse> => cachedGet('/stats/inpatient-clinical'),
|
||||||
|
getSymptoms: (top: number = 20): Promise<SymptomsResponse> => cachedGet('/stats/symptoms', { top }),
|
||||||
|
getIncidenceRate: (): Promise<IncidenceRateResponse> => cachedGet('/stats/incidence-rate'),
|
||||||
|
getEnvCorrelation: (): Promise<EnvCorrelationResponse> => cachedGet('/stats/env-correlation'),
|
||||||
|
getTemporal: (): Promise<TemporalResponse> => cachedGet('/stats/temporal'),
|
||||||
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export const useRiskStore = create<RiskState>((set, get) => ({
|
|||||||
export { useAnalysisStore } from './analysisStore';
|
export { useAnalysisStore } from './analysisStore';
|
||||||
export { useDrilldownStore } from './drilldownStore';
|
export { useDrilldownStore } from './drilldownStore';
|
||||||
export { useDiseaseStore } from './diseaseStore';
|
export { useDiseaseStore } from './diseaseStore';
|
||||||
|
export { useSessionStore } from './sessionStore';
|
||||||
|
|
||||||
|
|
||||||
interface TimelineState {
|
interface TimelineState {
|
||||||
|
|||||||
57
frontend/src/stores/sessionStore.test.ts
Normal file
57
frontend/src/stores/sessionStore.test.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { getRoleSource, type Role } from './sessionStore';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'cbpoa_role';
|
||||||
|
const ALL_ROLES: Role[] = ['official', 'community', 'doctor', 'admin'];
|
||||||
|
|
||||||
|
describe('sessionStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRoleSource', () => {
|
||||||
|
it("defaults to 'admin' when localStorage is empty", () => {
|
||||||
|
expect(getRoleSource()).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to 'admin' on an invalid stored value", () => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, 'hacker');
|
||||||
|
expect(getRoleSource()).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns each valid stored role', () => {
|
||||||
|
for (const r of ALL_ROLES) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, r);
|
||||||
|
expect(getRoleSource()).toBe(r);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useSessionStore', () => {
|
||||||
|
// useSessionStore 在模块加载时从 getRoleSource() 初始化一次,故用
|
||||||
|
// resetModules + 动态 import 来获得一个「按当前 localStorage 初始化」的全新实例。
|
||||||
|
it('initializes role from localStorage', async () => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, 'doctor');
|
||||||
|
vi.resetModules();
|
||||||
|
const { useSessionStore } = await import('./sessionStore');
|
||||||
|
expect(useSessionStore.getState().role).toBe('doctor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setRole updates state and persists to localStorage', async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
const { useSessionStore } = await import('./sessionStore');
|
||||||
|
|
||||||
|
useSessionStore.getState().setRole('community');
|
||||||
|
expect(useSessionStore.getState().role).toBe('community');
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBe('community');
|
||||||
|
|
||||||
|
useSessionStore.getState().setRole('official');
|
||||||
|
expect(useSessionStore.getState().role).toBe('official');
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBe('official');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
59
frontend/src/stores/sessionStore.ts
Normal file
59
frontend/src/stores/sessionStore.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视角/perspective 会话存储 —— 纯前端视图预设(D2:不做后端鉴权,不加 JWT role claim)。
|
||||||
|
*
|
||||||
|
* 角色仅决定「默认落地页 + 粒度 + 过滤预设」,不是访问控制。切换器在 UI 上标注为
|
||||||
|
* 「视角」而非「权限」,因此 URL 可编辑不构成可信度陷阱。
|
||||||
|
*
|
||||||
|
* 角色来源被隔离在单一可替换的 getRoleSource() 接缝里:今天读 localStorage,
|
||||||
|
* 将来若需真正 RBAC,只改这一个函数(改读 /api/auth/me),其余代码不变。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Role = 'official' | 'community' | 'doctor' | 'admin';
|
||||||
|
|
||||||
|
export const ROLES: Role[] = ['official', 'community', 'doctor', 'admin'];
|
||||||
|
|
||||||
|
// 标签与默认落地路径已迁出到纯模块 '@/utils/roleViews'(ROLE_LABELS / roleDefaultPath),
|
||||||
|
// 保持单一来源。本 store 只负责「当前视角是什么」+ 可替换的角色来源接缝。
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'cbpoa_role';
|
||||||
|
const DEFAULT_ROLE: Role = 'admin';
|
||||||
|
|
||||||
|
function isRole(v: unknown): v is Role {
|
||||||
|
return typeof v === 'string' && (ROLES as string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单一可替换接缝:角色来源。今天 = localStorage;将来 = /api/auth/me。
|
||||||
|
* 改 RBAC 只动这一个函数。
|
||||||
|
*/
|
||||||
|
export function getRoleSource(): Role {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return isRole(raw) ? raw : DEFAULT_ROLE;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_ROLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistRole(role: Role): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, role);
|
||||||
|
} catch {
|
||||||
|
/* localStorage 不可用时静默降级 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionState {
|
||||||
|
role: Role;
|
||||||
|
setRole: (role: Role) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSessionStore = create<SessionState>((set) => ({
|
||||||
|
role: getRoleSource(),
|
||||||
|
setRole: (role) => {
|
||||||
|
persistRole(role);
|
||||||
|
set({ role });
|
||||||
|
},
|
||||||
|
}));
|
||||||
42
frontend/src/utils/roleViews.test.ts
Normal file
42
frontend/src/utils/roleViews.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ROLE_LABELS, roleDefaultPath } from './roleViews';
|
||||||
|
import type { Role } from '@/stores/sessionStore';
|
||||||
|
|
||||||
|
const ALL_ROLES: Role[] = ['official', 'community', 'doctor', 'admin'];
|
||||||
|
|
||||||
|
describe('roleViews', () => {
|
||||||
|
describe('ROLE_LABELS', () => {
|
||||||
|
it('has a non-empty label for all 4 roles', () => {
|
||||||
|
for (const r of ALL_ROLES) {
|
||||||
|
expect(ROLE_LABELS[r]).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the expected short labels (no 视角 suffix)', () => {
|
||||||
|
expect(ROLE_LABELS).toEqual({
|
||||||
|
official: '厅领导',
|
||||||
|
community: '社区',
|
||||||
|
doctor: '医生',
|
||||||
|
admin: '管理员',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('roleDefaultPath', () => {
|
||||||
|
it('official → /overview with granularity=district', () => {
|
||||||
|
expect(roleDefaultPath('official')).toBe('/overview?granularity=district');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('community → /monitoring with granularity=street', () => {
|
||||||
|
expect(roleDefaultPath('community')).toBe('/monitoring?granularity=street');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('doctor → /alerts with view=cluster', () => {
|
||||||
|
expect(roleDefaultPath('doctor')).toBe('/alerts?view=cluster');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('admin → /monitoring (legacy full view; keeps user-flows baseline green)', () => {
|
||||||
|
expect(roleDefaultPath('admin')).toBe('/monitoring');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
42
frontend/src/utils/roleViews.ts
Normal file
42
frontend/src/utils/roleViews.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Role } from '@/stores/sessionStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视角/perspective 的纯展示元数据 —— 标签 + 默认落地路径。
|
||||||
|
*
|
||||||
|
* 纯模块(无副作用、无 React、无 store 依赖),便于单元测试。
|
||||||
|
* D2:角色只是「前端视图预设」,决定默认落地页 / 粒度 / 过滤预设,不是访问控制。
|
||||||
|
*
|
||||||
|
* wave-2 workers 的契约入口:
|
||||||
|
* import { roleDefaultPath, ROLE_LABELS } from '@/utils/roleViews'
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 视角中文短标签(switcher 在前面拼接「视角:」前缀,故此处不带「视角」后缀)。 */
|
||||||
|
export const ROLE_LABELS: Record<Role, string> = {
|
||||||
|
official: '厅领导',
|
||||||
|
community: '社区',
|
||||||
|
doctor: '医生',
|
||||||
|
admin: '管理员',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每个视角的默认落地 URL。query 参数由 wave-2 workers 消费:
|
||||||
|
* - granularity=district|street → 粒度控件初值(监测/概览)
|
||||||
|
* - view=cluster → 预警页医生聚类视图
|
||||||
|
*/
|
||||||
|
export function roleDefaultPath(role: Role): string {
|
||||||
|
switch (role) {
|
||||||
|
case 'official':
|
||||||
|
return '/overview?granularity=district';
|
||||||
|
case 'community':
|
||||||
|
return '/monitoring?granularity=street';
|
||||||
|
case 'doctor':
|
||||||
|
return '/alerts?view=cluster';
|
||||||
|
case 'admin':
|
||||||
|
default:
|
||||||
|
// admin = 旧「全量」视角,历史落地页即 /monitoring(与改造前 sessionStore 的
|
||||||
|
// ROLE_DEFAULT_PATH 一致)。保持 /monitoring 以兼容既有 user-flows 基线测试
|
||||||
|
// (裸 '/' 无 cbpoa_role ⇒ 默认 admin ⇒ /monitoring)。其余三个视角带查询参数,
|
||||||
|
// 由 wave-2 workers 消费,不受此选择影响。
|
||||||
|
return '/monitoring';
|
||||||
|
}
|
||||||
|
}
|
||||||
57
frontend/src/utils/testids.ts
Normal file
57
frontend/src/utils/testids.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// 集中管理所有 data-testid 字符串,供应用与 e2e 测试共享引用。
|
||||||
|
export const TESTIDS = {
|
||||||
|
// 布局骨架
|
||||||
|
appShell: 'app-shell',
|
||||||
|
hamburger: 'hamburger',
|
||||||
|
appDrawer: 'app-drawer',
|
||||||
|
sidebarRail: 'sidebar-rail',
|
||||||
|
pageLoading: 'page-loading',
|
||||||
|
|
||||||
|
// 登录
|
||||||
|
pageLogin: 'page-login',
|
||||||
|
loginSubmit: 'login-submit',
|
||||||
|
|
||||||
|
// 导航项
|
||||||
|
navMonitoring: 'nav-monitoring',
|
||||||
|
navAlerts: 'nav-alerts',
|
||||||
|
navOverview: 'nav-overview',
|
||||||
|
navTrend: 'nav-trend',
|
||||||
|
navDistrict: 'nav-district',
|
||||||
|
navInsights: 'nav-insights',
|
||||||
|
navReports: 'nav-reports',
|
||||||
|
navDemographics: 'nav-demographics',
|
||||||
|
navDisease: 'nav-disease',
|
||||||
|
navEnvironment: 'nav-environment',
|
||||||
|
navClinical: 'nav-clinical',
|
||||||
|
|
||||||
|
// 页面挂载点
|
||||||
|
pageMonitoring: 'page-monitoring',
|
||||||
|
pageAlerts: 'page-alerts',
|
||||||
|
pageOverview: 'page-overview',
|
||||||
|
pageTrend: 'page-trend',
|
||||||
|
pageDistrict: 'page-district',
|
||||||
|
pageInsights: 'page-insights',
|
||||||
|
pageReports: 'page-reports',
|
||||||
|
pageDemographics: 'page-demographics',
|
||||||
|
pageDisease: 'page-disease',
|
||||||
|
pageEnvironment: 'page-environment',
|
||||||
|
pageClinical: 'page-clinical',
|
||||||
|
clinicalKpis: 'clinical-kpis',
|
||||||
|
|
||||||
|
// 综合概览 大屏
|
||||||
|
kpiRow: 'kpi-row',
|
||||||
|
choroplethWrapper: 'choropleth-wrapper',
|
||||||
|
asofBadge: 'asof-badge',
|
||||||
|
outinpatientToggle: 'outinpatient-toggle',
|
||||||
|
|
||||||
|
// 视角/perspective + 粒度(Phase 3)
|
||||||
|
perspectiveSwitcher: 'perspective-switcher',
|
||||||
|
perspectiveOption: 'perspective-option', // 配合角色后缀,如 perspective-option-official
|
||||||
|
granularityControl: 'granularity-control',
|
||||||
|
districtRollup: 'district-rollup',
|
||||||
|
gridLayerWrapper: 'grid-layer-wrapper',
|
||||||
|
clusterView: 'cluster-view',
|
||||||
|
patientPoint: 'patient-point', // 个体病例点标记;医生视角下必须为 0
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TestId = (typeof TESTIDS)[keyof typeof TESTIDS];
|
||||||
Reference in New Issue
Block a user