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( '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 及以上:持久侧栏导轨 */} {/* lg 以下:离屏抽屉 + 遮罩 */} {drawerOpen && (
); }