Files
zclaw_openfang/admin-v2/src/layouts/AdminLayout.tsx
iven fbc8c9fdde
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
fix(industry): 审计修复 — 4 CRITICAL + 5 HIGH 全部解决
C1: SaaS industry/service.rs SQL 注入风险 → 参数化查询 ($N 绑定)
C2: INDUSTRY_CONFIGS 死链 → Kernel 共享 Arc 接通 ButlerRouter
C3: IndustryListItem 缺 keywords_count → SQL 查询 + 类型补全
C4: set_account_industries 非事务性 → batch 验证 + 事务 DELETE+INSERT
H8: Accounts.tsx mutate 竞态 → mutateAsync 顺序等待
H9: XML 注入未转义 → xml_escape() 辅助函数
H10: update_industry 覆盖 source → 保留原始值
H11: 面包屑缺少 /industries → 添加行业配置映射
2026-04-12 19:06:19 +08:00

404 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,
ShopOutlined,
} 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: '/industries', name: '行业配置', icon: <ShopOutlined />, permission: 'config: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': '系统配置',
'/industries': '行业配置',
'/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>
)
}