183 lines
6.0 KiB
TypeScript
183 lines
6.0 KiB
TypeScript
'use client'
|
|
|
|
import Link from 'next/link'
|
|
import { usePathname, useRouter } from 'next/navigation'
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import { BarChart3, Bot, LayoutDashboard, LogOut, Settings } from 'lucide-react'
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
import { cn } from '@/lib/utils'
|
|
import { ADMIN_UNAUTHORIZED_EVENT } from '@/lib/admin-api'
|
|
|
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000'
|
|
|
|
export default function AdminLayout({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode
|
|
}) {
|
|
const router = useRouter()
|
|
const pathname = usePathname()
|
|
const [authChecked, setAuthChecked] = useState(false)
|
|
const [adminName, setAdminName] = useState<string>('')
|
|
|
|
const navItems = useMemo(
|
|
() => [
|
|
{ href: '/admin', label: '概览', icon: LayoutDashboard },
|
|
{ href: '/admin/providers', label: 'AI 配置', icon: Bot },
|
|
{ href: '/admin/stats', label: '统计', icon: BarChart3 },
|
|
{ href: '/admin/settings', label: '设置', icon: Settings },
|
|
],
|
|
[]
|
|
)
|
|
|
|
const getToken = () => localStorage.getItem('admin_token') || ''
|
|
|
|
const fetchMe = async () => {
|
|
const token = getToken()
|
|
if (!token) return
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/v1/admin/me`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
})
|
|
if (res.status === 401) throw new Error('unauthorized')
|
|
if (!res.ok) return
|
|
const data = await res.json()
|
|
setAdminName(data.username || data.sub || '')
|
|
} catch {
|
|
localStorage.removeItem('admin_token')
|
|
router.replace('/login')
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
const token = getToken()
|
|
if (!token) {
|
|
router.replace('/login')
|
|
return
|
|
}
|
|
setAuthChecked(true)
|
|
fetchMe()
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const onUnauthorized = () => {
|
|
localStorage.removeItem('admin_token')
|
|
router.replace('/login')
|
|
}
|
|
window.addEventListener(ADMIN_UNAUTHORIZED_EVENT, onUnauthorized)
|
|
return () => window.removeEventListener(ADMIN_UNAUTHORIZED_EVENT, onUnauthorized)
|
|
}, [router])
|
|
|
|
const handleLogout = () => {
|
|
localStorage.removeItem('admin_token')
|
|
router.replace('/login')
|
|
}
|
|
|
|
if (!authChecked) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
|
<div className="text-sm text-muted-foreground">加载中...</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
<div className="flex min-h-screen">
|
|
{/* Sidebar */}
|
|
<aside className="hidden md:flex w-64 flex-col border-r bg-card">
|
|
<div className="h-14 px-4 flex items-center border-b">
|
|
<Link href="/" className="font-semibold tracking-tight">
|
|
AI Translator
|
|
</Link>
|
|
<span className="ml-2 text-xs text-muted-foreground">Admin</span>
|
|
</div>
|
|
|
|
<nav className="flex-1 p-2 space-y-1">
|
|
{navItems.map((item) => {
|
|
const Icon = item.icon
|
|
const active =
|
|
pathname === item.href ||
|
|
(item.href !== '/admin' && pathname.startsWith(item.href))
|
|
return (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors',
|
|
active
|
|
? 'bg-accent text-accent-foreground'
|
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
|
)}
|
|
>
|
|
<Icon className="h-4 w-4" />
|
|
{item.label}
|
|
</Link>
|
|
)
|
|
})}
|
|
</nav>
|
|
|
|
<div className="p-2 border-t">
|
|
<Button variant="ghost" className="w-full justify-start" onClick={handleLogout}>
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
退出登录
|
|
</Button>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Main */}
|
|
<div className="flex-1 flex flex-col">
|
|
<header className="h-14 border-b bg-card/50 backdrop-blur-sm">
|
|
<div className="h-full container flex items-center justify-between">
|
|
<div className="md:hidden flex items-center gap-3 overflow-x-auto">
|
|
<div className="font-semibold shrink-0">管理后台</div>
|
|
<div className="flex items-center gap-1">
|
|
{navItems.map((item) => {
|
|
const active =
|
|
pathname === item.href ||
|
|
(item.href !== '/admin' && pathname.startsWith(item.href))
|
|
return (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
className={cn(
|
|
'rounded-md px-2 py-1 text-sm transition-colors',
|
|
active
|
|
? 'bg-accent text-accent-foreground'
|
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
|
)}
|
|
>
|
|
{item.label}
|
|
</Link>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{adminName && (
|
|
<div className="text-sm text-muted-foreground">
|
|
{adminName}
|
|
</div>
|
|
)}
|
|
<div className="md:hidden">
|
|
<Button variant="ghost" size="sm" onClick={handleLogout}>
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
退出
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="flex-1">
|
|
<div className="container py-6">{children}</div>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|