/** * 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 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 } } const NAV_PAGES: Array<{ to: string; label: string; group: string }> = [ { to: '/admin', label: '仪表盘', group: '概览' }, { to: '/admin/collection', label: '数据采集', group: '数据流水线' }, { to: '/admin/data-completeness', label: '数据完整性', group: '数据流水线' }, { to: '/admin/data-pipeline', label: '数据管线', group: '数据流水线' }, { to: '/admin/predictions', label: '预测历史', group: '数据流水线' }, { to: '/admin/backtest', label: '回测', group: '数据流水线' }, { to: '/admin/monitoring', label: '监控', group: '评估与监控' }, { to: '/admin/eval', label: '评估', group: '评估与监控' }, { to: '/admin/settings', label: '设置', group: '系统' }, { to: '/admin/logs', label: '日志', group: '系统' }, ] // 路由 → 面包屑标签 const ROUTE_LABELS: Record = { '/admin': '仪表盘', '/admin/collection': '数据采集', '/admin/data-completeness': '数据完整性', '/admin/data-pipeline': '数据管线', '/admin/predictions': '预测历史', '/admin/backtest': '回测', '/admin/monitoring': '监控', '/admin/settings': '设置', '/admin/logs': '日志', '/admin/eval': '评估', } const NAV_SECTIONS: { title: string; items: Array<{ to: string; label: string; icon: string }> }[] = [ { title: '数据流水线', items: [ { to: '/admin/collection', label: '数据采集', icon: 'collection' }, { to: '/admin/data-completeness', label: '数据完整性', icon: 'chart' }, { to: '/admin/predictions', label: '预测历史', icon: 'logs' }, { to: '/admin/backtest', label: '回测', icon: 'repeat' }, ], }, { title: '评估与监控', items: [ { to: '/admin/eval', label: '评估', icon: 'eval' }, { to: '/admin/monitoring', label: '监控', icon: 'monitor' }, ], }, { title: '系统设置', items: [ { to: '/admin/settings', label: '设置', icon: 'settings' }, { to: '/admin/logs', label: '日志', icon: 'logs' }, ], }, ] /** 报眉日期行,与前台同款式 */ 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(NAV_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)} />
) }