Files
CA/frontend/src/components/AppShell.tsx
Akiba So 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

142 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useCallback, useEffect, useRef } from 'react';
import { Outlet } from 'react-router-dom';
import { TopNav } from '@/components/TopNav';
import { SideNav } from '@/components/SideNav';
import { RouteErrorBoundary } from '@/components/RouteErrorBoundary';
import { useRiskStore } from '@/stores';
import { TESTIDS } from '@/utils/testids';
interface AppShellProps {
onLogout?: () => void;
}
// 收集容器内当前可聚焦的元素,供初始聚焦与焦点循环陷阱使用。
function getFocusable(container: HTMLElement): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
).filter((el) => el.offsetParent !== null || el === document.activeElement);
}
// 仅负责布局骨架(顶栏 / 侧栏 / 内容区),不涉及路由匹配与鉴权。
export function AppShell({ onLogout }: AppShellProps) {
const alerts = useRiskStore((s) => s.alerts);
const [drawerOpen, setDrawerOpen] = useState(false);
// 提升手风琴展开态:导轨与抽屉两份 SideNav 共享,保持同步。
const [expandedNav, setExpandedNav] = useState<string | null>('monitoring');
const drawerRef = useRef<HTMLElement>(null);
const openDrawer = useCallback(() => setDrawerOpen(true), []);
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
// 抽屉作为模态ESC 关闭、锁定 body 滚动、焦点移入并在关闭后归还给汉堡。
useEffect(() => {
if (!drawerOpen) return;
const opener = document.activeElement as HTMLElement | null;
// 锁定 body 滚动,关闭时还原原值。
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
// 焦点移入抽屉(优先第一个可聚焦元素,否则聚焦抽屉容器本身)。
const drawer = drawerRef.current;
const focusables = drawer ? getFocusable(drawer) : [];
(focusables[0] ?? drawer)?.focus();
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
closeDrawer();
return;
}
// 焦点循环陷阱Tab 在抽屉内首尾元素之间循环。
if (e.key === 'Tab' && drawer) {
const items = getFocusable(drawer);
if (items.length === 0) {
e.preventDefault();
drawer.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (e.shiftKey && (active === first || active === drawer)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
document.body.style.overflow = prevOverflow;
// 关闭后把焦点还给打开抽屉的元素(汉堡按钮),回退到按 testid 查询。
const restoreTarget =
opener ??
document.querySelector<HTMLElement>(`[data-testid="${TESTIDS.hamburger}"]`);
restoreTarget?.focus();
};
}, [drawerOpen, closeDrawer]);
return (
<div data-testid={TESTIDS.appShell} className="h-screen bg-bg-page flex flex-col overflow-hidden">
<TopNav onLogout={onLogout} onToggleMenu={openDrawer} isMenuOpen={drawerOpen} />
<div className="flex flex-1 min-h-0">
{/* lg 及以上:持久侧栏导轨 */}
<aside
data-testid={TESTIDS.sidebarRail}
className="hidden lg:block w-[200px] shrink-0 bg-bg-card border-r border-border"
>
<SideNav
alertCount={alerts.length}
expanded={expandedNav}
onExpandedChange={setExpandedNav}
/>
</aside>
{/* lg 以下:离屏抽屉 + 遮罩 */}
{drawerOpen && (
<div
className="fixed inset-0 z-40 bg-black/40 lg:hidden"
onClick={closeDrawer}
aria-hidden="true"
/>
)}
<aside
ref={drawerRef}
id="app-drawer"
role="dialog"
aria-modal="true"
aria-label="导航菜单"
tabIndex={-1}
data-testid={TESTIDS.appDrawer}
className={`fixed top-0 left-0 bottom-0 z-50 w-[260px] max-w-[80vw] bg-bg-card border-r border-border shadow-xl transition-transform duration-200 lg:hidden ${
drawerOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<SideNav
alertCount={alerts.length}
onNavigate={closeDrawer}
expanded={expandedNav}
onExpandedChange={setExpandedNav}
/>
</aside>
<main className="flex-1 min-w-0 overflow-auto p-5">
<RouteErrorBoundary>
<Outlet />
</RouteErrorBoundary>
</main>
</div>
</div>
);
}