Files
zclaw_openfang/admin/src/app/(dashboard)/layout.tsx
iven 8b9d506893 refactor(saas): 架构重构 + 性能优化 — 借鉴 loco-rs 模式
Phase 0: 知识库
- docs/knowledge-base/loco-rs-patterns.md — loco-rs 10 个可借鉴模式研究

Phase 1: 数据层重构
- crates/zclaw-saas/src/models/ — 15 个 FromRow 类型化模型
- Login 3 次查询合并为 1 次 AccountLoginRow 查询
- 所有 service 文件从元组解构迁移到 FromRow 结构体

Phase 2: Worker + Scheduler 系统
- crates/zclaw-saas/src/workers/ — Worker trait + 5 个具体实现
- crates/zclaw-saas/src/scheduler.rs — TOML 声明式调度器
- crates/zclaw-saas/src/tasks/ — CLI 任务系统

Phase 3: 性能修复
- Relay N+1 查询 → 精准 SQL (relay/handlers.rs)
- Config RwLock → AtomicU32 无锁 rate limit (state.rs, middleware.rs)
- SSE std::sync::Mutex → tokio::sync::Mutex (relay/service.rs)
- /auth/refresh 阻塞清理 → Scheduler 定期执行

Phase 4: 多环境配置
- config/saas-{development,production,test}.toml
- ZCLAW_ENV 环境选择 + ZCLAW_SAAS_CONFIG 精确覆盖
- scheduler 配置集成到 TOML
2026-03-29 19:21:48 +08:00

242 lines
8.1 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,
MessageSquare,
Bot,
LogOut,
ChevronLeft,
Menu,
Bell,
} from 'lucide-react'
import { AuthGuard, useAuth } from '@/components/auth-guard'
import { logout } from '@/lib/auth'
import { cn } from '@/lib/utils'
/** 权限常量 — 与后端 db.rs SEED_ROLES 保持同步 */
const ROLE_PERMISSIONS: Record<string, string[]> = {
super_admin: ['admin:full', 'account:admin', 'provider:manage', 'model:manage', 'relay:admin', 'config:write', 'prompt:read', 'prompt:write', 'prompt:publish', 'prompt:admin'],
admin: ['account:read', 'account:admin', 'provider:manage', 'model:read', 'model:manage', 'relay:use', 'relay:admin', 'config:read', 'config:write', 'prompt:read', 'prompt:write', 'prompt:publish'],
user: ['model:read', 'relay:use', 'config:read', 'prompt:read'],
}
/** 根据 role 获取权限列表 */
function getPermissionsForRole(role: string): string[] {
return ROLE_PERMISSIONS[role] ?? []
}
const navItems = [
{ href: '/', label: '仪表盘', icon: LayoutDashboard },
{ href: '/accounts', label: '账号管理', icon: Users, permission: 'account:admin' },
{ href: '/providers', label: '服务商', icon: Server, permission: 'provider:manage' },
{ href: '/models', label: '模型管理', icon: Cpu, permission: 'model:read' },
{ href: '/agent-templates', label: 'Agent 模板', icon: Bot, permission: 'model:read' },
{ href: '/api-keys', label: 'API 密钥', icon: Key, permission: 'admin:full' },
{ href: '/usage', label: '用量统计', icon: BarChart3, permission: 'admin:full' },
{ href: '/relay', label: '中转任务', icon: ArrowLeftRight, permission: 'relay:use' },
{ href: '/config', label: '系统配置', icon: Settings, permission: 'config:read' },
{ href: '/prompts', label: '提示词管理', icon: MessageSquare, permission: 'prompt:read' },
{ href: '/logs', label: '操作日志', icon: FileText, permission: 'admin:full' },
]
function Sidebar({
collapsed,
onToggle,
}: {
collapsed: boolean
onToggle: () => void
}) {
const pathname = usePathname()
const router = useRouter()
const { account } = useAuth()
const permissions = account ? getPermissionsForRole(account.role) : []
function handleLogout() {
logout()
router.replace('/login')
}
const filteredNavItems = navItems.filter((item) => {
if (!item.permission) return true
return permissions.includes(item.permission) || permissions.includes('admin:full')
})
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">
{filteredNavItems.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>
)
}