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

@@ -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<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} />
<TopNav onLogout={onLogout} onToggleMenu={openDrawer} isMenuOpen={drawerOpen} />
<div className="flex flex-1 min-h-0">
{/* 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"
>
<SideNav alertCount={alerts.length} />
<SideNav
alertCount={alerts.length}
expanded={expandedNav}
onExpandedChange={setExpandedNav}
/>
</aside>
{/* lg 以下:离屏抽屉 + 遮罩 */}
@@ -39,16 +111,29 @@ export function AppShell({ onLogout }: AppShellProps) {
/>
)}
<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} />
<SideNav
alertCount={alerts.length}
onNavigate={closeDrawer}
expanded={expandedNav}
onExpandedChange={setExpandedNav}
/>
</aside>
<main className="flex-1 min-w-0 overflow-auto p-5">
<Outlet />
<RouteErrorBoundary>
<Outlet />
</RouteErrorBoundary>
</main>
</div>
</div>