import { useLayoutEffect, useEffect, useRef, useState, Suspense } from 'react' import { Navigate, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom' import { Avatar, Button, Separator, Spinner } from '@heroui/react' import { House, Rocket, Gear, FileText, Bars } from '@gravity-ui/icons' import { useAuth } from '../auth/auth' import { hubPlugins } from '../api/client' import { ErrorBoundary } from '../components/ErrorBoundary' import { useConnection } from '../connection' interface NavItem { id: string; label: string; description: string; icon: any } const NAV_ITEMS: NavItem[] = [ { id: 'home', label: '首页', description: '主机与服务状态', icon: House }, { id: 'plugins', label: '插件管理', description: '插件总览与分群控制', icon: Rocket }, { id: 'logs', label: '查看日志', description: 'bot 运行日志实时查看', icon: FileText }, { id: 'settings', label: '设置', description: '账号与系统设置', icon: Gear }, ] // 插件名映射做模块级缓存:布局多次挂载(路由切换)时不必重复拉取 let webNamesCache: Record | null = null export function AppLayout() { const { token, username, logout } = useAuth() const navigate = useNavigate() const location = useLocation() // 全局连接状态(顶栏徽章):由常驻 dashboard SSE 驱动 const { status, lastUpdate } = useConnection() const navItems = NAV_ITEMS const path = location.pathname.replace(/\/+$/, '') || '/' const isHome = path === '/hub' const isSettings = path.startsWith('/hub/settings') const isLogs = path.startsWith('/hub/logs') const isPluginList = path.startsWith('/hub/plugins') const isPlugins = isPluginList || (!isHome && !isSettings && !isLogs && path.startsWith('/hub/')) const isPluginSub = isPlugins && !isPluginList const activeId = isPlugins ? 'plugins' : isLogs ? 'logs' : isSettings ? 'settings' : 'home' const pageMeta = navItems.find(it => it.id === activeId) || navItems[0] // 插件子页:从 /hub/<插件id>/... 提取插件 id,并用注册表名字做顶栏面包屑 const pluginId = path.split('/')[2] || '' const [webNames, setWebNames] = useState>({}) useEffect(() => { if (webNamesCache) { setWebNames(webNamesCache); return } hubPlugins().then(d => { const m: Record = {} for (const p of (d && d.plugins) || []) m[p.id] = p.name webNamesCache = m setWebNames(m) }).catch(() => {}) }, []) const subName = isPluginSub ? (webNames[pluginId] || pluginId) : '' const navMain = navItems.filter(it => it.id !== 'settings') const navBottom = navItems.filter(it => it.id === 'settings') const activeMainId = navMain.find(it => it.id === activeId)?.id ?? null // 移动端侧栏抽屉:默认收起,点击汉堡按钮展开;切换路由后自动收起 const [sidebarOpen, setSidebarOpen] = useState(false) useEffect(() => { setSidebarOpen(false) }, [path]) useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setSidebarOpen(false) } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, []) // 滑动指示条:在主导航区中根据高亮项位置平滑移动 const [indicator, setIndicator] = useState<{ top: number; height: number } | null>(null) const mainNavRef = useRef(null) const recalcIndicator = () => { const nav = mainNavRef.current if (!nav || !activeMainId) { setIndicator(null); return } const el = nav.querySelector('[data-nav-id="' + activeMainId + '"]') if (!el) { setIndicator(null); return } const navRect = nav.getBoundingClientRect() const itemRect = el.getBoundingClientRect() setIndicator({ top: itemRect.top - navRect.top, height: itemRect.height }) } useLayoutEffect(() => { recalcIndicator() }, [activeMainId, path]) useEffect(() => { // 窗口尺寸变化(如移动端调转)时重算指示条位置 window.addEventListener('resize', recalcIndicator) return () => window.removeEventListener('resize', recalcIndicator) }, [activeMainId, path]) if (!token) return return (
{sidebarOpen && (
setSidebarOpen(false)} /> )}
{isPluginSub && ( )} {isPluginSub ? (
插件管理 › {subName}
) : ( <> {pageMeta.label} )}
{status} {lastUpdate && {new Date(lastUpdate).toLocaleTimeString('zh-CN', { hour12: false })}} {(username || 'U').charAt(0).toUpperCase()} {username || '未登录'}
{/* 插件懒加载只替换内容区;子页面异常也不拖垮整个布局 */}
}>
) }