/** * Admin 后台 - 布局组件(报刊风) * * 与前台同一套纸色语言:报头式侧栏 + 报眉顶栏 + 细线分区。 * 响应式: 移动端汉堡菜单 + 抽屉侧栏,桌面端固定侧栏。 */ import { useState, useEffect, useCallback } from 'react' import { NavLink, Outlet, useLocation } from 'react-router-dom' import { fetchAuthState, logout, UNAUTHORIZED_EVENT } from '../api/api' import { fetchHealth } from '../api/dal' import { COMMAND_PALETTE_PAGES, ROUTE_LABELS, NAV_SECTIONS } from './nav' import Login from './Login' import { useCommandPalette, CommandPalette } from './useCommandPalette' import { AdminIcon } from './AdminIcon' // 图标已抽到 ./AdminIcon —— 此前这里是约 90 行的内联 switch, // 让布局文件同时承担页面骨架与图标图形库两种职责。 /** * 侧栏导航项的 className 生成器。 * * 此前这段字符串在文件里出现了两次(仪表盘项 + 分组循环项), * 逐字相同。改一次选中态样式要改两处 —— 与 App.tsx 里 * 被抽成 SiteLayout 的重复是同一型问题,这里同样收敛为单一来源。 */ const SIDEBAR_LINK_BASE = 'nav-icon-wrap flex min-h-[40px] items-center gap-2.5 border-l-4 px-3 text-sm transition-all duration-300' const SIDEBAR_LINK_ACTIVE = 'border-press bg-press-wash/60 font-medium text-press active' const SIDEBAR_LINK_IDLE = 'border-transparent text-ink-500 hover:bg-paper-100 hover:text-ink-900' function sidebarLinkClass({ isActive }: { isActive: boolean }): string { return `${SIDEBAR_LINK_BASE} ${isActive ? SIDEBAR_LINK_ACTIVE : SIDEBAR_LINK_IDLE}` } // 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)} />
) }