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>
142 lines
4.9 KiB
TypeScript
142 lines
4.9 KiB
TypeScript
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>
|
||
);
|
||
}
|