7 Commits

Author SHA1 Message Date
e22b004f9e feat: GeoScene frontend POC + Docker deploy for remote host
Migrate maps to @geoscene/core, polish monitoring/alerts UX, fix timeline
basemap flicker and district alert regions, and ship compose/nginx Docker
deploy assets with CBPOA_ROOT data mounts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 03:28:29 +08:00
fe8bed58f5 feat: remove cost/费用 statistics from clinical analytics
Per request — drop all monetary statistics (住院费用 is sensitive).
- Backend statistics.py: remove mean_cost KPI + cost_histogram / cost_by_disease
  / cost_vs_los from /inpatient-clinical (models, computation, response)
- Frontend: drop 人均费用 KPI card (now 4 KPIs), 住院费用分布, 各病种平均费用,
  费用×住院天数散点; delete CostByDiseaseChart + CostVsLosScatter components;
  trim statsApi type + e2e fixture + chartColors

Clinical page now: KPI(总人次/中位住院日/治愈好转率/急诊占比) + LOS dist + LOS-by-disease
box + outcome donut + admission-route donut + age-band BMI box.

Gates: backend 106 pytest · tsc 0 · build ok · clinical+user-flows e2e 19/19 ·
live endpoint confirmed cost-free

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:50:30 +08:00
4df6c71628 feat: deep statistical analytics — clinical, symptoms, incidence, env correlation, weekday
Adds a substantial layer of data-backed statistics (all grounded in verified,
clean source data — no fabricated metrics).

Backend (new routers/statistics.py, prefix /api/stats; +106 pytest still green):
- /inpatient-clinical: LOS dist + by-disease quartiles, cost dist + by-disease +
  cost-vs-LOS, outcome counts, admission-route counts, BMI-by-age, KPIs
  (5822 admissions, median LOS 4d, mean ¥6294, cure 99.1%, emergency 47%)
- /symptoms: 主诉 keyword frequencies (发热/咳嗽/肺炎…) + revisit ratio (36%)
- /incidence-rate: per-10k-population standardized rate by district (cases ÷ pop)
- /env-correlation: pollutant×cases Pearson + 7×7 pairwise matrix + PM2.5 scatter
- /temporal: weekday distribution (+ month/yoy returned but UI omits them — data
  is December-only, so seasonality/YoY would be misleading)

Frontend:
- NEW 住院临床分析 page (/analysis/clinical, nav 临床分析): 9 charts + KPI row —
  LOS histogram + box-by-disease, cost histogram + scatter + by-disease, outcome
  donut (severity-colored), admission-route donut, age-band BMI box
- DiseaseAnalysis: 主诉症状词频 horizontal bar + revisit ratio
- DistrictComparison: 标化发病率(每万人)with 病例数↔发病率 toggle (rate is
  epidemiologically correct; raw counts mislead by population)
- EnvironmentalHealth: pollutant-cases correlation bar + 7×7 correlation heatmap +
  PM2.5×cases scatter with least-squares regression line
- TrendAnalysis: 星期就诊分布 + honest "data is December-only" note
- statsApi client + types

Gates: tsc 0 · build ok · functional e2e 43/43 (incl 2 new clinical) · verified
live against real backend data via dev proxy

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:42:52 +08:00
33f0f497d3 feat: Phase 4 — responsive analysis pages + perf harness + god-component splits
Final phase of the UX modernization. Four conflict-free lanes.

Responsive (D4 — desktop+mobile 并重):
- 7 analysis pages made usable at 375px: grid-cols-4/5 → grid-cols-2 sm:*
  responsive variants; raw tables wrapped in overflow-x-auto; page overflow guards
- new e2e/responsive.spec.ts loops all 7 analysis routes at 375px asserting no
  horizontal scroll

Perf harness:
- playwright.config.ts gains an isolated `perf` project (testMatch /perf/), default
  chromium project excludes it (testIgnore)
- new e2e/perf.spec.ts: CDP Network.emulateNetworkConditions (Fast 3G) +
  PerformanceObserver LCP on /overview kpi-row + route-transition timing; numbers
  reported as a relative regression signal (dev-server, not a prod SLA), not gated

God-component splits (pure refactors, behavior-preserving):
- MonitoringDashboard 686 → 239 lines: extracted components/monitoring/* (StatsBar,
  OverviewTab, CaseStatsTab, DistrictStatsTab) + useMonitoringData hook; URL-granularity
  source-of-truth + drilldown reconcile kept in the orchestrator (no desync regression)
- AlertsDashboard 816 → 301 lines: extracted components/alerts/* (Toolbar, List,
  RiskPanel, MapPanel, DetailModal, …); role/privacy/grid-hide logic kept in the
  orchestrator — doctor-view privacy invariant (zero patient-point) still holds

Gates: tsc 0 · vitest 75 · functional e2e 37/37 (incl doctor-view privacy +
granularity + responsive) · build ok · perf project runs + reports

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:00:15 +08:00
8ad7e086bb feat: Phase 3 — 视角/perspective presets + URL-driven granularity + doctor privacy
Phase 3 of the UX modernization. Frontend-only role view-presets (D2 — no
backend, no JWT role claim, honestly labeled 视角 not 权限). Three conflict-free lanes.

Role infrastructure (worker-session):
- sessionStore with single swappable getRoleSource() seam (localStorage today,
  one-line swap to /api/auth/me for future RBAC); Role = official|community|doctor|admin
- 视角 switcher in TopNav (replaces hardcoded "admin"); on change persists role +
  navigates to that perspective's default landing
- roleViews.ts: ROLE_LABELS + roleDefaultPath (official→/overview?granularity=district,
  community→/monitoring?granularity=street, doctor→/alerts?view=cluster, admin→/monitoring)
- RoleRedirect index route → current role's default; * fallback unchanged

URL-driven granularity (worker-monitoring):
- granularity (city|district|street) query param is the source of truth; drilldownStore
  DERIVES from it via a one-way effect; deep-linkable + reload-safe
- reconciled the imperative desync — DistrictBreakdown no longer calls
  useDrilldownStore.getState(); MonitoringDashboard owns useSearchParams, writes URL
- granularity-control Segmented (全市/区域/街道); district-rollup testid

Role-aware alerts + privacy (worker-alerts):
- doctor/cluster view: individual alert markers hard-locked off (effective flag is
  single source of truth; toggle not rendered) → aggregated density only; DiseaseFilter
  mounted; privacy invariant testable via hidden patient-point DOM mirror (count=0)
- 官员: 100m grid hidden (grid-layer-wrapper unrendered); admin/community unchanged

Gates: tsc 0 · vitest 75 · e2e 30/30 (incl 10 new P3 tests) · build ok

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:46:38 +08:00
9a94156acc feat: Phase 2 — leadership 大屏 (/overview) + district normalization + drawer a11y
Phase 2 of the UX modernization. Three conflict-free workstreams.

Leadership 驾驶舱 (/overview):
- Wuhan 13-district Leaflet choropleth (public/wuhan_districts.geojson, keyed
  on name, darker=higher per 高风险高亮), legend, hover/click-zoom
- 全部/门诊/住院 Segmented toggle drives choropleth + Top-5 district bar
- literal "数据截至2023-12" as-of badge (D3 honesty); raw spinner → LoadingState
- decompose OverviewDashboard 501→273; 6 components + 2 helpers under components/overview/

District normalization (backend data boundary):
- case_loader.normalize_district + load_cases_by_district_daily collapse the
  26 dirty labels (武昌/武昌区…) → 13 canonical; analysis/grid/insights repointed
  (fixes a grid-merge row-drop bug as a bonus); in-memory, schema unchanged

Shell a11y (code-review carryover):
- drawer is now a proper modal: ESC, body scroll-lock, focus-in + focus-trap
  cycle + focus-restore, role=dialog/aria-modal/aria-label, hamburger aria-expanded
- SideNav expanded state lifted to AppShell so rail+drawer stay in sync
- RouteErrorBoundary around <Outlet/> keeps shell chrome on page/chunk failure

Gates: tsc 0 · vitest 64 · e2e 19/19 (17 user-flows + 2 overview) · build ok
· backend pytest 6 new + 48 regression green

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:24:26 +08:00
3db3b12480 feat(frontend): Phase 1 UX foundation — react-router v6 + responsive AppShell
Atomic foundation for the consensus-approved UX modernization (makes the
platform URL-addressable, refresh-safe, and mobile-usable for hospital demos).

- Migrate hand-rolled useState page switching → react-router v6 (routes.tsx,
  thin App.tsx auth gate, NavLink SideNav, lazy+Suspense per route)
- Add responsive AppShell: persistent rail (lg:) ⇄ off-canvas drawer + hamburger
  (<lg); kills hardcoded ml-[200px]; usable at 375px
- Add ui primitive kit (Skeleton/LoadingState/EmptyState/Card/Panel/Segmented)
- Sweep all raw "加载中..." text loaders → skeleton primitives (G4)
- Centralize test ids (utils/testids.ts); rewrite e2e for URL nav (17/17 pass:
  deep-link, refresh-preserves-page, back, 375px drawer/no-scroll)
- Harden DemographicAnalysis against API shape mismatch (defensive normalize)
- gitignore playwright-report/ and test-results/

Gates: tsc --noEmit 0 · pnpm build ok · e2e 17/17 · grep 加载中 zero outside ui/

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:12:13 +08:00
110 changed files with 8947 additions and 3928 deletions

2
.gitignore vendored
View File

@@ -14,6 +14,8 @@ cache/
logs/
mlruns/
.playwright-mcp/
frontend/playwright-report/
frontend/test-results/
*Zone.Identifier
# Transcription processing intermediates
Outputs/transcript/chunks/

View File

@@ -1,12 +1,12 @@
# CBPOA — 武汉儿童呼吸疾病风险评估系统
FastAPI + React + PyTorch GCN pipeline. 预测空气质量对儿童健康的空间风险。
FastAPI + React + `@geoscene/core` + PyTorch GCN pipeline. 预测空气质量对儿童健康的空间风险。
## Development
```bash
# Frontend (pnpm)
cd frontend && pnpm dev # localhost:5173 → proxies /api to :8000
cd frontend && pnpm dev # localhost:3000 → proxies /api to :8000
# Backend (Python venv)
cd backend && uvicorn main:app --reload # localhost:8000
@@ -22,6 +22,7 @@ cd scripts && python train_model.py # PyTorch + MLflow
| API endpoint | `backend/routers/` |
| Database / PostGIS | `backend/database.py` |
| UI component | `frontend/src/components/` |
| GeoScene map helpers | `frontend/src/geoscene/` |
| Page view | `frontend/src/pages/` |
| API client / cache | `frontend/src/services/api.ts` |
| State management | `frontend/src/stores/` |

View File

@@ -13,7 +13,7 @@ load_dotenv()
# Paths
# ============================================================================
PROJECT_ROOT = Path(__file__).parent.parent
PROJECT_ROOT = Path(os.environ.get("CBPOA_ROOT", Path(__file__).parent.parent))
DATA_DIR = PROJECT_ROOT / "outputs" / "daily"
REPORTS_DIR = PROJECT_ROOT / "outputs" / "reports"
WUHAN_BOUNDARY_PATH = PROJECT_ROOT / "Datas" / "武汉市.geojson"

View File

@@ -25,12 +25,42 @@ _cache: dict[str, Optional[pd.DataFrame | datetime]] = {
"outpatient": None,
"inpatient": None,
"combined": None,
"cases_by_district_daily": None,
"loaded_at": None,
}
# Guards the lazy build so concurrent callers don't duplicate the load/concat.
_load_lock = threading.RLock()
# Canonical Wuhan administrative districts (13), matching the `name` field in
# Datas/武汉市.geojson. All district roll-ups must collapse to exactly these.
CANONICAL_DISTRICTS = [
'江岸区', '江汉区', '硚口区', '汉阳区', '武昌区', '青山区', '洪山区',
'东西湖区', '汉南区', '蔡甸区', '江夏区', '黄陂区', '新洲区',
]
# Bare (suffix-less) base name -> canonical 区-suffixed name.
_DISTRICT_BASE_TO_CANONICAL = {d[:-1]: d for d in CANONICAL_DISTRICTS}
_DISTRICT_SUFFIXES = ('', '', '')
def normalize_district(name: str) -> str:
"""Map a district label to its canonical 区-suffixed form.
The case parquet carries both bare ("武昌") and suffixed ("武昌区") spellings
of the same district, which double-counts in any roll-up. This collapses
them: known bare names map to their canonical form; already-suffixed names
pass through unchanged; anything else gets a "" appended.
"""
if name is None:
return name
name = str(name).strip()
if name in _DISTRICT_BASE_TO_CANONICAL:
return _DISTRICT_BASE_TO_CANONICAL[name]
if name.endswith(_DISTRICT_SUFFIXES):
return name
return f"{name}"
# Wuhan district mapping
WUHAN_DISTRICTS = {
'江岸区': ['江岸'],
@@ -170,3 +200,40 @@ def get_inpatient_data() -> pd.DataFrame:
"""Return the cached inpatient dataframe"""
load_data()
return _cache["inpatient"] # type: ignore[return-value]
def load_cases_by_district_daily() -> pd.DataFrame:
"""Load processed/cases_by_district_daily.parquet with districts normalized.
The on-disk parquet carries both bare and 区-suffixed spellings of each
district (26 labels = 13 districts × 2 spellings), so any groupby on the
raw `district` column double-counts. This is the single data-access
boundary: it normalizes labels to the canonical 13 and re-aggregates
(sum of outpatient_count / inpatient_count / total_cases per
normalized district + date), so every downstream consumer
(analysis / grid / insights) sees clean, deduped 13-district data.
Returns a copy with columns [date, district, outpatient_count,
inpatient_count, total_cases]. Raises FileNotFoundError if the parquet
is missing (callers handle this as they did before).
"""
path = PROCESSED_DIR / "cases_by_district_daily.parquet"
cached = _cache.get("cases_by_district_daily")
if cached is not None:
return cast(pd.DataFrame, cached).copy()
with _load_lock:
cached = _cache.get("cases_by_district_daily")
if cached is not None:
return cast(pd.DataFrame, cached).copy()
df = pd.read_parquet(path)
df["district"] = df["district"].map(normalize_district)
agg = (
df.groupby(["date", "district"], as_index=False)[
["outpatient_count", "inpatient_count", "total_cases"]
]
.sum()
)
_cache["cases_by_district_daily"] = agg # type: ignore[assignment]
return agg.copy()

View File

@@ -13,7 +13,7 @@ from logging_config import setup_logging
from middleware.request_logger import RequestLoggerMiddleware
from auth.router import router as auth_router
from auth.service import seed_default_admin
from routers import risk, alerts, analysis, insights, reports, cases, geocoded, grid, chat, environment
from routers import risk, alerts, analysis, insights, reports, cases, geocoded, grid, chat, environment, statistics
setup_logging()
@@ -59,6 +59,7 @@ app.include_router(geocoded.router)
app.include_router(grid.router)
app.include_router(chat.router)
app.include_router(environment.router)
app.include_router(statistics.router)
@app.get("/")

View File

@@ -13,6 +13,7 @@ from config import DATA_DIR, ALERT_P1_RISK, ALERT_P2_RISK, WUHAN_BOUNDS, LAT_STE
from models import Alert, AlertResponse
from utils.date_helpers import get_latest_date, validate_date_format
from utils.risk import risk_value_to_level
from utils.district_lookup import district_for_grid
router = APIRouter(prefix="/api/alerts", tags=["alerts"])
@@ -95,13 +96,14 @@ def _generate_alerts_cached(date: str) -> List[Alert]:
lat, lon = grid_id_to_center(grid_id)
risk_level = risk_value_to_level(max_risk)
district = district_for_grid(grid_id)
alerts.append(
Alert(
alert_id=f"alert_{date}_{grid_id}",
grid_id=grid_id,
region="武汉市",
street=f"Grid {grid_id}",
region=district,
street=grid_id,
latitude=lat,
longitude=lon,
risk_value=max_risk,

View File

@@ -12,10 +12,13 @@ import pandas as pd
from pydantic import BaseModel, Field
from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT, WUHAN_BOUNDS, LAT_STEP, LON_STEP
from data.case_loader import load_cases_by_district_daily
from utils.date_helpers import get_latest_date
from utils.geojson import parse_geojson_file, load_districts
from utils.geo import point_in_polygon
from utils.risk import calculate_trend
from utils.daily_risk_avg import daily_avg_risk
from utils.district_lookup import grid_district_lookup
router = APIRouter(prefix="/api/analysis", tags=["analysis"])
@@ -82,22 +85,10 @@ async def get_trend(days: int = Query(default=7, ge=1, le=30)):
for i in range(days):
date = base_date - timedelta(days=days - 1 - i)
date_str = date.strftime("%Y%m%d")
filepath = DATA_DIR / f"risk_{date_str}.geojson"
if filepath.exists():
grids = parse_geojson_file(filepath)
if grids:
avg_risk = sum(g["risk_value"] for g in grids) / len(grids)
values.append(round(avg_risk, 4))
else:
values.append(0)
else:
values.append(0)
# Disk+memory cached mean — avoids re-parsing ~45MB GeoJSON every request
values.append(daily_avg_risk(date_str))
dates.append(date.strftime("%Y-%m-%d"))
# Preserve the full requested date range: a "7天" request must return 7
# contiguous points. Days with no geojson (or empty grids) stay 0 rather
# than being dropped, which previously produced fewer, non-contiguous points.
trend_direction = calculate_trend(values)
return TrendResponse(
@@ -109,15 +100,8 @@ async def get_trend(days: int = Query(default=7, ge=1, le=30)):
@lru_cache(maxsize=1)
def _grid_district_lookup() -> dict:
"""Map precomputed r{row}_c{col} grid id -> district name (loaded once)."""
path = PROJECT_ROOT / "processed" / "grid_district_mapping.parquet"
if not path.exists():
return {}
df = pd.read_parquet(path)
# Some grids have a null district_name; drop them so the lookup only ever
# returns valid strings (missing keys fall back to "其他").
df = df.dropna(subset=["district_name"])
return dict(zip(df["grid_id"].astype(str), df["district_name"].astype(str)))
"""Backward-compatible alias — prefer utils.district_lookup."""
return grid_district_lookup()
@lru_cache(maxsize=1)
@@ -179,18 +163,16 @@ def _district_avg_aqi() -> dict:
def _district_total_cases() -> dict:
"""Real total recorded cases per district from cases_by_district_daily.
District labels in the case file are inconsistent ("武昌" vs "武昌区"),
so names are normalized by stripping the "" suffix and summed, then
keyed by the canonical mapping name (with ""). Returns {district: cases}.
District labels are normalized to the canonical 13 区-suffixed names at the
data-access boundary (data.case_loader), so this is a plain per-district
sum. Returns {district: cases}.
"""
path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
if not path.exists():
try:
df = load_cases_by_district_daily()
except FileNotFoundError:
return {}
df = pd.read_parquet(path, columns=["district", "total_cases"])
df = df.copy()
df["base"] = df["district"].str.replace("", "", regex=False)
by_base = df.groupby("base")["total_cases"].sum()
return {f"{base}": int(v) for base, v in by_base.items()}
by_district = df.groupby("district")["total_cases"].sum()
return {str(d): int(v) for d, v in by_district.items()}
@lru_cache(maxsize=8)

View File

@@ -21,6 +21,7 @@ from models import (
MultiDayPredictionRequest,
MultiDayPredictionResponse,
)
from data.case_loader import load_cases_by_district_daily
router = APIRouter(prefix="/api", tags=["grid"])
@@ -43,14 +44,13 @@ def _compute_historical_aggregation(
) -> HistoricalAggregationResponse:
"""Run the full pandas aggregation pipeline (called in thread pool)."""
try:
cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet")
cases_df = load_cases_by_district_daily()
except FileNotFoundError:
return HistoricalAggregationResponse(
aggregations=[], total_records=0,
date_range=(start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")),
timestamp=datetime.now().isoformat(),
)
cases_df = cases_df.copy()
cases_df['date'] = pd.to_datetime(cases_df['date'])
filtered_cases = cases_df[
@@ -196,7 +196,7 @@ def _grids_geojson_body(date: str, district: Optional[str], risk_level: Optional
if district:
merged = merged[merged['district_name'].str.contains(district.replace('', ''), na=False, regex=False)]
cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet").copy()
cases_df = load_cases_by_district_daily()
cases_df['date'] = pd.to_datetime(cases_df['date']).dt.strftime('%Y-%m-%d')
cases_df = cases_df[cases_df['date'] == date]
@@ -364,7 +364,7 @@ def _compute_grid_history(grid_id: str, days: int) -> dict:
district = grid_info.iloc[0]['district_name']
cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet")
cases_df = load_cases_by_district_daily()
cases_df['date'] = pd.to_datetime(cases_df['date'])
end_date = datetime.now()

View File

@@ -11,6 +11,7 @@ from pydantic import BaseModel, Field
from typing import Dict, List, Literal
from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT
from data.case_loader import load_cases_by_district_daily
from models import (
InsightsResponse,
InsightTrend,
@@ -491,22 +492,22 @@ async def get_insights_cards():
cases_path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
if cases_path.exists():
cases_df = _cached_parquet(str(cases_path))
# Districts already normalized to the canonical 13 区-suffixed names.
cases_df = load_cases_by_district_daily()
cases_df["date"] = pd.to_datetime(cases_df["date"])
latest_case_date = cases_df["date"].max()
latest_cases = cases_df[cases_df["date"] == latest_case_date].copy()
latest_cases["base_district"] = latest_cases["district"].str.replace("", "")
district_daily = latest_cases.groupby("base_district")["total_cases"].sum().sort_values(ascending=False)
latest_cases = cases_df[cases_df["date"] == latest_case_date]
district_daily = latest_cases.groupby("district")["total_cases"].sum().sort_values(ascending=False)
total_daily = int(district_daily.sum())
top_name = district_daily.index[0]
top_val = int(district_daily.iloc[0])
num_districts = len(district_daily)
week_ago = latest_case_date - pd.Timedelta(days=6)
week_cases = cases_df[cases_df["date"] >= week_ago].copy()
week_cases["base_district"] = week_cases["district"].str.replace("", "")
week_cases = cases_df[cases_df["date"] >= week_ago]
daily_totals = week_cases.groupby("date")["total_cases"].sum()
avg_daily = int(daily_totals.mean())
week_district = week_cases.groupby("base_district")["total_cases"].sum().sort_values(ascending=False)
week_district = week_cases.groupby("district")["total_cases"].sum().sort_values(ascending=False)
week_top_val = int(week_district.iloc[0])
date_str = latest_case_date.strftime("%m月%d")
@@ -515,8 +516,8 @@ async def get_insights_cards():
title=f"日病例统计 ({date_str})",
description=(
f"最近统计日({date_str})全市{num_districts}个区共记录{total_daily}例儿童呼吸道疾病病例,"
f"{top_name}{top_val}例为当日最高。近7日日均{avg_daily}例,"
f"{week_district.index[0]}累计{week_top_val}例居首。"
f"{top_name}{top_val}例为当日最高。近7日日均{avg_daily}例,"
f"{week_district.index[0]}累计{week_top_val}例居首。"
),
type="warning",
metric="日病例",

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

View 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

View File

@@ -0,0 +1,81 @@
"""Fast daily city-wide mean risk_1d with on-disk cache.
Avoids re-parsing ~45MB GeoJSON on every /analysis/trend request.
"""
from __future__ import annotations
import json
import logging
from functools import lru_cache
from pathlib import Path
from config import DATA_DIR, PROJECT_ROOT
logger = logging.getLogger(__name__)
_CACHE_PATH = PROJECT_ROOT / "processed" / "daily_avg_risk.json"
def _read_disk_cache() -> dict[str, float]:
if not _CACHE_PATH.exists():
return {}
try:
raw = json.loads(_CACHE_PATH.read_text(encoding="utf-8"))
return {str(k): float(v) for k, v in raw.items()}
except (OSError, json.JSONDecodeError, TypeError, ValueError):
return {}
def _write_disk_cache(cache: dict[str, float]) -> None:
try:
_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
_CACHE_PATH.write_text(
json.dumps(cache, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8",
)
except OSError as e:
logger.warning("Failed to persist daily avg risk cache: %s", e)
def _compute_mean_risk_1d(filepath: Path) -> float:
"""Parse one risk GeoJSON and return mean risk_1d (0 if empty/missing)."""
try:
with open(filepath, "r", encoding="utf-8") as f:
geojson = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.warning("Failed to parse %s: %s", filepath, e)
return 0.0
total = 0.0
n = 0
for feature in geojson.get("features", []):
props = feature.get("properties") or {}
r = props.get("risk_1d")
if r is None:
continue
total += float(r)
n += 1
return round(total / n, 4) if n else 0.0
@lru_cache(maxsize=64)
def daily_avg_risk(date_yyyymmdd: str) -> float:
"""Mean risk_1d for YYYYMMDD. Memory + disk cached."""
disk = _read_disk_cache()
if date_yyyymmdd in disk:
return disk[date_yyyymmdd]
filepath = DATA_DIR / f"risk_{date_yyyymmdd}.geojson"
if not filepath.exists():
return 0.0
avg = _compute_mean_risk_1d(filepath)
disk[date_yyyymmdd] = avg
_write_disk_cache(disk)
return avg
def warm_daily_avg_risk(dates: list[str]) -> None:
"""Precompute missing dates into the disk cache (blocking)."""
for d in dates:
daily_avg_risk(d)

View File

@@ -0,0 +1,21 @@
"""Map 100m grid_id (r{row}_c{col}) → Wuhan district name."""
from functools import lru_cache
import pandas as pd
from config import PROJECT_ROOT
@lru_cache(maxsize=1)
def grid_district_lookup() -> dict[str, str]:
"""Loaded once from processed/grid_district_mapping.parquet."""
path = PROJECT_ROOT / "processed" / "grid_district_mapping.parquet"
if not path.exists():
return {}
df = pd.read_parquet(path)
df = df.dropna(subset=["district_name"])
return dict(zip(df["grid_id"].astype(str), df["district_name"].astype(str)))
def district_for_grid(grid_id: str, default: str = "其他") -> str:
return grid_district_lookup().get(grid_id, default)

View File

@@ -1,32 +1,26 @@
FROM python:3.11-slim
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \
libpq-dev curl \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN groupadd --gid 1000 appgroup && \
useradd --uid 1000 --gid appgroup --shell /bin/bash --create-home appuser
WORKDIR /home/appuser
WORKDIR /app
# Copy requirements and install dependencies
COPY --chown=appuser:appgroup requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir -r requirements.txt \
-i https://pypi.tuna.tsinghua.edu.cn/simple
# Copy backend code
COPY --chown=appuser:appgroup . .
# Switch to non-root user
USER appuser
# Expose port
ENV CBPOA_ROOT=/data
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
CMD curl -f http://localhost:8000/docs || exit 1
# Run uvicorn
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

17
deploy/cbpoa-api.service Normal file
View File

@@ -0,0 +1,17 @@
[Unit]
Description=CBPOA FastAPI backend
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/cbpoa/backend
Environment=PATH=/opt/cbpoa/backend/.venv/bin:/usr/bin
EnvironmentFile=-/opt/cbpoa/backend/.env
ExecStart=/opt/cbpoa/backend/.venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000 --workers 1
Restart=on-failure
RestartSec=5
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target

View File

@@ -1,83 +1,47 @@
version: '3.8'
services:
postgres:
image: postgis/postgis:15-3.3
container_name: wuhan_postgres
environment:
POSTGRES_DB: wuhan_disease
POSTGRES_USER: wuhan_user
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-wuhan_password}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U wuhan_user -d wuhan_disease"]
interval: 5s
timeout: 5s
retries: 5
networks:
- wuhan_network
backend:
build:
context: ../backend
dockerfile: Dockerfile
container_name: wuhan_backend
dockerfile: ../deploy/backend/Dockerfile
container_name: cbpoa_backend
environment:
DATABASE_URL: postgresql://wuhan_user:password@postgres:5432/wuhan_disease
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
depends_on:
postgres:
condition: service_healthy
CBPOA_ROOT: /data
CORS_ORIGINS: "*"
volumes:
- ../outputs:/data/outputs:ro
- ../processed:/data/processed:ro
- ../Datas:/data/Datas:ro
ports:
- "8000:8000"
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8000/docs || exit 1"]
interval: 10s
interval: 15s
timeout: 5s
retries: 5
start_period: 30s
start_period: 40s
restart: unless-stopped
networks:
- wuhan_network
- cbpoa_net
frontend:
build:
context: ../frontend
dockerfile: Dockerfile
container_name: wuhan_frontend
environment:
VITE_API_URL: http://localhost:8000
dockerfile: ../deploy/frontend/Dockerfile
container_name: cbpoa_frontend
depends_on:
- backend
backend:
condition: service_healthy
ports:
- "3000:80"
- "80:80"
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:80 || exit 1"]
interval: 10s
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost/ || exit 1"]
interval: 15s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- wuhan_network
# jupyter:
# image: jupyter/scipy-notebook:latest
# container_name: wuhan_jupyter
# ports:
# - "8888:8888"
# volumes:
# - ../processed:/home/jovyan/processed
# - ../Datas:/home/jovyan/Datas
# networks:
# - wuhan_network
volumes:
postgres_data:
driver: local
- cbpoa_net
networks:
wuhan_network:
driver: bridge
cbpoa_net:
driver: bridge

View File

@@ -3,4 +3,4 @@ node_modules
*.md
tests
.env*
dist
dist

View File

@@ -5,16 +5,11 @@ FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package.json pnpm-lock.yaml ./
# Install dependencies (using pnpm since lock file is pnpm-lock.yaml)
RUN npm install -g pnpm && pnpm install --frozen-lockfile
# Copy source code
COPY . .
# Build the application
ENV VITE_API_URL=/api
RUN pnpm run build
# =============================================================================
@@ -22,15 +17,10 @@ RUN pnpm run build
# =============================================================================
FROM nginx:alpine AS production
# Copy custom nginx config for SPA routing
COPY --from=builder /app/nginx.conf /etc/nginx/conf.d/default.conf
# Copy built assets from builder
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
# Expose port 80
EXPOSE 80
# Health check for nginx
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-redirect --quiet --tries=1 --spider http://localhost/ || exit 1
CMD wget --no-redirect --quiet --tries=1 --spider http://localhost/ || exit 1

37
deploy/nginx-cbpoa.conf Normal file
View File

@@ -0,0 +1,37 @@
server {
listen 80;
server_name _;
root /var/www/cbpoa;
index index.html;
client_max_body_size 20m;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/geo+json;
gzip_min_length 1000;
location /api/ {
proxy_pass http://127.0.0.1:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
# Gaode basemap proxy (same as Vite /basemap-gaode)
location ~ ^/basemap-gaode/(\d+)/(\d+)/(\d+) {
proxy_pass https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&z=$1&x=$2&y=$3;
proxy_set_header Host webrd01.is.autonavi.com;
proxy_ssl_server_name on;
proxy_hide_header Set-Cookie;
expires 1d;
add_header Cache-Control "public";
}
location / {
try_files $uri $uri/ /index.html;
}
}

15
frontend/.env.example Normal file
View File

@@ -0,0 +1,15 @@
# Frontend env (Vite)
# API (default: Vite proxy /api → :8000)
# VITE_API_URL=/api
# 天地图个人密钥 https://console.tianditu.gov.cn/
# 不设则用高德矢量底图GeoScene CN 仅有 tianditu-* 命名底图,且内置 tk 会 418
# VITE_TIANDITU_TK=
# GeoScene Enterprise (optional — unused in POC)
# VITE_GEOSCENE_PORTAL_URL=https://cn18:7443/geoscene
# VITE_LAYER_DISTRICTS_URL=
# VITE_LAYER_RISK_URL=
# VITE_LAYER_CASES_URL=
# VITE_GEOSCENE_WEBMAP_ID=

View File

@@ -1,19 +1,21 @@
# Frontend — React + TypeScript + Leaflet
# Frontend — React + TypeScript + GeoScene Maps SDK
## Stack
- React 18, TypeScript 5, Vite 5
- Tailwind CSS, Recharts, Zustand (state), Axios
- Leaflet / react-leaflet (maps)
- `@geoscene/core` (Maps SDK for JavaScript) — POC uses Tianditu basemap + local GeoJSON / FastAPI risk tiles
- Playwright (e2e tests)
## Structure
```
frontend/src/
main.tsx # Entry point
main.tsx # Entry (+ GeoScene theme CSS)
App.tsx # Router setup
geoscene/ # MapView helpers + layer factories
components/ # Reusable UI (maps, charts, nav)
legacy/ # Unused former Leaflet map experiments
pages/ # Route-level views
services/api.ts # Axios client with TTL cache + request dedup
stores/ # Zustand stores
@@ -28,15 +30,21 @@ frontend/src/
## Patterns
- Components: PascalCase, one per file, default export
- API calls: use `services/api.ts` wrappers (`riskApi`, `alertApi`, `caseApi`, `gridApi`) — they handle caching and request dedup
- State: Zustand stores in `stores/`, typed with TypeScript interfaces from `types/`
- Styling: Tailwind utility classes, no CSS modules
- Map components: imperative `@geoscene/core` via `createMapView` — do not pass MapView instances between components
- Coordinates: GeoScene uses `[longitude, latitude]`
- API calls: use `services/api.ts` wrappers (`riskApi`, `alertApi`, `caseApi`, `gridApi`)
- State: Zustand stores in `stores/`
- Styling: Tailwind utility classes
## Env (optional Enterprise later)
See `.env.example` for `VITE_GEOSCENE_PORTAL_URL` / `VITE_LAYER_*`. POC runs without them.
## Running
```bash
cd frontend
pnpm dev # localhost:5173, proxies /api → localhost:8000
pnpm dev # localhost:3000, proxies /api → localhost:8000
pnpm build # tsc + vite build → dist/
```
@@ -47,3 +55,4 @@ pnpm build # tsc + vite build → dist/
- Don't mix data fetching with presentation — fetch in pages, render in components
- Don't inline styles when Tailwind classes work
- Don't create god components (>200 lines) — extract sub-components
- Don't reintroduce Leaflet or role/perspective switchers

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

View 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/);
});
});

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

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

View File

@@ -1,242 +1,291 @@
/**
* US-007 + US-008: E2E user flow and UI state tests.
* Simulates real user workflows through the CBPOA system.
* Phase-1 acceptance tests: URL-based navigation, responsive layout, and core user flows.
* Rewrites the previous click-nav suite for react-router v6 URL navigation.
*
* Auth strategy: seed localStorage['cbpoa_token'] via addInitScript (App.tsx gates on
* token presence only; no server validation). Backend is mocked via page.route so the
* suite runs hermetically without a live :8000 backend.
*/
import { test, expect } from '@playwright/test';
import { test, expect, Page } from '@playwright/test';
import { TESTIDS } from '../src/utils/testids';
const BASE_URL = 'http://localhost:3000';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
test.describe('认证流程 (Authentication Flow)', () => {
test('显示登录页面', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(1000);
// Should see login form or app (if cached token)
const isLogin = await page.locator('input').count();
const isApp = await page.locator('nav').count();
expect(isLogin > 0 || isApp > 0).toBeTruthy();
/** Seed auth token and mock all /api/** calls before each page load. */
async function seedAuthAndMockApi(page: Page) {
// Prevent login gate from appearing.
await page.addInitScript(() => {
localStorage.setItem('cbpoa_token', 'e2e-test-token');
});
test('登录表单可交互', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(1000);
// Mock backend responses so the suite is hermetic — no live :8000 required.
// Each response must match the TypeScript interface shape; returning {} causes
// pages to throw when accessing expected array properties.
await page.route('/api/**', (route) => {
const url = route.request().url();
const inputs = page.locator('input');
const count = await inputs.count();
if (count >= 2) {
// Login page is shown
await inputs.first().fill('admin');
await inputs.nth(1).fill('admin123');
const loginBtn = page.locator('button[type="submit"], button:has-text("登录"), button:has-text("Login")');
const btnCount = await loginBtn.count();
if (btnCount > 0) {
await loginBtn.first().click();
await page.waitForTimeout(2000);
}
if (url.includes('/alerts')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ alerts: [], total: 0 }),
});
return;
}
// If no inputs, user is already logged in (token in localStorage)
});
});
test.describe('监测面板 (Monitoring Dashboard)', () => {
test('面板加载并显示统计卡片', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(3000);
// Should show monitoring page by default
const statCards = page.locator('[class*="stat"], [class*="card"], [class*="Stat"]');
const cardsCount = await statCards.count();
// Should see some content
const bodyText = await page.textContent('body');
expect(bodyText).toBeTruthy();
});
test('时间线控件可交互', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(3000);
// Look for timeline controls
const playButton = page.locator('button:has-text("播放"), button[title*="play" i], button[class*="play" i]');
const prevButton = page.locator('button:has-text("前一天"), button[title*="prev" i]');
const nextButton = page.locator('button:has-text("后一天"), button[title*="next" i]');
if (await playButton.count() > 0) {
await playButton.first().click();
await page.waitForTimeout(1000);
if (url.includes('/history/aggregated')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ aggregations: [], total_records: 0 }),
});
return;
}
});
test('疾病筛选器可用', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(3000);
const selects = page.locator('select, [role="combobox"], [class*="select" i], [class*="filter" i]');
const count = await selects.count();
expect(count >= 0).toBeTruthy();
});
});
test.describe('预警面板 (Alerts Dashboard)', () => {
test('导航到预警面板', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
// Navigate to alerts - click sidebar link
const alertsLink = page.locator('a[href*="alert" i], button:has-text("预警"), button:has-text("告警"), span:has-text("预警"), span:has-text("告警")');
if (await alertsLink.count() > 0) {
await alertsLink.first().click();
await page.waitForTimeout(2000);
if (url.includes('/grids')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
});
return;
}
});
test('预警列表加载', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const alertsLink = page.locator('a[href*="alert" i], button:has-text("预警"), span:has-text("预警")');
if (await alertsLink.count() > 0) {
await alertsLink.first().click();
await page.waitForTimeout(3000);
const bodyText = await page.textContent('body');
expect(bodyText).toBeTruthy();
// DemographicsResponse — used by DemographicAnalysis page.
if (url.includes('/cases/demographics')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
age_distribution: [],
gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
age_diagnosis_matrix: [],
}),
});
return;
}
});
});
test.describe('趋势分析 (Trend Analysis)', () => {
test('导航到趋势分析页面', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const trendLink = page.locator('button:has-text("趋势"), span:has-text("趋势"), a[href*="trend" i]');
if (await trendLink.count() > 0) {
await trendLink.first().click();
await page.waitForTimeout(2000);
// DiseaseAnalysis calls: diagnosis-distribution, seasonality, districts.
if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([]),
});
return;
}
});
test('趋势图渲染', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const trendLink = page.locator('button:has-text("趋势"), span:has-text("趋势")');
if (await trendLink.count() > 0) {
await trendLink.first().click();
await page.waitForTimeout(3000);
// Recharts renders SVG charts
const svgCharts = page.locator('svg.recharts-surface');
const chartCount = await svgCharts.count();
expect(chartCount >= 0).toBeTruthy();
if (url.includes('/cases/districts') || url.includes('/districts')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([]),
});
return;
}
});
});
test.describe('区县对比 (District Comparison)', () => {
test('导航到区县对比页面', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const districtLink = page.locator('button:has-text("区县"), button:has-text("对比"), span:has-text("区县")');
if (await districtLink.count() > 0) {
await districtLink.first().click();
await page.waitForTimeout(2000);
// Trend / time-series endpoints.
if (url.includes('/cases/trend') || url.includes('/cases')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], total: 0 }),
});
return;
}
});
});
test.describe('报告中心 (Reports Center)', () => {
test('导航到报告中心', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const reportsLink = page.locator('button:has-text("报告"), span:has-text("报告"), a[href*="report" i]');
if (await reportsLink.count() > 0) {
await reportsLink.first().click();
await page.waitForTimeout(2000);
}
});
test('报告列表加载', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const reportsLink = page.locator('button:has-text("报告"), span:has-text("报告")');
if (await reportsLink.count() > 0) {
await reportsLink.first().click();
await page.waitForTimeout(3000);
const bodyText = await page.textContent('body');
expect(bodyText).toBeTruthy();
}
});
});
test.describe('UI 状态与错误处理 (UI States & Error Handling)', () => {
test('页面加载显示加载指示器而非白屏', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(500);
const bodyHTML = await page.innerHTML('body');
// Should have some content, even during loading
expect(bodyHTML.length).toBeGreaterThan(0);
});
test('侧边栏导航切换页面正常', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const navLinks = page.locator('nav a, nav button, [class*="side" i] a, [class*="side" i] button');
const count = await navLinks.count();
if (count >= 2) {
await navLinks.first().click();
await page.waitForTimeout(1000);
await navLinks.nth(1).click();
await page.waitForTimeout(1000);
}
});
test('未出现明显 console 报错', async ({ page }) => {
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
errors.push(msg.text());
}
});
page.on('pageerror', (err) => {
errors.push(err.message);
// Default fallback — return a safe empty object.
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], items: [], total: 0 }),
});
});
}
await page.goto(BASE_URL);
await page.waitForTimeout(3000);
// ---------------------------------------------------------------------------
// URL-based navigation (react-router v6)
// ---------------------------------------------------------------------------
const filtered = errors.filter(
(e) => !e.includes('favicon') && !e.includes('404') && !e.includes('OLMap')
test.describe('URL-based navigation', () => {
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('deep-link: /analysis/disease mounts page-disease directly', async ({ page }) => {
await page.goto('/analysis/disease');
await expect(page.locator(`[data-testid="${TESTIDS.pageDisease}"]`)).toBeVisible();
});
test('refresh preserves page: reload on /analysis/disease keeps URL and mounts page-disease', async ({
page,
}) => {
await page.goto('/analysis/disease');
await expect(page.locator(`[data-testid="${TESTIDS.pageDisease}"]`)).toBeVisible();
await page.reload();
await expect(page).toHaveURL(/\/analysis\/disease/);
await expect(page.locator(`[data-testid="${TESTIDS.pageDisease}"]`)).toBeVisible();
});
test('browser back: from /analysis/trend back to /monitoring restores page-monitoring', async ({
page,
}) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
await page.goto('/analysis/trend');
await expect(page.locator(`[data-testid="${TESTIDS.pageTrend}"]`)).toBeVisible();
await page.goBack();
await expect(page).toHaveURL(/\/monitoring/);
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
});
test('NavLink click updates URL to /alerts and mounts page-alerts', async ({ page }) => {
// Start on /monitoring. The SideNav collapses all modules except the active one,
// so nav-alerts (inside the "预警" module) is hidden behind a collapsed section.
// We must expand the "预警" module first by clicking its header button.
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
// Expand the "预警" module section so nav-alerts NavLink becomes visible.
// Both sidebar-rail and app-drawer render a SideNav; scope to sidebar-rail to avoid
// strict-mode ambiguity (the app-drawer's copy is also in the DOM but off-screen).
await page
.locator(`[data-testid="${TESTIDS.sidebarRail}"] button`)
.filter({ hasText: '预警' })
.click();
await expect(
page.locator(`[data-testid="${TESTIDS.sidebarRail}"] [data-testid="${TESTIDS.navAlerts}"]`)
).toBeVisible();
await page
.locator(`[data-testid="${TESTIDS.sidebarRail}"] [data-testid="${TESTIDS.navAlerts}"]`)
.click();
await expect(page).toHaveURL(/\/alerts/);
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
});
test('root / redirects to /monitoring', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveURL(/\/monitoring/);
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
});
test('unknown path redirects to /monitoring', async ({ page }) => {
await page.goto('/does-not-exist');
await expect(page).toHaveURL(/\/monitoring/);
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
});
});
// ---------------------------------------------------------------------------
// Responsive layout
// ---------------------------------------------------------------------------
test.describe('Responsive layout — mobile @375px', () => {
test.use({ viewport: { width: 375, height: 812 } });
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('hamburger is visible and sidebar-rail is hidden at 375px', async ({ page }) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.hamburger}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.sidebarRail}"]`)).not.toBeVisible();
});
test('tapping hamburger slides app-drawer into viewport', async ({ page }) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
// Drawer should be off-screen (translate-x-full) before toggle.
const drawer = page.locator(`[data-testid="${TESTIDS.appDrawer}"]`);
await expect(drawer).not.toBeInViewport();
await page.locator(`[data-testid="${TESTIDS.hamburger}"]`).click();
// After toggle, drawer slides in and becomes visible in viewport.
await expect(drawer).toBeInViewport();
});
test('no horizontal scroll on default route at 375px', async ({ page }) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
const noHorizontalScroll = await page.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth
);
expect(filtered).toHaveLength(0);
expect(noHorizontalScroll).toBe(true);
});
});
test.describe('响应式布局 (Responsive Layout)', () => {
test('移动端视口下不崩溃', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
test.describe('Responsive layout — desktop @1280px', () => {
test.use({ viewport: { width: 1280, height: 800 } });
const bodyText = await page.textContent('body');
expect(bodyText).toBeTruthy();
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('平板视口下正常显示', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
test('sidebar-rail is visible and hamburger is hidden at 1280px', async ({ page }) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
const bodyText = await page.textContent('body');
expect(bodyText).toBeTruthy();
await expect(page.locator(`[data-testid="${TESTIDS.sidebarRail}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.hamburger}"]`)).not.toBeVisible();
});
});
// ---------------------------------------------------------------------------
// Core page loading
// ---------------------------------------------------------------------------
test.describe('Core pages load via URL nav', () => {
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('/monitoring loads page-monitoring', async ({ page }) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
});
test('/alerts loads page-alerts', async ({ page }) => {
await page.goto('/alerts');
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
});
test('/analysis/trend loads page-trend', async ({ page }) => {
await page.goto('/analysis/trend');
await expect(page.locator(`[data-testid="${TESTIDS.pageTrend}"]`)).toBeVisible();
});
test('/analysis/district loads page-district', async ({ page }) => {
await page.goto('/analysis/district');
await expect(page.locator(`[data-testid="${TESTIDS.pageDistrict}"]`)).toBeVisible();
});
test('/analysis/reports loads page-reports', async ({ page }) => {
await page.goto('/analysis/reports');
await expect(page.locator(`[data-testid="${TESTIDS.pageReports}"]`)).toBeVisible();
});
test('/analysis/demographics loads page-demographics', async ({ page }) => {
await page.goto('/analysis/demographics');
await expect(page.locator(`[data-testid="${TESTIDS.pageDemographics}"]`)).toBeVisible();
});
test('/analysis/environment loads page-environment', async ({ page }) => {
await page.goto('/analysis/environment');
await expect(page.locator(`[data-testid="${TESTIDS.pageEnvironment}"]`)).toBeVisible();
});
});

View File

@@ -4,11 +4,10 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>武汉儿童呼吸疾病风险预测平台</title>
<title>CBPOA · 武汉儿童呼吸疾病风险评估系统</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Noto+Sans+SC:wght@400;500;600&family=Source+Sans+Pro:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500;600&family=Noto+Sans+SC:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>

35
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,35 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 20m;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/geo+json;
gzip_min_length 1000;
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
location ~ ^/basemap-gaode/(\d+)/(\d+)/(\d+) {
proxy_pass https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&z=$1&x=$2&y=$3;
proxy_set_header Host webrd01.is.autonavi.com;
proxy_ssl_server_name on;
proxy_hide_header Set-Cookie;
expires 1d;
add_header Cache-Control "public";
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -9,12 +9,12 @@
"preview": "vite preview"
},
"dependencies": {
"@geoscene/core": "4.32.10",
"axios": "^1.6.7",
"leaflet": "^1.9.4",
"lucide-react": "^0.330.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.30.4",
"recharts": "^2.12.0",
"zustand": "^4.5.0"
},
@@ -22,7 +22,6 @@
"@playwright/test": "^1.59.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^14.3.1",
"@types/leaflet": "^1.9.8",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",

View File

@@ -14,6 +14,16 @@ export default defineConfig({
projects: [
{
name: 'chromium',
// Functional suite. Exclude the throttled perf spec so emulated Fast-3G
// latency never bleeds into (or slows) the normal acceptance run.
testIgnore: /perf\.spec\.ts/,
use: { ...devices['Desktop Chrome'] },
},
{
// Dedicated perf project — only perf.spec.ts runs here, under CDP network
// throttling. Kept separate so functional and perf measurements don't mix.
name: 'perf',
testMatch: /perf\.spec\.ts/,
use: { ...devices['Desktop Chrome'] },
},
],

517
frontend/pnpm-lock.yaml generated
View File

@@ -8,12 +8,12 @@ importers:
.:
dependencies:
'@geoscene/core':
specifier: 4.32.10
version: 4.32.10
axios:
specifier: ^1.6.7
version: 1.15.2
leaflet:
specifier: ^1.9.4
version: 1.9.4
lucide-react:
specifier: ^0.330.0
version: 0.330.0(react@18.3.1)
@@ -23,9 +23,9 @@ importers:
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
react-leaflet:
specifier: ^4.2.1
version: 4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-router-dom:
specifier: ^6.30.4
version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
recharts:
specifier: ^2.12.0
version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -42,9 +42,6 @@ importers:
'@testing-library/react':
specifier: ^14.3.1
version: 14.3.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/leaflet':
specifier: ^1.9.8
version: 1.9.21
'@types/react':
specifier: ^18.2.55
version: 18.3.28
@@ -85,6 +82,18 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
'@arcgis/lumina@4.34.9':
resolution: {integrity: sha512-efqO+SwR+1IYf29AATh1l2FUeypRyRINTBNkaJY+KkaFe+8gqSJ45qOmputhyzF5WTRDb7WhOYgnChjp6VYPpA==}
peerDependencies:
'@lit/context': ^1.1.5
lit: ^3.3.0
peerDependenciesMeta:
'@lit/context':
optional: true
'@arcgis/toolkit@4.34.9':
resolution: {integrity: sha512-wFST+eVnCwmg9NyICVyn9bsBnR+TlWklsGqG3L7xqSTgfXo6TuCThE7wtTb8xWxsTBkGvImqMUgpgLuwQuTQ1g==}
'@asamuzakjp/css-color@3.2.0':
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
@@ -341,6 +350,32 @@ packages:
cpu: [x64]
os: [win32]
'@esri/arcgis-html-sanitizer@4.1.0':
resolution: {integrity: sha512-einEveDJ/k1180NOp78PB/4Hje9eBy3dyOGLLtLn6bSkizpUfCwuYBIXOA7Y3F/k/BsTQXgKqUVwQ0eiscWMdA==}
engines: {node: '>=18.0.0'}
'@esri/calcite-components@3.3.3':
resolution: {integrity: sha512-tw+EfJ3pb+Odj71W6E9GUkm8rMbNxfW1KeiI8GgsKDzhr39hMKwY+zYYFFYuO0FONxWGvAB+B8yqB0NvH7WeHw==}
'@esri/calcite-ui-icons@4.3.0':
resolution: {integrity: sha512-iOOuRurpjFxFVw6+aXW2JpSkRBrdOpBcbdibfPOmSPqMd1aoHBtYmYXetKoH9vfrXoBiPyO2PkDnczhsu/N9IA==}
hasBin: true
'@floating-ui/core@1.8.0':
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
'@floating-ui/dom@1.8.0':
resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==}
'@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@geoscene/core@4.32.10':
resolution: {integrity: sha512-suiMmwX2dGbAfRlu0KRfAzwQOZrVz6eU/YU0kAsU4VmRSZxCir+iSnvZQzoFAdTmoiivKMRSfnKwSeOqIN4ptg==}
'@interactjs/types@1.10.27':
resolution: {integrity: sha512-BUdv0cvs4H5ODuwft2Xp4eL8Vmi3LcihK42z0Ft/FbVJZoRioBsxH+LlsBdK4tAie7PqlKGy+1oyOncu1nQ6eA==}
'@jest/schemas@29.6.3':
resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -361,6 +396,12 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lit-labs/ssr-dom-shim@1.6.0':
resolution: {integrity: sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==}
'@lit/reactive-element@2.1.2':
resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -373,17 +414,20 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@open-wc/dedupe-mixin@1.4.0':
resolution: {integrity: sha512-Sj7gKl1TLcDbF7B6KUhtvr+1UCxdhMbNY5KxdU5IfMFWqL8oy1ZeAcCANjoB1TL0AJTcPmcCFsCbHf8X2jGDUA==}
'@playwright/test@1.59.1':
resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
engines: {node: '>=18'}
hasBin: true
'@react-leaflet/core@2.1.0':
resolution: {integrity: sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==}
peerDependencies:
leaflet: ^1.9.0
react: ^18.0.0
react-dom: ^18.0.0
'@polymer/polymer@3.5.2':
resolution: {integrity: sha512-fWwImY/UH4bb2534DVSaX+Azs2yKg8slkMBHOyGeU2kKx7Xmxp6Lee0jP8p6B3d7c1gFUPB2Z976dTUtX81pQA==}
'@remix-run/router@1.23.3':
resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==}
engines: {node: '>=14.0.0'}
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -589,12 +633,6 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/leaflet@1.9.21':
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
@@ -606,6 +644,55 @@ packages:
'@types/react@18.3.28':
resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==}
'@types/sortablejs@1.15.9':
resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@vaadin/a11y-base@24.6.11':
resolution: {integrity: sha512-yBZ0QGPngbItIJQx3FRIa9IXDW2Ftf6SFFPGhbdAZafJPBlFi6FElP9cVtL3qjJlI5KKBp/UXEcC8ehPK207gw==}
'@vaadin/checkbox@24.6.11':
resolution: {integrity: sha512-Uvd6gZ3xQQrZTtCJL6f4uLbg6mXsAKjiZto7Je39yJwUHz8r5MIQr+4mLF4zc6mYVSH/Ihj/a4n9FOuTwSEuQw==}
'@vaadin/component-base@24.6.11':
resolution: {integrity: sha512-7jR6vcJeCBgY2CNbAPLOcUTsxYspqdkA0slUGk3GwfgsRDD5FLkzqQDSM5+yE6O2+4Wah2Tk+kG/GsKGtlUlwg==}
'@vaadin/field-base@24.6.11':
resolution: {integrity: sha512-dRjxKzbW3xQAau1xuO8uZepVWaImS2wEyKDK9Oh+y8iiu4smYEmo9e4aqMqQN/sOHU6OSa4YtbyJZlvD1sBXrA==}
'@vaadin/grid@24.6.11':
resolution: {integrity: sha512-10ra384y81iIPwrVCsJwZda4vrdVeDk7SaZSXHe+pM8dVNAvBfmCNomdc9XdC6Q289GHt1AHn/3SaN+G3Wr7FQ==}
'@vaadin/icon@24.6.11':
resolution: {integrity: sha512-CKOh+I84+GZRfMHrhtATtrw3bSW5eUArgGT4cKsOY3asoCZXUdTObPD/PqKfP4e2uAA1bgLl27kOc+W8dmibJA==}
'@vaadin/input-container@24.6.11':
resolution: {integrity: sha512-fT1DK1QDp6VNKaHKkHxuuF3OlbNWbZOtK9IcLs3Q78/9jzhs8gg/nhIbQbyvhIhvjjHncIhzp0vPiw1l7Xxl+Q==}
'@vaadin/lit-renderer@24.6.11':
resolution: {integrity: sha512-JugFumbBQP4r28+HcbdDUVVGs5VRsqanLsifjkVrz/xb4saWv460lEYco5ES+StH+xZ2IuJZmEjEFUBSrVR/tA==}
'@vaadin/text-field@24.6.11':
resolution: {integrity: sha512-pqDPTf5AGwz5CcMfyFmF2215WzwWpjfudKlCje6u2qOcA/9kqBYCTQolemVYCtMDwn0yHXFSp4dU8UasxMCUJA==}
'@vaadin/vaadin-development-mode-detector@2.0.7':
resolution: {integrity: sha512-9FhVhr0ynSR3X2ao+vaIEttcNU5XfzCbxtmYOV8uIRnUCtNgbvMOIcyGBvntsX9I5kvIP2dV3cFAOG9SILJzEA==}
'@vaadin/vaadin-lumo-styles@24.6.11':
resolution: {integrity: sha512-WRluczao8lZgImdtl66v09YjFULb1iLAhcU48aiR9igAT7h6aLeHYBvRH3AA/gBlUNwHd4xlBSl89p4HP2GGog==}
'@vaadin/vaadin-material-styles@24.6.11':
resolution: {integrity: sha512-tDumwlaDp/s9u++MPi64I1o2ls/drWOZf4xVPhztUjt3NwYJUeVXtwu39q0wBRIeRM7UBrs06kug2CVT72U4qQ==}
'@vaadin/vaadin-themable-mixin@24.6.11':
resolution: {integrity: sha512-xCmn3X+2C7nI9LQn2OqLLkLw7VeJOCo99DlHwnxeLZpJJ/s8bjDXcIWflS+IOChzHixgEFkDSoLcNYoCR1RvYg==}
'@vaadin/vaadin-usage-statistics@2.1.3':
resolution: {integrity: sha512-8r4TNknD7OJQADe3VygeofFR7UNAXZ2/jjBFP5dgI8+2uMfnuGYgbuHivasKr9WSQ64sPej6m8rDoM1uSllXjQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
'@vitejs/plugin-react@4.7.0':
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -627,6 +714,13 @@ packages:
'@vitest/utils@1.6.1':
resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==}
'@webcomponents/shadycss@1.11.2':
resolution: {integrity: sha512-vRq+GniJAYSBmTRnhCYPAPq6THYqovJ/gzGThWbgEZUQaBccndGTi1hdiUP15HzEco0I6t4RCtXyX0rsSmwgPw==}
'@zip.js/zip.js@2.7.73':
resolution: {integrity: sha512-I2UP8/rdQE5hTtVVL08B7P8XuwXiKuuMUPjNuFOVL/9b+8IsExR9S5jz2H58u0rJjU4M1BikLgqEMG8gZJZVBw==}
engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=16.5.0'}
acorn-walk@8.3.5:
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
engines: {node: '>=0.4.0'}
@@ -757,17 +851,41 @@ packages:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-convert@3.1.3:
resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==}
engines: {node: '>=14.6'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-name@2.1.0:
resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==}
engines: {node: '>=12.20'}
color-string@2.1.4:
resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==}
engines: {node: '>=18'}
color@5.0.3:
resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==}
engines: {node: '>=18'}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
composed-offset-position@0.0.6:
resolution: {integrity: sha512-Q7dLompI6lUwd7LWyIcP66r4WcS9u7AL2h8HaeipiRfCRPLMWqRx8fYsjb4OHi6UQFifO7XtNC2IlEJ1ozIFxw==}
peerDependencies:
'@floating-ui/utils': ^0.2.5
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
@@ -786,6 +904,9 @@ packages:
engines: {node: '>=4'}
hasBin: true
cssfilter@0.0.10:
resolution: {integrity: sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==}
cssstyle@4.6.0:
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
engines: {node: '>=18'}
@@ -925,6 +1046,9 @@ packages:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
es-toolkit@1.49.0:
resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==}
esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
@@ -968,6 +1092,9 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
focus-trap@7.8.0:
resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==}
follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
@@ -1082,6 +1209,9 @@ packages:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
engines: {node: '>=8'}
interactjs@1.10.27:
resolution: {integrity: sha512-y/8RcCftGAF24gSp76X2JS3XpHiUvDQyhF8i7ujemBz77hwiHDuJzftHx7thY8cxGogwGiPJ+o97kWB6eAXnsA==}
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@@ -1212,9 +1342,6 @@ packages:
engines: {node: '>=6'}
hasBin: true
leaflet@1.9.4:
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
@@ -1222,6 +1349,15 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
lit-element@4.2.2:
resolution: {integrity: sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==}
lit-html@3.3.3:
resolution: {integrity: sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==}
lit@3.3.3:
resolution: {integrity: sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==}
local-pkg@0.5.1:
resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==}
engines: {node: '>=14'}
@@ -1247,6 +1383,10 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0
luxon@3.5.0:
resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==}
engines: {node: '>=12'}
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -1254,6 +1394,11 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@15.0.12:
resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==}
engines: {node: '>= 18'}
hasBin: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -1493,17 +1638,23 @@ packages:
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
react-leaflet@4.2.1:
resolution: {integrity: sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==}
peerDependencies:
leaflet: ^1.9.0
react: ^18.0.0
react-dom: ^18.0.0
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
react-router-dom@6.30.4:
resolution: {integrity: sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
react-router@6.30.4:
resolution: {integrity: sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react-smooth@4.0.4:
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
peerDependencies:
@@ -1628,6 +1779,9 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
sortablejs@1.15.7:
resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1669,6 +1823,9 @@ packages:
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
tabbable@6.5.0:
resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==}
tailwindcss@3.4.19:
resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
engines: {node: '>=14.0.0'}
@@ -1681,6 +1838,10 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
timezone-groups@0.10.4:
resolution: {integrity: sha512-AnkJYrbb7uPkDCEqGeVJiawZNiwVlSkkeX4jZg1gTEguClhyX+/Ezn07KB6DT29tG3UN418ldmS/W6KqGOTDjg==}
engines: {node: '>=18.12.0'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -1714,10 +1875,17 @@ packages:
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
type-detect@4.1.0:
resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==}
engines: {node: '>=4'}
type-fest@4.41.0:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -1873,6 +2041,11 @@ packages:
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
xss@1.0.13:
resolution: {integrity: sha512-clu7dxTm1e8Mo5fz3n/oW3UCXBfV89xZ72jM8yzo1vR/pIS0w3sgB3XV2H8Vm6zfGnHL0FzvLJPJEBhd86/z4Q==}
engines: {node: '>= 0.10.0'}
hasBin: true
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -1901,6 +2074,17 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
'@arcgis/lumina@4.34.9(lit@3.3.3)':
dependencies:
'@arcgis/toolkit': 4.34.9
csstype: 3.2.3
lit: 3.3.3
tslib: 2.8.1
'@arcgis/toolkit@4.34.9':
dependencies:
tslib: 2.8.1
'@asamuzakjp/css-color@3.2.0':
dependencies:
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
@@ -2112,6 +2296,56 @@ snapshots:
'@esbuild/win32-x64@0.21.5':
optional: true
'@esri/arcgis-html-sanitizer@4.1.0':
dependencies:
xss: 1.0.13
'@esri/calcite-components@3.3.3':
dependencies:
'@arcgis/lumina': 4.34.9(lit@3.3.3)
'@arcgis/toolkit': 4.34.9
'@esri/calcite-ui-icons': 4.3.0
'@floating-ui/dom': 1.8.0
'@floating-ui/utils': 0.2.12
'@types/sortablejs': 1.15.9
color: 5.0.3
composed-offset-position: 0.0.6(@floating-ui/utils@0.2.12)
es-toolkit: 1.49.0
focus-trap: 7.8.0
interactjs: 1.10.27
lit: 3.3.3
sortablejs: 1.15.7
timezone-groups: 0.10.4
type-fest: 4.41.0
transitivePeerDependencies:
- '@lit/context'
'@esri/calcite-ui-icons@4.3.0': {}
'@floating-ui/core@1.8.0':
dependencies:
'@floating-ui/utils': 0.2.12
'@floating-ui/dom@1.8.0':
dependencies:
'@floating-ui/core': 1.8.0
'@floating-ui/utils': 0.2.12
'@floating-ui/utils@0.2.12': {}
'@geoscene/core@4.32.10':
dependencies:
'@esri/arcgis-html-sanitizer': 4.1.0
'@esri/calcite-components': 3.3.3
'@vaadin/grid': 24.6.11
'@zip.js/zip.js': 2.7.73
luxon: 3.5.0
marked: 15.0.12
transitivePeerDependencies:
- '@lit/context'
'@interactjs/types@1.10.27': {}
'@jest/schemas@29.6.3':
dependencies:
'@sinclair/typebox': 0.27.10
@@ -2135,6 +2369,12 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@lit-labs/ssr-dom-shim@1.6.0': {}
'@lit/reactive-element@2.1.2':
dependencies:
'@lit-labs/ssr-dom-shim': 1.6.0
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -2147,15 +2387,17 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
'@open-wc/dedupe-mixin@1.4.0': {}
'@playwright/test@1.59.1':
dependencies:
playwright: 1.59.1
'@react-leaflet/core@2.1.0(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
'@polymer/polymer@3.5.2':
dependencies:
leaflet: 1.9.4
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@webcomponents/shadycss': 1.11.2
'@remix-run/router@1.23.3': {}
'@rolldown/pluginutils@1.0.0-beta.27': {}
@@ -2315,12 +2557,6 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/geojson@7946.0.16': {}
'@types/leaflet@1.9.21':
dependencies:
'@types/geojson': 7946.0.16
'@types/prop-types@15.7.15': {}
'@types/react-dom@18.3.7(@types/react@18.3.28)':
@@ -2332,6 +2568,118 @@ snapshots:
'@types/prop-types': 15.7.15
csstype: 3.2.3
'@types/sortablejs@1.15.9': {}
'@types/trusted-types@2.0.7': {}
'@vaadin/a11y-base@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
'@polymer/polymer': 3.5.2
'@vaadin/component-base': 24.6.11
lit: 3.3.3
'@vaadin/checkbox@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
'@polymer/polymer': 3.5.2
'@vaadin/a11y-base': 24.6.11
'@vaadin/component-base': 24.6.11
'@vaadin/field-base': 24.6.11
'@vaadin/vaadin-lumo-styles': 24.6.11
'@vaadin/vaadin-material-styles': 24.6.11
'@vaadin/vaadin-themable-mixin': 24.6.11
lit: 3.3.3
'@vaadin/component-base@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
'@polymer/polymer': 3.5.2
'@vaadin/vaadin-development-mode-detector': 2.0.7
'@vaadin/vaadin-usage-statistics': 2.1.3
lit: 3.3.3
'@vaadin/field-base@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
'@polymer/polymer': 3.5.2
'@vaadin/a11y-base': 24.6.11
'@vaadin/component-base': 24.6.11
lit: 3.3.3
'@vaadin/grid@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
'@polymer/polymer': 3.5.2
'@vaadin/a11y-base': 24.6.11
'@vaadin/checkbox': 24.6.11
'@vaadin/component-base': 24.6.11
'@vaadin/lit-renderer': 24.6.11
'@vaadin/text-field': 24.6.11
'@vaadin/vaadin-lumo-styles': 24.6.11
'@vaadin/vaadin-material-styles': 24.6.11
'@vaadin/vaadin-themable-mixin': 24.6.11
lit: 3.3.3
'@vaadin/icon@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
'@polymer/polymer': 3.5.2
'@vaadin/component-base': 24.6.11
'@vaadin/vaadin-lumo-styles': 24.6.11
'@vaadin/vaadin-themable-mixin': 24.6.11
lit: 3.3.3
'@vaadin/input-container@24.6.11':
dependencies:
'@polymer/polymer': 3.5.2
'@vaadin/component-base': 24.6.11
'@vaadin/vaadin-lumo-styles': 24.6.11
'@vaadin/vaadin-material-styles': 24.6.11
'@vaadin/vaadin-themable-mixin': 24.6.11
lit: 3.3.3
'@vaadin/lit-renderer@24.6.11':
dependencies:
lit: 3.3.3
'@vaadin/text-field@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
'@polymer/polymer': 3.5.2
'@vaadin/a11y-base': 24.6.11
'@vaadin/component-base': 24.6.11
'@vaadin/field-base': 24.6.11
'@vaadin/input-container': 24.6.11
'@vaadin/vaadin-lumo-styles': 24.6.11
'@vaadin/vaadin-material-styles': 24.6.11
'@vaadin/vaadin-themable-mixin': 24.6.11
lit: 3.3.3
'@vaadin/vaadin-development-mode-detector@2.0.7': {}
'@vaadin/vaadin-lumo-styles@24.6.11':
dependencies:
'@polymer/polymer': 3.5.2
'@vaadin/component-base': 24.6.11
'@vaadin/icon': 24.6.11
'@vaadin/vaadin-themable-mixin': 24.6.11
'@vaadin/vaadin-material-styles@24.6.11':
dependencies:
'@polymer/polymer': 3.5.2
'@vaadin/component-base': 24.6.11
'@vaadin/vaadin-themable-mixin': 24.6.11
'@vaadin/vaadin-themable-mixin@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
lit: 3.3.3
'@vaadin/vaadin-usage-statistics@2.1.3':
dependencies:
'@vaadin/vaadin-development-mode-detector': 2.0.7
'@vitejs/plugin-react@4.7.0(vite@5.4.21)':
dependencies:
'@babel/core': 7.29.0
@@ -2373,6 +2721,10 @@ snapshots:
loupe: 2.3.7
pretty-format: 29.7.0
'@webcomponents/shadycss@1.11.2': {}
'@zip.js/zip.js@2.7.73': {}
acorn-walk@8.3.5:
dependencies:
acorn: 8.16.0
@@ -2510,14 +2862,35 @@ snapshots:
dependencies:
color-name: 1.1.4
color-convert@3.1.3:
dependencies:
color-name: 2.1.0
color-name@1.1.4: {}
color-name@2.1.0: {}
color-string@2.1.4:
dependencies:
color-name: 2.1.0
color@5.0.3:
dependencies:
color-convert: 3.1.3
color-string: 2.1.4
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
commander@2.20.3: {}
commander@4.1.1: {}
composed-offset-position@0.0.6(@floating-ui/utils@0.2.12):
dependencies:
'@floating-ui/utils': 0.2.12
confbox@0.1.8: {}
convert-source-map@2.0.0: {}
@@ -2532,6 +2905,8 @@ snapshots:
cssesc@3.0.0: {}
cssfilter@0.0.10: {}
cssstyle@4.6.0:
dependencies:
'@asamuzakjp/css-color': 3.2.0
@@ -2681,6 +3056,8 @@ snapshots:
has-tostringtag: 1.0.2
hasown: 2.0.3
es-toolkit@1.49.0: {}
esbuild@0.21.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
@@ -2749,6 +3126,10 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
focus-trap@7.8.0:
dependencies:
tabbable: 6.5.0
follow-redirects@1.16.0: {}
for-each@0.3.5:
@@ -2853,6 +3234,10 @@ snapshots:
indent-string@4.0.0: {}
interactjs@1.10.27:
dependencies:
'@interactjs/types': 1.10.27
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -2988,12 +3373,26 @@ snapshots:
json5@2.2.3: {}
leaflet@1.9.4: {}
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
lit-element@4.2.2:
dependencies:
'@lit-labs/ssr-dom-shim': 1.6.0
'@lit/reactive-element': 2.1.2
lit-html: 3.3.3
lit-html@3.3.3:
dependencies:
'@types/trusted-types': 2.0.7
lit@3.3.3:
dependencies:
'@lit/reactive-element': 2.1.2
lit-element: 4.2.2
lit-html: 3.3.3
local-pkg@0.5.1:
dependencies:
mlly: 1.8.2
@@ -3019,12 +3418,16 @@ snapshots:
dependencies:
react: 18.3.1
luxon@3.5.0: {}
lz-string@1.5.0: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
marked@15.0.12: {}
math-intrinsics@1.1.0: {}
merge-stream@2.0.0: {}
@@ -3224,14 +3627,19 @@ snapshots:
react-is@18.3.1: {}
react-leaflet@4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
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:
'@react-leaflet/core': 2.1.0(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
leaflet: 1.9.4
'@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-refresh@0.17.0: {}
react-router@6.30.4(react@18.3.1):
dependencies:
'@remix-run/router': 1.23.3
react: 18.3.1
react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
@@ -3415,6 +3823,8 @@ snapshots:
signal-exit@4.1.0: {}
sortablejs@1.15.7: {}
source-map-js@1.2.1: {}
stackback@0.0.2: {}
@@ -3454,6 +3864,8 @@ snapshots:
symbol-tree@3.2.4: {}
tabbable@6.5.0: {}
tailwindcss@3.4.19:
dependencies:
'@alloc/quick-lru': 5.2.0
@@ -3490,6 +3902,8 @@ snapshots:
dependencies:
any-promise: 1.3.0
timezone-groups@0.10.4: {}
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
@@ -3520,8 +3934,12 @@ snapshots:
ts-interface-checker@0.1.13: {}
tslib@2.8.1: {}
type-detect@4.1.0: {}
type-fest@4.41.0: {}
typescript@5.9.3: {}
ufo@1.6.4: {}
@@ -3679,6 +4097,11 @@ snapshots:
xmlchars@2.2.0: {}
xss@1.0.13:
dependencies:
commander: 2.20.3
cssfilter: 0.0.10
yallist@3.1.1: {}
yocto-queue@1.2.2: {}

Binary file not shown.

View File

@@ -1,19 +1,9 @@
import { useEffect, useState, Component, ReactNode, Suspense, lazy, useCallback } from 'react';
import { TopNav } from '@/components/TopNav';
import { SideNav } from '@/components/SideNav';
import { useEffect, useState, Component, ReactNode, useCallback } from 'react';
import { BrowserRouter, Routes, Route, useRoutes } from 'react-router-dom';
import { AppShell } from '@/components/AppShell';
import { useRiskStore } from '@/stores';
import { Login } from '@/pages/Login';
const MonitoringDashboard = lazy(() => import('@/pages/MonitoringDashboard').then(m => ({ default: m.MonitoringDashboard })));
const AlertsDashboard = lazy(() => import('@/pages/AlertsDashboard').then(m => ({ default: m.AlertsDashboard })));
const TrendAnalysis = lazy(() => import('@/pages/TrendAnalysis').then(m => ({ default: m.TrendAnalysis })));
const DistrictComparison = lazy(() => import('@/pages/DistrictComparison').then(m => ({ default: m.DistrictComparison })));
const Insights = lazy(() => import('@/pages/Insights').then(m => ({ default: m.Insights })));
const ReportsCenter = lazy(() => import('@/pages/ReportsCenter').then(m => ({ default: m.ReportsCenter })));
const DemographicAnalysis = lazy(() => import('@/pages/DemographicAnalysis').then(m => ({ default: m.DemographicAnalysis })));
const DiseaseAnalysis = lazy(() => import('@/pages/DiseaseAnalysis').then(m => ({ default: m.DiseaseAnalysis })));
const EnvironmentalHealth = lazy(() => import('@/pages/EnvironmentalHealth').then(m => ({ default: m.EnvironmentalHealth })));
import { appRoutes } from '@/routes';
interface Props {
children: ReactNode;
@@ -55,28 +45,25 @@ class ErrorBoundary extends Component<Props, State> {
}
}
function PageLoader() {
return (
<div className="flex items-center justify-center h-[60vh]">
<div className="text-text-secondary text-[13px]">...</div>
</div>
);
// 已登录AppShell 提供布局骨架,子路由表渲染到其 <Outlet/>。
function AuthedApp({ onLogout }: { onLogout: () => void }) {
const element = useRoutes([
{
element: <AppShell onLogout={onLogout} />,
children: appRoutes,
},
]);
return element;
}
function App() {
const [activePage, setActivePage] = useState('monitoring');
const [token, setToken] = useState<string | null>(() => localStorage.getItem('cbpoa_token'));
const alerts = useRiskStore((s) => s.alerts);
const fetchAlerts = useRiskStore((s) => s.fetchAlerts);
useEffect(() => {
if (token) fetchAlerts();
}, [fetchAlerts, token]);
const handlePageChange = useCallback((page: string) => {
setActivePage(page);
}, []);
const handleLogin = useCallback((newToken: string) => {
setToken(newToken);
}, []);
@@ -86,41 +73,23 @@ function App() {
setToken(null);
}, []);
if (!token) {
return (
<ErrorBoundary>
<Login onLogin={handleLogin} />
</ErrorBoundary>
);
}
return (
<ErrorBoundary>
<div className="min-h-screen bg-bg-page flex flex-col">
<TopNav onLogout={handleLogout} />
<div className="flex flex-1 pt-[52px]">
<SideNav
activePage={activePage}
onPageChange={handlePageChange}
alertCount={alerts.length}
/>
<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>
<BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
{token ? (
<AuthedApp onLogout={handleLogout} />
) : (
// 鉴权门:无 token 时所有路由都进入登录页。
<Routes>
<Route path="*" element={<Login onLogin={handleLogin} />} />
</Routes>
)}
</BrowserRouter>
</ErrorBoundary>
);
}

View File

@@ -30,11 +30,6 @@ describe('Component exports', () => {
expect((mod as any).default || mod.DiseaseFilter).toBeDefined();
});
it('ChatBot 可以被导入', async () => {
const mod = await import('@/components/ChatBot');
expect((mod as any).default || mod.ChatBot).toBeDefined();
});
it('TimelinePlayer 可以被导入', async () => {
const mod = await import('@/components/TimelinePlayer');
expect((mod as any).default || mod.TimelinePlayer).toBeDefined();
@@ -45,11 +40,6 @@ describe('Component exports', () => {
expect((mod as any).default || mod.StatisticalCharts).toBeDefined();
});
it('RiskMap 可以被导入', async () => {
const mod = await import('@/components/RiskMap');
expect((mod as any).default || mod.RiskMap).toBeDefined();
});
it('AlertMap 可以被导入', async () => {
const mod = await import('@/components/AlertMap');
expect((mod as any).default || mod.AlertMap).toBeDefined();
@@ -60,9 +50,9 @@ describe('Component exports', () => {
expect((mod as any).default || mod.CaseLocationMap).toBeDefined();
});
it('CaseMap 可以被导入', async () => {
const mod = await import('@/components/CaseMap');
expect((mod as any).default || mod.CaseMap).toBeDefined();
it('geoscene createMapView 可以被导入', async () => {
const mod = await import('@/geoscene');
expect(mod.createMapView).toBeDefined();
});
it('DistributionChart 可以被导入', async () => {
@@ -75,11 +65,6 @@ describe('Component exports', () => {
expect((mod as any).default || mod.GridStatsOverlay).toBeDefined();
});
it('LodGridLayer 可以被导入', async () => {
const mod = await import('@/components/LodGridLayer');
expect((mod as any).default || mod.LodGridLayer).toBeDefined();
});
it('AdminBreadcrumb 可以被导入', async () => {
const mod = await import('@/components/AdminBreadcrumb');
expect((mod as any).default || mod.AdminBreadcrumb).toBeDefined();

View File

@@ -1,9 +1,18 @@
import { useEffect, useRef, useState, useCallback, memo } from 'react';
import L from 'leaflet';
import type MapView from '@geoscene/core/views/MapView';
import type WebTileLayer from '@geoscene/core/layers/WebTileLayer';
import type GraphicsLayer from '@geoscene/core/layers/GraphicsLayer';
import Graphic from '@geoscene/core/Graphic';
import Point from '@geoscene/core/geometry/Point';
import Polygon from '@geoscene/core/geometry/Polygon';
import SimpleFillSymbol from '@geoscene/core/symbols/SimpleFillSymbol';
import * as reactiveUtils from '@geoscene/core/core/reactiveUtils';
import { GridStatsOverlay } from '@/components/GridStatsOverlay';
import { riskApi } from '@/services/api';
import type { RiskGridStats } from '@/services/api';
import type { Alert } from '@/types';
import { createMapView } from '@/geoscene/createMapView';
import { createRiskTileLayer, createGraphicsLayer, pointGraphic } from '@/geoscene/layers';
export interface CellInfo {
lat: number;
@@ -28,10 +37,8 @@ interface AlertMapProps {
isFullscreen?: boolean;
}
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
const GRID_OPACITY = 0.72;
// Mirrors the server-side colormap in backend/utils/risk_raster.py.
const RISK_LEGEND: [number, number, string][] = [
[0.25, 0.4, '#38b000'],
[0.4, 0.6, '#facc15'],
@@ -57,79 +64,83 @@ function AlertMapComponent({
isFullscreen = false,
}: AlertMapProps) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
const riskTileRef = useRef<L.TileLayer | null>(null);
const alertLayerRef = useRef<L.LayerGroup | null>(null);
const markerMapRef = useRef<Map<string, L.CircleMarker>>(new Map());
const selectedMarkerRef = useRef<L.Rectangle | null>(null);
const viewRef = useRef<MapView | null>(null);
const destroyRef = useRef<(() => void) | null>(null);
const riskLayerRef = useRef<WebTileLayer | null>(null);
const alertLayerRef = useRef<GraphicsLayer | null>(null);
const selectLayerRef = useRef<GraphicsLayer | null>(null);
const clickHandlerRef = useRef(onGridClick);
const cellInfoRef = useRef(onCellInfo);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
// Latest inputs the once-subscribed map handlers read, so we never have to
// re-subscribe (and tear down listeners) when prop/callback identities change.
const inputsRef = useRef({ filteredAlerts, showAlertMarkers, forecastDay });
const [gridStats, setGridStats] = useState<RiskGridStats | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
const [mapReady, setMapReady] = useState(false);
useEffect(() => { clickHandlerRef.current = onGridClick; }, [onGridClick]);
useEffect(() => { cellInfoRef.current = onCellInfo; }, [onCellInfo]);
useEffect(() => {
clickHandlerRef.current = onGridClick;
}, [onGridClick]);
useEffect(() => {
cellInfoRef.current = onCellInfo;
}, [onCellInfo]);
useEffect(() => {
inputsRef.current = { filteredAlerts, showAlertMarkers, forecastDay };
}, [filteredAlerts, showAlertMarkers, forecastDay]);
// --- Initialize map once ---
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
if (!mapRef.current || viewRef.current) return;
const map = L.map(mapRef.current, {
center: WUHAN_CENTER,
zoom: 9,
zoomControl: true,
preferCanvas: true,
const { map, view, destroy } = createMapView({
container: mapRef.current,
zoom: 10,
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
}).addTo(map);
const riskTiles = createRiskTileLayer(forecastDay, showGrid ? GRID_OPACITY : 0);
const alertLayer = createGraphicsLayer('预警点');
const selectLayer = createGraphicsLayer('选中');
// Full-Wuhan 100m risk grid as raster tiles. The browser only fetches PNGs
// (Leaflet caches them per z/x/y); LOD is inherent in the tile pyramid.
const riskTiles = L.tileLayer(riskApi.tileUrlTemplate(forecastDay), {
opacity: showGrid ? GRID_OPACITY : 0,
maxNativeZoom: 16,
maxZoom: 19,
updateWhenZooming: false,
keepBuffer: 2,
zIndex: 200,
}).addTo(map);
riskTileRef.current = riskTiles;
map.addMany([riskTiles, alertLayer, selectLayer]);
const alertLayer = L.layerGroup().addTo(map);
riskLayerRef.current = riskTiles;
alertLayerRef.current = alertLayer;
selectLayerRef.current = selectLayer;
viewRef.current = view;
destroyRef.current = destroy;
// Click-to-inspect: query the 100m cell under the cursor. Clicks on alert
// markers are consumed by the marker handler and never reach this.
map.on('click', async (e: L.LeafletMouseEvent) => {
const { lat, lng } = e.latlng;
try {
view.ui.move('zoom', 'bottom-left');
} catch {
/* ignore */
}
view.when(() => setMapReady(true)).catch(() => setMapReady(true));
const clickHandle = view.on('click', async (event) => {
if (!event.mapPoint) return;
const lat = event.mapPoint.latitude;
const lng = event.mapPoint.longitude;
if (lat == null || lng == null) return;
const { filteredAlerts: alerts, forecastDay: day } = inputsRef.current;
try {
const cell = await riskApi.getCell(lat, lng, day);
// Nearest alert (squared degree distance — cheap, no sqrt).
let nearestId: string | null = null;
let minSq = Infinity;
for (const a of alerts) {
const dx = a.latitude - lat;
const dy = a.longitude - lng;
const d = dx * dx + dy * dy;
if (d < minSq) { minSq = d; nearestId = a.grid_id; }
if (d < minSq) {
minSq = d;
nearestId = a.grid_id;
}
}
const nearestDist = Math.sqrt(minSq);
if (nearestId && nearestDist < 0.01) {
clickHandlerRef.current(nearestId);
} else if (cellInfoRef.current) {
cellInfoRef.current({
lat, lon: lng,
lat,
lon: lng,
risk: cell.risk_value,
grid_id: cell.grid_id,
risk_1d: cell.risk_1d,
@@ -139,160 +150,199 @@ function AlertMapComponent({
nearestAlertDist: nearestDist,
});
}
} catch { /* transient fetch error — ignore the click */ }
} catch {
/* ignore transient click errors */
}
});
mapInstanceRef.current = map;
const resizeObserver = new ResizeObserver(() => {
mapInstanceRef.current?.invalidateSize({ animate: false });
});
resizeObserver.observe(mapRef.current);
resizeObserverRef.current = resizeObserver;
return () => {
resizeObserver.disconnect();
resizeObserverRef.current = null;
markerMapRef.current.clear();
clickHandle.remove();
riskLayerRef.current = null;
alertLayerRef.current = null;
riskTileRef.current = null;
map.remove();
mapInstanceRef.current = null;
selectLayerRef.current = null;
viewRef.current = null;
destroy();
destroyRef.current = null;
setMapReady(false);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// --- Update risk tiles + stats when the forecast horizon changes ---
useEffect(() => {
const layer = riskTileRef.current;
if (layer) layer.setUrl(riskApi.tileUrlTemplate(forecastDay));
const map = viewRef.current?.map;
if (!map || !mapReady) return;
if (riskLayerRef.current) {
map.remove(riskLayerRef.current);
riskLayerRef.current.destroy();
}
const next = createRiskTileLayer(forecastDay, showGrid ? GRID_OPACITY : 0);
map.add(next);
riskLayerRef.current = next;
let cancelled = false;
setStatsLoading(true);
riskApi.getGridStats(forecastDay)
.then((s) => { if (!cancelled) setGridStats(s); })
.catch(() => { if (!cancelled) setGridStats(null); })
.finally(() => { if (!cancelled) setStatsLoading(false); });
return () => { cancelled = true; };
}, [forecastDay]);
riskApi
.getGridStats(forecastDay)
.then((s) => {
if (!cancelled) setGridStats(s);
})
.catch(() => {
if (!cancelled) setGridStats(null);
})
.finally(() => {
if (!cancelled) setStatsLoading(false);
});
return () => {
cancelled = true;
};
}, [forecastDay, mapReady]);
// --- Toggle grid visibility without rebuilding tiles ---
useEffect(() => {
riskTileRef.current?.setOpacity(showGrid ? GRID_OPACITY : 0);
if (riskLayerRef.current) {
riskLayerRef.current.opacity = showGrid ? GRID_OPACITY : 0;
}
}, [showGrid]);
// --- Render alert markers via diffing against a persistent layer group ---
const renderAlertMarkers = useCallback(() => {
const map = mapInstanceRef.current;
const layer = alertLayerRef.current;
if (!map || !layer) return;
const view = viewRef.current;
if (!layer || !view) return;
layer.removeAll();
const markerMap = markerMapRef.current;
const { filteredAlerts: alerts, showAlertMarkers: showMarkers } = inputsRef.current;
if (!showMarkers || !alerts?.length) return;
if (!showMarkers || !alerts || alerts.length === 0) {
if (markerMap.size > 0) { layer.clearLayers(); markerMap.clear(); }
return;
}
const b = map.getBounds();
const south = b.getSouth(), north = b.getNorth(), west = b.getWest(), east = b.getEast();
const extent = view.extent;
const maxMarkers = 500;
const step = Math.max(1, Math.floor(alerts.length / maxMarkers));
const graphics: Graphic[] = [];
const desired = new Map<string, Alert>();
for (let i = 0; i < alerts.length; i += step) {
const alert = alerts[i];
if (!alert.latitude || !alert.longitude) continue;
if (alert.latitude < south || alert.latitude > north ||
alert.longitude < west || alert.longitude > east) continue;
const key = alert.grid_id || `${alert.latitude},${alert.longitude},${i}`;
desired.set(key, alert);
}
for (const [key, marker] of markerMap) {
if (!desired.has(key)) { layer.removeLayer(marker); markerMap.delete(key); }
}
for (const [key, alert] of desired) {
if (markerMap.has(key)) continue;
if (extent) {
if (
alert.longitude < extent.xmin ||
alert.longitude > extent.xmax ||
alert.latitude < extent.ymin ||
alert.latitude > extent.ymax
) {
continue;
}
}
const isP1 = alert.priority === 'P1';
const marker = L.circleMarker([alert.latitude, alert.longitude], {
radius: isP1 ? 6 : 4,
fillColor: isP1 ? '#ef4444' : '#f97316',
fillOpacity: 0.7,
color: isP1 ? '#ef4444' : '#f97316',
weight: 2,
dashArray: isP1 ? undefined : '4 2',
});
marker.bindTooltip(
`<div style="font-size:12px;">
<strong>${alert.priority}</strong> · ${(alert.risk_value * 100).toFixed(0)}%<br/>
${alert.region || ''} ${alert.street || ''}
</div>`,
{ direction: 'top', offset: [0, -5] }
const g = pointGraphic(
alert.longitude,
alert.latitude,
isP1 ? '#ef4444' : '#f97316',
isP1 ? 10 : 7,
{
grid_id: alert.grid_id,
priority: alert.priority,
risk_value: alert.risk_value,
region: alert.region,
street: alert.street,
}
);
const gridId = alert.grid_id;
marker.on('click', () => { if (gridId) clickHandlerRef.current(gridId); });
marker.addTo(layer);
markerMap.set(key, marker);
g.popupTemplate = {
title: '{priority}',
content: '{region} {street}<br/>风险 {(risk_value * 100).toFixed(0)}%',
};
graphics.push(g);
}
layer.addMany(graphics);
}, []);
useEffect(() => {
if (!mapReady) return;
renderAlertMarkers();
}, [filteredAlerts, showAlertMarkers, renderAlertMarkers]);
}, [filteredAlerts, showAlertMarkers, mapReady, renderAlertMarkers]);
// Re-render markers on pan/zoom, throttled, subscribed once per map instance.
useEffect(() => {
const map = mapInstanceRef.current;
if (!map) return;
const view = viewRef.current;
if (!view || !mapReady) return;
let throttle: ReturnType<typeof setTimeout> | null = null;
const handleMove = () => {
if (throttle) return;
throttle = setTimeout(() => { throttle = null; renderAlertMarkers(); }, 150);
};
map.on('moveend', handleMove);
const handle = reactiveUtils.watch(
() => view.extent,
() => {
if (throttle) return;
throttle = setTimeout(() => {
throttle = null;
renderAlertMarkers();
}, 150);
}
);
return () => {
map.off('moveend', handleMove);
handle.remove();
if (throttle) clearTimeout(throttle);
};
}, [renderAlertMarkers]);
}, [mapReady, renderAlertMarkers]);
// --- Selected alert highlight (located from the alert list, no grid scan) ---
useEffect(() => {
const map = mapInstanceRef.current;
if (!map) return;
if (selectedMarkerRef.current) {
try { map.removeLayer(selectedMarkerRef.current); } catch { /* ok */ }
selectedMarkerRef.current = null;
}
const layer = selectLayerRef.current;
const view = viewRef.current;
if (!layer || !view) return;
layer.removeAll();
if (!selectedGridId) return;
const alert = filteredAlerts.find((a) => a.grid_id === selectedGridId);
if (!alert) return;
const latHalf = 0.00045, lonHalf = 0.00052;
const rect = L.rectangle(
[[alert.latitude - latHalf, alert.longitude - lonHalf],
[alert.latitude + latHalf, alert.longitude + lonHalf]],
{ fillColor: '#3b82f6', fillOpacity: 0.3, color: '#3b82f6', weight: 3 }
).addTo(map);
selectedMarkerRef.current = rect;
map.flyTo([alert.latitude, alert.longitude], Math.max(map.getZoom(), 13), { duration: 0.5 });
const latHalf = 0.00045;
const lonHalf = 0.00052;
const ring = [
[alert.longitude - lonHalf, alert.latitude - latHalf],
[alert.longitude + lonHalf, alert.latitude - latHalf],
[alert.longitude + lonHalf, alert.latitude + latHalf],
[alert.longitude - lonHalf, alert.latitude + latHalf],
[alert.longitude - lonHalf, alert.latitude - latHalf],
];
layer.add(
new Graphic({
geometry: new Polygon({ rings: [ring], spatialReference: { wkid: 4326 } }),
symbol: new SimpleFillSymbol({
color: [59, 130, 246, 0.3],
outline: { color: [59, 130, 246], width: 2 },
}),
})
);
view.goTo(
{
center: new Point({ longitude: alert.longitude, latitude: alert.latitude }),
zoom: Math.max(view.zoom, 13),
},
{ duration: 500 }
).catch(() => undefined);
}, [selectedGridId, filteredAlerts]);
// --- Invalidate size after fullscreen toggle (CSS transition ~200ms) ---
useEffect(() => {
const map = mapInstanceRef.current;
if (!map) return;
map.invalidateSize({ animate: false });
const timer = setTimeout(() => map.invalidateSize({ animate: true }), 200);
// MapView observes container size; force a layout tick after fullscreen CSS settles.
const timer = setTimeout(() => {
const el = mapRef.current;
if (el) {
el.style.height = el.style.height;
}
}, 200);
return () => clearTimeout(timer);
}, [isFullscreen]);
const containerHeight = isFullscreen ? 'calc(100vh - 120px)' : 'calc(100vh - 280px)';
// 工作台零内边距后,给地图更多垂直空间(非小卡片)
const containerHeight = isFullscreen ? 'calc(100vh - 100px)' : 'calc(100vh - 220px)';
return (
<div className="relative">
<div ref={mapRef} className="w-full rounded-lg overflow-hidden" style={{ height: containerHeight }} />
<div
ref={mapRef}
className="w-full rounded-lg overflow-hidden bg-slate-100"
style={{ height: containerHeight }}
/>
{!mapReady && (
<div className="absolute inset-0 flex items-center justify-center bg-bg-card/70 rounded-lg text-[13px] text-text-muted">
</div>
)}
<GridStatsOverlay
count={gridStats?.cell_count ?? 0}
@@ -305,14 +355,16 @@ function AlertMapComponent({
<div className="absolute bottom-4 right-4 bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm z-[1000] px-4 py-3">
<div className="text-[11px] font-semibold text-text-secondary mb-2"> (100m )</div>
<div className="space-y-1.5">
{RISK_LEGEND.slice().reverse().map(([min, max, color]) => (
<div key={color} className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: color }} />
<span className="text-[11px] text-text-secondary">
{getRiskLabel((min + max) / 2)} ({(min * 100).toFixed(0)}-{(max * 100).toFixed(0)}%)
</span>
</div>
))}
{RISK_LEGEND.slice()
.reverse()
.map(([min, max, color]) => (
<div key={color} className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: color }} />
<span className="text-[11px] text-text-secondary">
{getRiskLabel((min + max) / 2)} ({(min * 100).toFixed(0)}-{(max * 100).toFixed(0)}%)
</span>
</div>
))}
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border border-border-light bg-transparent" />
<span className="text-[11px] text-text-muted">&lt;25% </span>

View File

@@ -0,0 +1,155 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { Outlet, useLocation } 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);
}
/** 监测 / 预警:地图工作台,主区零内边距、禁止外层滚动,把高度留给地图。 */
function isMapWorkbench(pathname: string): boolean {
return pathname.startsWith('/monitoring') || pathname.startsWith('/alerts');
}
export function AppShell({ onLogout }: AppShellProps) {
const alerts = useRiskStore((s) => s.alerts);
const location = useLocation();
const mapWorkbench = isMapWorkbench(location.pathname);
const [drawerOpen, setDrawerOpen] = useState(false);
const [expandedNav, setExpandedNav] = useState<string | null>('monitoring');
const drawerRef = useRef<HTMLElement>(null);
const openDrawer = useCallback(() => setDrawerOpen(true), []);
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
useEffect(() => {
if (!drawerOpen) return;
const opener = document.activeElement as HTMLElement | null;
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;
}
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;
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">
<aside
data-testid={TESTIDS.sidebarRail}
className="hidden lg:flex lg:flex-col w-[212px] shrink-0 bg-bg-card/95 border-r border-border backdrop-blur-sm"
>
<div className="px-4 pt-4 pb-2">
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted">
</p>
</div>
<SideNav
alertCount={alerts.length}
expanded={expandedNav}
onExpandedChange={setExpandedNav}
/>
</aside>
{drawerOpen && (
<div
className="fixed inset-0 z-40 bg-slate-900/35 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-[280px] max-w-[85vw] bg-bg-card border-r border-border shadow-lift transition-transform duration-200 lg:hidden ${
drawerOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="px-4 pt-5 pb-2 border-b border-border-light">
<p className="brand-mark text-xl leading-none">CBPOA</p>
<p className="mt-1 text-[11px] text-text-muted"></p>
</div>
<SideNav
alertCount={alerts.length}
onNavigate={closeDrawer}
expanded={expandedNav}
onExpandedChange={setExpandedNav}
/>
</aside>
<main
className={
mapWorkbench
? 'flex-1 min-w-0 min-h-0 overflow-hidden flex flex-col'
: 'flex-1 min-w-0 overflow-auto p-5'
}
>
<RouteErrorBoundary>
<Outlet />
</RouteErrorBoundary>
</main>
</div>
</div>
);
}

View File

@@ -9,30 +9,30 @@
## Component Types
**Map components** (`*Map.tsx`) — Leaflet-based maps:
- Use `react-leaflet` / direct Leaflet manipulation via `useRef`
- Risk coloring: centralized `RISK_COLORS` and `RISK_LABELS` constants
- Coordinate system: `[lat, lng]` (Leaflet convention, NOT `[lng, lat]`)
**Map components** (`*Map.tsx`, `DistrictChoropleth`) — `@geoscene/core`:
- Use `createMapView` / layer factories from `@/geoscene`
- Coordinate system: `[longitude, latitude]` (GeoScene convention)
- Destroy MapView on unmount
**Chart components** (`*Chart*.tsx`) — Recharts:
- Responsive containers with `width="100%" height={...}`
**Navigation** (`TopNav.tsx`, `SideNav.tsx`):
- No data fetching — pure navigation/presentation
- No role/perspective switcher
**Overlay/Utility** (`GridStatsOverlay`, `ErrorBanner`, `StatCard`, `TimelinePlayer`):
- Small, focused, reusable across pages
## Data Flow
- Components receive data via props, never fetch directly
- Components receive data via props, never fetch directly (maps may call tile/cell APIs they own)
- Callbacks passed as props: `onGridSelect`, `onClosePanel`, `onForecastChange`
- Complex stateful behavior extracted to custom hooks (e.g., `useTimelineStore`)
## Anti-Patterns
- Don't fetch data in components — receive via props or store hooks
- Don't fetch page-level data in leaf components — receive via props or store hooks
- Don't create god components (>200 lines) — extract sub-components
- Don't use `any` in prop types — use `unknown` and narrow
- Don't pass Leaflet map instances between components — each map manages its own instance
- Don't use CSS modules or inline styles — Tailwind only
- Don't pass MapView instances between components
- Don't use CSS modules or inline styles — Tailwind only (map symbol colors excepted)

View File

@@ -1,68 +1,117 @@
import { useEffect, useRef, useState, memo } from 'react';
import L from 'leaflet';
import type MapView from '@geoscene/core/views/MapView';
import type GraphicsLayer from '@geoscene/core/layers/GraphicsLayer';
import Graphic from '@geoscene/core/Graphic';
import { geocodedApi } from '@/services/api';
import { TESTIDS } from '@/utils/testids';
import type { GeocodedCase } from '@/types';
import { createMapView } from '@/geoscene/createMapView';
import { createGraphicsLayer, pointGraphic, jitterLonLat } from '@/geoscene/layers';
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
type CaseMapMode = 'points' | 'density';
interface CaseLocationMapProps {
height?: string;
district?: string | null;
street?: string | null;
date?: string | null;
mode?: CaseMapMode;
}
function CaseLocationMapComponent({ height = '400px', district = null, street = null, date = null }: CaseLocationMapProps) {
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 mapInstanceRef = useRef<L.Map | null>(null);
const layerRef = useRef<L.LayerGroup | null>(null);
const viewRef = useRef<MapView | null>(null);
const layerRef = useRef<GraphicsLayer | null>(null);
const cancelledRef = useRef(false);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const fittedScopeRef = useRef<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [caseCount, setCaseCount] = useState(0);
const [pointKeys, setPointKeys] = useState<string[]>([]);
const [clusterCount, setClusterCount] = useState(0);
const [mapReady, setMapReady] = useState(false);
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
if (!mapRef.current || viewRef.current) return;
setIsLoading(true);
cancelledRef.current = false;
const map = L.map(mapRef.current, {
center: WUHAN_CENTER,
const { map, view, destroy } = createMapView({
container: mapRef.current,
zoom: 11,
zoomControl: true,
});
const layer = createGraphicsLayer('病例');
map.add(layer);
layerRef.current = layer;
viewRef.current = view;
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap',
maxZoom: 18,
}).addTo(map);
mapInstanceRef.current = map;
layerRef.current = L.layerGroup().addTo(map);
// ResizeObserver: auto-invalidate when container size changes (window resize, layout shifts)
const resizeObserver = new ResizeObserver(() => {
if (mapInstanceRef.current) {
mapInstanceRef.current.invalidateSize({ animate: false });
}
});
if (mapRef.current) {
resizeObserver.observe(mapRef.current);
// 缩放控件移到左下,避开右上观测日与图例
try {
view.ui.move('zoom', 'bottom-left');
} catch {
/* zoom widget may be absent */
}
resizeObserverRef.current = resizeObserver;
// Fetch case locations
geocodedApi.getGeocoded({ limit: 5000, district: district || undefined, date: date || undefined })
view.when(() => setMapReady(true)).catch(() => setMapReady(true));
return () => {
cancelledRef.current = true;
layerRef.current = null;
viewRef.current = null;
fittedScopeRef.current = null;
destroy();
setMapReady(false);
};
}, []);
useEffect(() => {
if (!mapReady || !viewRef.current || !layerRef.current) return;
cancelledRef.current = false;
const layer = layerRef.current;
const view = viewRef.current;
const scopeKey = `${district ?? ''}|${street ?? ''}|${mode}`;
const shouldFit = fittedScopeRef.current !== scopeKey;
geocodedApi
.getGeocoded({ limit: 5000, district: district || undefined, date: date || undefined })
.then((data) => {
if (cancelledRef.current) return;
const cases: GeocodedCase[] = data.cases || [];
const layer = layerRef.current;
if (!layer) return;
layer.removeAll();
layer.clearLayers();
// Deduplicate by case_id to avoid overlapping markers
const seen = new Set<string>();
let unique: GeocodedCase[] = [];
for (const c of cases) {
@@ -71,44 +120,64 @@ function CaseLocationMapComponent({ height = '400px', district = null, street =
unique.push(c);
}
}
// Client-side street filtering
if (street) {
unique = unique.filter((c) => c.street === street);
}
if (cancelledRef.current) return;
for (const c of unique) {
if (!c.latitude || !c.longitude) continue;
const color = c.case_type === 'inpatient' ? '#ef4444' : '#3b82f6';
const marker = L.circleMarker([c.latitude, c.longitude], {
radius: 3,
fillColor: color,
fillOpacity: 0.6,
color: color,
weight: 1,
if (mode === 'density') {
const clusters = aggregateClusters(unique);
const maxCount = clusters.reduce((m, c) => Math.max(m, c.count), 1);
const graphics = clusters.map((cl) => {
const size = 10 + Math.round((cl.count / maxCount) * 22);
const g = pointGraphic(cl.longitude, cl.latitude, '#7c3aed', size, {
label: cl.label,
count: cl.count,
});
g.popupTemplate = {
title: '{label}',
content: '病例数: {count}',
};
return g;
});
marker.bindTooltip(
`<div style="font-size:12px">
<strong>${c.district}</strong> ${c.street}<br/>
类型: ${c.case_type === 'inpatient' ? '住院' : '门诊'}
</div>`,
{ direction: 'top', offset: [0, -4] }
);
marker.addTo(layer);
layer.addMany(graphics);
setClusterCount(clusters.length);
setCaseCount(unique.length);
setPointKeys([]);
setIsLoading(false);
if (shouldFit && graphics.length > 0) {
fittedScopeRef.current = scopeKey;
view.goTo(graphics).catch(() => undefined);
}
return;
}
const keys: string[] = [];
const graphics: Graphic[] = [];
for (const c of unique) {
if (!c.latitude || !c.longitude) continue;
const color = c.case_type === 'inpatient' ? '#ef4444' : '#3b82f6';
const [lon, lat] = jitterLonLat(c.longitude, c.latitude, c.case_id, 70);
const g = pointGraphic(lon, lat, color, 6, {
district: c.district,
street: c.street,
case_type: c.case_type,
});
g.popupTemplate = {
title: '{district} {street}',
content: '类型: {case_type}',
};
graphics.push(g);
keys.push(c.case_id);
}
layer.addMany(graphics);
setClusterCount(0);
setCaseCount(unique.length);
setPointKeys(keys);
setIsLoading(false);
// Fit bounds to case locations
if (unique.length > 0) {
const bounds = L.latLngBounds(unique.map((c) => [c.latitude, c.longitude]));
map.fitBounds(bounds, { padding: [30, 30] });
if (shouldFit && graphics.length > 0) {
fittedScopeRef.current = scopeKey;
view.goTo(graphics).catch(() => undefined);
}
})
.catch(() => {
@@ -117,30 +186,74 @@ function CaseLocationMapComponent({ height = '400px', district = null, street =
return () => {
cancelledRef.current = true;
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
resizeObserverRef.current = null;
}
map.remove();
mapInstanceRef.current = null;
};
}, [district, street, date]);
}, [district, street, date, mode, mapReady]);
const isDensity = mode === 'density';
const fillParent = height === '100%' || height === '100vh';
return (
<div className="relative">
<div ref={mapRef} style={{ height, width: '100%', borderRadius: '8px' }} />
{isLoading && (
<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={`relative ${fillParent ? 'h-full min-h-0' : ''}`}
data-case-map-mode={mode}
>
<div
ref={mapRef}
className={`w-full overflow-hidden bg-bg-hover ${fillParent ? 'h-full rounded-none' : 'rounded-xl'}`}
style={{ height: fillParent ? '100%' : height, width: '100%' }}
/>
{!mapReady && (
<div className="absolute inset-0 flex items-center justify-center bg-bg-card/80">
<div className="text-[13px] text-text-muted"></div>
</div>
)}
{!isLoading && (
<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="ml-2 text-red-500"> </span>
<span className="ml-1 text-blue-500"> </span>
{mapReady && isLoading && (
<div className="absolute top-14 right-3 z-[1000] bg-bg-card/95 px-2.5 py-1 rounded-md border border-border shadow-soft text-[11px] text-text-muted">
</div>
)}
{!isLoading && mapReady && (
<div
className="absolute bottom-3 left-14 z-[1000] bg-bg-card/95 px-3 py-2 rounded-lg border border-border shadow-soft text-[12px] max-w-[220px]"
aria-label="病例图例"
>
<div className="text-[11px] font-semibold text-text-secondary mb-1.5 tracking-wide">
</div>
{!isDensity ? (
<>
<div className="font-mono tabular-nums text-text-primary mb-1.5">
<span className="text-primary font-semibold">{caseCount.toLocaleString()}</span>
<span className="text-text-muted ml-1"></span>
</div>
<div className="flex items-center gap-3 text-[11px] text-text-muted">
<span className="inline-flex items-center gap-1">
<span className="w-2.5 h-2.5 rounded-full bg-[#3b82f6]" aria-hidden />
</span>
<span className="inline-flex items-center gap-1">
<span className="w-2.5 h-2.5 rounded-full bg-[#ef4444]" aria-hidden />
</span>
</div>
</>
) : (
<>
<div className="font-mono tabular-nums text-text-primary mb-1">
<span className="font-semibold text-mist-deep">{clusterCount.toLocaleString()}</span>
<span className="text-text-muted ml-1"></span>
</div>
<div className="text-[11px] text-text-muted">/ · </div>
</>
)}
</div>
)}
<div className="hidden" aria-hidden="true">
{pointKeys.map((id) => (
<span key={id} data-testid={TESTIDS.patientPoint} data-case-id={id} />
))}
</div>
</div>
);
}

View File

@@ -1,376 +0,0 @@
import { memo, useEffect, useRef, useState, useCallback } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { geocodedApi } from '@/services/api';
import type { CaseGrid, GeocodedCase } from '@/types';
interface CaseMapProps {
height?: string;
}
type ViewMode = 'grid' | 'point';
// Grid is 100m x 100m at Wuhan latitude (~30.5°N)
const GRID_HALF_SIZE_LAT = 0.00045; // ~50m in degrees
const GRID_HALF_SIZE_LON = 0.00052; // ~50m in degrees
function getGridBounds(g: { latitude: number; longitude: number }) {
if (typeof g.latitude !== 'number' || typeof g.longitude !== 'number') {
return null;
}
return {
lat_min: g.latitude - GRID_HALF_SIZE_LAT,
lat_max: g.latitude + GRID_HALF_SIZE_LAT,
lon_min: g.longitude - GRID_HALF_SIZE_LON,
lon_max: g.longitude + GRID_HALF_SIZE_LON,
};
}
const RISK_COLORS = {
high: '#ff4444',
medium: '#ffaa44',
low: '#44bb44',
};
function getRiskColor(riskIndex: number): string {
if (riskIndex >= 0.67) return RISK_COLORS.high;
if (riskIndex >= 0.33) return RISK_COLORS.medium;
return RISK_COLORS.low;
}
function getRiskLabel(riskIndex: number): string {
if (riskIndex >= 0.67) return '高风险';
if (riskIndex >= 0.33) return '中风险';
return '低风险';
}
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number) {
let timer: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
function CaseMapComponent({ height = '480px' }: CaseMapProps) {
const mapDivRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<any>(null);
const gridLayerRef = useRef<any>(null);
const pointLayerRef = useRef<any>(null);
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [grids, setGrids] = useState<CaseGrid[]>([]);
const [cases, setCases] = useState<GeocodedCase[]>([]);
const [totalCases, setTotalCases] = useState(0);
const [gridCount, setGridCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function fetchData() {
setIsLoading(true);
setError(null);
try {
const [gridRes, geoRes] = await Promise.all([
geocodedApi.getGrid(),
geocodedApi.getGeocoded({ limit: 5000 }),
]);
if (cancelled) return;
setGrids(gridRes.grids || []);
setGridCount(gridRes.total_count || 0);
setTotalCases(gridRes.total_cases || 0);
setCases(geoRes.cases || []);
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : '加载失败');
} finally {
if (!cancelled) setIsLoading(false);
}
}
fetchData();
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (!mapDivRef.current || mapRef.current) return;
const map = L.map(mapDivRef.current, {
center: [30.59, 114.31],
zoom: 11,
zoomControl: true,
preferCanvas: false,
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
}).addTo(map);
mapRef.current = map;
const handleZoom = debounce(() => renderLayers(), 150);
const handleMove = debounce(() => renderLayers(), 150);
map.on('zoomend', handleZoom);
map.on('moveend', handleMove);
return () => {
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
gridLayerRef.current = null;
pointLayerRef.current = null;
}
};
}, []);
useEffect(() => {
if (!mapRef.current) return;
renderLayers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [grids, cases, viewMode]);
const renderLayers = useCallback(() => {
if (!mapRef.current) return;
const map = mapRef.current;
if (gridLayerRef.current) {
try { map.removeLayer(gridLayerRef.current); } catch { /* silent */ }
gridLayerRef.current = null;
}
if (pointLayerRef.current) {
try { map.removeLayer(pointLayerRef.current); } catch { /* silent */ }
pointLayerRef.current = null;
}
const zoom = map.getZoom();
if (viewMode === 'grid') {
const gridLayer = L.layerGroup();
const bounds = map.getBounds();
let rendered = 0;
const maxRender = 5000;
for (const g of grids) {
if (rendered >= maxRender) break;
const gBounds = getGridBounds(g);
if (!gBounds) continue;
if (
gBounds.lat_max < bounds.getSouth() ||
gBounds.lat_min > bounds.getNorth() ||
gBounds.lon_max < bounds.getWest() ||
gBounds.lon_min > bounds.getEast()
) {
continue;
}
const color = getRiskColor(g.risk_index);
const opacity = 0.5 + g.risk_index * 0.35;
const rect = L.rectangle(
[[gBounds.lat_min, gBounds.lon_min], [gBounds.lat_max, gBounds.lon_max]],
{
fillColor: color,
fillOpacity: opacity,
color: color,
weight: zoom >= 14 ? 1 : 0,
opacity: 0.3,
}
);
rect.bindTooltip(
`<div style="font-size: 12px;">
<strong>网格 ${g.grid_id}</strong><br/>
病例数: ${g.total_cases.toLocaleString()}<br/>
风险指数: ${(g.risk_index * 100).toFixed(1)}%<br/>
<span style="color: ${color}; font-weight: 600;">${getRiskLabel(g.risk_index)}</span>
</div>`,
{ direction: 'top', offset: [0, -5] }
);
rect.addTo(gridLayer);
rendered++;
}
gridLayer.addTo(map);
gridLayerRef.current = gridLayer;
} else {
const pointLayer = L.layerGroup();
const bounds = map.getBounds();
const caseColor = (c: GeocodedCase) =>
c.case_type === 'inpatient' ? '#DC2626' : '#2563EB';
// Viewport culling + maxRender to avoid Leaflet canvas intersects bug
const maxRender = 500;
let rendered = 0;
for (const c of cases) {
if (rendered >= maxRender) break;
if (typeof c.latitude !== 'number' || typeof c.longitude !== 'number') continue;
// Viewport culling - skip points outside visible area
if (
c.latitude < bounds.getSouth() ||
c.latitude > bounds.getNorth() ||
c.longitude < bounds.getWest() ||
c.longitude > bounds.getEast()
) {
continue;
}
// Use tiny rectangles instead of circleMarker to avoid Leaflet 1.9.4 intersects bug
const size = zoom >= 14 ? 0.00005 : zoom >= 12 ? 0.00003 : 0.00002;
const rect = L.rectangle(
[[c.latitude - size, c.longitude - size], [c.latitude + size, c.longitude + size]],
{
fillColor: caseColor(c),
fillOpacity: 0.8,
color: '#FFFFFF',
weight: 0.5,
}
);
rect.bindTooltip(
`<div style="font-size: 12px;">
<strong>${c.case_type === 'inpatient' ? '住院' : '门诊'}病例</strong><br/>
坐标:${c.latitude.toFixed(5)}, ${c.longitude.toFixed(5)}
</div>`,
{ direction: 'top', offset: [0, -5] }
);
rect.addTo(pointLayer);
rendered++;
}
pointLayer.addTo(map);
pointLayerRef.current = pointLayer;
}
}, [grids, cases, viewMode]);
const handleToggle = useCallback((mode: ViewMode) => {
setViewMode(mode);
}, []);
return (
<div className="card">
<div className="flex items-center justify-between px-4 py-3 border-b border-border-light">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-primary" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.5 3l-.16.03L15 5.1 9 3 3.36 4.9c-.21.07-.36.25-.36.48V20.5c0 .28.22.5.5.5l.16-.03L9 18.9l6 2.1 5.64-1.9c.21-.07.36-.25.36-.48V3.5c0-.28-.22-.5-.5-.5zM15 19l-6-2.11V5l6 2.11V19z"/>
</svg>
<span className="font-medium text-[14px]"></span>
</div>
<div className="flex items-center gap-3">
<div className="flex gap-0.5 bg-bg-page p-0.5 rounded">
<button
onClick={() => handleToggle('grid')}
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
viewMode === 'grid'
? 'bg-bg-card text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
<button
onClick={() => handleToggle('point')}
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
viewMode === 'point'
? 'bg-bg-card text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
</div>
<div className="text-[11px] text-text-muted">
{viewMode === 'grid' ? '100×100m 网格' : '个体病例定位'}
</div>
</div>
</div>
<div className="relative" style={{ height }}>
<div ref={mapDivRef} className="w-full h-full overflow-hidden rounded-lg" />
<div className="absolute bottom-4 right-4 bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm z-[1000] px-4 py-3">
{viewMode === 'grid' ? (
<>
<div className="text-[11px] font-semibold text-text-secondary mb-2"></div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS.high }} />
<span className="text-[11px] text-text-secondary"> (&gt;67%)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS.medium }} />
<span className="text-[11px] text-text-secondary"> (33-67%)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS.low }} />
<span className="text-[11px] text-text-secondary"> (&lt;33%)</span>
</div>
</div>
</>
) : (
<>
<div className="text-[11px] font-semibold text-text-secondary mb-2"></div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: '#DC2626' }} />
<span className="text-[11px] text-text-secondary"></span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: '#2563EB' }} />
<span className="text-[11px] text-text-secondary"></span>
</div>
</div>
</>
)}
</div>
<div className="absolute top-4 left-4 space-y-2 z-[1000]">
<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">
{isLoading ? (
<span className="text-text-muted">...</span>
) : error ? (
<span className="text-danger">: {error}</span>
) : (
<>
<span className="font-semibold text-text-primary">{totalCases.toLocaleString()}</span>
<span className="mx-2 text-border">|</span>
{viewMode === 'grid' ? (
<>
<span className="font-semibold text-text-primary">{gridCount.toLocaleString()}</span>
</>
) : (
<>
<span className="font-semibold text-text-primary">{cases.length.toLocaleString()}</span>
</>
)}
</>
)}
</div>
</div>
{!isLoading && !error && viewMode === 'grid' && (
<div className="bg-success/10 backdrop-blur rounded-lg border border-success/30 shadow-sm px-3 py-2">
<div className="text-[11px] text-success font-medium">
</div>
</div>
)}
</div>
</div>
</div>
);
}
export const CaseMap = memo(CaseMapComponent);

View File

@@ -1,185 +0,0 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { MessageSquare, X, Send, RefreshCw, Loader2 } from 'lucide-react';
import { chatApi } from '@/services/api';
interface Message {
role: 'user' | 'assistant';
content: string;
}
export function ChatBot() {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const scrollToBottom = useCallback(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, isLoading, scrollToBottom]);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
const handleSend = useCallback(async () => {
const trimmed = input.trim();
if (!trimmed || isLoading) return;
const userMessage: Message = { role: 'user', content: trimmed };
const updatedMessages = [...messages, userMessage];
setMessages(updatedMessages);
setInput('');
setError(null);
setIsLoading(true);
try {
const data = await chatApi.sendMessage(
updatedMessages.map((m) => ({ role: m.role, content: m.content }))
);
setMessages((prev) => [...prev, { role: 'assistant', content: data.reply }]);
} catch (err: any) {
const errMsg = err?.response?.data?.detail || err?.message || '请求失败,请稍后重试';
setError(errMsg);
} finally {
setIsLoading(false);
}
}, [input, isLoading, messages]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend]
);
const handleRetry = useCallback(() => {
setError(null);
handleSend();
}, [handleSend]);
return (
<>
{/* Float toggle button */}
<button
onClick={() => setIsOpen((prev) => !prev)}
className={`fixed bottom-5 right-5 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary shadow-lg transition-all hover:bg-primary-light ${
isOpen ? 'scale-0 opacity-0' : 'scale-100 opacity-100'
}`}
aria-label={isOpen ? '关闭聊天' : '打开聊天'}
>
<MessageSquare className="h-5 w-5 text-white" />
</button>
{/* Chat panel */}
{isOpen && (
<div className="fixed bottom-20 right-5 z-50 flex w-[380px] flex-col rounded-xl border border-border bg-bg-card shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between rounded-t-xl bg-primary p-3 text-white">
<h3 className="text-[14px] font-semibold">AI </h3>
<button
onClick={() => setIsOpen(false)}
className="rounded p-1 transition-colors hover:bg-white/20"
aria-label="关闭"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Messages area */}
<div
ref={scrollRef}
className="flex flex-col gap-3 overflow-y-auto p-4"
style={{ height: '420px' }}
>
{messages.length === 0 && !error && (
<div className="flex flex-1 flex-col items-center justify-center py-12 text-center">
<MessageSquare className="mb-3 h-10 w-10 text-text-muted" />
<p className="text-[13px] text-text-muted">
</p>
</div>
)}
{messages.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] rounded-2xl px-4 py-2 text-[13px] leading-relaxed ${
msg.role === 'user'
? 'rounded-br-sm bg-primary text-white'
: 'rounded-bl-sm bg-bg-page text-text-primary'
}`}
>
{msg.content}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="flex items-center gap-2 rounded-2xl rounded-bl-sm bg-bg-page px-4 py-3">
<Loader2 className="h-4 w-4 animate-spin text-text-muted" />
<span className="text-[12px] text-text-muted">...</span>
</div>
</div>
)}
{error && (
<div className="flex flex-col items-start gap-2 rounded-lg border border-danger/20 bg-danger-light px-4 py-3">
<span className="text-[13px] text-danger">{error}</span>
<button
onClick={handleRetry}
className="flex items-center gap-1 rounded px-2.5 py-1 text-[12px] font-medium text-danger transition-colors hover:bg-danger/10"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
{/* Input area */}
<div className="flex gap-2 border-t border-border p-3">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入您的问题..."
disabled={isLoading}
className="flex-1 rounded-lg border border-border bg-bg-page px-3 py-2 text-[13px] text-text-primary placeholder-text-muted outline-none transition-colors focus:border-primary disabled:opacity-50"
/>
<button
onClick={handleSend}
disabled={isLoading || !input.trim()}
className="flex items-center justify-center rounded-lg bg-primary px-4 text-[13px] font-medium text-white transition-colors hover:bg-primary-light disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</button>
</div>
</div>
)}
</>
);
}

View File

@@ -1,375 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import L from 'leaflet';
import { useLodGrid, type MapBounds } from '@/hooks/useLodGrid';
const RISK_COLORS: [number, number, string][] = [
[0.0, 0.2, '#22c55e'],
[0.2, 0.4, '#3b82f6'],
[0.4, 0.6, '#eab308'],
[0.6, 0.8, '#f97316'],
[0.8, 1.0, '#ef4444'],
];
// Pre-computed color buckets for fillStyle caching
const COLOR_BUCKETS: Record<string, { full: string; dim: string }> = {};
for (const [, , color] of RISK_COLORS) {
COLOR_BUCKETS[color] = { full: color, dim: color + '14' };
}
function getRiskColor(value: number): string {
for (const [min, max, color] of RISK_COLORS) {
if (value >= min && value < max) return color;
}
return '#ef4444';
}
// 100m grid step in degrees
const LAT_STEP = 0.0009;
const LON_STEP = 0.001046;
// Mercator helpers (avoid per-cell latLngToContainerPoint)
function latToMercY(lat: number): number {
return 128 - (256 * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360))) / (2 * Math.PI);
}
function lonToMercX(lon: number): number {
return ((lon + 180) / 360) * 256;
}
interface LodGridLayerProps {
map: L.Map | null;
forecastDay: 1 | 3 | 7;
visible?: boolean;
riskRange?: [number, number];
onCellClick?: (lat: number, lon: number, risk: number) => void;
}
export function LodGridLayer({
map,
forecastDay,
visible = true,
riskRange,
onCellClick,
}: LodGridLayerProps) {
const [zoom, setZoom] = useState(map?.getZoom() ?? 10);
const [mapBounds, setMapBounds] = useState<MapBounds | undefined>();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const paneRef = useRef<HTMLElement | null>(null);
const animFrameRef = useRef<number>(0);
const clickCallbackRef = useRef(onCellClick);
const gridsRef = useRef<number[][]>([]);
const forecastDayRef = useRef(forecastDay);
const riskRangeRef = useRef(riskRange);
const visibleRef = useRef(visible);
const drawnOriginRef = useRef<{ x: number; y: number } | null>(null);
// Keep refs in sync
useEffect(() => { clickCallbackRef.current = onCellClick; }, [onCellClick]);
useEffect(() => { forecastDayRef.current = forecastDay; }, [forecastDay]);
useEffect(() => { riskRangeRef.current = riskRange; }, [riskRange]);
useEffect(() => { visibleRef.current = visible; }, [visible]);
// Track map bounds and zoom
useEffect(() => {
if (!map) return;
const update = () => {
const b = map.getBounds();
setMapBounds({
min_lat: b.getSouth(),
max_lat: b.getNorth(),
min_lon: b.getWest(),
max_lon: b.getEast(),
});
setZoom(map.getZoom());
};
update();
map.on('moveend', update);
map.on('zoomend', update);
return () => {
map.off('moveend', update);
map.off('zoomend', update);
};
}, [map]);
const { grids } = useLodGrid(zoom, forecastDay, mapBounds);
// Update gridsRef only when we have actual data (preserve stale data during loading)
useEffect(() => {
if (grids.length > 0) {
gridsRef.current = grids;
}
}, [grids]);
// Create canvas overlay pane and attach to map
useEffect(() => {
if (!map) return;
const pane = map.createPane('lod-grid-pane');
pane.style.zIndex = '450';
pane.style.pointerEvents = 'none';
paneRef.current = pane;
const canvas = document.createElement('canvas');
canvas.style.position = 'absolute';
canvas.style.top = '0';
canvas.style.left = '0';
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.pointerEvents = 'none';
canvas.style.display = visibleRef.current ? '' : 'none';
pane.appendChild(canvas);
canvasRef.current = canvas;
// Handle map clicks for grid cell selection
const handleMapClick = (e: L.LeafletMouseEvent) => {
if (!clickCallbackRef.current) return;
const currentGrids = gridsRef.current;
if (!currentGrids || currentGrids.length === 0) return;
const { lat, lng } = e.latlng;
const riskIdx = forecastDayRef.current === 1 ? 2 : forecastDayRef.current === 3 ? 3 : 4;
let nearestDist = Infinity;
let nearestRisk = 0;
let nearestLat = 0;
let nearestLon = 0;
for (const g of currentGrids) {
const d = Math.sqrt((g[0] - lat) ** 2 + (g[1] - lng) ** 2);
if (d < nearestDist) {
nearestDist = d;
nearestRisk = g[riskIdx] ?? 0;
nearestLat = g[0];
nearestLon = g[1];
}
}
if (nearestDist < 0.01) {
clickCallbackRef.current(nearestLat, nearestLon, nearestRisk);
}
};
map.on('click', handleMapClick);
// Full redraw function
const redraw = () => {
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
animFrameRef.current = requestAnimationFrame(() => {
const container = map.getContainer();
const w = container.clientWidth;
const h = container.clientHeight;
const dpr = window.devicePixelRatio || 1;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
// Reset drift transform after redraw
canvas.style.transform = '';
drawnOriginRef.current = null;
// Visibility is controlled via canvas CSS display (see visible effect),
// so we still draw pixels even when hidden to keep them ready on re-show.
const currentGrids = gridsRef.current;
if (!currentGrids || currentGrids.length === 0) return;
const z = map.getZoom();
const riskIdx = forecastDayRef.current === 1 ? 2 : forecastDayRef.current === 3 ? 3 : 4;
const range = riskRangeRef.current;
const mapBounds = map.getBounds();
const south = mapBounds.getSouth();
const north = mapBounds.getNorth();
const west = mapBounds.getWest();
const east = mapBounds.getEast();
// Use Mercator math for pixel conversion (avoids per-cell latLngToContainerPoint)
const scale = 2 ** z;
const origin = map.getPixelOrigin();
drawnOriginRef.current = { x: origin.x, y: origin.y };
// Pre-compute Mercator Y steps for cell size at this zoom
const halfLat = LAT_STEP / 2;
const halfLon = LON_STEP / 2;
// Group cells by color to minimize fillStyle changes
const colorGroups: Record<string, { x: number; y: number; w: number; h: number }[]> = {};
// Viewport culling margin in degrees
const margin = 0.02;
const isHighZoom = z >= 12;
const isMedZoom = z >= 10;
for (const g of currentGrids) {
const lat = g[0];
const lon = g[1];
const risk = g[riskIdx] ?? 0;
// Pre-filter: skip zero-risk cells (majority of cells at most zooms)
if (risk === 0) continue;
// Viewport culling
if (lat < south - margin || lat > north + margin ||
lon < west - margin || lon > east + margin) {
continue;
}
// Risk range filter
let alpha = 0.85;
if (range) {
if (risk < range[0]) {
alpha = 0.08;
} else if (risk > range[1]) {
alpha = 0.3;
}
}
const color = getRiskColor(risk);
if (isHighZoom) {
// Compute cell rectangle using Mercator math
const lx = lonToMercX(lon - halfLon) * scale - origin.x;
const rx = lonToMercX(lon + halfLon) * scale - origin.x;
const ty = latToMercY(lat + halfLat) * scale - origin.y;
const by = latToMercY(lat - halfLat) * scale - origin.y;
const cellW = rx - lx;
const cellH = by - ty;
if (cellW < 0.5 || cellH < 0.5) continue;
// Group by color+alpha for batch rendering
const key = alpha < 1 ? `${color}_${alpha}` : color;
if (!colorGroups[key]) colorGroups[key] = [];
colorGroups[key].push({ x: lx, y: ty, w: cellW, h: cellH });
} else {
// Medium/low zoom: compute center pixel
const cx = lonToMercX(lon) * scale - origin.x;
const cy = latToMercY(lat) * scale - origin.y;
const key = alpha < 1 ? `${color}_${alpha}` : color;
if (!colorGroups[key]) colorGroups[key] = [];
colorGroups[key].push({ x: cx, y: cy, w: 0, h: 0 });
}
}
// Render grouped cells
for (const [key, cells] of Object.entries(colorGroups)) {
const parts = key.split('_');
const color = parts[0];
const alpha = parts.length > 1 ? parseFloat(parts[1]) : 1;
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
if (isHighZoom) {
for (const c of cells) {
ctx.fillRect(c.x, c.y, c.w, c.h);
}
// Stroke only at high enough cell sizes
ctx.globalAlpha = 0.4;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 0.5;
for (const c of cells) {
if (c.w > 2 && c.h > 2) {
ctx.strokeRect(c.x, c.y, c.w, c.h);
}
}
} else if (isMedZoom) {
const size = Math.max(2, Math.min(6, z - 7));
const halfSize = size / 2;
for (const c of cells) {
ctx.fillRect(c.x - halfSize, c.y - halfSize, size, size);
}
} else {
const radius = Math.max(1, Math.min(3, z - 5));
for (const c of cells) {
ctx.beginPath();
ctx.arc(c.x, c.y, radius, 0, Math.PI * 2);
ctx.fill();
}
}
}
ctx.globalAlpha = 1;
});
};
// Debounced full redraw (avoid thrashing during rapid pan/zoom)
let redrawTimer: ReturnType<typeof setTimeout> | null = null;
const debouncedRedraw = () => {
if (redrawTimer) clearTimeout(redrawTimer);
redrawTimer = setTimeout(redraw, 300);
};
// During pan: apply CSS transform to track tile movement (fixes drift)
const onMove = () => {
const drawn = drawnOriginRef.current;
if (!drawn) {
// No previous draw yet, just request a redraw
redraw();
return;
}
const current = map.getPixelOrigin();
const dx = drawn.x - current.x;
const dy = drawn.y - current.y;
canvas.style.transform = `translate(${dx}px, ${dy}px)`;
};
// On moveend/zoomend: reset transform and do debounced full redraw
const onMoveEnd = () => {
canvas.style.transform = '';
drawnOriginRef.current = null;
debouncedRedraw();
};
const onResize = () => redraw();
map.on('move', onMove);
map.on('moveend', onMoveEnd);
map.on('zoomend', onMoveEnd);
map.on('resize', onResize);
// Store redraw reference for external triggers
(canvas as any).__lodRedraw = redraw;
// Initial draw
redraw();
return () => {
map.off('move', onMove);
map.off('moveend', onMoveEnd);
map.off('zoomend', onMoveEnd);
map.off('resize', onResize);
map.off('click', handleMapClick);
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
if (redrawTimer) clearTimeout(redrawTimer);
pane.removeChild(canvas);
if (pane.parentNode) pane.parentNode.removeChild(pane);
canvasRef.current = null;
paneRef.current = null;
};
}, [map]);
// Trigger redraw when data/geometry-affecting inputs change.
useEffect(() => {
const canvas = canvasRef.current;
if (canvas && (canvas as any).__lodRedraw) {
(canvas as any).__lodRedraw();
}
}, [grids, forecastDay, riskRange]);
// Visibility toggle: hide/show via CSS instead of a full geometry redraw.
useEffect(() => {
const canvas = canvasRef.current;
if (canvas) {
canvas.style.display = visible ? '' : 'none';
}
}, [visible]);
return null;
}

View File

@@ -1,407 +0,0 @@
import { memo, useEffect, useRef, useMemo, useCallback } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import type { GridRisk, GridDetail, ForecastDay } from '@/types';
interface RiskMapProps {
grids: GridRisk[];
selectedGridId: string | null;
selectedGrid: GridDetail | null;
forecastDay: ForecastDay;
onGridSelect: (gridId: string) => void;
onClosePanel: () => void;
onFullscreen: () => void;
onForecastChange: (day: ForecastDay) => void;
isFullscreen?: boolean;
}
const RISK_COLORS: Record<string, string> = {
low: '#22c55e',
medium_low: '#3b82f6',
medium: '#eab308',
medium_high: '#f97316',
high: '#ef4444',
};
const RISK_LABELS: Record<string, string> = {
low: '低风险',
medium_low: '中低',
medium: '中风险',
medium_high: '中高',
high: '高风险',
};
const WUHAN_BOUNDS = {
minLat: 29.97,
maxLat: 31.37,
minLon: 113.69,
maxLon: 115.07,
};
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number) {
let timer: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
// Mercator helpers (avoid per-cell latLngToContainerPoint) — mirrors LodGridLayer.
function latToMercY(lat: number): number {
return 128 - (256 * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360))) / (2 * Math.PI);
}
function lonToMercX(lon: number): number {
return ((lon + 180) / 360) * 256;
}
function riskColorForValue(riskValue: number): string {
if (riskValue >= 0.7) return RISK_COLORS.high;
if (riskValue >= 0.5) return RISK_COLORS.medium_high;
if (riskValue >= 0.3) return RISK_COLORS.medium_low;
return RISK_COLORS.low;
}
function RiskMapComponent(props: RiskMapProps) {
const {
grids,
selectedGrid,
forecastDay,
onGridSelect,
onClosePanel,
onFullscreen,
onForecastChange,
isFullscreen,
} = props;
const mapDivRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<any>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const paneRef = useRef<HTMLElement | null>(null);
const animFrameRef = useRef<number>(0);
const redrawRef = useRef<() => void>(() => {});
const zoomRef = useRef(9);
const callbacksRef = useRef({ onGridSelect, onClosePanel, onFullscreen, onForecastChange });
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const gridMapRef = useRef<Map<string, GridRisk>>(new Map());
useEffect(() => {
callbacksRef.current = { onGridSelect, onClosePanel, onFullscreen, onForecastChange };
}, [onGridSelect, onClosePanel, onFullscreen, onForecastChange]);
const containerHeight = isFullscreen ? 'calc(100vh - 52px)' : '420px';
// Invalidate map size when container size changes (window resize, fullscreen, layout shifts)
useEffect(() => {
const map = mapRef.current;
if (!map) return;
// Fullscreen transition: wait for CSS transition to complete
const timer = setTimeout(() => {
map.invalidateSize({ animate: true });
}, 150);
return () => clearTimeout(timer);
}, [isFullscreen, containerHeight]);
const gridMap = useMemo(() => {
const map = new Map<string, GridRisk>();
grids.forEach((g) => {
const key = `${g.latitude.toFixed(4)}-${g.longitude.toFixed(4)}`;
map.set(key, g);
});
return map;
}, [grids]);
// Keep gridMap accessible to the canvas render fn (read via ref, no re-init).
useEffect(() => {
gridMapRef.current = gridMap;
redrawRef.current();
}, [gridMap]);
// Create the map + tile layer + canvas overlay + handlers ONCE.
useEffect(() => {
if (!mapDivRef.current || mapRef.current) return;
const map = L.map(mapDivRef.current, {
center: [(WUHAN_BOUNDS.minLat + WUHAN_BOUNDS.maxLat) / 2, (WUHAN_BOUNDS.minLon + WUHAN_BOUNDS.maxLon) / 2],
zoom: 9,
zoomControl: true,
preferCanvas: true,
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
}).addTo(map);
mapRef.current = map;
// Canvas overlay pane for batched grid rendering (replaces per-cell rectangles).
const pane = map.createPane('risk-grid-pane');
pane.style.zIndex = '450';
pane.style.pointerEvents = 'none';
paneRef.current = pane;
const canvas = document.createElement('canvas');
canvas.style.position = 'absolute';
canvas.style.top = '0';
canvas.style.left = '0';
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.pointerEvents = 'none';
pane.appendChild(canvas);
canvasRef.current = canvas;
// ResizeObserver: auto-invalidate map size when container changes
const resizeObserver = new ResizeObserver(
debounce(() => {
if (mapRef.current) {
mapRef.current.invalidateSize({ animate: false });
}
}, 100)
);
if (mapDivRef.current) {
resizeObserver.observe(mapDivRef.current);
}
resizeObserverRef.current = resizeObserver;
// Batched canvas render: group cells by color and fillRect on one canvas.
function renderGridLayer() {
if (!mapRef.current || !canvasRef.current) return;
const map = mapRef.current;
const canvas = canvasRef.current;
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
animFrameRef.current = requestAnimationFrame(() => {
const container = map.getContainer();
const w = container.clientWidth;
const h = container.clientHeight;
const dpr = window.devicePixelRatio || 1;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const zoom = map.getZoom();
let cellSize: number;
let step: number;
if (zoom <= 8) { cellSize = 0.1; step = 10; }
else if (zoom <= 10) { cellSize = 0.025; step = 4; }
else if (zoom <= 12) { cellSize = 0.01; step = 2; }
else { cellSize = 0.005; step = 2; }
const bounds = map.getBounds();
const minLat = Math.max(bounds.getSouth(), WUHAN_BOUNDS.minLat);
const maxLat = Math.min(bounds.getNorth(), WUHAN_BOUNDS.maxLat);
const minLon = Math.max(bounds.getWest(), WUHAN_BOUNDS.minLon);
const maxLon = Math.min(bounds.getEast(), WUHAN_BOUNDS.maxLon);
const latStart = Math.floor((minLat - WUHAN_BOUNDS.minLat) / cellSize) * cellSize + WUHAN_BOUNDS.minLat;
const lonStart = Math.floor((minLon - WUHAN_BOUNDS.minLon) / cellSize) * cellSize + WUHAN_BOUNDS.minLon;
const currentGridMap = gridMapRef.current;
if (currentGridMap.size > 5000) {
console.warn(`[RiskMap] Data too dense: ${currentGridMap.size} grid cells, rendering may be slow`);
}
const scale = 2 ** zoom;
const origin = map.getPixelOrigin();
const cellDeg = cellSize * step;
// Group cells by color to minimize fillStyle changes.
const colorGroups: Record<string, { x: number; y: number; w: number; h: number }[]> = {};
let count = 0;
const maxCount = 1500;
for (let lat = latStart; lat < maxLat && count < maxCount; lat += cellDeg) {
for (let lon = lonStart; lon < maxLon && count < maxCount; lon += cellDeg) {
const key = `${lat.toFixed(4)}-${lon.toFixed(4)}`;
const grid = currentGridMap.get(key);
const riskValue = grid?.risk_value ?? 0.5;
const color = riskColorForValue(riskValue);
const lx = lonToMercX(lon) * scale - origin.x;
const rx = lonToMercX(lon + cellDeg) * scale - origin.x;
const ty = latToMercY(lat + cellDeg) * scale - origin.y;
const by = latToMercY(lat) * scale - origin.y;
if (!colorGroups[color]) colorGroups[color] = [];
colorGroups[color].push({ x: lx, y: ty, w: rx - lx, h: by - ty });
count++;
}
}
ctx.globalAlpha = 0.6;
for (const [color, cells] of Object.entries(colorGroups)) {
ctx.fillStyle = color;
for (const c of cells) {
ctx.fillRect(c.x, c.y, c.w, c.h);
}
}
ctx.globalAlpha = 1;
});
}
redrawRef.current = renderGridLayer;
// Single map-level click handler: nearest-cell lookup (replaces 1500 handlers).
const handleMapClick = (e: L.LeafletMouseEvent) => {
const currentGridMap = gridMapRef.current;
if (currentGridMap.size === 0) return;
const { lat, lng } = e.latlng;
let nearestDist = Infinity;
let nearestGrid: GridRisk | null = null;
for (const grid of currentGridMap.values()) {
const d = (grid.latitude - lat) ** 2 + (grid.longitude - lng) ** 2;
if (d < nearestDist) {
nearestDist = d;
nearestGrid = grid;
}
}
if (nearestGrid && nearestDist < 0.01 * 0.01) {
callbacksRef.current.onGridSelect(nearestGrid.grid_id);
}
};
const handleZoom = debounce(() => {
zoomRef.current = map.getZoom();
renderGridLayer();
}, 150);
const handleMove = debounce(() => {
renderGridLayer();
}, 150);
map.on('zoomend', handleZoom);
map.on('moveend', handleMove);
map.on('resize', renderGridLayer);
map.on('click', handleMapClick);
// Initial render
renderGridLayer();
return () => {
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
resizeObserverRef.current = null;
}
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
redrawRef.current = () => {};
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
}
canvasRef.current = null;
paneRef.current = null;
};
}, []);
const handleForecastChange = useCallback((d: ForecastDay) => {
callbacksRef.current.onForecastChange(d);
}, []);
const handleFullscreen = useCallback(() => {
callbacksRef.current.onFullscreen();
}, []);
const handleClosePanel = useCallback(() => {
callbacksRef.current.onClosePanel();
}, []);
return (
<div className="card">
<div className="flex items-center justify-between px-5 py-3.5 border-b border-gray-100">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-blue-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.5 3l-.16.03L15 5.1 9 3 3.36 4.9c-.21.07-.36.25-.36.48V20.5c0 .28.22.5.5.5l.16-.03L9 18.9l6 2.1 5.64-1.9c.21-.07.36-.25.36-.48V3.5c0-.28-.22-.5-.5-.5zM15 19l-6-2.11V5l6 2.11V19z"/>
</svg>
<span className="font-medium text-[14px]"></span>
</div>
<div className="flex gap-0.5 bg-gray-100 p-0.5 rounded">
{([0, 1, 3, 7] as ForecastDay[]).map((d) => (
<button
key={d}
onClick={() => handleForecastChange(d)}
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
forecastDay === d ? 'bg-blue-500 text-white' : 'text-gray-600 hover:text-blue-500'
}`}
>
{d === 0 ? '今日' : d + '天后'}
</button>
))}
</div>
<button
onClick={handleFullscreen}
className="px-3 py-1.5 text-[12px] text-gray-600 bg-gray-100 border border-gray-200 rounded hover:border-blue-400 transition-colors"
>
</button>
</div>
<div className="relative" style={{ height: containerHeight }}>
<div ref={mapDivRef} className="w-full h-full overflow-hidden rounded-lg" />
<div className="absolute bottom-4 right-4 bg-white px-4 py-3 rounded-lg border border-gray-200 shadow-sm z-[1000]">
<div className="text-[11px] font-semibold text-gray-600 mb-2"></div>
<div className="flex flex-wrap gap-3">
{Object.entries(RISK_LABELS).map(([level, label]) => (
<div key={level} className="flex items-center gap-1.5 text-[11px] text-gray-600">
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS[level] }} />
<span>{label}</span>
</div>
))}
</div>
</div>
<div className="absolute top-4 left-4 bg-white px-3 py-2 rounded-lg border border-gray-200 shadow-sm z-[1000]">
<div className="text-[11px] text-gray-600">
<span className="font-semibold text-gray-900">{grids.length.toLocaleString()}</span>
<span className="mx-2 text-gray-300">|</span>
{forecastDay === 0 ? '实时监测' : forecastDay + '天预报'}
</div>
</div>
{selectedGrid && (
<div className="absolute top-4 right-4 w-[280px] bg-white border border-gray-200 rounded-lg shadow-lg z-[1001]">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<span className="text-[13px] font-semibold"></span>
<button onClick={handleClosePanel} className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100">
<svg className="w-3.5 h-3.5 fill-gray-400" viewBox="0 0 24 24">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
</button>
</div>
<div className="p-4">
<div className={`rounded-md p-3 mb-4 ${selectedGrid.risk_value >= 0.7 ? 'bg-red-50' : 'bg-yellow-50'}`}>
<div className="text-[12px] text-gray-500 mb-1"></div>
<div className={`text-[24px] font-bold ${selectedGrid.risk_value >= 0.7 ? 'text-red-600' : 'text-yellow-600'}`}>
{Math.round(selectedGrid.risk_value * 100)}%
</div>
</div>
<div className="space-y-2 text-[12px]">
<div className="flex justify-between py-1.5 border-b border-gray-100">
<span className="text-gray-400"></span>
<span className="font-medium">{selectedGrid.region || '--'}</span>
</div>
<div className="flex justify-between py-1.5 border-b border-gray-100">
<span className="text-gray-400"></span>
<span className="font-medium">{selectedGrid.street || '--'}</span>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}
export const RiskMap = memo(RiskMapComponent);

View 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;
}
}

View File

@@ -1,116 +1,147 @@
import { useState } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { TESTIDS } from '@/utils/testids';
interface SideNavProps {
activePage: string;
onPageChange: (page: string) => void;
alertCount?: number;
onNavigate?: () => void;
expanded?: string | null;
onExpandedChange?: (moduleId: string | null) => void;
}
const modules: { id: string; label: string; icon: React.ReactNode; items: { id: string; label: string }[] }[] = [
interface NavItem {
to: string;
label: string;
testid: string;
}
const modules: { id: string; label: string; icon: React.ReactNode; items: NavItem[] }[] = [
{
id: 'monitoring',
label: '监测',
icon: (
<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>
),
items: [
{ id: 'monitoring', label: '监测面板' },
],
items: [{ to: '/monitoring', label: '监测面板', testid: TESTIDS.navMonitoring }],
},
{
id: 'alert',
label: '预警',
icon: (
<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>
),
items: [
{ id: 'alerts', label: '预警地图' },
],
items: [{ to: '/alerts', label: '预警地图', testid: TESTIDS.navAlerts }],
},
{
id: 'analysis',
label: '分析',
icon: (
<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>
),
items: [
{ id: 'trend-analysis', label: '趋势分析' },
{ id: 'district-comparison', label: '区域对比' },
{ id: 'insights', label: '智能洞察' },
{ id: 'reports', label: '报表中心' },
{ id: 'demographics', label: '人群分析' },
{ id: 'disease', label: '疾病分析' },
{ id: 'environment', label: '环境健康' },
{ to: '/overview', label: '总览', testid: TESTIDS.navOverview },
{ to: '/analysis/trend', label: '趋势分析', testid: TESTIDS.navTrend },
{ to: '/analysis/district', label: '区域对比', testid: TESTIDS.navDistrict },
{ to: '/analysis/insights', label: '智能洞察', testid: TESTIDS.navInsights },
{ to: '/analysis/reports', label: '报表中心', testid: TESTIDS.navReports },
{ to: '/analysis/demographics', label: '人群分析', testid: TESTIDS.navDemographics },
{ to: '/analysis/disease', label: '疾病分析', testid: TESTIDS.navDisease },
{ to: '/analysis/clinical', label: '临床分析', testid: TESTIDS.navClinical },
{ to: '/analysis/environment', label: '环境健康', testid: TESTIDS.navEnvironment },
],
},
];
export function SideNav({
activePage,
onPageChange,
alertCount = 0,
onNavigate,
expanded: expandedProp,
onExpandedChange,
}: SideNavProps) {
const [expanded, setExpanded] = useState<string | null>('monitoring');
const location = useLocation();
const handleItemClick = (moduleId: string, itemId: string) => {
setExpanded(moduleId);
onPageChange(itemId);
const moduleForPath = (pathname: string) =>
modules.find((m) => m.items.some((item) => pathname.startsWith(item.to)))?.id ?? 'monitoring';
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 module = modules.find(m => m.id === moduleId);
const module = modules.find((m) => m.id === moduleId);
if (!module) return false;
return module.items.some(item => item.id === activePage);
return module.items.some((item) => location.pathname.startsWith(item.to));
};
return (
<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-2 px-2.5">
{modules.map((module) => (
<div key={module.id} className="mb-4">
<div key={module.id} className="mb-1.5">
<button
type="button"
onClick={() => setExpanded(expanded === module.id ? null : module.id)}
className={`w-full flex items-center gap-[10px] px-3 py-[9px] rounded-md text-[14px] font-semibold transition-colors ${
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-[13px] font-semibold transition-colors ${
isActiveModule(module.id)
? 'bg-primary-muted text-primary'
: 'text-text-primary hover:bg-bg-hover'
}`}
>
<span className="w-4 h-4 flex items-center justify-center">
{module.icon}
</span>
<span>{module.label}</span>
<span className="w-4 h-4 flex items-center justify-center opacity-90">{module.icon}</span>
<span className="flex-1 text-left">{module.label}</span>
{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="bg-danger-light text-danger text-[10px] font-semibold px-1.5 py-0.5 rounded-md tabular-nums">
{alertCount > 99 ? '99+' : alertCount}
</span>
)}
<svg
className={`w-3.5 h-3.5 text-text-muted shrink-0 transition-transform ${
expanded === module.id ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{expanded === module.id && (
<div className="mt-1 pl-7">
<div className="mt-0.5 ml-3 pl-3 border-l border-border-light">
{module.items.map((item) => (
<button
key={item.id}
onClick={() => handleItemClick(module.id, item.id)}
className={`w-full text-left px-3 py-[7px] rounded text-[13px] font-medium transition-colors ${
activePage === item.id
? 'bg-bg-active text-primary'
: 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
}`}
<NavLink
key={item.to}
to={item.to}
data-testid={item.testid}
onClick={onNavigate}
className={({ isActive }) =>
`block w-full text-left px-2.5 py-1.5 rounded-md text-[12.5px] font-medium transition-colors ${
isActive
? 'bg-bg-active text-primary'
: 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
}`
}
>
{item.label}
</button>
</NavLink>
))}
</div>
)}
</div>
))}
</aside>
</nav>
);
}

View File

@@ -27,12 +27,12 @@ export const StatCard = React.memo(function StatCard({
}: StatCardProps) {
const trendIndicator = trend ? (
<span
className={`inline-flex items-center gap-0.5 text-xs font-medium ${
className={`inline-flex items-center gap-0.5 text-[11px] font-medium ${
trend.direction === 'up'
? 'text-green-600'
? 'text-danger'
: trend.direction === 'down'
? 'text-red-600'
: 'text-gray-500'
? 'text-success'
: 'text-text-muted'
}`}
>
{trend.direction === 'up' && <span aria-hidden>&#9650;</span>}
@@ -42,32 +42,28 @@ export const StatCard = React.memo(function StatCard({
</span>
) : null;
const sparklineSvg = sparkline && sparkline.data.length >= 2 ? (
<svg
width="60"
height="24"
className="shrink-0"
aria-hidden="true"
>
<polyline
fill="none"
stroke={sparkline.color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
points={sparkline.data
.map((val, i) => {
const x = (i / (sparkline.data.length - 1)) * 58 + 1;
const max = Math.max(...sparkline.data);
const min = Math.min(...sparkline.data);
const range = max - min || 1;
const y = 22 - ((val - min) / range) * 20 - 1;
return `${x},${y}`;
})
.join(' ')}
/>
</svg>
) : null;
const sparklineSvg =
sparkline && sparkline.data.length >= 2 ? (
<svg width="64" height="26" className="shrink-0" aria-hidden="true">
<polyline
fill="none"
stroke={sparkline.color}
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
points={sparkline.data
.map((val, i) => {
const x = (i / (sparkline.data.length - 1)) * 62 + 1;
const max = Math.max(...sparkline.data);
const min = Math.min(...sparkline.data);
const range = max - min || 1;
const y = 24 - ((val - min) / range) * 20 - 1;
return `${x},${y}`;
})
.join(' ')}
/>
</svg>
) : null;
return (
<div
@@ -84,24 +80,22 @@ export const StatCard = React.memo(function StatCard({
}
: undefined
}
className={`bg-white rounded-lg border border-gray-200 p-4 ${
onClick ? 'cursor-pointer hover:shadow-md transition-shadow' : ''
}`}
className={`stat-card ${onClick ? 'cursor-pointer' : ''}`}
>
<div className="flex items-center gap-2 text-sm text-gray-500 mb-1">
<div className="flex items-center gap-2 text-[12px] text-text-secondary mb-1.5 pl-1">
{icon}
<span>{label}</span>
<span className="font-medium tracking-wide">{label}</span>
</div>
<div className="flex items-end justify-between gap-2">
<div className="flex items-end justify-between gap-2 pl-1">
<div
className="text-2xl font-bold text-gray-900"
className="data-num text-[22px] leading-none"
style={color ? { color } : undefined}
>
{value}
</div>
{sparklineSvg}
</div>
{trendIndicator && <div className="mt-1">{trendIndicator}</div>}
{trendIndicator && <div className="mt-1.5 pl-1">{trendIndicator}</div>}
</div>
);
});

View File

@@ -28,8 +28,9 @@ export function TimelinePlayer({
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const advanceRef = useRef<() => void>(() => {});
// Keep internal play state in sync when the parent/store changes isPlaying.
useEffect(() => { setPlaying(isPlaying); }, [isPlaying]);
useEffect(() => {
setPlaying(isPlaying);
}, [isPlaying]);
const generateDateRange = useCallback((start: string, end: string) => {
const dates: string[] = [];
@@ -44,15 +45,22 @@ export function TimelinePlayer({
return dates;
}, []);
const dateRange = useMemo(() => generateDateRange(startDate, endDate), [startDate, endDate, generateDateRange]);
// Compute index arithmetically from the day difference instead of indexOf.
const dateRange = useMemo(
() => generateDateRange(startDate, endDate),
[startDate, endDate, generateDateRange]
);
const currentIndex = useMemo(() => {
if (dateRange.length === 0) return -1;
const ms = new Date(currentDate).getTime() - new Date(startDate).getTime();
const idx = Math.round(ms / 86400000);
return idx >= 0 && idx < dateRange.length ? idx : dateRange.indexOf(currentDate);
}, [startDate, currentDate, dateRange]);
const progress = useMemo(() => ((currentIndex + 1) / dateRange.length) * 100, [currentIndex, dateRange.length]);
const progress = useMemo(
() => ((currentIndex + 1) / dateRange.length) * 100,
[currentIndex, dateRange.length]
);
const play = useCallback(() => {
setPlaying(true);
@@ -65,11 +73,8 @@ export function TimelinePlayer({
}, [onPlayPause]);
const togglePlay = () => {
if (playing) {
pause();
} else {
play();
}
if (playing) pause();
else play();
};
const goToNext = useCallback(() => {
@@ -81,33 +86,24 @@ export function TimelinePlayer({
onDateChange(dateRange[0]);
};
// Keep the advance logic in a ref so the interval doesn't get recreated each
// tick when goToNext's identity changes.
useEffect(() => {
advanceRef.current = goToNext;
}, [goToNext]);
// Interval is created once per play/speed change (not per tick).
useEffect(() => {
if (playing) {
const interval = 1000 / speed;
timerRef.current = setInterval(() => {
advanceRef.current();
}, interval);
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
if (timerRef.current) clearInterval(timerRef.current);
};
}
}, [playing, speed]);
useEffect(() => {
if (currentIndex >= dateRange.length - 1) {
pause();
}
if (currentIndex >= dateRange.length - 1) pause();
}, [currentIndex, dateRange.length, pause]);
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -116,24 +112,15 @@ export function TimelinePlayer({
};
const handleSpeedChange = () => {
const currentIndex = SPEEDS.indexOf(speed);
const nextIndex = (currentIndex + 1) % SPEEDS.length;
const idx = SPEEDS.indexOf(speed);
const nextIndex = (idx + 1) % SPEEDS.length;
onSpeedChange?.(SPEEDS[nextIndex]);
};
const formatSpeed = (s: number) => {
return s >= 1 ? `${s}x` : `${s.toFixed(1)}x`;
};
const formatSpeed = (s: number) => (s >= 1 ? `${s}x` : `${s.toFixed(1)}x`);
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const today = new Date();
const isToday = date.toDateString() === today.toDateString();
if (isToday) {
return `今天 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;
}
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
@@ -141,72 +128,82 @@ export function TimelinePlayer({
});
};
const fmtShort = (d: string) =>
new Date(d).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
return (
<div className="fixed right-4 top-1/2 -translate-y-1/2 z-[9999] w-64">
<div className="bg-white/95 backdrop-blur-xl border border-gray-200/80 rounded-2xl shadow-[0_8px_32px_rgba(0,0,0,0.12)] px-4 py-3">
{/* Date display */}
<div className="text-center mb-3">
<div className="font-medium text-gray-900 text-sm">{formatDate(currentDate)}</div>
<div className="text-xs text-gray-400 mt-0.5">
{currentIndex + 1} / {dateRange.length}
<div className="timeline-dock" role="region" aria-label="时间轴播放器">
<div className="timeline-dock__inner">
<div className="flex items-center gap-3 sm:gap-4">
{/* 播放控件 */}
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={goToStart}
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-bg-hover rounded-lg transition-colors"
title="跳到开始"
aria-label="跳到开始"
>
<SkipBack className="w-4 h-4" />
</button>
<button
type="button"
onClick={togglePlay}
className="p-2 bg-primary text-white rounded-xl hover:bg-primary-deep transition-colors shadow-brand"
aria-label={playing ? '暂停' : '播放'}
>
{playing ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4 ml-0.5" />}
</button>
<button
type="button"
onClick={goToNext}
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-bg-hover rounded-lg transition-colors"
title="跳到下一天"
aria-label="跳到下一天"
>
<SkipForward className="w-4 h-4" />
</button>
</div>
</div>
{/* Vertical slider */}
<div className="flex justify-center mb-3">
<input
type="range"
min="0"
max="100"
value={progress}
onChange={handleSliderChange}
className="h-1.5 w-full bg-gray-200 rounded-full appearance-none cursor-pointer accent-blue-600"
style={{
background: `linear-gradient(to right, #2563eb 0%, #2563eb ${progress}%, #e5e7eb ${progress}%, #e5e7eb 100%)`,
}}
/>
</div>
<div className="flex justify-between text-[10px] text-gray-400 mb-3">
<span>{new Date(startDate).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })}</span>
<span>{new Date(endDate).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })}</span>
</div>
{/* 进度轨 */}
<div className="flex-1 min-w-0">
<div className="flex items-baseline justify-between gap-2 mb-1.5">
<div className="font-mono text-[13px] font-semibold tabular-nums text-text-primary">
{formatDate(currentDate)}
</div>
<div className="text-[11px] text-text-muted tabular-nums font-mono">
{currentIndex + 1} / {dateRange.length}
</div>
</div>
<div className="relative">
<div className="timeline-dock__track">
<div className="timeline-dock__fill" style={{ width: `${progress}%` }} />
</div>
<input
type="range"
min="0"
max="100"
value={progress}
onChange={handleSliderChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
aria-label="时间进度"
/>
</div>
<div className="flex justify-between text-[10px] text-text-muted mt-1">
<span>{fmtShort(startDate)}</span>
<span>{fmtShort(endDate)}</span>
</div>
</div>
{/* Transport controls */}
<div className="flex items-center justify-center gap-2">
<button
onClick={goToStart}
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
title="跳到开始"
>
<SkipBack className="w-4 h-4" />
</button>
<button
onClick={togglePlay}
className="p-2.5 bg-blue-600 text-white rounded-full hover:bg-blue-700 transition-colors shadow-md"
>
{playing ? (
<Pause className="w-5 h-5" />
) : (
<Play className="w-5 h-5 ml-0.5" />
)}
</button>
<button
onClick={goToNext}
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
title="跳到下一天"
>
<SkipForward className="w-4 h-4" />
</button>
</div>
{/* Speed */}
<div className="flex items-center justify-center gap-2 mt-2">
{/* 倍速 */}
<button
type="button"
onClick={handleSpeedChange}
className="px-2 py-0.5 text-xs font-medium text-gray-600 bg-gray-100/80 rounded-full hover:bg-gray-200 transition-colors"
className="shrink-0 px-2.5 py-1 text-[11px] font-mono font-semibold text-text-secondary
bg-bg-hover border border-border rounded-lg hover:border-primary hover:text-primary
transition-colors"
title="调整播放速度"
aria-label={`播放速度 ${formatSpeed(speed)}`}
>
{formatSpeed(speed)}
</button>

View File

@@ -1,7 +1,10 @@
import { useState, useEffect } from 'react';
import { TESTIDS } from '@/utils/testids';
interface TopNavProps {
onLogout?: () => void;
onToggleMenu?: () => void;
isMenuOpen?: boolean;
}
function Clock() {
@@ -10,44 +13,64 @@ function Clock() {
const id = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(id);
}, []);
return <span>{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>;
return (
<span className="font-mono tabular-nums">
{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
</span>
);
}
export function TopNav({ onLogout }: TopNavProps) {
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 fixed top-0 left-0 right-0 z-50">
<div className="flex items-center gap-3">
<div className="w-7 h-7 bg-primary rounded-md flex items-center justify-center">
<nav className="h-[54px] shrink-0 bg-bg-card/95 border-b border-border flex items-center px-4 sm:px-5 z-50 backdrop-blur-md">
{onToggleMenu && (
<button
type="button"
onClick={onToggleMenu}
aria-label="打开菜单"
aria-expanded={isMenuOpen}
aria-controls="app-drawer"
data-testid={TESTIDS.hamburger}
className="lg:hidden mr-2.5 -ml-0.5 w-9 h-9 flex items-center justify-center rounded-lg 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 min-w-0">
<div
className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-mist flex items-center justify-center shadow-soft shrink-0"
aria-hidden
>
<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="M12 3c-1.2 2.4-3.5 4-6 4 .6 3.4 2.8 6.2 6 7.5 3.2-1.3 5.4-4.1 6-7.5-2.5 0-4.8-1.6-6-4zm0 14.5c-2.2-.9-4-2.5-5.2-4.5C5.5 15.2 4 18 4 21h16c0-3-1.5-5.8-2.8-8-1.2 2-3 3.6-5.2 4.5z" />
</svg>
</div>
<span className="font-display font-semibold text-[15px] text-text-primary">
WuhanChildRisk
</span>
<div className="min-w-0 leading-tight">
<span className="brand-mark text-[16px] block truncate">CBPOA</span>
<span className="text-[11px] text-text-muted hidden sm:block truncate">
</span>
</div>
</div>
<div className="w-px h-5 bg-border ml-4 mr-4" />
<div className="w-px h-6 bg-border ml-4 mr-4 hidden md:block" />
<span className="text-[13px] text-text-secondary">
<span className="text-[12px] text-text-secondary hidden md:inline truncate">
· ·
</span>
<div className="ml-auto flex items-center gap-5">
<span className="text-[12px] text-text-muted">
<div className="ml-auto flex items-center gap-4">
<span className="text-[12px] text-text-muted hidden sm:inline">
<Clock />
</span>
<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">
<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>
admin
</div>
{onLogout && (
<button
type="button"
onClick={onLogout}
className="text-[12px] text-text-muted hover:text-danger transition-colors"
className="text-[12px] text-text-muted hover:text-danger transition-colors px-2 py-1 rounded-md hover:bg-danger-light/60"
>
退
</button>

View 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">&times;</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>
);
});

View File

@@ -0,0 +1,179 @@
import React from 'react';
import { TESTIDS } from '@/utils/testids';
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;
}
export const AlertsFilterBar = React.memo(function AlertsFilterBar({
selectedHorizon,
onHorizonChange,
selectedPriority,
onPriorityChange,
riskRange,
onRiskRangeChange,
showMap,
onToggleMap,
showAlertMarkers,
onToggleAlertMarkers,
showGrid,
onToggleGrid,
sortBy,
onSortByChange,
}: 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>
<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>
<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>
</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>
);
});

View File

@@ -0,0 +1,74 @@
import React from 'react';
interface AlertsHeaderProps {
total: number;
p1: number;
p2: number;
activeTab: 'list' | 'stats';
onTabChange: (tab: 'list' | 'stats') => void;
}
export const AlertsHeader = React.memo(function AlertsHeader({
total,
p1,
p2,
activeTab,
onTabChange,
}: AlertsHeaderProps) {
return (
<>
<div className="flex items-end justify-between mb-4 flex-wrap gap-x-4 gap-y-3">
<div className="min-w-0">
<h1 className="font-display text-[20px] font-semibold text-text-primary mb-0.5">
</h1>
<p className="text-[12px] text-text-muted truncate">
100m · ·
</p>
</div>
{/* 指挥台式计数,非 badge 堆叠 */}
<div
className="command-rail !flex-none stagger-children"
role="group"
aria-label="预警计数"
>
<div className="command-rail__cell !py-2 !px-4" style={{ flex: '0 0 auto' }}>
<div className="command-rail__label"></div>
<div className="command-rail__value text-[20px]">{total}</div>
</div>
<div className="command-rail__cell !py-2 !px-4" style={{ flex: '0 0 auto' }}>
<div className="command-rail__label text-danger">P1 </div>
<div className="command-rail__value text-[20px] text-danger">{p1}</div>
</div>
<div className="command-rail__cell !py-2 !px-4" style={{ flex: '0 0 auto' }}>
<div className="command-rail__label text-warning">P2 </div>
<div className="command-rail__value text-[20px] text-warning">{p2}</div>
</div>
</div>
</div>
<div className="tab-strip mb-4 border-b border-border" role="tablist" aria-label="预警视图">
{(
[
{ key: 'list' as const, label: '预警列表' },
{ key: 'stats' as const, label: '风险统计' },
] as const
).map((tab) => (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={activeTab === tab.key}
onClick={() => onTabChange(tab.key)}
className={`tab-strip__item ${
activeTab === tab.key ? 'tab-strip__item--active' : ''
}`}
>
{tab.label}
</button>
))}
</div>
</>
);
});

View File

@@ -0,0 +1,157 @@
import React, { useCallback } from 'react';
import { HORIZON_LABELS } from './types';
import type { ExtendedAlert, RiskStats } from './types';
interface RiskDistributionSummaryProps {
riskStats: RiskStats;
total: number;
}
/** 预警列表顶部风险分布 — 连续 risk-strip非四块同质小卡。 */
export const RiskDistributionSummary = React.memo(function RiskDistributionSummary({
riskStats,
total,
}: RiskDistributionSummaryProps) {
const pct = (n: number) => (total > 0 ? (n / total) * 100 : 0);
return (
<div className="risk-strip mb-4" role="group" aria-label="风险分布">
<div className="risk-strip__cell">
<div className="risk-strip__label"> (0.8)</div>
<div className="risk-strip__value text-danger">{riskStats.high}</div>
<div className="mt-2 h-1 bg-bg-hover rounded-full overflow-hidden">
<div className="h-full bg-danger rounded-full" style={{ width: `${pct(riskStats.high)}%` }} />
</div>
</div>
<div className="risk-strip__cell">
<div className="risk-strip__label"> (0.60.8)</div>
<div className="risk-strip__value text-warning">{riskStats.mediumHigh}</div>
<div className="mt-2 h-1 bg-bg-hover rounded-full overflow-hidden">
<div
className="h-full bg-warning rounded-full"
style={{ width: `${pct(riskStats.mediumHigh)}%` }}
/>
</div>
</div>
<div className="risk-strip__cell">
<div className="risk-strip__label"> (0.40.6)</div>
<div className="risk-strip__value text-primary">{riskStats.medium}</div>
<div className="mt-2 h-1 bg-bg-hover rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: `${pct(riskStats.medium)}%` }}
/>
</div>
</div>
<div className="risk-strip__cell">
<div className="risk-strip__label"></div>
<div className="risk-strip__value">{(riskStats.avgRisk * 100).toFixed(1)}%</div>
<div className="mt-2 text-[10px] text-text-muted truncate">
{' '}
{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={`relative overflow-hidden rounded-xl border transition-all cursor-pointer ${
isSelected
? 'border-primary bg-primary-muted/40 shadow-soft ring-1 ring-primary/30'
: 'border-border/80 bg-bg-card/90 hover:border-primary/40 hover:shadow-soft'
}`}
onClick={handleClick}
>
<div
className={`absolute left-0 top-0 bottom-0 w-1 ${isP1 ? 'bg-danger' : 'bg-warning'}`}
aria-hidden
/>
<div className="pl-4 pr-3.5 py-3">
<div className="flex items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-2 min-w-0">
<span
className={`text-[10px] font-bold tracking-wide px-1.5 py-0.5 rounded ${
isP1 ? 'bg-danger-light text-danger' : 'bg-warning-light text-warning'
}`}
>
{alert.priority}
</span>
<span className="text-[10px] text-text-muted truncate">
{HORIZON_LABELS[alert.forecast_horizon] || '未知'}
</span>
</div>
<span className={`data-num text-[18px] ${isP1 ? 'text-danger' : 'text-warning'}`}>
{riskPercent}%
</span>
</div>
<div className="mb-2">
<div className="text-[13px] font-semibold text-text-primary truncate">
{alert.region} · {alert.street}
</div>
<div className="text-[11px] text-text-muted font-mono">{alert.grid_id}</div>
</div>
<div
className={`text-[12px] px-2.5 py-1.5 rounded-lg mb-2 line-clamp-2 ${
isP1 ? 'bg-danger/10 text-danger' : 'bg-warning/10 text-warning'
}`}
>
{alert.reason}
</div>
<div className="flex items-center justify-between text-[10px] text-text-muted gap-2">
<span className="truncate"> {alert.forecast_time}</span>
<span className="shrink-0"> {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-2 max-h-none overflow-visible">
{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>
);
});

View File

@@ -0,0 +1,133 @@
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 {
forecastDay: 1 | 3 | 7;
onForecastDayChange: (day: 1 | 3 | 7) => void;
isFullscreen: boolean;
onToggleFullscreen: () => void;
onExportCsv: () => void;
onExportJson: () => void;
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;
riskStats: RiskStats;
filteredAlerts: ExtendedAlert[];
isLoading: boolean;
selectedGridId: string | null;
selectedAlert: string | null;
onGridClick: (gridId: string) => void;
onCellInfo: (info: CellInfo) => void;
onCardClick: (id: string) => void;
}
export const AlertsListTab = React.memo(function AlertsListTab(props: AlertsListTabProps) {
const { filteredAlerts, isLoading, 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}
/>
<RiskDistributionSummary riskStats={riskStats} total={filteredAlerts.length} />
{isLoading ? (
<div className="workbench-panel p-8">
<LoadingState />
</div>
) : filteredAlerts.length === 0 && !showMap ? (
<div className="workbench-panel 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-0 overflow-hidden rounded-2xl border border-border shadow-soft ${
isFullscreen ? 'grid-cols-1' : 'grid-cols-1 lg:grid-cols-[1fr_380px]'
}`}
>
{showMap && (
<div className="map-stage min-h-[420px] relative">
<div className="map-chrome">
<div className="map-chrome__chip">
<span className="h-1.5 w-1.5 rounded-full bg-danger animate-pulse" aria-hidden />
<span className="text-[12px] font-semibold text-text-primary"></span>
</div>
</div>
<div className="absolute inset-0">
<AlertsMapPanel
selectedGridId={props.selectedGridId}
onGridClick={props.onGridClick}
onCellInfo={props.onCellInfo}
forecastDay={props.forecastDay}
showAlertMarkers={props.showAlertMarkers}
showGrid={props.showGrid}
filteredAlerts={filteredAlerts}
isFullscreen={isFullscreen}
/>
</div>
</div>
)}
{!isFullscreen && (
<aside className="glass-wing max-h-[min(720px,70vh)] overflow-auto border-l-0 lg:border-l border-t lg:border-t-0 border-border">
<div className="glass-wing__section !border-b-0 flex-1">
<h3 className="glass-wing__title"></h3>
<AlertsList
filteredAlerts={filteredAlerts}
selectedAlert={props.selectedAlert}
onCardClick={props.onCardClick}
/>
</div>
</aside>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,39 @@
import React from 'react';
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;
showAlertMarkers: boolean;
showGrid: boolean;
filteredAlerts: ExtendedAlert[];
isFullscreen: boolean;
}
export const AlertsMapPanel = React.memo(function AlertsMapPanel({
selectedGridId,
onGridClick,
onCellInfo,
forecastDay,
showAlertMarkers,
showGrid,
filteredAlerts,
isFullscreen,
}: AlertsMapPanelProps) {
return (
<AlertMap
selectedGridId={selectedGridId}
onGridClick={onGridClick}
onCellInfo={onCellInfo}
forecastDay={forecastDay}
showAlertMarkers={showAlertMarkers}
showGrid={showGrid}
filteredAlerts={filteredAlerts}
isFullscreen={isFullscreen}
/>
);
});

View File

@@ -0,0 +1,159 @@
import React, { useMemo } from 'react';
import { LoadingState } from '@/components/ui';
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) {
const alertPie = useMemo(
() => [
{ name: 'P1 (紧急)', value: riskStats.p1, color: '#C2410C' },
{ name: 'P2 (关注)', value: riskStats.p2, color: '#C27803' },
],
[riskStats.p1, riskStats.p2]
);
const topDistrictMax = useMemo(
() => riskStats.topDistricts.reduce((m, [, n]) => Math.max(m, n), 0),
[riskStats.topDistricts]
);
return (
<div className="space-y-5">
{/* 风险分布连续条 */}
<div className="risk-strip" role="group" aria-label="风险等级统计">
<div className="risk-strip__cell">
<div className="risk-strip__label"> (0.8)</div>
<div className="risk-strip__value text-danger">{riskStats.high}</div>
</div>
<div className="risk-strip__cell">
<div className="risk-strip__label"> (0.60.8)</div>
<div className="risk-strip__value text-warning">{riskStats.mediumHigh}</div>
</div>
<div className="risk-strip__cell">
<div className="risk-strip__label"> (0.40.6)</div>
<div className="risk-strip__value text-primary">{riskStats.medium}</div>
</div>
<div className="risk-strip__cell">
<div className="risk-strip__label"></div>
<div className="risk-strip__value">{(riskStats.avgRisk * 100).toFixed(1)}%</div>
</div>
</div>
{/* 趋势图 */}
<section className="workbench-panel" aria-label="风险趋势">
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title"></h3>
<p className="workbench-panel__sub"> 14 </p>
</div>
</div>
<div className="workbench-panel__body">
{trendLoading ? (
<LoadingState />
) : trendError ? (
<div className="text-center py-8 text-danger text-[13px]">{trendError}</div>
) : trendData.length === 0 ? (
<div className="text-center py-8 text-text-muted text-[13px]"></div>
) : (
<StatisticalCharts data={trendData} showCases={false} showRisk height={280} />
)}
</div>
</section>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<section className="workbench-panel" aria-label="高风险区域">
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title"> Top 5</h3>
<p className="workbench-panel__sub"></p>
</div>
</div>
<div className="workbench-panel__body space-y-3">
{riskStats.topDistricts.length > 0 ? (
riskStats.topDistricts.map(([district, count], i) => (
<div key={district} className="district-bar !cursor-default hover:!bg-transparent !px-0">
<div className="w-4 text-[10px] font-mono text-text-muted tabular-nums">{i + 1}</div>
<div className="w-16 text-[12px] font-medium text-text-secondary shrink-0 truncate">
{district}
</div>
<div className="district-bar__track">
<div
className="district-bar__fill bg-danger/80"
style={{
width: `${topDistrictMax > 0 ? (count / topDistrictMax) * 100 : 0}%`,
}}
/>
</div>
<div className="w-12 text-right data-num text-[12px]">{count}</div>
</div>
))
) : (
<div className="text-center py-8 text-text-muted text-[13px]"></div>
)}
</div>
</section>
<section className="workbench-panel" aria-label="预警严重度">
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title"></h3>
<p className="workbench-panel__sub">P1 / P2 </p>
</div>
</div>
<div className="workbench-panel__body">
{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 #D4DEE4',
borderRadius: '10px',
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>
</section>
</div>
</div>
);
});

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

View 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][];
}

View 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,
};
}

View File

@@ -0,0 +1,87 @@
import { memo } from 'react';
import { CLINICAL_COLORS } from './chartColors';
export interface BoxRow {
label: string;
p25: number;
median: number;
p75: number;
n: number;
}
interface BoxPlotRowsProps {
rows: BoxRow[];
/** 数值单位后缀,如 "天" / ""。 */
unit?: string;
/** 标签列宽px。 */
labelWidth?: number;
}
/**
* 横向箱线图p25中位p75。Recharts 无原生 box plot
* 故用纯 div 渲染:每行一条从 p25 到 p75 的横条,中位处一根竖向刻度。
* 复用于「各病种住院天数」与「年龄别BMI」。
*/
export const BoxPlotRows = memo(function BoxPlotRows({
rows,
unit = '',
labelWidth = 96,
}: BoxPlotRowsProps) {
if (!rows || rows.length === 0) {
return <div className="text-center py-8 text-text-muted text-sm"></div>;
}
// 统一横轴域:覆盖所有行的 p25..p75留一点边距。
const domainMin = Math.min(...rows.map((r) => r.p25));
const domainMax = Math.max(...rows.map((r) => r.p75));
const span = domainMax - domainMin || 1;
const pct = (v: number) => ((v - domainMin) / span) * 100;
return (
<div className="space-y-2.5">
{rows.map((r) => {
const left = pct(r.p25);
const right = pct(r.p75);
const width = Math.max(right - left, 0.5);
const medianLeft = pct(r.median);
return (
<div key={r.label} className="flex items-center gap-2 text-[11px]">
<div
className="shrink-0 truncate text-text-secondary text-right"
style={{ width: labelWidth }}
title={r.label}
>
{r.label}
</div>
<div className="relative flex-1 h-5 rounded bg-bg-hover">
{/* p25p75 箱体 */}
<div
className="absolute top-1 bottom-1 rounded-sm"
style={{
left: `${left}%`,
width: `${width}%`,
backgroundColor: CLINICAL_COLORS.box,
opacity: 0.35,
}}
/>
{/* 中位刻度 */}
<div
className="absolute top-0.5 bottom-0.5 w-[2px] rounded"
style={{
left: `${medianLeft}%`,
backgroundColor: CLINICAL_COLORS.boxMedian,
}}
title={`中位 ${r.median}${unit}`}
/>
</div>
<div className="shrink-0 w-28 text-text-muted tabular-nums">
{r.p25}<span className="font-semibold text-text-secondary">{r.median}</span>{r.p75}
{unit}
<span className="ml-1 text-[10px] text-text-muted">n={r.n}</span>
</div>
</div>
);
})}
</div>
);
});

View File

@@ -0,0 +1,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>
);
});

View File

@@ -0,0 +1,56 @@
import { memo } from 'react';
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
export interface DonutSlice {
name: string;
value: number;
}
interface DonutChartProps {
data: DonutSlice[];
/** name -> color。未命中时按 palette 顺序回退。 */
colorMap?: Record<string, string>;
}
/** 通用环形图。复用于「出院结局构成」与「入院途径构成」。 */
export const DonutChart = memo(function DonutChart({ data, colorMap }: DonutChartProps) {
if (!data || data.length === 0) {
return <div className="text-center py-8 text-text-muted text-sm"></div>;
}
const total = data.reduce((s, d) => s + d.value, 0);
const colorFor = (name: string, idx: number) =>
colorMap?.[name] ??
CLINICAL_COLORS.routePalette[idx % CLINICAL_COLORS.routePalette.length] ??
CLINICAL_COLORS.outcomeFallback;
return (
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={data}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={56}
outerRadius={88}
paddingAngle={2}
>
{data.map((d, idx) => (
<Cell key={d.name} fill={colorFor(d.name, idx)} />
))}
</Pie>
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(v: number, name: string) => [
`${v.toLocaleString()}${total > 0 ? ((v / total) * 100).toFixed(1) : '0'}%`,
name,
]}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
</PieChart>
</ResponsiveContainer>
);
});

View File

@@ -0,0 +1,51 @@
import { memo } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
interface HistogramChartProps {
data: { bin_label: string; count: number }[];
color?: string;
/** tooltip 中数量的标签,如 "住院天数"。 */
countLabel?: string;
}
/** 通用直方图。用于「住院天数分布」。 */
export const HistogramChart = memo(function HistogramChart({
data,
color = CLINICAL_COLORS.los,
countLabel = '人次',
}: HistogramChartProps) {
if (!data || data.length === 0) {
return <div className="text-center py-8 text-text-muted text-sm"></div>;
}
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data} margin={{ top: 5, right: 12, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CLINICAL_COLORS.grid} vertical={false} />
<XAxis
dataKey="bin_label"
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }}
interval={0}
angle={-30}
textAnchor="end"
height={50}
/>
<YAxis tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }} width={40} />
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(v: number) => [`${v.toLocaleString()}`, countLabel]}
/>
<Bar dataKey="count" fill={color} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);
});

View File

@@ -0,0 +1,31 @@
/**
* 住院临床分析页图表字面色值集中处。
* 对齐江雾青绿 / 雾蓝体系,避免紫系默认配色。
*/
export const CLINICAL_COLORS = {
primary: '#0F766E',
los: '#0F766E',
box: '#14B8A6',
boxMedian: '#0D5C56',
grid: '#D4DEE4',
axis: '#5A6F7A',
axisLabel: '#1A2B33',
tooltipBorder: '#D4DEE4',
tooltipText: '#1A2B33',
outcome: {
: '#0D9488',
: '#5EEAD4',
: '#8A9BA5',
: '#C27803',
: '#C2410C',
} as Record<string, string>,
outcomeFallback: '#8A9BA5',
routePalette: ['#0F766E', '#5B8FA8', '#14B8A6', '#C27803', '#0D9488', '#C2410C'],
} as const;
export const TOOLTIP_STYLE = {
backgroundColor: '#FFFFFF',
border: `1px solid ${CLINICAL_COLORS.tooltipBorder}`,
borderRadius: '10px',
fontSize: '12px',
} as const;

View File

@@ -0,0 +1,194 @@
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()}`;
}
const TOOLTIP = {
backgroundColor: '#FFFFFF',
border: '1px solid #D4DEE4',
borderRadius: '10px',
fontSize: '12px',
boxShadow: '0 4px 16px rgba(26,43,51,0.08)',
};
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 — 诊断 / 趋势 / 日历,工作台面板构图。 */
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-2 border-primary border-t-transparent" />
</div>
);
}
return (
<div className="space-y-5 max-w-[1400px]">
{error && <ErrorBanner error={error} onRetry={onRetry} onDismiss={onDismissError} />}
<div className="grid grid-cols-1 xl:grid-cols-5 gap-5">
{/* Top 5 诊断 — 占 2 列 */}
<section className="workbench-panel xl:col-span-2" aria-label="Top 5 诊断分布">
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title">Top 5 </h3>
<p className="workbench-panel__sub"> / </p>
</div>
</div>
<div className="workbench-panel__body">
{topDiagnoses.length > 0 ? (
<ResponsiveContainer width="100%" height={260}>
<BarChart
data={[...topDiagnoses].reverse()}
layout="vertical"
margin={{ top: 0, right: 12, left: 8, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E8EEF1" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: '#8A9BA5' }} />
<YAxis
type="category"
dataKey="diagnosis"
tick={{ fontSize: 11, fill: '#5A6F7A' }}
width={96}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={TOOLTIP}
formatter={(value: number) => [value.toLocaleString(), '病例数']}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Bar dataKey="outpatient" stackId="a" fill="#C27803" name="门诊" barSize={18} radius={[0, 0, 0, 0]} />
<Bar dataKey="inpatient" stackId="a" fill="#C2410C" name="住院" barSize={18} radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="text-center py-12 text-text-muted text-sm"></div>
)}
</div>
</section>
{/* 病例与 AQI 趋势 — 占 3 列 */}
<section className="workbench-panel xl:col-span-3" aria-label="病例与AQI趋势">
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title"> AQI </h3>
<p className="workbench-panel__sub"> {currentDate} 30 </p>
</div>
</div>
<div className="workbench-panel__body">
{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="#E8EEF1" />
<XAxis
dataKey="date"
tickFormatter={formatDateLabel}
tick={{ fontSize: 10, fill: '#8A9BA5' }}
interval="preserveStartEnd"
axisLine={{ stroke: '#D4DEE4' }}
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 10, fill: '#8A9BA5' }}
axisLine={{ stroke: '#D4DEE4' }}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 10, fill: '#C27803' }}
axisLine={{ stroke: '#D4DEE4' }}
/>
<Tooltip contentStyle={TOOLTIP} labelStyle={{ color: '#1A2B33', fontWeight: 600 }} />
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Line
yAxisId="left"
type="monotone"
dataKey="cases"
name="病例数"
stroke="#0F766E"
strokeWidth={2.25}
dot={false}
activeDot={{ r: 3 }}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="aqi"
name="AQI"
stroke="#C27803"
strokeWidth={2}
strokeDasharray="5 4"
dot={false}
activeDot={{ r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="text-center py-12 text-text-muted text-sm"></div>
)}
</div>
</section>
</div>
{/* 日历热力图 — 通栏 */}
<section className="workbench-panel" aria-label="每日病例日历">
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title">
{heatmapYear ? `${heatmapYear}` : ''}
</h3>
<p className="workbench-panel__sub"></p>
</div>
</div>
<div className="workbench-panel__body">
{heatmapYear && heatmapData.length > 0 ? (
<CalendarHeatmap data={heatmapData} year={heatmapYear} />
) : (
<div className="text-center py-12 text-text-muted text-sm"></div>
)}
</div>
</section>
</div>
);
});

View File

@@ -0,0 +1,73 @@
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-2 border-primary border-t-transparent" />
</div>
);
}
return (
<div className="space-y-5 max-w-[1400px]">
{error && <ErrorBanner error={error} onRetry={onRetry} onDismiss={onDismissError} />}
<section className="workbench-panel" aria-label="区域指标热力表">
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title"></h3>
<p className="workbench-panel__sub"> · </p>
</div>
{rows.length > 0 && (
<span className="text-[11px] font-mono tabular-nums text-text-muted bg-bg-hover px-2.5 py-1 rounded-md border border-border">
{rows.length}
</span>
)}
</div>
<div className="workbench-panel__body">
{rows.length > 0 ? (
<div className="rounded-xl border border-border/80 overflow-hidden bg-bg-elevated/50">
<MetricHeatmapTable
rows={rows}
columns={[
{ key: 'total', label: '病例' },
{ key: 'outpatient', label: '门诊' },
{ key: 'inpatient', label: '住院' },
{ key: 'inpatient_ratio', label: '住院占比%' },
]}
data={data}
onSort={onSort}
/>
</div>
) : (
<div className="text-center py-12 text-text-muted text-sm"></div>
)}
</div>
</section>
</div>
);
});

View File

@@ -0,0 +1,119 @@
import { memo } from 'react';
import { Calendar, Activity, TrendingUp, TrendingDown, Stethoscope, Zap, BarChart3 } from 'lucide-react';
import type { MonitoringStats } from './types';
interface MonitoringStatsBarProps {
stats: MonitoringStats;
sparkline7d: number[];
}
function MiniSpark({ data, color }: { data: number[]; color: string }) {
if (data.length < 2) return null;
const max = Math.max(...data);
const min = Math.min(...data);
const range = max - min || 1;
const points = data
.map((val, i) => {
const x = (i / (data.length - 1)) * 72 + 1;
const y = 22 - ((val - min) / range) * 18 - 1;
return `${x},${y}`;
})
.join(' ');
const area = `1,23 ${points} 73,23`;
return (
<svg width="74" height="24" className="shrink-0" aria-hidden="true">
<polygon fill={`${color}22`} points={area} />
<polyline
fill="none"
stroke={color}
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
points={points}
/>
</svg>
);
}
/** 监测页指挥台指标带 — 6 KPI + sparkline连续仪表条构图。 */
export const MonitoringStatsBar = memo(function MonitoringStatsBar({
stats,
sparkline7d,
}: MonitoringStatsBarProps) {
const trendLabel = stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳';
const trendClass =
stats.trend === 'up' ? 'text-danger' : stats.trend === 'down' ? 'text-success' : 'text-text-muted';
return (
<div className="command-rail flex-1 min-w-0 stagger-children" role="group" aria-label="监测关键指标">
<div className="command-rail__cell command-rail__cell--hero">
<div className="command-rail__label">
<Calendar className="w-3.5 h-3.5 text-primary" aria-hidden />
</div>
<div className="command-rail__value">
{stats.todayCases !== null ? stats.todayCases.toLocaleString() : '--'}
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<Activity className="w-3.5 h-3.5 text-mist" aria-hidden />
7
</div>
<div className="flex items-end justify-between gap-2">
<div className="command-rail__value text-[20px]">{stats.avg7d.toLocaleString()}</div>
{sparkline7d.length >= 2 && <MiniSpark data={sparkline7d} color="#0F766E" />}
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
{stats.trend === 'up' ? (
<TrendingUp className="w-3.5 h-3.5 text-danger" aria-hidden />
) : stats.trend === 'down' ? (
<TrendingDown className="w-3.5 h-3.5 text-success" aria-hidden />
) : (
<Activity className="w-3.5 h-3.5 text-text-muted" aria-hidden />
)}
</div>
<div className={`command-rail__value text-[20px] ${trendClass}`}>{trendLabel}</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<Zap className="w-3.5 h-3.5 text-warning" aria-hidden />
</div>
<div className="command-rail__value text-[18px]">
{stats.maxDay.cases.toLocaleString()}
<span className="ml-1.5 text-[12px] font-medium text-text-muted font-sans tracking-normal">
{stats.maxDay.date.slice(5)}
</span>
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<BarChart3 className="w-3.5 h-3.5 text-mist-deep" aria-hidden />
</div>
<div className="command-rail__value text-[20px]">{stats.stdDev.toLocaleString()}</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<Stethoscope className="w-3.5 h-3.5 text-primary-light" aria-hidden />
/
</div>
<div className="command-rail__value text-[17px]">
<span className="text-warning">{stats.totalOutpatient.toLocaleString()}</span>
<span className="mx-1 text-text-muted font-sans font-normal">/</span>
<span className="text-danger">{stats.totalInpatient.toLocaleString()}</span>
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,215 @@
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
* 功能保留病例地图、AQI/病例趋势、区县分解、粒度切换。
* 播放时间轴时地图始终挂载,仅侧翼显示刷新态,避免底图闪烁。
*/
export const OverviewTab = memo(function OverviewTab({
isLoading,
chartData,
districtCases,
selectedDistrict,
selectedStreet,
currentDate,
granularity,
onGranularityChange,
onDistrictSelect,
}: OverviewTabProps) {
const totalDistrictCases = useMemo(
() => districtCases.reduce((s, d) => s + d.total, 0),
[districtCases]
);
const showWingSkeleton = isLoading && districtCases.length === 0;
return (
<div className="flex flex-col xl:flex-row h-full min-h-0 pb-[4.5rem]">
{/* 地图舞台 — 全高沉浸;永不因 isLoading 卸载 */}
<section className="map-stage min-h-[380px] xl:min-h-0 border-b xl:border-b-0">
<div className="map-chrome">
<div className="map-chrome__chip">
<span className="text-[11px] text-text-muted"></span>
<span className="font-mono text-[12px] font-semibold tabular-nums text-text-primary">
{currentDate}
</span>
</div>
</div>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 z-[6] h-16
bg-gradient-to-t from-[#dce6eb]/70 to-transparent"
aria-hidden
/>
<div className="absolute inset-0">
<CaseLocationMap
height="100%"
district={selectedDistrict}
street={selectedStreet}
date={currentDate}
/>
</div>
</section>
{/* 玻璃统计翼 */}
<aside className="glass-wing xl:w-[400px] 2xl:w-[440px] shrink-0 max-h-[48vh] xl:max-h-none overflow-auto">
{showWingSkeleton ? (
<div className="flex items-center justify-center min-h-[280px]">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
</div>
) : (
<>
<div className="glass-wing__section">
<h3 className="glass-wing__title"> AQI </h3>
<div className="rounded-xl border border-border/70 bg-bg-card/80 p-1.5 shadow-soft">
<StatisticalCharts data={chartData} height={240} showCases={true} showAQI={true} />
</div>
</div>
<div data-testid={TESTIDS.districtRollup} className="glass-wing__section !pb-4">
<div className="flex items-center justify-between mb-1 gap-2 flex-wrap">
<div>
<h3 className="glass-wing__title !mb-1"></h3>
<p className="text-[11px] text-text-muted mb-2">
{' '}
<span className="data-num text-text-secondary text-[12px]">
{totalDistrictCases.toLocaleString()}
</span>
{selectedDistrict ? (
<span className="ml-2 text-primary">· {selectedDistrict}</span>
) : null}
{isLoading ? (
<span className="ml-2 text-text-muted"></span>
) : null}
</p>
</div>
<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-0.5 max-h-[min(360px,42vh)] overflow-y-auto pr-0.5 -mx-1">
<DistrictBreakdown
districtCases={districtCases}
selectedDistrict={selectedDistrict}
onDistrictSelect={onDistrictSelect}
/>
</div>
<div className="flex items-center gap-5 mt-3 pt-3 border-t border-border-light">
<div className="flex items-center gap-1.5 text-[11px] text-text-muted">
<span className="w-3 h-2 rounded-sm bg-warning/85" aria-hidden />
</div>
<div className="flex items-center gap-1.5 text-[11px] text-text-muted">
<span className="w-3 h-2 rounded-sm bg-danger/75" aria-hidden />
</div>
</div>
</div>
</>
)}
</aside>
</div>
);
});
interface DistrictBreakdownProps {
districtCases: DistrictCaseRow[];
selectedDistrict: string | null;
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, rank) => {
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;
const isActive = selectedDistrict === d.district;
return (
<div
key={d.district}
role="button"
tabIndex={0}
aria-pressed={isActive}
className={`district-bar ${isActive ? 'district-bar--active' : ''}`}
onClick={() => handleDistrictClick(d.district)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleDistrictClick(d.district);
}
}}
>
<div className="w-4 text-[10px] font-mono text-text-muted tabular-nums shrink-0 text-right">
{rank + 1}
</div>
<div className="w-[3.25rem] text-[12px] font-medium text-text-secondary shrink-0 truncate">
{d.district}
</div>
<div className="district-bar__track" title={`门诊 ${d.outpatient} · 住院 ${d.inpatient}`}>
<div className="district-bar__fill" style={{ width: `${barWidth}%` }}>
<div
className="h-full bg-warning/85"
style={{ width: `${outPct}%` }}
/>
<div
className="h-full bg-danger/75"
style={{ width: `${inPct}%` }}
/>
</div>
</div>
<div className="w-[3.75rem] text-right data-num text-[12px] shrink-0">
{d.total.toLocaleString()}
</div>
</div>
);
})}
</>
);
});

View 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;
}

View 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,
};
}

View File

@@ -0,0 +1,82 @@
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[];
embed?: boolean;
}
const tooltipStyle = {
backgroundColor: '#FFFFFF',
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
borderRadius: '10px',
fontSize: '12px',
};
function AlertSeverityDonutComponent({ data, embed }: AlertSeverityDonutProps) {
const hasData = data.some((d) => d.value > 0);
const body = (
<>
{embed ? (
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title"></h3>
<p className="workbench-panel__sub">P1 / P2 </p>
</div>
</div>
) : (
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
</div>
)}
<div className={embed ? 'workbench-panel__body' : undefined}>
{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>
</>
);
if (embed) return <>{body}</>;
return <div className="card p-4">{body}</div>;
}
export const AlertSeverityDonut = memo(AlertSeverityDonutComponent);

View File

@@ -0,0 +1,116 @@
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[];
/** 嵌入 workbench-panel 时去掉外层 card */
embed?: boolean;
}
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: '10px',
fontSize: '12px',
};
function CaseAqiTrendComponent({ data, embed }: CaseAqiTrendProps) {
const body = (
<>
{embed ? (
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title"> 30 AQI</h3>
<p className="workbench-panel__sub"></p>
</div>
</div>
) : (
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
30AQI趋势
</div>
)}
<div className={embed ? 'workbench-panel__body' : undefined}>
{data.length > 0 ? (
<ResponsiveContainer width="100%" height={embed ? 280 : 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.25}
dot={false}
activeDot={{ r: 3 }}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="aqi"
name="AQI"
stroke={CHART_COLORS.aqi}
strokeWidth={2}
strokeDasharray="5 4"
dot={false}
activeDot={{ r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<EmptyState title="暂无数据" />
)}
</div>
</>
);
if (embed) return <div className="h-full flex flex-col">{body}</div>;
return <div className="card p-4">{body}</div>;
}
export const CaseAqiTrend = memo(CaseAqiTrendComponent);

View File

@@ -0,0 +1,197 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import type MapView from '@geoscene/core/views/MapView';
import type GraphicsLayer from '@geoscene/core/layers/GraphicsLayer';
import Graphic from '@geoscene/core/Graphic';
import Polygon from '@geoscene/core/geometry/Polygon';
import SimpleFillSymbol from '@geoscene/core/symbols/SimpleFillSymbol';
import { CHART_COLORS } from './chartColors';
import { createMapView } from '@/geoscene/createMapView';
import { createGraphicsLayer } from '@/geoscene/layers';
import { TESTIDS } from '@/utils/testids';
interface DistrictChoroplethProps {
metricLookup: Record<string, number>;
metricLabel: string;
}
interface WuhanFeatureProps {
name: string;
}
interface WuhanFeature {
type: 'Feature';
properties: WuhanFeatureProps;
geometry: {
type: 'Polygon' | 'MultiPolygon';
coordinates: number[][][] | number[][][][];
};
}
interface WuhanFeatureCollection {
type: 'FeatureCollection';
features: WuhanFeature[];
}
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];
}
function hexToRgba(hex: string, alpha = 0.78): number[] {
const h = hex.replace('#', '');
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
return [r, g, b, alpha];
}
function ringsFromGeometry(geometry: WuhanFeature['geometry']): number[][][] {
if (geometry.type === 'Polygon') {
return geometry.coordinates as number[][][];
}
return (geometry.coordinates as number[][][][]).flat();
}
function DistrictChoroplethComponent({ metricLookup, metricLabel }: DistrictChoroplethProps) {
const mapDivRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<MapView | null>(null);
const layerRef = useRef<GraphicsLayer | null>(null);
const geoDataRef = useRef<WuhanFeatureCollection | null>(null);
const [mapReady, setMapReady] = useState(false);
const maxValue = useMemo(() => {
const vals = Object.values(metricLookup);
return vals.length ? Math.max(...vals) : 0;
}, [metricLookup]);
useEffect(() => {
if (!mapDivRef.current || viewRef.current) return;
const { map, view, destroy } = createMapView({
container: mapDivRef.current,
zoom: 9,
constraints: { minZoom: 8, maxZoom: 14 },
});
const layer = createGraphicsLayer('区县填色');
map.add(layer);
layerRef.current = layer;
viewRef.current = view;
view.ui.remove('zoom');
view.when(() => setMapReady(true)).catch(() => setMapReady(true));
let cancelled = false;
fetch('/wuhan_districts.geojson')
.then((r) => r.json())
.then((data: WuhanFeatureCollection) => {
if (cancelled) return;
geoDataRef.current = data;
})
.catch(() => undefined);
return () => {
cancelled = true;
layerRef.current = null;
viewRef.current = null;
destroy();
setMapReady(false);
};
}, []);
useEffect(() => {
const layer = layerRef.current;
const view = viewRef.current;
const data = geoDataRef.current;
if (!layer || !view || !mapReady) return;
const draw = (fc: WuhanFeatureCollection) => {
layer.removeAll();
const graphics: Graphic[] = [];
for (const feature of fc.features) {
const name = feature.properties?.name ?? '未知';
const value = metricLookup[name] ?? 0;
const rings = ringsFromGeometry(feature.geometry);
if (!rings.length) continue;
graphics.push(
new Graphic({
geometry: new Polygon({ rings, spatialReference: { wkid: 4326 } }),
symbol: new SimpleFillSymbol({
color: hexToRgba(colorForValue(value, maxValue)),
outline: { color: hexToRgba(CHART_COLORS.choroplethStroke, 1), width: 1 },
}),
attributes: { name, value, metricLabel },
popupTemplate: {
title: '{name}',
content: `${metricLabel}{value}`,
},
})
);
}
layer.addMany(graphics);
if (graphics.length > 0) {
view.goTo(graphics).catch(() => undefined);
}
};
if (data) {
draw(data);
return;
}
let cancelled = false;
fetch('/wuhan_districts.geojson')
.then((r) => r.json())
.then((fc: WuhanFeatureCollection) => {
if (cancelled) return;
geoDataRef.current = fc;
draw(fc);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [metricLookup, maxValue, metricLabel, mapReady]);
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={TESTIDS.choroplethWrapper} className="relative">
<div
ref={mapDivRef}
className="w-full rounded-lg overflow-hidden bg-slate-100"
style={{ height: 420 }}
/>
{!mapReady && (
<div className="absolute inset-0 flex items-center justify-center bg-bg-card/70 rounded-lg text-[13px] text-text-muted">
</div>
)}
<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);

View File

@@ -0,0 +1,124 @@
import { memo } from 'react';
import {
Activity,
AlertTriangle,
Droplets,
Building2,
TrendingUp,
TrendingDown,
Users,
} from 'lucide-react';
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);
const trendClass =
changeTrend?.direction === 'up'
? 'text-danger'
: changeTrend?.direction === 'down'
? 'text-success'
: 'text-text-muted';
return (
<div
data-testid={TESTIDS.kpiRow}
className="command-rail stagger-children"
role="group"
aria-label="综合关键指标"
>
<div className="command-rail__cell command-rail__cell--hero">
<div className="command-rail__label">
<Users className="w-3.5 h-3.5 text-primary" aria-hidden />
</div>
<div className="command-rail__value">
{kpi?.totalCases?.toLocaleString() ?? '--'}
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<Activity className="w-3.5 h-3.5 text-success" aria-hidden />
</div>
<div className="command-rail__value text-[20px]">
{kpi?.todayCases?.toLocaleString() ?? '--'}
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
{changeTrend?.direction === 'up' ? (
<TrendingUp className="w-3.5 h-3.5 text-danger" aria-hidden />
) : changeTrend?.direction === 'down' ? (
<TrendingDown className="w-3.5 h-3.5 text-success" aria-hidden />
) : (
<Activity className="w-3.5 h-3.5 text-text-muted" aria-hidden />
)}
7
</div>
<div className={`command-rail__value text-[20px] ${trendClass}`}>
{changeTrend ? changeTrend.value : '--'}
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<AlertTriangle className="w-3.5 h-3.5 text-warning" aria-hidden />
</div>
<div
className="command-rail__value text-[20px]"
style={
kpi && kpi.activeAlerts > 0 ? { color: CHART_COLORS.alertP1 } : undefined
}
>
{kpi?.activeAlerts?.toLocaleString() ?? '--'}
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<Building2 className="w-3.5 h-3.5 text-danger" aria-hidden />
</div>
<div className="command-rail__value text-[20px]">
{kpi?.highRiskGrids?.toLocaleString() ?? '--'}
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<Droplets className="w-3.5 h-3.5 text-primary-light" aria-hidden />
AQI
</div>
<div className="command-rail__value text-[20px]">
{kpi?.avgAQI?.toLocaleString() ?? '--'}
</div>
</div>
</div>
);
}
export const KpiRow = memo(KpiRowComponent);

View File

@@ -0,0 +1,79 @@
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[];
embed?: boolean;
}
const tooltipStyle = {
backgroundColor: '#FFFFFF',
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
borderRadius: '10px',
fontSize: '12px',
};
function TopDiagnosesBarComponent({ diagnoses, embed }: TopDiagnosesBarProps) {
const body = (
<>
{embed ? (
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title">Top 5 </h3>
<p className="workbench-panel__sub"> / </p>
</div>
</div>
) : (
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
Top 5
</div>
)}
<div className={embed ? 'workbench-panel__body' : undefined}>
{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>
</>
);
if (embed) return <>{body}</>;
return <div className="card p-4">{body}</div>;
}
export const TopDiagnosesBar = memo(TopDiagnosesBarComponent);

View File

@@ -0,0 +1,109 @@
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 {
districts: DistrictMetric[];
metric: MetricKey;
metricLabel: string;
embed?: boolean;
}
const tooltipStyle = {
backgroundColor: '#FFFFFF',
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
borderRadius: '10px',
fontSize: '12px',
};
function TopDistrictsBarComponent({
districts,
metric,
metricLabel,
embed,
}: TopDistrictsBarProps) {
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';
const body = (
<>
{embed ? (
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title">Top 5 {metricLabel}</h3>
<p className="workbench-panel__sub"></p>
</div>
</div>
) : (
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
Top 5 {metricLabel}
</div>
)}
<div className={embed ? 'workbench-panel__body' : undefined}>
{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>
</>
);
if (embed) return <>{body}</>;
return <div className="card p-4">{body}</div>;
}
export const TopDistrictsBar = memo(TopDistrictsBarComponent);

View File

@@ -0,0 +1,22 @@
/**
* 概览大屏图表与地图使用的字面色值集中处。
* Recharts / GeoScene 符号需要原始 hex无法用 Tailwind class故在此集中定义。
* 色系对齐「江雾」青绿 / 雾蓝 / 石板体系。
*/
export const CHART_COLORS = {
outpatient: '#0F766E', // primary teal
inpatient: '#C2410C', // danger warm
cases: '#0F766E',
aqi: '#C27803', // warning
grid: '#D4DEE4',
axis: '#5A6F7A',
axisLabel: '#1A2B33',
tooltipBorder: '#D4DEE4',
tooltipText: '#1A2B33',
alertP1: '#C2410C',
alertP2: '#C27803',
// choropleth 顺序色阶(浅雾 → 深青),高值高亮
choropleth: ['#E6F4F1', '#CCFBF1', '#99F6E4', '#5EEAD4', '#2DD4BF', '#14B8A6', '#0F766E'],
choroplethEmpty: '#E8EEF1',
choroplethStroke: '#FFFFFF',
} as const;

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

View 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;
}

View 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-xl border border-border shadow-soft',
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>
);
});

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

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

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

View 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-lg bg-bg-hover p-0.5 border border-border-light"
>
{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-md font-medium transition-colors',
sizeClasses,
isActive
? 'bg-primary text-white shadow-soft'
: '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;

View 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(' ')}
/>
);
});

View 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';

View File

@@ -0,0 +1,154 @@
import Basemap from '@geoscene/core/Basemap';
import WebTileLayer from '@geoscene/core/layers/WebTileLayer';
import TileInfo from '@geoscene/core/layers/support/TileInfo';
import SpatialReference from '@geoscene/core/geometry/SpatialReference';
import Map from '@geoscene/core/Map';
import MapView from '@geoscene/core/views/MapView';
/** Wuhan city center — GeoScene / ArcGIS uses [longitude, latitude]. */
export const WUHAN_CENTER: [number, number] = [114.31, 30.59];
const XYZ_TILE_INFO = TileInfo.create({
spatialReference: new SpatialReference({ wkid: 3857 }),
size: 256,
});
export interface CreateMapViewOptions {
container: HTMLDivElement;
zoom?: number;
/** Custom Basemap, or omit to use resolveDefaultBasemap(). */
basemap?: Basemap;
center?: [number, number];
constraints?: {
minZoom?: number;
maxZoom?: number;
};
}
export interface MapHandle {
map: Map;
view: MapView;
destroy: () => void;
}
/**
* Tianditu vector + annotation. Requires a personal key from
* https://console.tianditu.gov.cn/
* (GeoScene named `tianditu-vector` ships a dead public tk → HTTP 418.)
*/
export function createTiandituBasemap(tk: string): Basemap {
const vec = new WebTileLayer({
urlTemplate:
`https://t{subDomain}.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&VERSION=1.0.0` +
`&REQUEST=GetTile&LAYER=vec&STYLE=default&FORMAT=tiles&TILEMATRIXSET=w` +
`&TILEMATRIX={level}&TILEROW={row}&TILECOL={col}&tk=${tk}`,
subDomains: ['0', '1', '2', '3', '4', '5', '6', '7'],
title: '天地图矢量',
tileInfo: XYZ_TILE_INFO,
});
const cva = new WebTileLayer({
urlTemplate:
`https://t{subDomain}.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&VERSION=1.0.0` +
`&REQUEST=GetTile&LAYER=cva&STYLE=default&FORMAT=tiles&TILEMATRIXSET=w` +
`&TILEMATRIX={level}&TILEROW={row}&TILECOL={col}&tk=${tk}`,
subDomains: ['0', '1', '2', '3', '4', '5', '6', '7'],
title: '天地图注记',
tileInfo: XYZ_TILE_INFO,
});
return new Basemap({
baseLayers: [vec, cva],
title: '天地图矢量',
id: 'tianditu-custom',
});
}
/**
* Gaode vector via Vite `/basemap-gaode` proxy (avoids CORS).
* Production without Tianditu: put the same rewrite behind nginx, or set VITE_TIANDITU_TK.
*/
export function createGaodeBasemap(): Basemap {
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const layer = new WebTileLayer({
urlTemplate: `${origin}/basemap-gaode/{level}/{col}/{row}`,
title: '高德矢量',
copyright: '© 高德地图',
tileInfo: XYZ_TILE_INFO,
});
return new Basemap({
baseLayers: [layer],
title: '高德矢量',
id: 'gaode-vector',
});
}
/** One shared basemap — React StrictMode remounts must not abort a fresh #load(). */
let sharedDefaultBasemap: Basemap | null = null;
/**
* - VITE_TIANDITU_TK → 天地图
* - else → 高德GeoScene CN 版没有 topo-vector 等 Esri 命名底图)
*/
export function resolveDefaultBasemap(): Basemap {
if (sharedDefaultBasemap) return sharedDefaultBasemap;
const tk = import.meta.env.VITE_TIANDITU_TK?.trim();
sharedDefaultBasemap = tk ? createTiandituBasemap(tk) : createGaodeBasemap();
return sharedDefaultBasemap;
}
/**
* Create a Map + MapView pair. Caller owns lifecycle via destroy().
* Never pass constraints into the MapView constructor (4.32 Viewport2DMixin crash).
*/
export function createMapView(options: CreateMapViewOptions): MapHandle {
const {
container,
zoom = 10,
basemap = resolveDefaultBasemap(),
center = WUHAN_CENTER,
constraints,
} = options;
const map = new Map({ basemap });
const view = new MapView({
container,
map,
center,
zoom,
});
if (constraints && (constraints.minZoom != null || constraints.maxZoom != null)) {
view
.when(() => {
view.constraints = {
...(constraints.minZoom != null ? { minZoom: constraints.minZoom } : {}),
...(constraints.maxZoom != null ? { maxZoom: constraints.maxZoom } : {}),
};
})
.catch(() => undefined);
}
view.ui.remove('attribution');
return {
map,
view,
destroy: () => {
// Detach shared basemap + destroy operational layers before view teardown,
// otherwise StrictMode cleanup aborts Basemap#load() → AbortError spam.
const m = view.map;
if (m) {
const ops = m.layers.toArray();
m.removeAll();
for (const layer of ops) {
layer.destroy();
}
if (m.basemap === sharedDefaultBasemap) {
m.basemap = null as unknown as Basemap;
}
}
view.map = null as unknown as Map;
view.destroy();
},
};
}

View File

@@ -0,0 +1,17 @@
export {
createMapView,
WUHAN_CENTER,
resolveDefaultBasemap,
createTiandituBasemap,
createGaodeBasemap,
} from './createMapView';
export type { CreateMapViewOptions, MapHandle } from './createMapView';
export {
createRiskTileLayer,
riskTileUrlTemplate,
createGraphicsLayer,
createDistrictsGeoJSONLayer,
pointGraphic,
jitterLonLat,
geosceneEnv,
} from './layers';

View File

@@ -0,0 +1,100 @@
import WebTileLayer from '@geoscene/core/layers/WebTileLayer';
import GraphicsLayer from '@geoscene/core/layers/GraphicsLayer';
import GeoJSONLayer from '@geoscene/core/layers/GeoJSONLayer';
import Graphic from '@geoscene/core/Graphic';
import Point from '@geoscene/core/geometry/Point';
import SimpleMarkerSymbol from '@geoscene/core/symbols/SimpleMarkerSymbol';
import TileInfo from '@geoscene/core/layers/support/TileInfo';
import SpatialReference from '@geoscene/core/geometry/SpatialReference';
const XYZ_TILE_INFO = TileInfo.create({
spatialReference: new SpatialReference({ wkid: 3857 }),
size: 256,
});
/**
* Absolute FastAPI risk XYZ URL for WebTileLayer.
* Relative `/api/...` is resolved to `https://null/...` by the SDK — always use origin.
* Placeholders: {level}/{col}/{row} ≡ Leaflet {z}/{x}/{y} for Web Mercator.
*/
export function riskTileUrlTemplate(day: 1 | 3 | 7, date?: string): string {
const apiBase = (import.meta.env.VITE_API_URL || '/api').replace(/\/$/, '');
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const prefix = apiBase.startsWith('http') ? apiBase : `${origin}${apiBase}`;
const dateParam = date ? `&date=${date}` : '';
return `${prefix}/risk/tiles/{level}/{col}/{row}.png?day=${day}${dateParam}`;
}
export function createRiskTileLayer(day: 1 | 3 | 7, opacity = 0.72): WebTileLayer {
return new WebTileLayer({
urlTemplate: riskTileUrlTemplate(day),
opacity,
title: '风险预测',
listMode: 'hide',
tileInfo: XYZ_TILE_INFO,
});
}
export function createGraphicsLayer(title: string): GraphicsLayer {
return new GraphicsLayer({ title, listMode: 'hide' });
}
export function createDistrictsGeoJSONLayer(url = '/wuhan_districts.geojson'): GeoJSONLayer {
const absolute =
url.startsWith('http') || typeof window === 'undefined'
? url
: `${window.location.origin}${url.startsWith('/') ? '' : '/'}${url}`;
return new GeoJSONLayer({
url: absolute,
title: '武汉区县',
outFields: ['*'],
listMode: 'hide',
});
}
export function pointGraphic(
longitude: number,
latitude: number,
color: string,
size = 8,
attributes?: Record<string, unknown>
): Graphic {
return new Graphic({
geometry: new Point({ longitude, latitude }),
symbol: new SimpleMarkerSymbol({
style: 'circle',
color,
size,
outline: { color: [255, 255, 255, 0.85], width: 1 },
}),
attributes,
});
}
/** Deterministic ~meter jitter so stacked street/district centroids separate visually. */
export function jitterLonLat(
longitude: number,
latitude: number,
seed: string,
meters = 55
): [number, number] {
let h = 2166136261;
for (let i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 16777619);
}
const u = ((h >>> 0) % 1000) / 1000;
const v = (((h >>> 10) % 1000) / 1000);
const angle = u * Math.PI * 2;
const radius = (0.25 + v * 0.75) * meters;
const dLat = (radius * Math.cos(angle)) / 111_320;
const dLon = (radius * Math.sin(angle)) / (111_320 * Math.cos((latitude * Math.PI) / 180));
return [longitude + dLon, latitude + dLat];
}
export const geosceneEnv = {
portalUrl: import.meta.env.VITE_GEOSCENE_PORTAL_URL as string | undefined,
districtsUrl: import.meta.env.VITE_LAYER_DISTRICTS_URL as string | undefined,
riskUrl: import.meta.env.VITE_LAYER_RISK_URL as string | undefined,
casesUrl: import.meta.env.VITE_LAYER_CASES_URL as string | undefined,
};

View File

@@ -3,45 +3,410 @@
@tailwind utilities;
@layer base {
:root {
--cbpoa-teal: #0f766e;
--cbpoa-mist: #5b8fa8;
--cbpoa-slate: #1a2b33;
--cbpoa-fog: #f0f4f6;
--cbpoa-river: linear-gradient(
145deg,
#e8f2f6 0%,
#f0f4f6 38%,
#e6f4f1 72%,
#eef3f5 100%
);
--cbpoa-glass: rgba(250, 252, 253, 0.82);
--cbpoa-glass-strong: rgba(255, 255, 255, 0.92);
--cbpoa-ink: #1a2b33;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
@apply bg-bg-page text-text-primary font-sans;
background-image: var(--cbpoa-river);
background-attachment: fixed;
}
::selection {
background: rgba(15, 118, 110, 0.18);
}
}
@layer components {
.card {
@apply bg-bg-card border border-border rounded-lg;
@apply bg-bg-card border border-border rounded-xl shadow-soft;
}
.btn-primary {
@apply bg-primary text-white px-4 py-2 rounded-md text-sm font-medium
hover:bg-primary-light transition-colors;
@apply bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium
hover:bg-primary-deep transition-colors shadow-soft;
}
.btn-secondary {
@apply bg-bg-page text-text-secondary px-4 py-2 rounded-md text-sm font-medium
@apply bg-bg-elevated text-text-secondary px-4 py-2 rounded-lg text-sm font-medium
border border-border hover:border-primary hover:text-primary transition-colors;
}
.page-inset {
@apply px-5 py-5;
}
/* —— 指挥台指标带:连续仪表条,非六块同质小卡 —— */
.metrics-band {
@apply relative border-b border-border;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.94) 0%, rgba(240, 244, 246, 0.88) 100%);
backdrop-filter: blur(10px);
}
.metrics-band::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(15, 118, 110, 0.28) 20%,
rgba(91, 143, 168, 0.35) 50%,
rgba(15, 118, 110, 0.28) 80%,
transparent 100%
);
}
.command-rail {
@apply flex flex-wrap items-stretch w-full min-w-0 rounded-xl overflow-hidden
border border-border shadow-soft;
background-color: rgba(255, 255, 255, 0.72);
background-image:
linear-gradient(135deg, rgba(15, 118, 110, 0.04) 0%, transparent 42%),
linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(248, 251, 252, 0.75));
}
.command-rail__cell {
@apply relative flex flex-col justify-center gap-1 px-3.5 py-2.5 min-w-0;
flex: 1 1 140px;
}
.command-rail__cell + .command-rail__cell::before {
content: '';
position: absolute;
left: 0;
top: 18%;
bottom: 18%;
width: 1px;
background: linear-gradient(
180deg,
transparent,
rgba(212, 222, 228, 0.95) 30%,
rgba(212, 222, 228, 0.95) 70%,
transparent
);
}
.command-rail__cell--hero {
flex: 1.35 1 170px;
background: linear-gradient(
120deg,
rgba(15, 118, 110, 0.08) 0%,
rgba(91, 143, 168, 0.04) 55%,
transparent 100%
);
}
.command-rail__label {
@apply flex items-center gap-1.5 text-[11px] font-medium text-text-secondary tracking-wide;
}
.command-rail__value {
@apply data-num text-[22px] leading-none text-text-primary;
}
.command-rail__cell--hero .command-rail__value {
@apply text-[28px] text-primary-deep;
}
.command-rail__meta {
@apply flex items-center gap-2 mt-0.5 min-h-[18px];
}
/* 兼容旧 StatCard总览等仍用 */
.stat-card {
@apply relative overflow-hidden bg-bg-card border border-border rounded-xl p-4 shadow-soft;
transition: box-shadow 200ms ease, transform 200ms ease, border-color 200ms ease;
}
.stat-card::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(180deg, var(--cbpoa-teal), var(--cbpoa-mist));
opacity: 0.85;
}
.stat-card:hover {
@apply shadow-lift border-primary/25;
}
/* —— 地图舞台 / 玻璃翼 —— */
.map-stage {
@apply relative flex-1 min-h-0 overflow-hidden;
background-color: #dce6eb;
}
.map-stage--flush {
@apply rounded-none border-0 shadow-none;
}
.map-chrome {
@apply pointer-events-none absolute top-0 right-0 z-10
flex items-start justify-end gap-2 p-3;
}
@media (min-width: 640px) {
.map-chrome {
@apply p-4;
}
}
.map-chrome__chip {
@apply pointer-events-auto inline-flex items-center gap-2
rounded-lg border border-white/50 bg-bg-card/90 backdrop-blur-md
px-3 py-1.5 shadow-soft;
}
.glass-wing {
@apply relative flex flex-col min-h-0 overflow-hidden;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.88) 0%, rgba(240, 244, 246, 0.92) 100%);
border-left: 1px solid rgba(212, 222, 228, 0.9);
box-shadow: -12px 0 32px rgba(26, 43, 51, 0.04);
}
.glass-wing__section {
@apply px-4 pt-4 pb-3 border-b border-border-light;
}
.glass-wing__section:last-child {
@apply border-b-0 flex-1 min-h-0 flex flex-col;
}
.glass-wing__title {
@apply text-[11px] font-semibold uppercase text-mist-deep mb-3;
letter-spacing: 0.12em;
}
/* 区县条:层次更强 */
.district-bar {
@apply flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer;
transition: background-color 150ms ease, box-shadow 150ms ease;
}
.district-bar:hover {
@apply bg-mist-light;
}
.district-bar--active {
@apply bg-primary-muted ring-1 ring-primary/25;
}
.district-bar__track {
@apply relative flex-1 h-6 rounded-md overflow-hidden;
background: linear-gradient(90deg, #e8eef1 0%, #f3f6f8 100%);
}
.district-bar__fill {
@apply absolute inset-y-0 left-0 flex overflow-hidden rounded-md;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25);
}
/* —— 图表工作区面板 —— */
.workbench-panel {
@apply relative overflow-hidden rounded-2xl border border-border bg-bg-card shadow-soft;
}
.workbench-panel::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(180deg, var(--cbpoa-teal), var(--cbpoa-mist));
opacity: 0.75;
}
.workbench-panel__head {
@apply flex items-end justify-between gap-3 px-5 pt-4 pb-2;
}
.workbench-panel__title {
@apply font-display text-[15px] font-semibold text-text-primary tracking-tight;
}
.workbench-panel__sub {
@apply text-[12px] text-text-muted mt-0.5;
}
.workbench-panel__body {
@apply px-5 pb-5 pt-1;
}
/* 页内分段 tab */
.tab-strip {
@apply flex items-center gap-0;
}
.tab-strip__item {
@apply relative px-4 py-2.5 text-[13px] font-medium text-text-secondary
border-b-2 border-transparent -mb-px transition-colors;
}
.tab-strip__item:hover {
@apply text-text-primary;
}
.tab-strip__item--active {
@apply text-primary border-primary;
}
/* —— 底部时间轴坞 —— */
.timeline-dock {
@apply fixed right-0 bottom-0 pointer-events-none;
z-index: 60;
left: 0;
padding-left: max(0.75rem, env(safe-area-inset-left));
padding-right: max(0.75rem, env(safe-area-inset-right));
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
}
@media (min-width: 1024px) {
.timeline-dock {
left: 212px;
}
}
.timeline-dock__inner {
@apply pointer-events-auto mx-auto max-w-5xl
rounded-2xl border border-border shadow-lift
px-3 py-2.5;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.94) 0%,
rgba(240, 244, 246, 0.96) 100%
);
backdrop-filter: blur(14px);
}
@media (min-width: 640px) {
.timeline-dock__inner {
@apply px-4 py-3;
}
}
.timeline-dock__track {
@apply relative h-1.5 rounded-full overflow-hidden bg-bg-hover;
}
.timeline-dock__fill {
@apply absolute inset-y-0 left-0 rounded-full;
background: linear-gradient(90deg, #0d5c56, #0f766e 55%, #5b8fa8);
}
/* 风险分布条(预警) */
.risk-strip {
@apply grid grid-cols-2 gap-px rounded-xl overflow-hidden
border border-border bg-border shadow-soft;
}
@media (min-width: 640px) {
.risk-strip {
@apply grid-cols-4;
}
}
.risk-strip__cell {
@apply bg-bg-card px-3.5 py-3;
}
.risk-strip__label {
@apply text-[11px] text-text-muted mb-1;
}
.risk-strip__value {
@apply data-num text-[22px] leading-none;
}
/* 登录页雾感氛围 */
.login-atmosphere {
background:
radial-gradient(ellipse 80% 60% at 15% 20%, rgba(91, 143, 168, 0.22), transparent 55%),
radial-gradient(ellipse 70% 50% at 85% 75%, rgba(15, 118, 110, 0.16), transparent 50%),
radial-gradient(ellipse 50% 40% at 50% 100%, rgba(232, 242, 246, 0.9), transparent 60%),
linear-gradient(160deg, #dce8ee 0%, #eef4f6 45%, #e4f0ed 100%);
}
.login-atmosphere::after {
content: '';
position: absolute;
inset: 0;
opacity: 0.04;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
.brand-mark {
@apply font-display font-bold tracking-tight text-primary;
}
.data-num {
@apply font-mono font-semibold tabular-nums tracking-tight text-text-primary;
}
}
/* Leaflet overrides — keep z-index below TopNav (z-50) and SideNav */
.leaflet-container {
/* GeoScene MapView — keep below TopNav (z-50) */
.esri-view,
.geoscene-view {
font-family: inherit;
}
.leaflet-pane {
z-index: 1 !important;
}
.leaflet-top,
.leaflet-bottom {
.esri-ui,
.geoscene-ui {
z-index: 5 !important;
}
.leaflet-popup-content-wrapper {
@apply rounded-lg shadow-lg;
/* 交错入场 */
.stagger-children > * {
animation: fade-up 0.45s ease-out both;
}
.stagger-children > *:nth-child(1) { animation-delay: 0.04s; }
.stagger-children > *:nth-child(2) { animation-delay: 0.08s; }
.stagger-children > *:nth-child(3) { animation-delay: 0.12s; }
.stagger-children > *:nth-child(4) { animation-delay: 0.16s; }
.stagger-children > *:nth-child(5) { animation-delay: 0.2s; }
.stagger-children > *:nth-child(6) { animation-delay: 0.24s; }
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.leaflet-popup-content {
@apply m-0;
@keyframes rail-shimmer {
0% { background-position: 0% 50%; }
100% { background-position: 100% 50%; }
}

View File

@@ -1,6 +1,7 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import '@geoscene/core/assets/geoscene/themes/light/main.css'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(

View File

@@ -1,35 +1,14 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import React from 'react';
import { useRiskStore } from '@/stores';
import { AlertMap } from '@/components/AlertMap';
import { TESTIDS } from '@/utils/testids';
import type { CellInfo } from '@/components/AlertMap';
import { ErrorBanner } from '@/components/ErrorBanner';
import { StatCard } from '@/components/StatCard';
import { StatisticalCharts } from '@/components/StatisticalCharts';
import { analysisApi } from '@/services/api';
import { PieChart, Pie, Cell, Tooltip as RechartsTooltip, Legend, ResponsiveContainer } from 'recharts';
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;
}
const HORIZON_LABELS: Record<number, string> = {
1: '1 天后',
3: '3 天后',
7: '7 天后',
};
import { AlertsHeader } from '@/components/alerts/AlertsHeader';
import { AlertsListTab } from '@/components/alerts/AlertsListTab';
import { AlertsRiskPanel } from '@/components/alerts/AlertsRiskPanel';
import { AlertDetailModal, CellInfoPanel } from '@/components/alerts/AlertDetailModal';
import type { ExtendedAlert, RiskStats } from '@/components/alerts/types';
export function AlertsDashboard() {
const alerts = useRiskStore((s) => s.alerts);
@@ -42,7 +21,7 @@ export function AlertsDashboard() {
const [selectedPriority, setSelectedPriority] = useState<'all' | 'P1' | 'P2'>('all');
const [sortBy, setSortBy] = useState<'risk' | 'time'>('risk');
const [showMap, setShowMap] = useState(true);
const [showAlertMarkers, setShowAlertMarkers] = useState(false);
const [showAlertMarkers, setShowAlertMarkers] = useState(true);
const [selectedAlert, setSelectedAlert] = useState<string | null>(null);
const [riskRange, setRiskRange] = useState<[number, number]>([0.6, 1.0]);
const [debouncedRiskRange, setDebouncedRiskRange] = useState<[number, number]>([0.6, 1.0]);
@@ -51,28 +30,23 @@ export function AlertsDashboard() {
const [showGrid, setShowGrid] = useState(true);
const [cellInfo, setCellInfo] = useState<CellInfo | null>(null);
// In-page tab strip (no router) — matches existing activePage pattern
const [activeTab, setActiveTab] = useState<'list' | 'stats'>('list');
// Risk-trend data for the 风险统计 tab, fetched on demand
const [trendData, setTrendData] = useState<Array<{ date: string; cases: number; risk: number }>>([]);
const [trendLoading, setTrendLoading] = useState(false);
const [trendError, setTrendError] = useState<string | null>(null);
const [trendLoaded, setTrendLoaded] = useState(false);
// Debounce riskRange for filteredAlerts computation
useEffect(() => {
const timer = setTimeout(() => setDebouncedRiskRange(riskRange), 300);
return () => clearTimeout(timer);
}, [riskRange]);
// Fetch grids (for map) and alerts (for side panel) on mount
useEffect(() => {
fetchRiskMap();
fetchAlerts();
}, [fetchRiskMap, fetchAlerts]);
// Fetch real risk-trend data when the 风险统计 tab is first opened
useEffect(() => {
if (activeTab !== 'stats' || trendLoaded) return;
let cancelled = false;
@@ -94,7 +68,9 @@ export function AlertsDashboard() {
.finally(() => {
if (!cancelled) setTrendLoading(false);
});
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [activeTab, trendLoaded]);
const extendedAlerts: ExtendedAlert[] = useMemo(() => {
@@ -118,7 +94,8 @@ export function AlertsDashboard() {
.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];
const riskMatch =
alert.risk_value >= debouncedRiskRange[0] && alert.risk_value <= debouncedRiskRange[1];
return horizonMatch && priorityMatch && riskMatch;
})
.sort((a, b) => {
@@ -129,9 +106,7 @@ export function AlertsDashboard() {
});
}, [extendedAlerts, selectedHorizon, selectedPriority, sortBy, debouncedRiskRange]);
// Risk distribution stats (includes p1/p2 counts) — single pass over each array
const riskStats = useMemo(() => {
// p1/p2 reflect the full (unfiltered) alert set
const riskStats: RiskStats = useMemo(() => {
let p1 = 0;
let p2 = 0;
for (const a of extendedAlerts) {
@@ -139,7 +114,6 @@ export function AlertsDashboard() {
else if (a.priority === 'P2') p2++;
}
// Single pass over filteredAlerts: counters + sum + district map
let high = 0;
let mediumHigh = 0;
let medium = 0;
@@ -157,6 +131,7 @@ export function AlertsDashboard() {
const avgRisk = filteredAlerts.length > 0 ? sum / filteredAlerts.length : 0;
const topDistricts = Object.entries(byDistrict)
.filter(([name]) => name && name !== '武汉市' && name !== '未知')
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
@@ -164,31 +139,22 @@ export function AlertsDashboard() {
}, [extendedAlerts, filteredAlerts]);
const selectedAlertData = useMemo(() => {
return filteredAlerts.find(a => a.alert_id === selectedAlert);
return filteredAlerts.find((a) => a.alert_id === 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(() => {
if (!selectedAlert) return null;
const alert = filteredAlerts.find(a => a.alert_id === selectedAlert);
const alert = filteredAlerts.find((a) => a.alert_id === selectedAlert);
return alert?.grid_id ?? null;
}, [filteredAlerts, selectedAlert]);
const filteredAlertsRef = useRef(filteredAlerts);
useEffect(() => { filteredAlertsRef.current = filteredAlerts; }, [filteredAlerts]);
useEffect(() => {
filteredAlertsRef.current = filteredAlerts;
}, [filteredAlerts]);
const handleGridClick = useCallback((gridId: string) => {
const alertForGrid = filteredAlertsRef.current.find(a => a.grid_id === gridId);
const alertForGrid = filteredAlertsRef.current.find((a) => a.grid_id === gridId);
if (alertForGrid) {
setSelectedAlert(alertForGrid.alert_id);
}
@@ -204,23 +170,42 @@ export function AlertsDashboard() {
const handleCellInfo = useCallback((info: CellInfo) => {
setCellInfo(info);
setSelectedAlert(null); // Close alert modal if open
setSelectedAlert(null);
}, []);
const clearCellInfo = useCallback(() => {
setCellInfo(null);
}, []);
// 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 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(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
const csv = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -241,575 +226,83 @@ export function AlertsDashboard() {
}, [filteredAlerts]);
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-[54px] p-4 overflow-auto'
: 'flex flex-col h-full min-h-0 overflow-auto p-4'
}
>
{error && (
<ErrorBanner
error={error}
onRetry={() => { clearError(); fetchRiskMap(); fetchAlerts(); }}
onRetry={() => {
clearError();
fetchRiskMap();
fetchAlerts();
}}
onDismiss={clearError}
/>
)}
{/* 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">{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>
<AlertsHeader
total={filteredAlerts.length}
p1={riskStats.p1}
p2={riskStats.p2}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
{activeTab === 'list' && (
<>
{/* Toolbar Row 1: Forecast + Fullscreen + Export */}
<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={() => setForecastDay(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={() => setIsFullscreen(!isFullscreen)}
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={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>
)}
</>
<AlertsListTab
forecastDay={forecastDay}
onForecastDayChange={setForecastDay}
isFullscreen={isFullscreen}
onToggleFullscreen={() => setIsFullscreen(!isFullscreen)}
onExportCsv={exportToCsv}
onExportJson={exportToJson}
selectedHorizon={selectedHorizon}
onHorizonChange={setSelectedHorizon}
selectedPriority={selectedPriority}
onPriorityChange={setSelectedPriority}
riskRange={riskRange}
onRiskRangeChange={setRiskRange}
showMap={showMap}
onToggleMap={() => setShowMap(!showMap)}
showAlertMarkers={showAlertMarkers}
onToggleAlertMarkers={() => setShowAlertMarkers(!showAlertMarkers)}
showGrid={showGrid}
onToggleGrid={() => setShowGrid(!showGrid)}
sortBy={sortBy}
onSortByChange={setSortBy}
riskStats={riskStats}
filteredAlerts={filteredAlerts}
isLoading={isLoading}
selectedGridId={selectedGridId}
selectedAlert={selectedAlert}
onGridClick={handleGridClick}
onCellInfo={handleCellInfo}
onCardClick={handleAlertCardClick}
/>
)}
{activeTab === 'stats' && (
<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 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>
<AlertsRiskPanel
riskStats={riskStats}
trendData={trendData}
trendLoading={trendLoading}
trendError={trendError}
/>
)}
{/* Cell info panel - shown when clicking grid cell without alert */}
{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]">
<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">&times;</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>
<CellInfoPanel cellInfo={cellInfo} onClose={clearCellInfo} />
)}
{/* Alert detail modal */}
{selectedAlertData && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center" onClick={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>
<AlertDetailModal alert={selectedAlertData} onClose={clearSelectedAlert} />
)}
</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>
);
});

View 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="年龄别 BMIP25中位P75">
<BoxPlotRows rows={bmiBox} />
</Card>
</div>
</div>
);
}

View File

@@ -14,6 +14,8 @@ import {
import { Users, Activity } from 'lucide-react';
import { caseApi } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import { LoadingState } from '@/components/ui';
import { TESTIDS } from '@/utils/testids';
import type { DemographicsResponse, AgeBin, AgeDiagnosisMatrixItem } from '@/types';
// --- Chart 3 helpers ---
@@ -108,9 +110,11 @@ export function DemographicAnalysis() {
}, []);
// --- Derived data ---
// 防御性归一化:后端返回意外形状(缺字段/类型不符)时降级为空,避免 .map 抛错
// 冒泡到根 ErrorBoundary 把整页白屏(与 DiseaseAnalysis/EnvironmentalHealth 的处理一致)。
const ageData: AgeBin[] = useMemo(() => {
if (!data) return [];
return data.age_distribution.map((d) => ({
const bins = Array.isArray(data?.age_distribution) ? data!.age_distribution : [];
return bins.map((d) => ({
age_bin: d.age_bin,
outpatient: d.outpatient,
inpatient: d.inpatient,
@@ -119,8 +123,8 @@ export function DemographicAnalysis() {
const genderData = useMemo(() => {
if (!data) return [];
const male = data.gender_split.male.inpatient;
const female = data.gender_split.female.inpatient;
const male = data.gender_split?.male?.inpatient ?? 0;
const female = data.gender_split?.female?.inpatient ?? 0;
return [
{ name: '男性', value: male, color: '#3B82F6' },
{ name: '女性', value: female, color: '#EC4899' },
@@ -132,8 +136,8 @@ export function DemographicAnalysis() {
}, [genderData]);
const heatmapData = useMemo(() => {
if (!data) return { diagnoses: [], matrix: [], totals: [] };
return buildHeatmapMatrix(data.age_diagnosis_matrix);
const matrix = Array.isArray(data?.age_diagnosis_matrix) ? data!.age_diagnosis_matrix : [];
return buildHeatmapMatrix(matrix);
}, [data]);
const heatmapMax = useMemo(() => {
@@ -148,11 +152,7 @@ export function DemographicAnalysis() {
// --- Loading state ---
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>
);
return <LoadingState testid={TESTIDS.pageLoading} />;
}
const isEmpty =
@@ -162,7 +162,7 @@ export function DemographicAnalysis() {
heatmapData.matrix.length === 0);
return (
<div className="flex flex-col h-full overflow-auto">
<div data-testid="page-demographics" className="flex flex-col h-full overflow-auto">
{error && (
<div className="px-6 pt-4">
<ErrorBanner

View File

@@ -11,8 +11,9 @@ import {
Cell,
ReferenceLine,
} from 'recharts';
import { Stethoscope, Activity } from 'lucide-react';
import { caseApi } from '@/services/api';
import { Stethoscope, Activity, MessageSquareText } from 'lucide-react';
import { caseApi, statsApi } from '@/services/api';
import type { SymptomsResponse } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import type {
DiagnosisDistributionItem,
@@ -154,8 +155,9 @@ function SeasonalityHeatmap({ data }: { data: DiseaseSeasonalityPoint[] }) {
</div>
)}
<div className="overflow-x-auto">
<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={{
gridTemplateColumns: `minmax(90px, auto) repeat(${uniqueMonths > 0 ? uniqueMonths : 12}, 1fr)`,
}}
@@ -198,6 +200,7 @@ function SeasonalityHeatmap({ data }: { data: DiseaseSeasonalityPoint[] }) {
</Fragment>
))}
</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 ---
export function DiseaseAnalysis() {
const [diagDistribution, setDiagDistribution] = useState<DiagnosisDistributionItem[]>([]);
const [seasonality, setSeasonality] = useState<DiseaseSeasonalityPoint[]>([]);
const [districts, setDistricts] = useState<DistrictCaseData[]>([]);
const [symptoms, setSymptoms] = useState<SymptomsResponse['symptoms']>([]);
const [revisitRatio, setRevisitRatio] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [errors, setErrors] = useState<string[]>([]);
@@ -388,10 +443,11 @@ export function DiseaseAnalysis() {
setIsLoading(true);
setErrors([]);
const [distR, seasonR, districtR] = await Promise.allSettled([
const [distR, seasonR, districtR, symptomR] = await Promise.allSettled([
caseApi.getDiagnosisDistribution(15),
caseApi.getDiseaseSeasonality(),
caseApi.getDistricts(),
statsApi.getSymptoms(20),
]);
if (cancelled) return;
@@ -416,6 +472,15 @@ export function DiseaseAnalysis() {
newErrors.push('区县数据加载失败');
}
if (symptomR.status === 'fulfilled') {
setSymptoms(symptomR.value.symptoms || []);
setRevisitRatio(
typeof symptomR.value.revisit_ratio === 'number' ? symptomR.value.revisit_ratio : null,
);
} else {
newErrors.push('症状词频数据加载失败');
}
setErrors(newErrors);
setIsLoading(false);
};
@@ -442,7 +507,7 @@ export function DiseaseAnalysis() {
const avgOIRatio = totalIn > 0 ? totalOut / totalIn : 0;
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 && (
<div className="px-6 pt-4">
<ErrorBanner
@@ -495,6 +560,26 @@ export function DiseaseAnalysis() {
</div>
<DiagnosisSummaryTable diagnoses={diagDistribution} districtsData={districts} />
</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>
);

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { LoadingState, Segmented } from '@/components/ui';
import {
BarChart,
Bar,
@@ -11,7 +12,8 @@ import {
ReferenceLine,
} from 'recharts';
import { useAnalysisStore } from '@/stores/analysisStore';
import { caseApi } from '@/services/api';
import { caseApi, statsApi } from '@/services/api';
import type { IncidenceRateResponse } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import { MetricHeatmapTable } from '@/components/MetricHeatmapTable';
import { BarChart3, MapPin, Users, Shield } from 'lucide-react';
@@ -35,6 +37,10 @@ export function DistrictComparison() {
const [caseDistrictData, setCaseDistrictData] = useState<DistrictCaseData[]>([]);
const [caseDataLoading, setCaseDataLoading] = useState(false);
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(() => {
fetchDistricts();
@@ -60,6 +66,26 @@ export function DistrictComparison() {
return () => { cancelled = true; };
}, []);
useEffect(() => {
let cancelled = false;
setIncidenceLoading(true);
setIncidenceError(null);
statsApi.getIncidenceRate()
.then((res) => {
if (!cancelled) {
setIncidence(res.districts || []);
setIncidenceLoading(false);
}
})
.catch((e) => {
if (!cancelled) {
setIncidenceError((e as Error).message || '加载发病率数据失败');
setIncidenceLoading(false);
}
});
return () => { cancelled = true; };
}, []);
const metricConfig = {
avg_aqi: { label: '平均AQI', color: '#2563EB', unit: '' },
avg_risk: { label: '平均风险', color: '#DC2626', unit: '' },
@@ -103,6 +129,12 @@ export function DistrictComparison() {
return { data, cityOIAvg };
}, [caseDistrictData]);
const incidenceChart = useMemo(() => {
const valueKey = incidenceMetric === 'rate' ? 'rate_per_10k' : 'total_cases';
const data = [...incidence].sort((a, b) => b[valueKey] - a[valueKey]);
return { data, valueKey };
}, [incidence, incidenceMetric]);
const heatmapMetrics = useMemo(() => {
const caseMap = new Map(caseDistrictData.map((d) => [d.district, d]));
const rows: string[] = [];
@@ -127,7 +159,7 @@ export function DistrictComparison() {
};
return (
<div>
<div data-testid="page-district" className="overflow-x-hidden">
{error && (
<ErrorBanner
error={error}
@@ -154,6 +186,25 @@ export function DistrictComparison() {
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">
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-primary" />
@@ -184,8 +235,8 @@ export function DistrictComparison() {
</div>
{isLoading && (
<div className="mb-4 text-center py-8 bg-bg-card rounded-lg border border-border">
<span className="text-text-secondary">...</span>
<div className="mb-4 bg-bg-card rounded-lg border border-border">
<LoadingState />
</div>
)}
@@ -244,7 +295,7 @@ export function DistrictComparison() {
</ResponsiveContainer>
</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) => (
<div key={district.district} className="card p-4">
<div className="flex items-center justify-between mb-3">
@@ -379,13 +430,86 @@ export function DistrictComparison() {
)}
</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 */}
<div className="card p-4 mb-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
/ (O/I Ratio)
</div>
{caseDataLoading && (
<div className="text-center py-8 text-text-secondary">...</div>
<LoadingState />
)}
{!caseDataLoading && oiRatioData.data.length > 0 ? (
<ResponsiveContainer width="100%" height={350}>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from 'react';
import { useEffect, useState, useMemo, Fragment } from 'react';
import {
LineChart,
Line,
@@ -12,9 +12,13 @@ import {
ResponsiveContainer,
ReferenceLine,
Cell,
ScatterChart,
Scatter,
ZAxis,
} from 'recharts';
import { Wind } from 'lucide-react';
import { envApi, caseApi } from '@/services/api';
import { envApi, caseApi, statsApi } from '@/services/api';
import type { EnvCorrelationResponse } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import { CalendarHeatmap } from '@/components/CalendarHeatmap';
import type {
@@ -58,6 +62,55 @@ function getAQICategory(aqi: number): typeof AQI_CATEGORIES[number] {
return AQI_CATEGORIES[AQI_CATEGORIES.length - 1];
}
// Canonical pollutant order for the 7×7 correlation matrix.
const CORR_POLLUTANTS = ['AQI', 'PM25', 'PM10', 'SO2', 'NO2', 'O3', 'CO'];
function pollutantLabel(key: string): string {
switch (key) {
case 'PM25':
return 'PM2.5';
case 'SO2':
return 'SO₂';
case 'NO2':
return 'NO₂';
case 'O3':
return 'O₃';
default:
return key;
}
}
// Diverging color scale for a correlation value in [-1, 1].
// Blue (negative) → white (0) → red (positive); opacity scales with |corr|.
function corrColor(corr: number): string {
const v = Math.max(-1, Math.min(1, corr));
if (v >= 0) return `rgba(220, 38, 38, ${0.12 + 0.88 * v})`;
return `rgba(37, 99, 235, ${0.12 + 0.88 * -v})`;
}
// Least-squares linear regression: returns slope/intercept over (x, y) points.
function linearRegression(
points: { x: number; y: number }[],
): { slope: number; intercept: number } | null {
const n = points.length;
if (n < 2) return null;
let sx = 0;
let sy = 0;
let sxx = 0;
let sxy = 0;
for (const p of points) {
sx += p.x;
sy += p.y;
sxx += p.x * p.x;
sxy += p.x * p.y;
}
const denom = n * sxx - sx * sx;
if (Math.abs(denom) < 1e-9) return null;
const slope = (n * sxy - sx * sy) / denom;
const intercept = (sy - slope * sx) / n;
return { slope, intercept };
}
function findMaxLag(correlations: LagCorrelationItem[], pollutant: string): number | null {
if (correlations.length === 0) return null;
const pollData = correlations.filter(
@@ -83,6 +136,7 @@ export function EnvironmentalHealth() {
const [pollutants365, setPollutants365] = useState<PollutantPoint[]>([]);
const [pollutants30, setPollutants30] = useState<PollutantPoint[]>([]);
const [caseTrend, setCaseTrend] = useState<CaseTrendPoint[]>([]);
const [envCorr, setEnvCorr] = useState<EnvCorrelationResponse | null>(null);
// UI states
const [isLoading, setIsLoading] = useState(true);
@@ -104,11 +158,12 @@ export function EnvironmentalHealth() {
setIsLoading(true);
setErrors([]);
const [lagR, p365R, p30R, caseTrendR] = await Promise.allSettled([
const [lagR, p365R, p30R, caseTrendR, envCorrR] = await Promise.allSettled([
envApi.getLagCorrelations(),
envApi.getPollutants(365),
envApi.getPollutants(30),
caseApi.getTrend({ group_by: 'day' }),
statsApi.getEnvCorrelation(),
]);
if (cancelled) return;
@@ -145,6 +200,12 @@ export function EnvironmentalHealth() {
newErrors.push('病例趋势数据加载失败');
}
if (envCorrR.status === 'fulfilled') {
setEnvCorr(envCorrR.value);
} else {
newErrors.push('污染物关联分析数据加载失败');
}
setErrors(newErrors);
setIsLoading(false);
};
@@ -266,6 +327,61 @@ export function EnvironmentalHealth() {
}));
}, [pollutants30]);
// --- Correlation: pollutant × cases (grid-level Pearson) ---
const corrWithCases = useMemo(() => {
const matrix = envCorr?.correlation_matrix ?? [];
return matrix
.map((m) => ({
pollutant: m.pollutant,
label: pollutantLabel(m.pollutant),
corr: m.corr_with_cases,
}))
.sort((a, b) => Math.abs(b.corr) - Math.abs(a.corr));
}, [envCorr]);
// --- Correlation: pollutant pairwise 7×7 heatmap ---
// pollutant_pairwise carries the upper triangle (21 pairs); we mirror it into
// a symmetric lookup and fill the diagonal with 1.
const pairwiseGrid = useMemo(() => {
const pairs = envCorr?.pollutant_pairwise ?? [];
if (pairs.length === 0) return null;
const lookup = new Map<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 ---
if (isLoading) {
return (
@@ -276,7 +392,7 @@ export function EnvironmentalHealth() {
}
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 */}
{errors.length > 0 && (
<div className="px-6 pt-4">
@@ -706,6 +822,229 @@ export function EnvironmentalHealth() {
</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>
);

View File

@@ -1,7 +1,7 @@
import { useEffect, useState, useMemo } from 'react';
import { LoadingState } from '@/components/ui';
import { useAnalysisStore } from '@/stores/analysisStore';
import { ErrorBanner } from '@/components/ErrorBanner';
import { ChatBot } from '@/components/ChatBot';
import { caseApi } from '@/services/api';
import type { CaseTrendPoint } from '@/types';
import {
@@ -186,7 +186,7 @@ export function Insights() {
: [];
return (
<div>
<div data-testid="page-insights" className="overflow-x-hidden">
{error && (
<ErrorBanner
error={error}
@@ -205,13 +205,13 @@ export function Insights() {
</div>
{isLoading && (
<div className="mb-4 text-center py-8 bg-bg-card rounded-lg border border-border">
<span className="text-text-secondary">...</span>
<div className="mb-4 bg-bg-card rounded-lg border border-border">
<LoadingState />
</div>
)}
{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) => (
<div key={stat.label} className="card p-4">
<div className="flex items-center gap-2 mb-2">
@@ -231,7 +231,7 @@ export function 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) => {
const config = TYPE_CONFIG[card.type];
const Icon = config.icon;
@@ -307,8 +307,8 @@ export function Insights() {
</p>
{anomalyLoading && (
<div className="card p-8 text-center">
<span className="text-text-secondary">...</span>
<div className="card p-8">
<LoadingState />
</div>
)}
@@ -423,8 +423,6 @@ export function Insights() {
</>
)}
</div>
<ChatBot />
</div>
);
}

View File

@@ -1,11 +1,14 @@
import { useState, FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '@/services/api';
import { TESTIDS } from '@/utils/testids';
interface LoginProps {
onLogin: (token: string) => void;
}
export function Login({ onLogin }: LoginProps) {
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
@@ -20,6 +23,7 @@ export function Login({ onLogin }: LoginProps) {
const token = res.data.access_token;
localStorage.setItem('cbpoa_token', token);
onLogin(token);
navigate('/monitoring');
} catch {
setError('用户名或密码错误');
} finally {
@@ -28,51 +32,92 @@ export function Login({ onLogin }: LoginProps) {
};
return (
<div className="min-h-screen bg-bg-page flex items-center justify-center">
<form
onSubmit={handleSubmit}
className="bg-white rounded-lg shadow-md p-8 w-full max-w-sm"
>
<h1 className="text-xl font-semibold text-text-primary mb-6 text-center">
CBPOA
</h1>
<div
data-testid={TESTIDS.pageLogin}
className="login-atmosphere relative min-h-screen flex items-center justify-center overflow-hidden px-4"
>
{/* 呼吸感雾层 */}
<div
className="pointer-events-none absolute -top-24 left-1/4 h-72 w-72 rounded-full bg-mist/30 blur-3xl animate-breath"
aria-hidden
/>
<div
className="pointer-events-none absolute bottom-0 right-1/5 h-64 w-80 rounded-full bg-primary/20 blur-3xl animate-breath"
style={{ animationDelay: '2.5s' }}
aria-hidden
/>
{error && (
<div className="mb-4 p-2 bg-red-50 text-danger text-sm rounded">
{error}
</div>
)}
<div className="relative z-10 w-full max-w-[420px] animate-fade-up">
{/* 品牌 hero */}
<header className="mb-8 text-center">
<p className="brand-mark text-[42px] sm:text-[48px] leading-none mb-3">CBPOA</p>
<h1 className="font-display text-[17px] sm:text-[18px] font-semibold text-text-primary tracking-wide mb-2">
</h1>
<p className="text-[13px] text-text-secondary leading-relaxed max-w-sm mx-auto">
· ·
</p>
</header>
<label className="block mb-4">
<span className="text-text-secondary text-sm"></span>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
required
/>
</label>
<label className="block mb-6">
<span className="text-text-secondary text-sm"></span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
required
/>
</label>
<button
type="submit"
disabled={loading}
className="w-full py-2 bg-primary text-white rounded text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
<form
onSubmit={handleSubmit}
className="bg-bg-card/95 backdrop-blur-sm rounded-2xl border border-border shadow-lift p-7 sm:p-8"
>
{loading ? '登录中...' : '登录'}
</button>
</form>
<h2 className="sr-only"></h2>
{error && (
<div
role="alert"
className="mb-4 px-3 py-2.5 bg-danger-light text-danger text-sm rounded-lg border border-danger/20"
>
{error}
</div>
)}
<label className="block mb-4">
<span className="text-text-secondary text-[13px] font-medium"></span>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
className="mt-1.5 block w-full rounded-lg border border-border bg-bg-elevated px-3.5 py-2.5 text-sm text-text-primary
placeholder:text-text-muted
focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-shadow"
required
/>
</label>
<label className="block mb-6">
<span className="text-text-secondary text-[13px] font-medium"></span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
className="mt-1.5 block w-full rounded-lg border border-border bg-bg-elevated px-3.5 py-2.5 text-sm text-text-primary
placeholder:text-text-muted
focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-shadow"
required
/>
</label>
<button
type="submit"
disabled={loading}
data-testid={TESTIDS.loginSubmit}
className="w-full py-2.5 bg-primary text-white rounded-lg text-sm font-semibold
hover:bg-primary-deep shadow-brand transition-colors
disabled:opacity-50 disabled:shadow-none"
>
{loading ? '登录中…' : '进入系统'}
</button>
</form>
<p className="mt-6 text-center text-[11px] text-text-muted tracking-wide">
·
</p>
</div>
</div>
);
}

View File

@@ -1,46 +1,21 @@
import { useEffect, useState, useMemo, useRef, useCallback, memo } from 'react';
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { Calendar, Activity, TrendingUp, TrendingDown, Stethoscope, Zap, BarChart3 } from 'lucide-react';
import { useEffect, useState, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useTimelineStore, useMonitoringStore } from '@/stores';
import { useDiseaseStore } from '@/stores/diseaseStore';
import { useDrilldownStore } from '@/stores/drilldownStore';
import { gridApi, caseApi, envApi } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import { TimelinePlayer } from '@/components/TimelinePlayer';
import { StatisticalCharts } from '@/components/StatisticalCharts';
import { CaseLocationMap } from '@/components/CaseLocationMap';
import { DiseaseFilter } from '@/components/DiseaseFilter';
import { AdminBreadcrumb } from '@/components/AdminBreadcrumb';
import { StatCard } from '@/components/StatCard';
import { CalendarHeatmap } from '@/components/CalendarHeatmap';
import { MetricHeatmapTable } from '@/components/MetricHeatmapTable';
import type { DistrictCaseData } from '@/types';
import { MonitoringStatsBar } from '@/components/monitoring/MonitoringStatsBar';
import { OverviewTab } from '@/components/monitoring/OverviewTab';
import { CaseStatsTab } from '@/components/monitoring/CaseStatsTab';
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';
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 {
defaultStartDate?: string;
defaultEndDate?: string;
@@ -50,27 +25,8 @@ export function MonitoringDashboard({
defaultStartDate = '2022-12-01',
defaultEndDate = '2024-12-30',
}: 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)
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 {
currentDate,
isPlaying,
@@ -81,354 +37,137 @@ export function MonitoringDashboard({
setDateRange,
} = useTimelineStore();
const districtCases = useMonitoringStore((s) => s.districtCases);
const error = useMonitoringStore((s) => s.error);
const clearError = useMonitoringStore((s) => s.clearError);
const fetchDistrictCases = useMonitoringStore((s) => s.fetchDistrictCases);
const isLoading = useMonitoringStore((s) => s.isLoading);
const { selectedDistrict, selectedStreet } = useDrilldownStore();
const { selectedDiagnoses } = useDiseaseStore();
const drillDown = useDrilldownStore((s) => s.drillDown);
const resetDrillDown = useDrilldownStore((s) => s.resetDrillDown);
const [searchParams, setSearchParams] = useSearchParams();
const granularity = parseGranularity(searchParams.get('granularity'));
const districtParam = searchParams.get('district');
const streetParam = searchParams.get('street');
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]
);
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]
);
const handleDistrictSelect = useCallback(
(district: string) => {
if (selectedDistrict === district) updateUrl({ granularity: 'city' });
else updateUrl({ granularity: 'district', district });
},
[updateUrl, selectedDistrict]
);
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;
}
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(() => {
setDateRange(defaultStartDate, defaultEndDate);
setCurrentDate(defaultEndDate);
}, [defaultStartDate, defaultEndDate, setDateRange, setCurrentDate]);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const data = useMonitoringData({ activeTab, currentDate, selectedDistrict });
// 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];
const handleDateChange = useCallback(
(date: string) => {
setCurrentDate(date);
},
[setCurrentDate]
);
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); });
}
const handlePlayPause = useCallback(
(playing: boolean) => {
setPlaying(playing);
},
[setPlaying]
);
// 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) => {
setCurrentDate(date);
}, [setCurrentDate]);
const handlePlayPause = useCallback((playing: boolean) => {
setPlaying(playing);
}, [setPlaying]);
const tabs = [
{ key: 'overview' as const, label: '概览' },
{ key: 'cases' as const, label: '病例统计' },
{ key: 'districts' as const, label: '区域统计' },
];
return (
<div className="flex flex-col h-full">
<div data-testid="page-monitoring" className="flex flex-col h-full min-h-0">
{error && (
<div className="px-6 pt-4">
<div className="px-4 pt-3 shrink-0">
<ErrorBanner
error={error}
onRetry={() => {
clearError();
loadChartData(currentDate, selectedDistrict || undefined);
data.loadChartData(currentDate, selectedDistrict || undefined);
}}
onDismiss={clearError}
/>
</div>
)}
{/* Top stats bar — standardized with StatCard */}
<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-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>
{/* Disease filter */}
<div className="flex items-center gap-2 shrink-0">
{stats.noData && (
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded"></span>
{/* 顶部指标带 + 筛选 + tab */}
<div className="metrics-band px-4 pt-3 pb-0 shrink-0">
<div className="flex items-stretch justify-between flex-wrap gap-x-4 gap-y-3 mb-3">
<MonitoringStatsBar stats={data.stats} sparkline7d={data.sparkline7d} />
<div className="flex items-center gap-2 shrink-0 flex-wrap self-center">
{data.stats.noData && (
<span className="text-xs text-warning bg-warning-light px-2 py-1 rounded-md border border-warning/20">
</span>
)}
<DiseaseFilter onFilterChange={() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
loadChartData(currentDate, selectedDistrict || undefined);
}, 300);
}} />
<DiseaseFilter onFilterChange={data.debouncedLoadChart} />
<AdminBreadcrumb />
</div>
</div>
{/* In-page tab strip */}
<div className="flex items-center gap-1 mt-4 border-b border-gray-100 -mb-4">
{([
{ key: 'overview', label: '概览' },
{ key: 'cases', label: '病例统计' },
{ key: 'districts', label: '区域统计' },
] as const).map((tab) => (
<div className="tab-strip border-b border-border" role="tablist" aria-label="监测视图">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={activeTab === tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.key
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
className={`tab-strip__item ${
activeTab === tab.key ? 'tab-strip__item--active' : ''
}`}
>
{tab.label}
@@ -437,186 +176,55 @@ export function MonitoringDashboard({
</div>
</div>
{/* Main content — bottom padding for floating player */}
<div className="flex-1 overflow-auto p-6 pb-24">
{/* 概览 tab — unchanged Monitoring content */}
{/* 主内容:概览为地图舞台;统计 tab 可滚动(底部留坞空间) */}
<div
className={`flex-1 min-h-0 ${
activeTab === 'overview' ? 'overflow-hidden' : 'overflow-auto p-4 pb-28'
}`}
>
{activeTab === 'overview' && (
isLoading ? (
<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>
) : (
<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 */}
<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>
)
<OverviewTab
isLoading={isLoading}
chartData={data.chartData}
districtCases={data.districtCases}
selectedDistrict={selectedDistrict}
selectedStreet={selectedStreet}
currentDate={currentDate}
granularity={granularity}
onGranularityChange={handleGranularityChange}
onDistrictSelect={handleDistrictSelect}
/>
)}
{/* 病例统计 tab */}
{activeTab === 'cases' && (
casesTabLoading && !casesTabLoaded ? (
<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>
) : (
<div className="space-y-6">
{casesTabError && (
<ErrorBanner
error={casesTabError}
onRetry={() => loadCasesTab(currentDate)}
onDismiss={() => 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>
)
<CaseStatsTab
loading={data.casesTabLoading}
loaded={data.casesTabLoaded}
error={data.casesTabError}
currentDate={currentDate}
topDiagnoses={data.topDiagnoses}
caseTrend={data.caseTrend}
heatmapData={data.heatmapData}
heatmapYear={data.heatmapYear}
onRetry={() => data.loadCasesTab(currentDate)}
onDismissError={() => data.setCasesTabError(null)}
/>
)}
{/* 区域统计 tab */}
{activeTab === 'districts' && (
districtTabLoading && !districtTabLoaded ? (
<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>
) : (
<div className="space-y-6">
{districtTabError && (
<ErrorBanner
error={districtTabError}
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>
)
<DistrictStatsTab
loading={data.districtTabLoading}
loaded={data.districtTabLoaded}
error={data.districtTabError}
rows={data.districtTableRows}
data={data.districtTableData}
onRetry={() => data.loadDistrictTab()}
onDismissError={() => data.setDistrictTabError(null)}
onSort={(col) => data.setDistrictSortKey(col)}
/>
)}
</div>
{/* Timeline Player */}
<TimelinePlayer
startDate={defaultStartDate}
endDate={defaultEndDate}
@@ -630,57 +238,3 @@ export function MonitoringDashboard({
</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>
);
})}
</>
);
});

View File

@@ -1,31 +1,9 @@
import { useEffect, useState, useMemo } from 'react';
import {
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 { Activity } from 'lucide-react';
import { caseApi, riskApi, alertApi, envApi } from '@/services/api';
import { StatCard } from '@/components/StatCard';
import { ErrorBanner } from '@/components/ErrorBanner';
import { LoadingState, Segmented } from '@/components/ui';
import { TESTIDS } from '@/utils/testids';
import type {
CaseTrendPoint,
DistrictCaseData,
@@ -33,27 +11,30 @@ import type {
DiagnosisBreakdown,
Alert,
} 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 ---
interface KpiData {
totalCases: number;
todayCases: number;
changeRatio: number | null;
activeAlerts: number;
highRiskGrids: number;
avgAQI: number;
}
const METRIC_OPTIONS: { value: MetricKey; label: string }[] = [
{ value: 'all', label: '全部' },
{ value: 'outpatient', label: '门诊' },
{ value: 'inpatient', label: '住院' },
];
interface MergedTrendItem {
date: string;
cases: number;
aqi: number;
}
function formatDateLabel(dateStr: string): string {
const d = new Date(dateStr);
return `${d.getMonth() + 1}/${d.getDate()}`;
}
const METRIC_LABEL: Record<MetricKey, string> = {
all: '病例',
outpatient: '门诊',
inpatient: '住院',
};
function computeChangeRatio(trend: CaseTrendPoint[]): number | null {
if (trend.length < 8) return null;
@@ -66,11 +47,12 @@ function computeChangeRatio(trend: CaseTrendPoint[]): number | null {
export function OverviewDashboard() {
const [kpi, setKpi] = useState<KpiData | null>(null);
const [mergedTrend, setMergedTrend] = useState<MergedTrendItem[]>([]);
const [topDistricts, setTopDistricts] = useState<DistrictCaseData[]>([]);
const [districts, setDistricts] = useState<DistrictCaseData[]>([]);
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 [errors, setErrors] = useState<string[]>([]);
const [metric, setMetric] = useState<MetricKey>('all');
useEffect(() => {
let cancelled = false;
@@ -88,7 +70,6 @@ export function OverviewDashboard() {
start30.setDate(start30.getDate() - 30);
const start30Str = start30.toISOString().split('T')[0];
// KPI sources — Promise.allSettled to survive individual failures
const [statsR, trend14R, alertsR, riskStatsR, pollutantsR] = await Promise.allSettled([
caseApi.getStats(),
caseApi.getTrend({ start_date: start14Str, end_date: endStr, group_by: 'day' }),
@@ -97,18 +78,16 @@ export function OverviewDashboard() {
envApi.getPollutants(7),
]);
// Trend sources
const [trend30R, districtsR, diagStatsR] = await Promise.allSettled([
caseApi.getTrend({ start_date: start30Str, end_date: endStr, group_by: 'day' }),
caseApi.getDistricts(),
caseApi.getStats(), // reuse for top_diagnoses
caseApi.getStats(),
]);
if (cancelled) return;
const newErrors: string[] = [];
// --- Build KPI ---
let totalCases = 0;
if (statsR.status === 'fulfilled') {
const s = statsR.value;
@@ -121,9 +100,7 @@ export function OverviewDashboard() {
let changeRatio: number | null = null;
if (trend14R.status === 'fulfilled') {
const trend = trend14R.value.trend || [];
if (trend.length > 0) {
todayCases = trend[trend.length - 1].total;
}
if (trend.length > 0) todayCases = trend[trend.length - 1].total;
changeRatio = computeChangeRatio(trend);
} else {
newErrors.push('今日病例数据加载失败');
@@ -158,36 +135,24 @@ export function OverviewDashboard() {
}
setKpi({ totalCases, todayCases, changeRatio, activeAlerts, highRiskGrids, avgAQI });
setErrors(newErrors);
// --- Merge case trend + AQI ---
if (trend30R.status === 'fulfilled') {
const trend30 = trend30R.value.trend || [];
const aqiMap: Record<string, number> = {};
if (pollutantsR.status === 'fulfilled') {
for (const p of pollutantData) {
aqiMap[p.date] = p.AQI || 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);
for (const p of pollutantData) aqiMap[p.date] = p.AQI || 0;
setMergedTrend(
trend30.map((t) => ({ date: t.date, cases: t.total, aqi: aqiMap[t.date] || 0 }))
);
} else if (!newErrors.includes('今日病例数据加载失败')) {
newErrors.push('趋势数据加载失败');
}
// --- Top 5 Districts ---
if (districtsR.status === 'fulfilled') {
const districts = districtsR.value.districts || [];
const sorted = [...districts].sort((a, b) => b.total - a.total);
setTopDistricts(sorted.slice(0, 5));
setDistricts(districtsR.value.districts || []);
} else {
newErrors.push('区县数据加载失败');
}
// --- Top 5 Diagnoses ---
if (diagStatsR.status === 'fulfilled') {
const topDiag = diagStatsR.value.top_diagnoses || [];
setTopDiagnoses(
@@ -200,49 +165,37 @@ export function OverviewDashboard() {
);
}
// --- Alert severity donut ---
const p1 = alertList.filter((a) => a.priority === 'P1').length;
const p2 = alertList.filter((a) => a.priority === 'P2').length;
setAlertPie([
{ name: 'P1 紧急', value: p1, color: '#EF4444' },
{ name: 'P2 关注', value: p2, color: '#F59E0B' },
{ name: 'P1 紧急', value: p1, color: CHART_COLORS.alertP1 },
{ name: 'P2 关注', value: p2, color: CHART_COLORS.alertP2 },
]);
setErrors(newErrors);
setIsLoading(false);
};
fetchAll();
return () => {
cancelled = true;
};
}, []);
const changeTrend = useMemo(() => {
if (kpi?.changeRatio == null) return undefined;
if (kpi.changeRatio > 0) {
return { direction: 'up' as const, value: `${kpi.changeRatio.toFixed(1)}%` };
}
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]);
const joinedDistricts = useMemo(() => joinDistrictCases(districts), [districts]);
const metricLookup = useMemo(
() => buildMetricLookup(joinedDistricts, metric),
[joinedDistricts, metric]
);
// --- Loading state ---
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>
);
return <LoadingState label="加载概览数据…" testid={TESTIDS.pageLoading} />;
}
return (
<div className="flex flex-col h-full overflow-auto">
{/* Error banner */}
<div data-testid={TESTIDS.pageOverview} className="flex flex-col h-full overflow-auto">
{errors.length > 0 && (
<div className="px-6 pt-4">
<div className="px-1 pt-1">
<ErrorBanner
error={errors.join('')}
onRetry={() => window.location.reload()}
@@ -251,249 +204,76 @@ export function OverviewDashboard() {
</div>
)}
<div className="p-6 space-y-6">
{/* Page 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-gray-500"></p>
</div>
{/* Section 1: KPI Row */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard
icon={<Users className="w-4 h-4 text-blue-600" />}
label="累计病例总数"
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">
30AQI趋势
<div className="space-y-5">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="font-display text-[20px] font-semibold mb-1 flex items-center gap-2 text-text-primary">
<Activity className="w-5 h-5 text-primary" />
<span
data-testid={TESTIDS.asofBadge}
className="ml-1 inline-flex items-center rounded-md bg-mist-light/80 px-2 py-0.5
text-[11px] font-medium text-mist-deep border border-mist/20"
>
2023-12
</span>
</h1>
<p className="text-[12px] text-text-secondary"></p>
</div>
{mergedTrend.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={mergedTrend} 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>
)}
<Segmented
options={METRIC_OPTIONS}
value={metric}
onChange={setMetric}
testid={TESTIDS.outinpatientToggle}
/>
</div>
{/* Section 3 + 4: Top Districts + Top Diagnoses side by side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Section 3: Top 5 Districts */}
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
Top 5
<section aria-label="关键指标">
<KpiRow kpi={kpi} />
</section>
{/* 地图舞台 + 趋势:双栏构图 */}
<div className="grid grid-cols-1 xl:grid-cols-5 gap-5">
<section className="workbench-panel xl:col-span-3 overflow-hidden" aria-label="区县分布">
<div className="workbench-panel__head">
<div>
<h2 className="workbench-panel__title">
13 {METRIC_LABEL[metric]}
</h2>
<p className="workbench-panel__sub"> · </p>
</div>
</div>
{topDistricts.length > 0 ? (
<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 className="px-2 pb-3 min-h-[340px]">
<DistrictChoropleth
metricLookup={metricLookup}
metricLabel={`${METRIC_LABEL[metric]}`}
/>
</div>
</section>
<div className="xl:col-span-2 flex flex-col gap-5 min-h-0">
<div className="workbench-panel flex-1">
<CaseAqiTrend data={mergedTrend} embed />
</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>
{/* Section 5: Alert Severity Donut */}
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<div className="workbench-panel">
<TopDistrictsBar
districts={joinedDistricts}
metric={metric}
metricLabel={METRIC_LABEL[metric]}
embed
/>
</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 className="workbench-panel">
<TopDiagnosesBar diagnoses={topDiagnoses} embed />
</div>
</div>
<div className="workbench-panel max-w-xl">
<AlertSeverityDonut data={alertPie} embed />
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More