221 lines
12 KiB
TypeScript
221 lines
12 KiB
TypeScript
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>
|
||
)
|
||
}
|