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>
This commit is contained in:
2026-06-21 20:24:26 +08:00
parent 3db3b12480
commit 9a94156acc
22 changed files with 1378 additions and 358 deletions

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()