后端: - 添加 GET /api/v1/stats/dashboard 聚合统计端点 (账号数/活跃服务商/今日请求/今日Token用量等7项指标) - 需要 account:admin 权限 Admin 前端 (Next.js 14 + shadcn/ui + Tailwind + Recharts): - 设计系统: Dark Mode OLED (#020617 背景, #22C55E CTA) - 登录页: 双栏布局, 品牌区 + 表单 - Dashboard 布局: Sidebar 导航 + Header + 主内容区 - 仪表盘: 4 统计卡片 + AreaChart 请求趋势 + BarChart Token用量 - 8 个 CRUD 页面: - 账号管理 (搜索/角色/状态筛选, 编辑/启用禁用) - 服务商 (CRUD + API Key masked) - 模型管理 (Provider筛选, CRUD) - API 密钥 (创建/撤销, 一次性显示token) - 用量统计 (LineChart + BarChart) - 中转任务 (状态筛选, 展开详情) - 系统配置 (分类Tab, 编辑) - 操作日志 (Action筛选, 展开详情) - 14 个 shadcn 风格 UI 组件 (手写实现) - 类型化 API 客户端 (SaaSClient, 20+ 方法, 401 自动跳转) - AuthGuard 路由保护 + useAuth() hook 验证: tsc --noEmit 零 error, pnpm build 13 页面成功, cargo test 21 通过
219 lines
6.7 KiB
TypeScript
219 lines
6.7 KiB
TypeScript
'use client'
|
|
|
|
import { useState, type ReactNode } from 'react'
|
|
import Link from 'next/link'
|
|
import { usePathname, useRouter } from 'next/navigation'
|
|
import {
|
|
LayoutDashboard,
|
|
Users,
|
|
Server,
|
|
Cpu,
|
|
Key,
|
|
BarChart3,
|
|
ArrowLeftRight,
|
|
Settings,
|
|
FileText,
|
|
LogOut,
|
|
ChevronLeft,
|
|
Menu,
|
|
Bell,
|
|
} from 'lucide-react'
|
|
import { AuthGuard, useAuth } from '@/components/auth-guard'
|
|
import { logout } from '@/lib/auth'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
const navItems = [
|
|
{ href: '/', label: '仪表盘', icon: LayoutDashboard },
|
|
{ href: '/accounts', label: '账号管理', icon: Users },
|
|
{ href: '/providers', label: '服务商', icon: Server },
|
|
{ href: '/models', label: '模型管理', icon: Cpu },
|
|
{ href: '/api-keys', label: 'API 密钥', icon: Key },
|
|
{ href: '/usage', label: '用量统计', icon: BarChart3 },
|
|
{ href: '/relay', label: '中转任务', icon: ArrowLeftRight },
|
|
{ href: '/config', label: '系统配置', icon: Settings },
|
|
{ href: '/logs', label: '操作日志', icon: FileText },
|
|
]
|
|
|
|
function Sidebar({
|
|
collapsed,
|
|
onToggle,
|
|
}: {
|
|
collapsed: boolean
|
|
onToggle: () => void
|
|
}) {
|
|
const pathname = usePathname()
|
|
const router = useRouter()
|
|
const { account } = useAuth()
|
|
|
|
function handleLogout() {
|
|
logout()
|
|
router.replace('/login')
|
|
}
|
|
|
|
return (
|
|
<aside
|
|
className={cn(
|
|
'fixed left-0 top-0 z-40 flex h-screen flex-col border-r border-border bg-card transition-all duration-300',
|
|
collapsed ? 'w-16' : 'w-64',
|
|
)}
|
|
>
|
|
{/* Logo */}
|
|
<div className="flex h-14 items-center border-b border-border px-4">
|
|
<Link href="/" className="flex items-center gap-2 cursor-pointer">
|
|
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary text-primary-foreground font-bold text-sm">
|
|
Z
|
|
</div>
|
|
{!collapsed && (
|
|
<div className="flex flex-col">
|
|
<span className="text-sm font-bold text-foreground">ZCLAW</span>
|
|
<span className="text-[10px] text-muted-foreground">Admin</span>
|
|
</div>
|
|
)}
|
|
</Link>
|
|
</div>
|
|
|
|
{/* 导航 */}
|
|
<nav className="flex-1 overflow-y-auto scrollbar-thin py-2 px-2">
|
|
<ul className="space-y-1">
|
|
{navItems.map((item) => {
|
|
const isActive =
|
|
item.href === '/'
|
|
? pathname === '/'
|
|
: pathname.startsWith(item.href)
|
|
const Icon = item.icon
|
|
|
|
return (
|
|
<li key={item.href}>
|
|
<Link
|
|
href={item.href}
|
|
className={cn(
|
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors duration-200 cursor-pointer',
|
|
isActive
|
|
? 'bg-muted text-green-400'
|
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
|
collapsed && 'justify-center px-2',
|
|
)}
|
|
title={collapsed ? item.label : undefined}
|
|
>
|
|
<Icon className="h-4 w-4 shrink-0" />
|
|
{!collapsed && <span>{item.label}</span>}
|
|
</Link>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
</nav>
|
|
|
|
{/* 底部折叠按钮 */}
|
|
<div className="border-t border-border p-2">
|
|
<button
|
|
onClick={onToggle}
|
|
className="flex w-full items-center justify-center rounded-md px-3 py-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 cursor-pointer"
|
|
>
|
|
<ChevronLeft
|
|
className={cn(
|
|
'h-4 w-4 transition-transform duration-200',
|
|
collapsed && 'rotate-180',
|
|
)}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{/* 用户信息 */}
|
|
{!collapsed && (
|
|
<div className="border-t border-border p-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-foreground">
|
|
{account?.display_name?.[0] || account?.username?.[0] || 'A'}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="truncate text-sm font-medium text-foreground">
|
|
{account?.display_name || account?.username || 'Admin'}
|
|
</p>
|
|
<p className="truncate text-xs text-muted-foreground">
|
|
{account?.role || 'admin'}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={handleLogout}
|
|
className="rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-destructive transition-colors duration-200 cursor-pointer"
|
|
title="退出登录"
|
|
>
|
|
<LogOut className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</aside>
|
|
)
|
|
}
|
|
|
|
function Header() {
|
|
const pathname = usePathname()
|
|
const currentNav = navItems.find(
|
|
(item) =>
|
|
item.href === '/'
|
|
? pathname === '/'
|
|
: pathname.startsWith(item.href),
|
|
)
|
|
|
|
return (
|
|
<header className="sticky top-0 z-30 flex h-14 items-center border-b border-border bg-background/80 backdrop-blur-sm px-6">
|
|
{/* 移动端菜单按钮 */}
|
|
<MobileMenuButton />
|
|
|
|
{/* 页面标题 */}
|
|
<h1 className="text-lg font-semibold text-foreground">
|
|
{currentNav?.label || '仪表盘'}
|
|
</h1>
|
|
|
|
<div className="ml-auto flex items-center gap-2">
|
|
{/* 通知 */}
|
|
<button
|
|
className="relative rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 cursor-pointer"
|
|
title="通知"
|
|
>
|
|
<Bell className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
)
|
|
}
|
|
|
|
function MobileMenuButton() {
|
|
// Placeholder for mobile menu toggle
|
|
return (
|
|
<button
|
|
className="mr-3 rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 lg:hidden cursor-pointer"
|
|
>
|
|
<Menu className="h-5 w-5" />
|
|
</button>
|
|
)
|
|
}
|
|
|
|
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
|
|
|
return (
|
|
<AuthGuard>
|
|
<div className="flex min-h-screen">
|
|
<Sidebar
|
|
collapsed={sidebarCollapsed}
|
|
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
|
|
/>
|
|
<div
|
|
className={cn(
|
|
'flex flex-1 flex-col transition-all duration-300',
|
|
sidebarCollapsed ? 'ml-16' : 'ml-64',
|
|
)}
|
|
>
|
|
<Header />
|
|
<main className="flex-1 overflow-auto p-6 scrollbar-thin">
|
|
{children}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</AuthGuard>
|
|
)
|
|
}
|