Files
HeXi/hexi/web/src/layout/AppLayout.tsx
T
sansenhoshi 131b92b319 结构调整
视频解析多图/多媒体结构 消息体适配
2026-09-08 14:25:32 +08:00

221 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> | 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<Record<string, string>>({})
useEffect(() => {
if (webNamesCache) { setWebNames(webNamesCache); return }
hubPlugins().then(d => {
const m: Record<string, string> = {}
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<HTMLElement>(null)
const recalcIndicator = () => {
const nav = mainNavRef.current
if (!nav || !activeMainId) { setIndicator(null); return }
const el = nav.querySelector<HTMLElement>('[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 <Navigate to="/hub/login" replace state={{ from: location.pathname + location.search }} />
return (
<div className="flex h-screen w-screen overflow-hidden bg-slate-50 text-slate-900">
{sidebarOpen && (
<div className="fixed inset-0 z-30 bg-slate-900/40 backdrop-blur-sm md:hidden" onClick={() => setSidebarOpen(false)} />
)}
<aside className={'fixed inset-y-0 left-0 z-40 flex h-full w-60 shrink-0 flex-col overflow-hidden bg-white border-r border-slate-200 transition-transform duration-300 ease-out md:static md:translate-x-0 ' + (sidebarOpen ? 'translate-x-0' : '-translate-x-full')}>
{/* 品牌头 */}
<div className="flex h-16 shrink-0 items-center px-4">
<div className="grid size-9 place-items-center rounded-xl bg-indigo-100 text-indigo-600"><Gear className="size-5" /></div>
<div className="ml-3 min-w-0 flex-1">
<div className="truncate text-sm font-bold">HeXi 管理系统</div>
<div className="truncate text-xs text-slate-400">统一管理台</div>
</div>
<Button size="sm" variant="tertiary" className="md:hidden" onPress={() => setSidebarOpen(false)} aria-label="关闭菜单">✕</Button>
</div>
{/* 导航(主区,含滑动指示条) */}
<nav ref={mainNavRef} className="relative flex-1 min-h-0 space-y-1 overflow-y-auto px-2 py-2">
{indicator && (
<span
className="pointer-events-none absolute left-2 w-0.5 rounded-full bg-indigo-500 transition-all duration-300 ease-out"
style={{ top: indicator.top + (indicator.height - 20) / 2, height: 20 }}
/>
)}
{navMain.map(it => {
const to = it.id === 'home' ? '/hub' : '/hub/' + it.id
return (
<NavLink
key={it.id}
to={to}
end={it.id === 'home'}
data-nav-id={it.id}
className={({ isActive }) => {
const base = 'group relative flex items-center rounded-lg py-2.5 text-sm font-medium transition-colors duration-200 cursor-pointer'
return base + (isActive ? ' bg-indigo-50 text-indigo-700' : ' text-slate-600 hover:bg-slate-100 hover:text-slate-800')
}}
>
{({ isActive }) => (
<>
<span className={'relative z-10 grid w-11 shrink-0 place-items-center transition-transform duration-200 ' + (isActive ? 'scale-110' : 'group-hover:scale-105')}><it.icon className="size-4" /></span>
<span className="relative z-10 flex min-w-0 flex-1 flex-col items-start overflow-hidden pr-3">
<span className="w-full truncate leading-tight">{it.label}</span>
<span className={'w-full truncate text-xs font-normal transition-colors duration-200 ' + (isActive ? 'text-indigo-400' : 'text-slate-400')}>{it.description}</span>
</span>
</>
)}
</NavLink>
)
})}
</nav>
{/* 底部设置(单条目时用缩放指示条) */}
<nav className="shrink-0 space-y-1 border-t border-slate-100 px-2 py-2">
{navBottom.map(it => {
const to = '/hub/' + it.id
return (
<NavLink
key={it.id}
to={to}
className={({ isActive }) => {
const base = 'group relative flex items-center rounded-lg py-2.5 text-sm font-medium transition-colors duration-200 cursor-pointer'
return base + (isActive ? ' bg-indigo-50 text-indigo-700' : ' text-slate-600 hover:bg-slate-100 hover:text-slate-800')
}}
>
{({ isActive }) => (
<>
<span className={'absolute left-0 top-1/2 h-5 w-0.5 origin-center -translate-y-1/2 rounded-full bg-indigo-500 transition-all duration-300 ' + (isActive ? 'scale-y-100 opacity-100' : 'scale-y-0 opacity-0')} />
<span className={'relative z-10 grid w-11 shrink-0 place-items-center transition-transform duration-200 ' + (isActive ? 'scale-110' : 'group-hover:scale-105')}><it.icon className="size-4" /></span>
<span className="relative z-10 flex min-w-0 flex-1 flex-col items-start overflow-hidden pr-3">
<span className="w-full truncate leading-tight">{it.label}</span>
<span className="w-full truncate text-xs font-normal text-slate-400">{it.description}</span>
</span>
</>
)}
</NavLink>
)
})}
</nav>
</aside>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<header className="flex h-16 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-3 md:px-6">
<div className="flex min-w-0 items-center gap-2 md:gap-3">
<Button size="sm" variant="tertiary" className="shrink-0 md:hidden" onPress={() => setSidebarOpen(v => !v)} aria-label="打开菜单">
<Bars className="size-5" />
</Button>
{isPluginSub && (
<Button size="sm" variant="tertiary" className="max-w-24 shrink-0 truncate" onPress={() => navigate('/hub/plugins')}>返回</Button>
)}
{isPluginSub ? (
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-sm text-slate-400">插件管理</span>
<span className="shrink-0 text-sm text-slate-300">›</span>
<span className="min-w-0 truncate text-sm font-semibold text-slate-800">{subName}</span>
</div>
) : (
<>
<pageMeta.icon className="hidden size-4 shrink-0 text-slate-500 sm:block" />
<span className="truncate text-sm font-semibold text-slate-800">{pageMeta.label}</span>
</>
)}
</div>
<div className="flex shrink-0 items-center gap-2 md:gap-3">
<span
title={lastUpdate ? '最后更新 ' + new Date(lastUpdate).toLocaleTimeString('zh-CN', { hour12: false }) : '正在连接后端…'}
className={'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors duration-300 ' + (status === '实时' ? 'border-emerald-200 bg-emerald-50 text-emerald-600' : status === '重连中' ? 'border-amber-200 bg-amber-50 text-amber-600' : 'border-slate-200 bg-slate-50 text-slate-400')}
>
<span className={'size-1.5 rounded-full ' + (status === '实时' ? 'bg-emerald-500' : status === '重连中' ? 'animate-pulse bg-amber-500' : 'bg-slate-300')} />
{status}
{lastUpdate && <span className="hidden text-[10px] opacity-70 sm:inline">{new Date(lastUpdate).toLocaleTimeString('zh-CN', { hour12: false })}</span>}
</span>
<Avatar size="sm"><Avatar.Fallback>{(username || 'U').charAt(0).toUpperCase()}</Avatar.Fallback></Avatar>
<span className="hidden text-sm text-slate-600 sm:inline">{username || '未登录'}</span>
<Separator orientation="vertical" className="hidden h-5 sm:block" />
<Button size="sm" variant="tertiary" onPress={() => { logout(); navigate('/hub/login') }}>退出</Button>
</div>
</header>
<main className="min-h-0 flex-1 overflow-y-auto p-4 md:p-6">
<div key={path} className="hexi-page-enter">
{/* 插件懒加载只替换内容区;子页面异常也不拖垮整个布局 */}
<ErrorBoundary compact>
<Suspense fallback={<div className="flex justify-center py-10"><Spinner /></div>}>
<Outlet />
</Suspense>
</ErrorBoundary>
</div>
</main>
</div>
</div>
)
}