diff --git a/backend/data/case_loader.py b/backend/data/case_loader.py index 35e4486..7260a25 100644 --- a/backend/data/case_loader.py +++ b/backend/data/case_loader.py @@ -25,12 +25,42 @@ _cache: dict[str, Optional[pd.DataFrame | datetime]] = { "outpatient": None, "inpatient": None, "combined": None, + "cases_by_district_daily": None, "loaded_at": None, } # Guards the lazy build so concurrent callers don't duplicate the load/concat. _load_lock = threading.RLock() +# Canonical Wuhan administrative districts (13), matching the `name` field in +# Datas/武汉市.geojson. All district roll-ups must collapse to exactly these. +CANONICAL_DISTRICTS = [ + '江岸区', '江汉区', '硚口区', '汉阳区', '武昌区', '青山区', '洪山区', + '东西湖区', '汉南区', '蔡甸区', '江夏区', '黄陂区', '新洲区', +] +# Bare (suffix-less) base name -> canonical 区-suffixed name. +_DISTRICT_BASE_TO_CANONICAL = {d[:-1]: d for d in CANONICAL_DISTRICTS} +_DISTRICT_SUFFIXES = ('区', '县', '市') + + +def normalize_district(name: str) -> str: + """Map a district label to its canonical 区-suffixed form. + + The case parquet carries both bare ("武昌") and suffixed ("武昌区") spellings + of the same district, which double-counts in any roll-up. This collapses + them: known bare names map to their canonical form; already-suffixed names + pass through unchanged; anything else gets a "区" appended. + """ + if name is None: + return name + name = str(name).strip() + if name in _DISTRICT_BASE_TO_CANONICAL: + return _DISTRICT_BASE_TO_CANONICAL[name] + if name.endswith(_DISTRICT_SUFFIXES): + return name + return f"{name}区" + + # Wuhan district mapping WUHAN_DISTRICTS = { '江岸区': ['江岸'], @@ -170,3 +200,40 @@ def get_inpatient_data() -> pd.DataFrame: """Return the cached inpatient dataframe""" load_data() return _cache["inpatient"] # type: ignore[return-value] + + +def load_cases_by_district_daily() -> pd.DataFrame: + """Load processed/cases_by_district_daily.parquet with districts normalized. + + The on-disk parquet carries both bare and 区-suffixed spellings of each + district (26 labels = 13 districts × 2 spellings), so any groupby on the + raw `district` column double-counts. This is the single data-access + boundary: it normalizes labels to the canonical 13 and re-aggregates + (sum of outpatient_count / inpatient_count / total_cases per + normalized district + date), so every downstream consumer + (analysis / grid / insights) sees clean, deduped 13-district data. + + Returns a copy with columns [date, district, outpatient_count, + inpatient_count, total_cases]. Raises FileNotFoundError if the parquet + is missing (callers handle this as they did before). + """ + path = PROCESSED_DIR / "cases_by_district_daily.parquet" + cached = _cache.get("cases_by_district_daily") + if cached is not None: + return cast(pd.DataFrame, cached).copy() + + with _load_lock: + cached = _cache.get("cases_by_district_daily") + if cached is not None: + return cast(pd.DataFrame, cached).copy() + + df = pd.read_parquet(path) + df["district"] = df["district"].map(normalize_district) + agg = ( + df.groupby(["date", "district"], as_index=False)[ + ["outpatient_count", "inpatient_count", "total_cases"] + ] + .sum() + ) + _cache["cases_by_district_daily"] = agg # type: ignore[assignment] + return agg.copy() diff --git a/backend/routers/analysis.py b/backend/routers/analysis.py index 1af86e1..be3dd31 100644 --- a/backend/routers/analysis.py +++ b/backend/routers/analysis.py @@ -12,6 +12,7 @@ import pandas as pd from pydantic import BaseModel, Field from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT, WUHAN_BOUNDS, LAT_STEP, LON_STEP +from data.case_loader import load_cases_by_district_daily from utils.date_helpers import get_latest_date from utils.geojson import parse_geojson_file, load_districts from utils.geo import point_in_polygon @@ -179,18 +180,16 @@ def _district_avg_aqi() -> dict: def _district_total_cases() -> dict: """Real total recorded cases per district from cases_by_district_daily. - District labels in the case file are inconsistent ("武昌" vs "武昌区"), - so names are normalized by stripping the "区" suffix and summed, then - keyed by the canonical mapping name (with "区"). Returns {district: cases}. + District labels are normalized to the canonical 13 区-suffixed names at the + data-access boundary (data.case_loader), so this is a plain per-district + sum. Returns {district: cases}. """ - path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet" - if not path.exists(): + try: + df = load_cases_by_district_daily() + except FileNotFoundError: return {} - df = pd.read_parquet(path, columns=["district", "total_cases"]) - df = df.copy() - df["base"] = df["district"].str.replace("区", "", regex=False) - by_base = df.groupby("base")["total_cases"].sum() - return {f"{base}区": int(v) for base, v in by_base.items()} + by_district = df.groupby("district")["total_cases"].sum() + return {str(d): int(v) for d, v in by_district.items()} @lru_cache(maxsize=8) diff --git a/backend/routers/grid.py b/backend/routers/grid.py index 5a4a42d..a12f5c2 100644 --- a/backend/routers/grid.py +++ b/backend/routers/grid.py @@ -21,6 +21,7 @@ from models import ( MultiDayPredictionRequest, MultiDayPredictionResponse, ) +from data.case_loader import load_cases_by_district_daily router = APIRouter(prefix="/api", tags=["grid"]) @@ -43,14 +44,13 @@ def _compute_historical_aggregation( ) -> HistoricalAggregationResponse: """Run the full pandas aggregation pipeline (called in thread pool).""" try: - cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet") + cases_df = load_cases_by_district_daily() except FileNotFoundError: return HistoricalAggregationResponse( aggregations=[], total_records=0, date_range=(start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")), timestamp=datetime.now().isoformat(), ) - cases_df = cases_df.copy() cases_df['date'] = pd.to_datetime(cases_df['date']) filtered_cases = cases_df[ @@ -196,7 +196,7 @@ def _grids_geojson_body(date: str, district: Optional[str], risk_level: Optional if district: merged = merged[merged['district_name'].str.contains(district.replace('区', ''), na=False, regex=False)] - cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet").copy() + cases_df = load_cases_by_district_daily() cases_df['date'] = pd.to_datetime(cases_df['date']).dt.strftime('%Y-%m-%d') cases_df = cases_df[cases_df['date'] == date] @@ -364,7 +364,7 @@ def _compute_grid_history(grid_id: str, days: int) -> dict: district = grid_info.iloc[0]['district_name'] - cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet") + cases_df = load_cases_by_district_daily() cases_df['date'] = pd.to_datetime(cases_df['date']) end_date = datetime.now() diff --git a/backend/routers/insights.py b/backend/routers/insights.py index 27d2d28..00d9308 100644 --- a/backend/routers/insights.py +++ b/backend/routers/insights.py @@ -11,6 +11,7 @@ from pydantic import BaseModel, Field from typing import Dict, List, Literal from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT +from data.case_loader import load_cases_by_district_daily from models import ( InsightsResponse, InsightTrend, @@ -491,22 +492,22 @@ async def get_insights_cards(): cases_path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet" if cases_path.exists(): - cases_df = _cached_parquet(str(cases_path)) + # Districts already normalized to the canonical 13 区-suffixed names. + cases_df = load_cases_by_district_daily() + cases_df["date"] = pd.to_datetime(cases_df["date"]) latest_case_date = cases_df["date"].max() - latest_cases = cases_df[cases_df["date"] == latest_case_date].copy() - latest_cases["base_district"] = latest_cases["district"].str.replace("区", "") - district_daily = latest_cases.groupby("base_district")["total_cases"].sum().sort_values(ascending=False) + latest_cases = cases_df[cases_df["date"] == latest_case_date] + district_daily = latest_cases.groupby("district")["total_cases"].sum().sort_values(ascending=False) total_daily = int(district_daily.sum()) top_name = district_daily.index[0] top_val = int(district_daily.iloc[0]) num_districts = len(district_daily) week_ago = latest_case_date - pd.Timedelta(days=6) - week_cases = cases_df[cases_df["date"] >= week_ago].copy() - week_cases["base_district"] = week_cases["district"].str.replace("区", "") + week_cases = cases_df[cases_df["date"] >= week_ago] daily_totals = week_cases.groupby("date")["total_cases"].sum() avg_daily = int(daily_totals.mean()) - week_district = week_cases.groupby("base_district")["total_cases"].sum().sort_values(ascending=False) + week_district = week_cases.groupby("district")["total_cases"].sum().sort_values(ascending=False) week_top_val = int(week_district.iloc[0]) date_str = latest_case_date.strftime("%m月%d日") @@ -515,8 +516,8 @@ async def get_insights_cards(): title=f"日病例统计 ({date_str})", description=( f"最近统计日({date_str})全市{num_districts}个区共记录{total_daily}例儿童呼吸道疾病病例," - f"{top_name}区{top_val}例为当日最高。近7日日均{avg_daily}例," - f"{week_district.index[0]}区累计{week_top_val}例居首。" + f"{top_name}{top_val}例为当日最高。近7日日均{avg_daily}例," + f"{week_district.index[0]}累计{week_top_val}例居首。" ), type="warning", metric="日病例", diff --git a/backend/tests/test_district_normalization.py b/backend/tests/test_district_normalization.py new file mode 100644 index 0000000..f30179c --- /dev/null +++ b/backend/tests/test_district_normalization.py @@ -0,0 +1,82 @@ +"""Tests for district label normalization at the case-loader boundary. + +The processed/cases_by_district_daily.parquet carries both bare ("武昌") and +区-suffixed ("武昌区") spellings of each district (26 labels = 13 districts × 2 +spellings), which double-counts in any roll-up. data.case_loader normalizes +these to the canonical 13 区-suffixed names and re-aggregates. These tests pin +that behavior. +""" +import sys +from pathlib import Path + +import pandas as pd +import pytest + +# Ensure the backend package root is importable at collection time (mirrors the +# sys.path handling other modules rely on once the app is imported). +BACKEND_ROOT = Path(__file__).parent.parent +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + +from data.case_loader import ( # noqa: E402 + CANONICAL_DISTRICTS, + normalize_district, + load_cases_by_district_daily, +) + +PROJECT_ROOT = Path(__file__).parent.parent.parent +RAW_PARQUET = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet" + + +def test_normalize_district_known_bare_forms(): + """Every known bare form maps to its canonical 区-suffixed name.""" + cases = { + "武昌": "武昌区", "汉阳": "汉阳区", "江岸": "江岸区", "硚口": "硚口区", + "青山": "青山区", "洪山": "洪山区", "东西湖": "东西湖区", "汉南": "汉南区", + "蔡甸": "蔡甸区", "江夏": "江夏区", "黄陂": "黄陂区", "新洲": "新洲区", + "江汉": "江汉区", + } + for bare, canonical in cases.items(): + assert normalize_district(bare) == canonical + + +def test_normalize_district_already_suffixed_passes_through(): + for d in CANONICAL_DISTRICTS: + assert normalize_district(d) == d + + +def test_canonical_set_is_exactly_thirteen(): + assert len(CANONICAL_DISTRICTS) == 13 + assert len(set(CANONICAL_DISTRICTS)) == 13 + + +@pytest.mark.skipif(not RAW_PARQUET.exists(), reason="case parquet not present") +def test_loader_collapses_to_thirteen_canonical_districts(): + df = load_cases_by_district_daily() + districts = set(df["district"].unique()) + + # (a) exactly 13 unique districts, all canonical + assert len(districts) == 13, f"expected 13 districts, got {len(districts)}: {sorted(districts)}" + assert districts == set(CANONICAL_DISTRICTS) + + # (b) no bare / unsuffixed duplicates remain + for name in districts: + assert name.endswith(("区", "县", "市")), f"unsuffixed district leaked: {name}" + + +@pytest.mark.skipif(not RAW_PARQUET.exists(), reason="case parquet not present") +def test_loader_preserves_totals_no_rows_dropped_or_double_counted(): + """Sum integrity: normalized total == raw parquet total.""" + raw = pd.read_parquet(RAW_PARQUET) + normalized = load_cases_by_district_daily() + + assert int(normalized["total_cases"].sum()) == int(raw["total_cases"].sum()) + assert int(normalized["outpatient_count"].sum()) == int(raw["outpatient_count"].sum()) + assert int(normalized["inpatient_count"].sum()) == int(raw["inpatient_count"].sum()) + + +@pytest.mark.skipif(not RAW_PARQUET.exists(), reason="case parquet not present") +def test_raw_parquet_actually_has_dirty_labels(): + """Sanity: the raw file really has the 26-label problem we are fixing.""" + raw = pd.read_parquet(RAW_PARQUET) + assert raw["district"].nunique() > 13 diff --git a/frontend/e2e/overview.spec.ts b/frontend/e2e/overview.spec.ts new file mode 100644 index 0000000..66f3565 --- /dev/null +++ b/frontend/e2e/overview.spec.ts @@ -0,0 +1,119 @@ +/** + * 综合概览大屏 (/overview) 验收测试。 + * + * 与 user-flows.spec.ts 一致:用 addInitScript 注入 cbpoa_token 绕过登录门, + * page.route 拦截 /api/** 使套件 hermetic(无需 :8000)。/wuhan_districts.geojson + * 走真实静态资源(dev server 提供),由 Leaflet 取用。 + */ +import { test, expect, Page } from '@playwright/test'; +import { TESTIDS } from '../src/utils/testids'; + +/** 13 区里造两条数据,断言 choropleth 能着色、toggle 能切换。 */ +function districtPayload() { + return { + districts: [ + { district: '武昌区', outpatient: 120, inpatient: 30, total: 150, outpatient_ratio: 0.8, inpatient_ratio: 0.2 }, + { district: '江岸', outpatient: 60, inpatient: 10, total: 70, outpatient_ratio: 0.86, inpatient_ratio: 0.14 }, + ], + total: 220, + }; +} + +async function seedAuthAndMockApi(page: Page) { + await page.addInitScript(() => { + localStorage.setItem('cbpoa_token', 'e2e-test-token'); + }); + + await page.route('/api/**', (route) => { + const url = route.request().url(); + + const json = (body: unknown) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); + + if (url.includes('/alerts')) { + return json({ alerts: [], total: 0 }); + } + if (url.includes('/cases/stats')) { + return json({ + total_outpatient: 1000, + total_inpatient: 200, + date_range: { start: '2023-01-01', end: '2023-12-01' }, + top_districts: [], + top_diagnoses: [ + { diagnosis: '上呼吸道感染', outpatient: 300, inpatient: 40 }, + { diagnosis: '肺炎', outpatient: 120, inpatient: 80 }, + ], + }); + } + if (url.includes('/cases/trend')) { + return json({ + trend: [ + { date: '2023-11-01', outpatient: 10, inpatient: 2, total: 12 }, + { date: '2023-11-02', outpatient: 14, inpatient: 3, total: 17 }, + ], + summary: { + total_outpatient: 24, + total_inpatient: 5, + period_count: 2, + avg_daily_outpatient: 12, + avg_daily_inpatient: 2.5, + }, + }); + } + if (url.includes('/cases/districts')) { + return json(districtPayload()); + } + if (url.includes('/risk/stats')) { + return json({ high_risk_count: 7, total_grids: 100, avg_risk: 0.4 }); + } + if (url.includes('/environment/pollutants')) { + return json({ data: [{ date: '2023-11-01', AQI: 80 }, { date: '2023-11-02', AQI: 95 }] }); + } + + return json({ data: [], items: [], total: 0 }); + }); +} + +test.describe('Overview 大屏', () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test.beforeEach(async ({ page }) => { + await seedAuthAndMockApi(page); + }); + + test('renders kpi-row, choropleth, as-of badge and metric toggle', async ({ page }) => { + await page.goto('/overview'); + + await expect(page.locator(`[data-testid="${TESTIDS.pageOverview}"]`)).toBeVisible(); + await expect(page.locator(`[data-testid="${TESTIDS.kpiRow}"]`)).toBeVisible(); + await expect(page.locator(`[data-testid="${TESTIDS.choroplethWrapper}"]`)).toBeVisible(); + + // Literal honesty badge — exact text. + const badge = page.locator(`[data-testid="${TESTIDS.asofBadge}"]`); + await expect(badge).toBeVisible(); + await expect(badge).toHaveText('数据截至2023-12'); + }); + + test('门诊/住院 toggle switches active segment without error', async ({ page }) => { + await page.goto('/overview'); + await expect(page.locator(`[data-testid="${TESTIDS.choroplethWrapper}"]`)).toBeVisible(); + + const outBtn = page.locator(`[data-testid="${TESTIDS.outinpatientToggle}-outpatient"]`); + const inBtn = page.locator(`[data-testid="${TESTIDS.outinpatientToggle}-inpatient"]`); + const allBtn = page.locator(`[data-testid="${TESTIDS.outinpatientToggle}-all"]`); + + // Default: 全部 active (primary background). + await expect(allBtn).toHaveClass(/bg-primary/); + + await outBtn.click(); + await expect(outBtn).toHaveClass(/bg-primary/); + await expect(allBtn).not.toHaveClass(/bg-primary/); + + await inBtn.click(); + await expect(inBtn).toHaveClass(/bg-primary/); + await expect(outBtn).not.toHaveClass(/bg-primary/); + + // Wrapper still mounted after toggling — no render crash. + await expect(page.locator(`[data-testid="${TESTIDS.choroplethWrapper}"]`)).toBeVisible(); + }); +}); diff --git a/frontend/public/wuhan_districts.geojson b/frontend/public/wuhan_districts.geojson new file mode 100644 index 0000000..67a507e --- /dev/null +++ b/frontend/public/wuhan_districts.geojson @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12cf8f99e45b7faabca53cea2ba9fab112815163e6475c38a07af358e906feac +size 80098 diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 9a766ee..8cbdbaa 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,7 +1,8 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; import { Outlet } from 'react-router-dom'; import { TopNav } from '@/components/TopNav'; import { SideNav } from '@/components/SideNav'; +import { RouteErrorBoundary } from '@/components/RouteErrorBoundary'; import { useRiskStore } from '@/stores'; import { TESTIDS } from '@/utils/testids'; @@ -9,17 +10,84 @@ interface AppShellProps { onLogout?: () => void; } +// 收集容器内当前可聚焦的元素,供初始聚焦与焦点循环陷阱使用。 +function getFocusable(container: HTMLElement): HTMLElement[] { + return Array.from( + container.querySelectorAll( + 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) + ).filter((el) => el.offsetParent !== null || el === document.activeElement); +} + // 仅负责布局骨架(顶栏 / 侧栏 / 内容区),不涉及路由匹配与鉴权。 export function AppShell({ onLogout }: AppShellProps) { const alerts = useRiskStore((s) => s.alerts); const [drawerOpen, setDrawerOpen] = useState(false); + // 提升手风琴展开态:导轨与抽屉两份 SideNav 共享,保持同步。 + const [expandedNav, setExpandedNav] = useState('monitoring'); + const drawerRef = useRef(null); const openDrawer = useCallback(() => setDrawerOpen(true), []); const closeDrawer = useCallback(() => setDrawerOpen(false), []); + // 抽屉作为模态:ESC 关闭、锁定 body 滚动、焦点移入并在关闭后归还给汉堡。 + useEffect(() => { + if (!drawerOpen) return; + + const opener = document.activeElement as HTMLElement | null; + + // 锁定 body 滚动,关闭时还原原值。 + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + // 焦点移入抽屉(优先第一个可聚焦元素,否则聚焦抽屉容器本身)。 + const drawer = drawerRef.current; + const focusables = drawer ? getFocusable(drawer) : []; + (focusables[0] ?? drawer)?.focus(); + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + closeDrawer(); + return; + } + // 焦点循环陷阱:Tab 在抽屉内首尾元素之间循环。 + if (e.key === 'Tab' && drawer) { + const items = getFocusable(drawer); + if (items.length === 0) { + e.preventDefault(); + drawer.focus(); + return; + } + const first = items[0]; + const last = items[items.length - 1]; + const active = document.activeElement; + if (e.shiftKey && (active === first || active === drawer)) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } + } + }; + + document.addEventListener('keydown', onKeyDown); + + return () => { + document.removeEventListener('keydown', onKeyDown); + document.body.style.overflow = prevOverflow; + // 关闭后把焦点还给打开抽屉的元素(汉堡按钮),回退到按 testid 查询。 + const restoreTarget = + opener ?? + document.querySelector(`[data-testid="${TESTIDS.hamburger}"]`); + restoreTarget?.focus(); + }; + }, [drawerOpen, closeDrawer]); + return (
- +
{/* lg 及以上:持久侧栏导轨 */} @@ -27,7 +95,11 @@ export function AppShell({ onLogout }: AppShellProps) { data-testid={TESTIDS.sidebarRail} className="hidden lg:block w-[200px] shrink-0 bg-bg-card border-r border-border" > - + {/* lg 以下:离屏抽屉 + 遮罩 */} @@ -39,16 +111,29 @@ export function AppShell({ onLogout }: AppShellProps) { /> )}
- + + +
diff --git a/frontend/src/components/RouteErrorBoundary.tsx b/frontend/src/components/RouteErrorBoundary.tsx new file mode 100644 index 0000000..cab69e5 --- /dev/null +++ b/frontend/src/components/RouteErrorBoundary.tsx @@ -0,0 +1,45 @@ +import { Component, ReactNode } from 'react'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; +} + +// 路由级错误边界:单个页面(含懒加载 chunk)崩溃时只降级内容区, +// 保留外层骨架(顶栏 + 侧栏),避免整页白屏。 +export class RouteErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError() { + return { hasError: true }; + } + + private reset = () => { + this.setState({ hasError: false }); + }; + + render() { + if (this.state.hasError) { + return ( +
+
+
此页面加载失败
+ +
+
+ ); + } + return this.props.children; + } +} diff --git a/frontend/src/components/SideNav.tsx b/frontend/src/components/SideNav.tsx index 742098c..7291e98 100644 --- a/frontend/src/components/SideNav.tsx +++ b/frontend/src/components/SideNav.tsx @@ -6,6 +6,10 @@ interface SideNavProps { alertCount?: number; // 抽屉模式下点击导航项后关闭抽屉(持久侧栏可不传)。 onNavigate?: () => void; + // 受控的展开手风琴分组:由 AppShell 提供时,导轨与抽屉两份实例保持同步。 + // 不传则回退到内部 state,向后兼容独立使用。 + expanded?: string | null; + onExpandedChange?: (moduleId: string | null) => void; } interface NavItem { @@ -56,14 +60,28 @@ const modules: { id: string; label: string; icon: React.ReactNode; items: NavIte }, ]; -export function SideNav({ alertCount = 0, onNavigate }: SideNavProps) { +export function SideNav({ + alertCount = 0, + onNavigate, + expanded: expandedProp, + onExpandedChange, +}: SideNavProps) { const location = useLocation(); // 当前路径命中的模块默认展开。 const moduleForPath = (pathname: string) => modules.find((m) => m.items.some((item) => pathname.startsWith(item.to)))?.id ?? 'monitoring'; - const [expanded, setExpanded] = useState(() => moduleForPath(location.pathname)); + // 受控/非受控双模式:父级传入 expanded 时由父级管理,否则回退内部 state。 + const [internalExpanded, setInternalExpanded] = useState(() => + moduleForPath(location.pathname) + ); + const isControlled = expandedProp !== undefined; + const expanded = isControlled ? expandedProp : internalExpanded; + const setExpanded = (next: string | null) => { + if (isControlled) onExpandedChange?.(next); + else setInternalExpanded(next); + }; const isActiveModule = (moduleId: string) => { const module = modules.find((m) => m.id === moduleId); diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 92da3cc..452ede7 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -5,6 +5,8 @@ interface TopNavProps { onLogout?: () => void; // 移动端汉堡按钮:切换侧栏抽屉。 onToggleMenu?: () => void; + // 抽屉是否展开(用于汉堡按钮的 aria-expanded)。 + isMenuOpen?: boolean; } function Clock() { @@ -16,7 +18,7 @@ function Clock() { return {time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}; } -export function TopNav({ onLogout, onToggleMenu }: TopNavProps) { +export function TopNav({ onLogout, onToggleMenu, isMenuOpen = false }: TopNavProps) { return (