fad06fd
import {
Activity,
Database,
FolderGit2,
HardDrive,
LayoutDashboard,
ListChecks,
LogOut,
MessagesSquare,
Moon,
ScrollText,
Settings,
ShieldCheck,
Sun,
Users,
Workflow,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { Menu, X } from 'lucide-react'
import { NavLink, Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { Cpu } from 'lucide-react'
import { useSession } from '@/app/session'
import { useTheme } from '@/app/theme'
import { Badge, Button, Loading } from '@/shared/ui'
const NAV_ITEMS = [
{ to: '/', label: '系统状态', icon: LayoutDashboard, end: true },
{ to: '/users', label: '用户', icon: Users },
{ to: '/spaces', label: 'Space', icon: Database },
{ to: '/repos', label: '仓库', icon: FolderGit2 },
{ to: '/chat', label: '聊天记录', icon: MessagesSquare },
{ to: '/storage', label: '存储', icon: HardDrive },
{ to: '/queues', label: '队列', icon: Activity },
{ to: '/act', label: 'ACT', icon: Workflow },
{ to: '/runners', label: 'Runner', icon: Cpu },
{ to: '/tasks', label: '任务', icon: ListChecks },
{ to: '/logs', label: '日志', icon: ScrollText },
{ to: '/admins', label: '管理员', icon: ShieldCheck },
{ to: '/system', label: '系统配置', icon: Settings },
]
function roleLabel(role: string): string {
switch (role) {
case 'super_admin':
return '超级管理员'
case 'operator':
return '运维'
default:
return '只读'
}
}
export function AdminShell() {
const { me, loading, logout } = useSession()
const { dark, toggle } = useTheme()
const navigate = useNavigate()
const location = useLocation()
const [menuOpen, setMenuOpen] = useState(false)
// The drawer belongs to the current page only: any navigation closes it.
useEffect(() => { setMenuOpen(false) }, [location.pathname])
if (loading) {
return <Loading label="正在校验管理会话…" />
}
if (!me) {
return <Navigate to="/login" replace />
}
if (me.must_change_password) {
return <Navigate to="/password" replace />
}
return (
<div className="flex min-h-full">
{menuOpen ? (
<button
type="button"
aria-label="关闭导航"
tabIndex={-1}
onClick={() => setMenuOpen(false)}
className="fixed inset-0 z-40 bg-slate-900/40 md:hidden"
/>
) : null}
<aside
className={`fixed inset-y-0 left-0 z-50 flex w-56 shrink-0 flex-col overflow-y-auto border-r border-slate-200 bg-white p-3 transition-transform duration-200 dark:border-slate-800 dark:bg-slate-950 md:static md:z-auto md:translate-x-0 ${
menuOpen ? 'translate-x-0' : 'invisible -translate-x-full md:visible md:translate-x-0'
}`}
>
<div className="mb-4 flex items-start justify-between gap-2 px-2">
<div>
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">GitData</div>
<div className="text-xs text-slate-500">管理后台</div>
</div>
<span className="md:hidden">
<Button variant="ghost" size="sm" title="关闭导航" onClick={() => setMenuOpen(false)}>
<X size={16} />
</Button>
</span>
</div>
<nav className="flex flex-1 flex-col gap-1">
{NAV_ITEMS.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`flex items-center gap-2 rounded-md px-2 py-1.5 text-sm ${
isActive
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
}`
}
>
<item.icon size={16} />
{item.label}
</NavLink>
))}
</nav>
<div className="mt-4 border-t border-slate-200 pt-3 text-xs text-slate-500 dark:border-slate-800">
adminhost
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-4 py-3 dark:border-slate-800 dark:bg-slate-950">
<div className="flex min-w-0 items-center gap-2">
<span className="md:hidden">
<Button variant="ghost" size="sm" title="打开导航" onClick={() => setMenuOpen(true)}>
<Menu size={18} />
</Button>
</span>
<span className="truncate font-medium text-slate-800 dark:text-slate-100">{me.display_name}</span>
<Badge tone={me.role === 'super_admin' ? 'danger' : 'info'}>{roleLabel(me.role)}</Badge>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="ghost" size="sm" title="切换主题" onClick={toggle}>
{dark ? <Sun size={16} /> : <Moon size={16} />}
</Button>
<Button variant="secondary" size="sm" onClick={() => navigate('/password')}>
修改口令
</Button>
<Button
variant="secondary"
size="sm"
onClick={async () => {
await logout()
navigate('/login')
}}
>
<LogOut size={14} />
退出
</Button>
</div>
</header>
<main className="min-w-0 flex-1 overflow-y-auto p-4">
<Outlet />
</main>
</div>
</div>
)
}