Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- ADMIN-01 FIXED: ConfigSync.tsx page with ProTable + pagination - config-sync service calling GET /config/sync-logs - route + nav item + breadcrumb - backend @reserved → @connected - ADMIN-02 FALSE_POSITIVE: Logs.tsx + logs service already exist
401 lines
14 KiB
TypeScript
401 lines
14 KiB
TypeScript
import { useState, useCallback, useEffect, useMemo } from 'react'
|
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
|
import {
|
|
DashboardOutlined,
|
|
TeamOutlined,
|
|
CloudServerOutlined,
|
|
BarChartOutlined,
|
|
SwapOutlined,
|
|
SettingOutlined,
|
|
FileTextOutlined,
|
|
MessageOutlined,
|
|
RobotOutlined,
|
|
LogoutOutlined,
|
|
MenuFoldOutlined,
|
|
MenuUnfoldOutlined,
|
|
SunOutlined,
|
|
MoonOutlined,
|
|
ApiOutlined,
|
|
BookOutlined,
|
|
CrownOutlined,
|
|
SafetyOutlined,
|
|
FieldTimeOutlined,
|
|
SyncOutlined,
|
|
} from '@ant-design/icons'
|
|
import { Avatar, Dropdown, Tooltip, Drawer } from 'antd'
|
|
import { useAuthStore } from '@/stores/authStore'
|
|
import { useThemeStore, setThemeMode } from '@/stores/themeStore'
|
|
import type { ReactNode } from 'react'
|
|
|
|
// ============================================================
|
|
// Navigation Configuration
|
|
// ============================================================
|
|
|
|
interface NavItem {
|
|
path: string
|
|
name: string
|
|
icon: ReactNode
|
|
permission?: string
|
|
group: string
|
|
}
|
|
|
|
const navItems: NavItem[] = [
|
|
{ path: '/', name: '仪表盘', icon: <DashboardOutlined />, group: '核心' },
|
|
{ path: '/accounts', name: '账号管理', icon: <TeamOutlined />, permission: 'account:admin', group: '资源管理' },
|
|
{ path: '/roles', name: '角色与权限', icon: <SafetyOutlined />, permission: 'account:admin', group: '资源管理' },
|
|
{ path: '/model-services', name: '模型服务', icon: <CloudServerOutlined />, permission: 'provider:manage', group: '资源管理' },
|
|
{ path: '/agent-templates', name: 'Agent 模板', icon: <RobotOutlined />, permission: 'model:read', group: '资源管理' },
|
|
{ path: '/api-keys', name: 'API 密钥', icon: <ApiOutlined />, permission: 'provider:manage', group: '资源管理' },
|
|
{ path: '/usage', name: '用量统计', icon: <BarChartOutlined />, permission: 'admin:full', group: '运维' },
|
|
{ path: '/relay', name: '中转任务', icon: <SwapOutlined />, permission: 'relay:use', group: '运维' },
|
|
{ path: '/scheduled-tasks', name: '定时任务', icon: <FieldTimeOutlined />, permission: 'scheduler:read', group: '运维' },
|
|
{ path: '/knowledge', name: '知识库', icon: <BookOutlined />, permission: 'knowledge:read', group: '资源管理' },
|
|
{ path: '/billing', name: '计费管理', icon: <CrownOutlined />, permission: 'billing:read', group: '核心' },
|
|
{ path: '/logs', name: '操作日志', icon: <FileTextOutlined />, permission: 'admin:full', group: '运维' },
|
|
{ path: '/config-sync', name: '同步日志', icon: <SyncOutlined />, permission: 'config:read', group: '运维' },
|
|
{ path: '/config', name: '系统配置', icon: <SettingOutlined />, permission: 'config:read', group: '系统' },
|
|
{ path: '/prompts', name: '提示词管理', icon: <MessageOutlined />, permission: 'prompt:read', group: '系统' },
|
|
]
|
|
|
|
// ============================================================
|
|
// Sidebar Component
|
|
// ============================================================
|
|
|
|
function Sidebar({
|
|
collapsed,
|
|
onNavigate,
|
|
activePath,
|
|
}: {
|
|
collapsed: boolean
|
|
onNavigate: (path: string) => void
|
|
activePath: string
|
|
}) {
|
|
const { hasPermission } = useAuthStore()
|
|
const visibleItems = navItems.filter(
|
|
(item) => !item.permission || hasPermission(item.permission),
|
|
)
|
|
|
|
const groups = useMemo(() => {
|
|
const map = new Map<string, NavItem[]>()
|
|
for (const item of visibleItems) {
|
|
const list = map.get(item.group) || []
|
|
list.push(item)
|
|
map.set(item.group, list)
|
|
}
|
|
return map
|
|
}, [visibleItems])
|
|
|
|
return (
|
|
<nav className="flex flex-col h-full" aria-label="主导航">
|
|
{/* Logo */}
|
|
<div className="flex items-center h-14 px-4 border-b border-neutral-200 dark:border-neutral-800">
|
|
<div
|
|
className="flex items-center justify-center w-8 h-8 rounded-lg shrink-0"
|
|
style={{ background: 'linear-gradient(135deg, #863bff, #47bfff)' }}
|
|
>
|
|
<span className="text-white font-bold text-sm">Z</span>
|
|
</div>
|
|
{!collapsed && (
|
|
<span className="ml-3 text-lg font-bold text-neutral-900 dark:text-neutral-100 tracking-tight">
|
|
ZCLAW
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Navigation Items */}
|
|
<div className="flex-1 overflow-y-auto py-3 px-2">
|
|
{Array.from(groups.entries()).map(([groupName, items]) => (
|
|
<div key={groupName} className="mb-3">
|
|
{!collapsed && (
|
|
<div className="px-3 mb-1 text-[11px] font-semibold uppercase tracking-wider text-neutral-400 dark:text-neutral-600">
|
|
{groupName}
|
|
</div>
|
|
)}
|
|
{items.map((item) => {
|
|
const isActive =
|
|
item.path === '/'
|
|
? activePath === '/'
|
|
: activePath.startsWith(item.path)
|
|
|
|
const btn = (
|
|
<button
|
|
key={item.path}
|
|
onClick={() => onNavigate(item.path)}
|
|
className={`
|
|
w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium
|
|
transition-all duration-150 ease-in-out cursor-pointer border-none bg-transparent
|
|
${
|
|
isActive
|
|
? 'text-brand-purple bg-brand-purple/8 dark:text-brand-purple dark:bg-brand-purple/12'
|
|
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
|
}
|
|
${collapsed ? 'justify-center' : ''}
|
|
`}
|
|
aria-current={isActive ? 'page' : undefined}
|
|
>
|
|
<span
|
|
className={`text-base ${isActive ? 'text-brand-purple' : ''}`}
|
|
>
|
|
{item.icon}
|
|
</span>
|
|
{!collapsed && <span>{item.name}</span>}
|
|
{isActive && (
|
|
<span className="ml-auto w-1.5 h-1.5 rounded-full bg-brand-purple" />
|
|
)}
|
|
</button>
|
|
)
|
|
|
|
return collapsed ? (
|
|
<Tooltip key={item.path} title={item.name} placement="right">
|
|
{btn}
|
|
</Tooltip>
|
|
) : (
|
|
<div key={item.path}>{btn}</div>
|
|
)
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Bottom section */}
|
|
<div className="border-t border-neutral-200 dark:border-neutral-800 p-3">
|
|
{!collapsed && (
|
|
<div className="text-[11px] text-neutral-400 dark:text-neutral-600 text-center">
|
|
ZCLAW Admin v2
|
|
</div>
|
|
)}
|
|
</div>
|
|
</nav>
|
|
)
|
|
}
|
|
|
|
// ============================================================
|
|
// Mobile Drawer Sidebar
|
|
// ============================================================
|
|
|
|
function MobileDrawer({
|
|
open,
|
|
onClose,
|
|
onNavigate,
|
|
activePath,
|
|
}: {
|
|
open: boolean
|
|
onClose: () => void
|
|
onNavigate: (path: string) => void
|
|
activePath: string
|
|
}) {
|
|
return (
|
|
<Drawer
|
|
placement="left"
|
|
onClose={onClose}
|
|
open={open}
|
|
width={280}
|
|
styles={{
|
|
body: { padding: 0 },
|
|
header: { display: 'none' },
|
|
}}
|
|
>
|
|
<Sidebar collapsed={false} onNavigate={onNavigate} activePath={activePath} />
|
|
</Drawer>
|
|
)
|
|
}
|
|
|
|
// ============================================================
|
|
// Breadcrumb
|
|
// ============================================================
|
|
|
|
const breadcrumbMap: Record<string, string> = {
|
|
'/': '仪表盘',
|
|
'/accounts': '账号管理',
|
|
'/roles': '角色与权限',
|
|
'/model-services': '模型服务',
|
|
'/providers': '模型服务',
|
|
'/models': '模型服务',
|
|
'/api-keys': 'API 密钥',
|
|
'/agent-templates': 'Agent 模板',
|
|
'/usage': '用量统计',
|
|
'/relay': '中转任务',
|
|
'/scheduled-tasks': '定时任务',
|
|
'/knowledge': '知识库',
|
|
'/billing': '计费管理',
|
|
'/config': '系统配置',
|
|
'/prompts': '提示词管理',
|
|
'/logs': '操作日志',
|
|
'/config-sync': '同步日志',
|
|
}
|
|
|
|
// ============================================================
|
|
// Main Layout
|
|
// ============================================================
|
|
|
|
export default function AdminLayout() {
|
|
const navigate = useNavigate()
|
|
const location = useLocation()
|
|
const { account, logout } = useAuthStore()
|
|
const themeState = useThemeStore()
|
|
const [collapsed, setCollapsed] = useState(false)
|
|
const [mobileOpen, setMobileOpen] = useState(false)
|
|
const [isMobile, setIsMobile] = useState(false)
|
|
|
|
// Responsive detection
|
|
useEffect(() => {
|
|
const mq = window.matchMedia('(max-width: 768px)')
|
|
setIsMobile(mq.matches)
|
|
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
|
mq.addEventListener('change', handler)
|
|
return () => mq.removeEventListener('change', handler)
|
|
}, [])
|
|
|
|
const handleNavigate = useCallback(
|
|
(path: string) => {
|
|
navigate(path)
|
|
setMobileOpen(false)
|
|
},
|
|
[navigate],
|
|
)
|
|
|
|
const handleLogout = useCallback(() => {
|
|
logout()
|
|
navigate('/login', { replace: true })
|
|
}, [logout, navigate])
|
|
|
|
const toggleTheme = useCallback(() => {
|
|
setThemeMode(themeState.resolved === 'dark' ? 'light' : 'dark')
|
|
}, [themeState.resolved])
|
|
|
|
const currentPage = breadcrumbMap[location.pathname] || '页面'
|
|
|
|
return (
|
|
<div className="flex h-screen overflow-hidden bg-neutral-50 dark:bg-neutral-950">
|
|
{/* Desktop Sidebar */}
|
|
{!isMobile && (
|
|
<aside
|
|
className={`
|
|
shrink-0 border-r border-neutral-200 dark:border-neutral-800
|
|
bg-white dark:bg-neutral-900
|
|
transition-all duration-200 ease-in-out
|
|
${collapsed ? 'w-12' : 'w-64'}
|
|
`}
|
|
>
|
|
<Sidebar
|
|
collapsed={collapsed}
|
|
onNavigate={handleNavigate}
|
|
activePath={location.pathname}
|
|
/>
|
|
</aside>
|
|
)}
|
|
|
|
{/* Mobile Drawer */}
|
|
{isMobile && (
|
|
<MobileDrawer
|
|
open={mobileOpen}
|
|
onClose={() => setMobileOpen(false)}
|
|
onNavigate={handleNavigate}
|
|
activePath={location.pathname}
|
|
/>
|
|
)}
|
|
|
|
{/* Main Area */}
|
|
<div className="flex-1 flex flex-col min-w-0">
|
|
{/* Header */}
|
|
<header className="h-14 shrink-0 flex items-center justify-between px-4 border-b border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
|
|
<div className="flex items-center gap-3">
|
|
{/* Mobile menu button */}
|
|
{isMobile && (
|
|
<button
|
|
onClick={() => setMobileOpen(true)}
|
|
className="p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
|
aria-label="打开菜单"
|
|
>
|
|
<MenuUnfoldOutlined className="text-lg" />
|
|
</button>
|
|
)}
|
|
|
|
{/* Collapse toggle (desktop) */}
|
|
{!isMobile && (
|
|
<button
|
|
onClick={() => setCollapsed(!collapsed)}
|
|
className="p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
|
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
|
|
>
|
|
{collapsed ? (
|
|
<MenuUnfoldOutlined className="text-lg" />
|
|
) : (
|
|
<MenuFoldOutlined className="text-lg" />
|
|
)}
|
|
</button>
|
|
)}
|
|
|
|
{/* Breadcrumb */}
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<span className="text-neutral-400 dark:text-neutral-600">ZCLAW</span>
|
|
<span className="text-neutral-300 dark:text-neutral-700">/</span>
|
|
<span className="text-neutral-900 dark:text-neutral-100 font-medium">
|
|
{currentPage}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right actions */}
|
|
<div className="flex items-center gap-2">
|
|
{/* Theme toggle */}
|
|
<Tooltip title={themeState.resolved === 'dark' ? '切换亮色' : '切换暗色'}>
|
|
<button
|
|
onClick={toggleTheme}
|
|
className="p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
|
aria-label="切换主题"
|
|
>
|
|
{themeState.resolved === 'dark' ? (
|
|
<SunOutlined className="text-lg" />
|
|
) : (
|
|
<MoonOutlined className="text-lg" />
|
|
)}
|
|
</button>
|
|
</Tooltip>
|
|
|
|
{/* User avatar */}
|
|
<Dropdown
|
|
menu={{
|
|
items: [
|
|
{
|
|
key: 'logout',
|
|
icon: <LogoutOutlined />,
|
|
label: '退出登录',
|
|
onClick: handleLogout,
|
|
},
|
|
],
|
|
}}
|
|
>
|
|
<button className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors">
|
|
<Avatar
|
|
size={28}
|
|
style={{
|
|
background: 'linear-gradient(135deg, #863bff, #47bfff)',
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
{(account?.display_name || account?.username || 'A')[0].toUpperCase()}
|
|
</Avatar>
|
|
{!isMobile && (
|
|
<span className="text-sm text-neutral-700 dark:text-neutral-300 font-medium">
|
|
{account?.display_name || account?.username || 'Admin'}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</Dropdown>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Content */}
|
|
<main
|
|
id="main-content"
|
|
className="flex-1 overflow-y-auto p-6"
|
|
>
|
|
<Outlet />
|
|
</main>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|