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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user