2026-06-21 20:24:26 +08:00
|
|
|
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
2026-06-21 20:12:13 +08:00
|
|
|
|
import { Outlet } from 'react-router-dom';
|
|
|
|
|
|
import { TopNav } from '@/components/TopNav';
|
|
|
|
|
|
import { SideNav } from '@/components/SideNav';
|
2026-06-21 20:24:26 +08:00
|
|
|
|
import { RouteErrorBoundary } from '@/components/RouteErrorBoundary';
|
2026-06-21 20:12:13 +08:00
|
|
|
|
import { useRiskStore } from '@/stores';
|
|
|
|
|
|
import { TESTIDS } from '@/utils/testids';
|
|
|
|
|
|
|
|
|
|
|
|
interface AppShellProps {
|
|
|
|
|
|
onLogout?: () => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 20:24:26 +08:00
|
|
|
|
// 收集容器内当前可聚焦的元素,供初始聚焦与焦点循环陷阱使用。
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
|
// 仅负责布局骨架(顶栏 / 侧栏 / 内容区),不涉及路由匹配与鉴权。
|
|
|
|
|
|
export function AppShell({ onLogout }: AppShellProps) {
|
|
|
|
|
|
const alerts = useRiskStore((s) => s.alerts);
|
|
|
|
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
2026-06-21 20:24:26 +08:00
|
|
|
|
// 提升手风琴展开态:导轨与抽屉两份 SideNav 共享,保持同步。
|
|
|
|
|
|
const [expandedNav, setExpandedNav] = useState<string | null>('monitoring');
|
|
|
|
|
|
const drawerRef = useRef<HTMLElement>(null);
|
2026-06-21 20:12:13 +08:00
|
|
|
|
|
|
|
|
|
|
const openDrawer = useCallback(() => setDrawerOpen(true), []);
|
|
|
|
|
|
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
|
|
|
|
|
|
|
2026-06-21 20:24:26 +08:00
|
|
|
|
// 抽屉作为模态: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]);
|
|
|
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<div data-testid={TESTIDS.appShell} className="h-screen bg-bg-page flex flex-col overflow-hidden">
|
2026-06-21 20:24:26 +08:00
|
|
|
|
<TopNav onLogout={onLogout} onToggleMenu={openDrawer} isMenuOpen={drawerOpen} />
|
2026-06-21 20:12:13 +08:00
|
|
|
|
|
|
|
|
|
|
<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"
|
|
|
|
|
|
>
|
2026-06-21 20:24:26 +08:00
|
|
|
|
<SideNav
|
|
|
|
|
|
alertCount={alerts.length}
|
|
|
|
|
|
expanded={expandedNav}
|
|
|
|
|
|
onExpandedChange={setExpandedNav}
|
|
|
|
|
|
/>
|
2026-06-21 20:12:13 +08:00
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
|
|
{/* lg 以下:离屏抽屉 + 遮罩 */}
|
|
|
|
|
|
{drawerOpen && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="fixed inset-0 z-40 bg-black/40 lg:hidden"
|
|
|
|
|
|
onClick={closeDrawer}
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<aside
|
2026-06-21 20:24:26 +08:00
|
|
|
|
ref={drawerRef}
|
|
|
|
|
|
id="app-drawer"
|
|
|
|
|
|
role="dialog"
|
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
|
aria-label="导航菜单"
|
|
|
|
|
|
tabIndex={-1}
|
2026-06-21 20:12:13 +08:00
|
|
|
|
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'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2026-06-21 20:24:26 +08:00
|
|
|
|
<SideNav
|
|
|
|
|
|
alertCount={alerts.length}
|
|
|
|
|
|
onNavigate={closeDrawer}
|
|
|
|
|
|
expanded={expandedNav}
|
|
|
|
|
|
onExpandedChange={setExpandedNav}
|
|
|
|
|
|
/>
|
2026-06-21 20:12:13 +08:00
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
|
|
<main className="flex-1 min-w-0 overflow-auto p-5">
|
2026-06-21 20:24:26 +08:00
|
|
|
|
<RouteErrorBoundary>
|
|
|
|
|
|
<Outlet />
|
|
|
|
|
|
</RouteErrorBoundary>
|
2026-06-21 20:12:13 +08:00
|
|
|
|
</main>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|