/** * Admin 后台 - 布局组件(报刊风) * * 与前台同一套纸色语言:报头式侧栏 + 报眉顶栏 + 细线分区。 * 响应式: 移动端汉堡菜单 + 抽屉侧栏,桌面端固定侧栏。 */ import { useState, useEffect, useCallback } from 'react' import { NavLink, Outlet, useLocation } from 'react-router-dom' import { fetchAuthState, logout, UNAUTHORIZED_EVENT } from './api' import { fetchHealth } from './dal' import { COMMAND_PALETTE_PAGES, ROUTE_LABELS, NAV_SECTIONS } from './nav' import Login from './Login' import { useCommandPalette, CommandPalette } from './useCommandPalette' // 线条风格 SVG 图标组件 function Icon({ name }: { name: string }) { const common = 'nav-icon' switch (name) { case 'collection': return ( ) case 'chart': return ( ) case 'target': return ( ) case 'repeat': return ( ) case 'eval': return ( ) case 'monitor': return ( ) case 'settings': return ( ) case 'logs': return ( ) default: return null } } // D6: 导航三视图(侧栏/命令面板/面包屑)统一由 admin/nav.ts 的 NAV_ITEMS // 单源派生 —— 此处的 NAV_PAGES/ROUTE_LABELS/NAV_SECTIONS 平行清单已删除, // 新增页面/改标签只改 nav.ts 一处。 /** 报眉日期行,与前台同款式 */ function dateLine(): string { return new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long', }) } export default function AdminLayout() { const [sidebarOpen, setSidebarOpen] = useState(false) const [healthOk, setHealthOk] = useState(null) const [authed, setAuthed] = useState(null) const location = useLocation() const palette = useCommandPalette(COMMAND_PALETTE_PAGES) // 登录门禁:挂载时探测会话,收到 401 事件(会话过期)自动切回登录页 useEffect(() => { let alive = true fetchAuthState() .then(s => alive && setAuthed(s.authenticated)) .catch(() => alive && setAuthed(false)) const onUnauthorized = () => setAuthed(false) window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized) return () => { alive = false window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized) } }, []) const handleLogout = useCallback(async () => { try { await logout() } catch { /* 会话可能已失效,直接切回登录页 */ } setAuthed(false) }, []) const checkHealth = useCallback(async () => { try { const h = await fetchHealth() setHealthOk(h?.status === 'healthy' || h?.status === 'ok') } catch { setHealthOk(false) } }, []) useEffect(() => { checkHealth() const t = setInterval(checkHealth, 60_000) return () => clearInterval(t) }, [checkHealth]) // 路由变化时关闭移动端菜单 const closeSidebar = useCallback(() => setSidebarOpen(false), []) useEffect(() => { closeSidebar() }, [location.pathname, closeSidebar]) // ESC 键关闭菜单 useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setSidebarOpen(false) } document.addEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler) }, []) // 登录门禁:未登录只渲染登录页,不泄露后台任何内容 if (authed === null) { return (
正在验证登录状态…
) } if (!authed) { return setAuthed(true)} /> } return (
{/* ── 通栏顶部报眉 ── */}
{/* 移动端汉堡按钮 */} {/* 面包屑 */} {dateLine()}
{/* ── 主体:侧边栏 + 内容 ── */}
{/* ── 移动端遮罩层 ── */} {sidebarOpen && (
setSidebarOpen(false)} aria-hidden="true" /> )} {/* ── 侧边栏 ── */} {/* ── 主内容区 ── */}
{/* 命令面板(⌘K) */} palette.setOpen(false)} />
) }