Files
HeXi/hexi/web/src/layout/AppLayout.tsx
T

118 lines
5.9 KiB
TypeScript
Raw Normal View History

import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { Avatar, Button, Separator } from '@heroui/react'
import { House, Rocket, Gear } from '@gravity-ui/icons'
import { useAuth } from '../auth/auth'
interface NavItem { id: string; label: string; description: string; icon: any }
export function AppLayout() {
const { token, username, logout } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const navItems: NavItem[] = [
{ id: 'home', label: '首页', description: '主机与服务状态', icon: House },
{ id: 'plugins', label: '插件管理', description: '插件总览与分群控制', icon: Rocket },
{ id: 'settings', label: '设置', description: '账号与系统设置', icon: Gear },
]
const isHome = location.pathname === '/hub'
const isSettings = location.pathname.startsWith('/hub/settings')
const isPluginList = location.pathname.startsWith('/hub/plugins')
const isPlugins = isPluginList || (!isHome && !isSettings && location.pathname.startsWith('/hub/'))
const isPluginSub = isPlugins && !isPluginList
const activeId = isPlugins ? 'plugins' : isSettings ? 'settings' : 'home'
const pageMeta = navItems.find(it => it.id === activeId) || navItems[0]
if (!token) return null
return (
<div className="flex h-screen w-screen overflow-hidden bg-slate-50 text-slate-900">
<aside className="flex h-full w-60 shrink-0 flex-col overflow-hidden bg-white border-r border-slate-200">
{/* 品牌头 */}
<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>
</div>
{/* 导航 */}
<nav className="flex-1 min-h-0 space-y-1 overflow-y-auto px-2 py-2">
{navItems.filter(it => it.id !== 'settings').map(it => {
const to = it.id === 'home' ? '/hub' : '/hub/' + it.id
return (
<NavLink
key={it.id}
to={to}
end={it.id === 'home'}
className={({ isActive }) => {
const base = 'group relative flex items-center rounded-lg py-2.5 text-sm font-medium transition-colors cursor-pointer'
return base + (isActive ? ' bg-indigo-50 text-indigo-700' : ' text-slate-600 hover:bg-slate-100 hover:text-slate-800')
}}
>
{({ isActive }) => (
<>
{isActive && <span className="absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-indigo-500" />}
<span className="relative z-10 grid w-11 shrink-0 place-items-center"><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>
{/* 底部设置 */}
<nav className="shrink-0 space-y-1 border-t border-slate-100 px-2 py-2">
{navItems.filter(it => it.id === 'settings').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 cursor-pointer'
return base + (isActive ? ' bg-indigo-50 text-indigo-700' : ' text-slate-600 hover:bg-slate-100 hover:text-slate-800')
}}
>
{({ isActive }) => (
<>
{isActive && <span className="absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-indigo-500" />}
<span className="relative z-10 grid w-11 shrink-0 place-items-center"><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-6">
<div className="flex items-center gap-3">
{isPluginSub && (
<Button size="sm" variant="tertiary" onPress={() => navigate('/hub/plugins')}>← 返回插件管理</Button>
)}
<pageMeta.icon className="size-4 text-slate-500" />
<span className="text-sm font-semibold text-slate-800">{pageMeta.label}</span>
</div>
<div className="flex items-center gap-3">
<Avatar size="sm"><Avatar.Fallback>{(username || 'U').charAt(0).toUpperCase()}</Avatar.Fallback></Avatar>
<span className="text-sm text-slate-600">{username || '未登录'}</span>
<Separator orientation="vertical" className="h-5" />
<Button size="sm" variant="tertiary" onPress={logout}>退出</Button>
</div>
</header>
<main className="min-h-0 flex-1 overflow-y-auto p-6"><Outlet /></main>
</div>
</div>
)
}