fix(desktop): session persistence — refresh/login/context/empty-content 4-bug fix
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

1. App.tsx: add restoreSession() call on startup to prevent redirect
   to login page after refresh (isRestoring guard + BootstrapScreen)
2. CloneManager: call syncAgents() after loadClones() to restore
   currentAgent and conversation history on app load
3. zclaw-memory: add get_or_create_session() so frontend session UUID
   is persisted directly — kernel no longer creates mismatched IDs
4. openai.rs: assistant message content must be non-empty for
   Kimi/Qwen APIs — replace empty content with meaningful placeholders

Also includes admin-v2 ModelServices unified page (merge providers +
models + API keys into expandable row layout)
This commit is contained in:
iven
2026-03-31 13:38:59 +08:00
parent 3e5d64484e
commit 6cae768401
29 changed files with 1982 additions and 933 deletions

View File

@@ -0,0 +1,51 @@
import { Button, Result } from 'antd'
import type { FallbackProps } from 'react-error-boundary'
interface ErrorStateProps {
title?: string
message?: string
onRetry?: () => void
}
export function ErrorState({
title = '加载失败',
message,
onRetry,
}: ErrorStateProps) {
return (
<div className="flex items-center justify-center min-h-[200px] p-8">
<Result
status="error"
title={title}
subTitle={message}
extra={
onRetry ? (
<Button type="primary" onClick={onRetry}>
</Button>
) : undefined
}
/>
</div>
)
}
export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<div className="flex items-center justify-center min-h-[200px] p-8">
<Result
status="error"
title="页面出现错误"
subTitle={error.message}
extra={
<div className="flex gap-2">
<Button onClick={resetErrorBoundary}></Button>
<Button type="primary" onClick={() => window.location.reload()}>
</Button>
</div>
}
/>
</div>
)
}

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from 'react'
interface PageHeaderProps {
title: string
description?: string
actions?: ReactNode
}
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{title}
</h1>
{description && (
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
{description}
</p>
)}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
)
}

View File

@@ -0,0 +1,15 @@
import { Tag } from 'antd'
interface StatusTagProps {
status: string
labels: Record<string, string>
colors: Record<string, string>
}
export function StatusTag({ status, labels, colors }: StatusTagProps) {
return (
<Tag color={colors[status] || 'default'}>
{labels[status] || status}
</Tag>
)
}

View File

@@ -0,0 +1,45 @@
// ============================================================
// 操作日志状态映射 — Dashboard 与 Logs 共用
// ============================================================
export const actionLabels: Record<string, string> = {
login: '登录',
logout: '登出',
create_account: '创建账号',
update_account: '更新账号',
delete_account: '删除账号',
create_provider: '创建服务商',
update_provider: '更新服务商',
delete_provider: '删除服务商',
create_model: '创建模型',
update_model: '更新模型',
delete_model: '删除模型',
create_token: '创建密钥',
revoke_token: '撤销密钥',
update_config: '更新配置',
create_prompt: '创建提示词',
update_prompt: '更新提示词',
archive_prompt: '归档提示词',
desktop_audit: '桌面端审计',
}
export const actionColors: Record<string, string> = {
login: 'green',
logout: 'default',
create_account: 'blue',
update_account: 'orange',
delete_account: 'red',
create_provider: 'blue',
update_provider: 'orange',
delete_provider: 'red',
create_model: 'blue',
update_model: 'orange',
delete_model: 'red',
create_token: 'blue',
revoke_token: 'red',
update_config: 'orange',
create_prompt: 'blue',
update_prompt: 'orange',
archive_prompt: 'red',
desktop_audit: 'default',
}

View File

@@ -1,15 +1,9 @@
// ============================================================
// AdminLayout — ProLayout 管理后台布局
// ============================================================
import { useState, useCallback, useEffect, useMemo } from 'react'
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
import ProLayout from '@ant-design/pro-layout'
import {
DashboardOutlined,
TeamOutlined,
CloudServerOutlined,
ApiOutlined,
KeyOutlined,
BarChartOutlined,
SwapOutlined,
SettingOutlined,
@@ -17,87 +11,375 @@ import {
MessageOutlined,
RobotOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
SunOutlined,
MoonOutlined,
ApiOutlined,
} from '@ant-design/icons'
import { Avatar, Dropdown, Tooltip, Drawer } from 'antd'
import { useAuthStore } from '@/stores/authStore'
import { Avatar, Dropdown, message } from 'antd'
import type { MenuDataItem } from '@ant-design/pro-layout'
import { useThemeStore, setThemeMode } from '@/stores/themeStore'
import type { ReactNode } from 'react'
const menuConfig: MenuDataItem[] = [
{ path: '/', name: '仪表盘', icon: <DashboardOutlined /> },
{ path: '/accounts', name: '账号管理', icon: <TeamOutlined />, permission: 'account:admin' },
{ path: '/providers', name: '服务商', icon: <CloudServerOutlined />, permission: 'provider:manage' },
{ path: '/models', name: '模型管理', icon: <ApiOutlined />, permission: 'model:read' },
{ path: '/agent-templates', name: 'Agent 模板', icon: <RobotOutlined />, permission: 'model:read' },
{ path: '/api-keys', name: 'API 密钥', icon: <KeyOutlined />, permission: 'admin:full' },
{ path: '/usage', name: '用量统计', icon: <BarChartOutlined />, permission: 'admin:full' },
{ path: '/relay', name: '中转任务', icon: <SwapOutlined />, permission: 'relay:use' },
{ path: '/config', name: '系统配置', icon: <SettingOutlined />, permission: 'config:read' },
{ path: '/prompts', name: '提示词管理', icon: <MessageOutlined />, permission: 'prompt:read' },
{ path: '/logs', name: '操作日志', icon: <FileTextOutlined />, permission: 'admin:full' },
// ============================================================
// 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: '/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: '/logs', name: '操作日志', icon: <FileTextOutlined />, permission: 'admin:full', group: '运维' },
{ path: '/config', name: '系统配置', icon: <SettingOutlined />, permission: 'config:read', group: '系统' },
{ path: '/prompts', name: '提示词管理', icon: <MessageOutlined />, permission: 'prompt:read', group: '系统' },
]
function filterMenuByPermission(
items: MenuDataItem[],
hasPermission: (p: string) => boolean,
): MenuDataItem[] {
return items
.filter((item) => !item.permission || hasPermission(item.permission as string))
.map(({ permission, ...rest }) => ({
...rest,
children: rest.children ? filterMenuByPermission(rest.children, hasPermission) : undefined,
}))
// ============================================================
// 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': '账号管理',
'/model-services': '模型服务',
'/providers': '模型服务',
'/models': '模型服务',
'/api-keys': 'API 密钥',
'/agent-templates': 'Agent 模板',
'/usage': '用量统计',
'/relay': '中转任务',
'/config': '系统配置',
'/prompts': '提示词管理',
'/logs': '操作日志',
}
// ============================================================
// Main Layout
// ============================================================
export default function AdminLayout() {
const navigate = useNavigate()
const location = useLocation()
const { account, hasPermission, logout } = useAuthStore()
const { account, logout } = useAuthStore()
const themeState = useThemeStore()
const [collapsed, setCollapsed] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const [isMobile, setIsMobile] = useState(false)
const menuData = filterMenuByPermission(menuConfig, hasPermission)
// 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 handleLogout = () => {
const handleNavigate = useCallback(
(path: string) => {
navigate(path)
setMobileOpen(false)
},
[navigate],
)
const handleLogout = useCallback(() => {
logout()
message.success('已退出登录')
navigate('/login', { replace: true })
}
}, [logout, navigate])
const toggleTheme = useCallback(() => {
setThemeMode(themeState.resolved === 'dark' ? 'light' : 'dark')
}, [themeState.resolved])
const currentPage = breadcrumbMap[location.pathname] || '页面'
return (
<ProLayout
title="ZCLAW"
logo={<div style={{ width: 28, height: 28, background: '#1677ff', borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontWeight: 700, fontSize: 14 }}>Z</div>}
layout="mix"
fixSiderbar
fixedHeader
location={{ pathname: location.pathname }}
menuDataRender={() => menuData}
menuItemRender={(item, dom) => (
<div onClick={() => item.path && navigate(item.path)}>{dom}</div>
<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>
)}
avatarProps={{
src: undefined,
title: account?.display_name || account?.username || 'Admin',
size: 'small',
render: (_, dom) => (
<Dropdown
menu={{
items: [
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: handleLogout,
},
],
}}
>
{dom}
</Dropdown>
),
}}
suppressSiderWhenMenuEmpty
contentStyle={{ padding: 24 }}
>
<Outlet />
</ProLayout>
{/* 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>
)
}

View File

@@ -1,10 +1,12 @@
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from 'react-router-dom'
import { ConfigProvider, App as AntApp } from 'antd'
import { ConfigProvider, App as AntApp, theme } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import { router } from './router'
import { ErrorBoundary } from './components/ErrorBoundary'
import { useThemeStore } from './stores/themeStore'
import './styles/globals.css'
const queryClient = new QueryClient({
defaultOptions: {
@@ -16,14 +18,71 @@ const queryClient = new QueryClient({
},
})
createRoot(document.getElementById('root')!).render(
<ErrorBoundary>
<ConfigProvider locale={zhCN}>
function ThemedApp() {
const resolved = useThemeStore((s) => s.resolved)
return (
<ConfigProvider
locale={zhCN}
theme={{
token: {
colorPrimary: '#863bff',
colorBgContainer: resolved === 'dark' ? '#292524' : '#ffffff',
colorBgElevated: resolved === 'dark' ? '#1c1917' : '#ffffff',
colorBgLayout: resolved === 'dark' ? '#0c0a09' : '#fafaf9',
colorBorder: resolved === 'dark' ? '#44403c' : '#e7e5e4',
colorBorderSecondary: resolved === 'dark' ? '#44403c' : '#f5f5f4',
colorText: resolved === 'dark' ? '#fafaf9' : '#1c1917',
colorTextSecondary: resolved === 'dark' ? '#a8a29e' : '#78716c',
colorTextTertiary: resolved === 'dark' ? '#78716c' : '#a8a29e',
borderRadius: 8,
borderRadiusLG: 12,
fontFamily:
'"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
fontSize: 14,
controlHeight: 36,
},
algorithm: resolved === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm,
components: {
Card: {
borderRadiusLG: 12,
},
Table: {
borderRadiusLG: 12,
headerBg: resolved === 'dark' ? '#1c1917' : '#fafaf9',
headerColor: resolved === 'dark' ? '#a8a29e' : '#78716c',
rowHoverBg: resolved === 'dark' ? 'rgba(134,59,255,0.06)' : 'rgba(134,59,255,0.04)',
},
Button: {
borderRadius: 8,
controlHeight: 36,
},
Input: {
borderRadius: 8,
},
Select: {
borderRadius: 8,
},
Modal: {
borderRadiusLG: 12,
},
Tag: {
borderRadiusSM: 9999,
},
},
}}
>
<AntApp>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AntApp>
</ConfigProvider>
)
}
createRoot(document.getElementById('root')!).render(
<ErrorBoundary>
<ThemedApp />
</ErrorBoundary>,
)

View File

@@ -5,10 +5,10 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, Select, Popconfirm, Space } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { accountService } from '@/services/accounts'
import { PageHeader } from '@/components/PageHeader'
import type { AccountPublic } from '@/types'
const roleLabels: Record<string, string> = {
@@ -116,7 +116,10 @@ export default function Accounts() {
hideInSearch: true,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
<Button
size="small"
onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}
>
</Button>
{record.status === 'active' ? (
@@ -142,6 +145,8 @@ export default function Accounts() {
return (
<div>
<PageHeader title="账号管理" description="管理系统用户账号、角色与权限" />
<ProTable<AccountPublic>
columns={columns}
dataSource={data?.items ?? []}
@@ -158,13 +163,13 @@ export default function Accounts() {
/>
<Modal
title="编辑账号"
title={<span className="text-base font-semibold"></span>}
open={modalOpen}
onOk={handleSave}
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
confirmLoading={updateMutation.isPending}
>
<Form form={form} layout="vertical">
<Form form={form} layout="vertical" className="mt-4">
<Form.Item name="display_name" label="显示名">
<Input />
</Form.Item>

View File

@@ -4,8 +4,8 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, Select, InputNumber, Space, Popconfirm, Descriptions, MinusCircleOutlined } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import { Button, message, Tag, Modal, Form, Input, Select, InputNumber, Space, Popconfirm, Descriptions } from 'antd'
import { PlusOutlined, MinusCircleOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { agentTemplateService } from '@/services/agent-templates'

View File

@@ -1,165 +0,0 @@
// ============================================================
// API 密钥管理
// ============================================================
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, InputNumber, Select, Popconfirm, Space, Typography } from 'antd'
import { PlusOutlined, CopyOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { apiKeyService } from '@/services/api-keys'
import type { TokenInfo } from '@/types'
const { Text } = Typography
export default function ApiKeys() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [newToken, setNewToken] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['api-keys'],
queryFn: ({ signal }) => apiKeyService.list(signal),
})
const createMutation = useMutation({
mutationFn: (data: { name: string; expires_days?: number; permissions: string[] }) =>
apiKeyService.create(data),
onSuccess: (result: TokenInfo) => {
message.success('创建成功')
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
if (result.token) {
setNewToken(result.token)
}
setModalOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const revokeMutation = useMutation({
mutationFn: (id: string) => apiKeyService.revoke(id),
onSuccess: () => {
message.success('已撤销')
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
},
onError: (err: Error) => message.error(err.message || '撤销失败'),
})
const columns: ProColumns<TokenInfo>[] = [
{ title: '名称', dataIndex: 'name', width: 160 },
{ title: '前缀', dataIndex: 'token_prefix', width: 120, render: (_, r) => <Text code>{r.token_prefix}...</Text> },
{
title: '权限',
dataIndex: 'permissions',
width: 200,
render: (_, r) => r.permissions?.map((p) => <Tag key={p}>{p}</Tag>),
},
{
title: '过期时间',
dataIndex: 'expires_at',
width: 180,
render: (_, r) => r.expires_at ? new Date(r.expires_at).toLocaleString('zh-CN') : '永不过期',
},
{
title: '最后使用',
dataIndex: 'last_used_at',
width: 180,
render: (_, r) => r.last_used_at ? new Date(r.last_used_at).toLocaleString('zh-CN') : '-',
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 180,
render: (_, r) => new Date(r.created_at).toLocaleString('zh-CN'),
},
{
title: '操作',
width: 100,
render: (_, record) => (
<Popconfirm title="确定撤销此密钥?撤销后无法恢复。" onConfirm={() => revokeMutation.mutate(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
),
},
]
const handleCreate = async () => {
const values = await form.validateFields()
createMutation.mutate(values)
}
const copyToken = () => {
if (newToken) {
navigator.clipboard.writeText(newToken)
message.success('已复制到剪贴板')
}
}
return (
<div>
<ProTable<TokenInfo>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true) }}>
</Button>,
]}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
/>
<Modal
title="创建 API 密钥"
open={modalOpen}
onOk={handleCreate}
onCancel={() => { setModalOpen(false); form.resetFields() }}
confirmLoading={createMutation.isPending}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="给密钥起个名字" />
</Form.Item>
<Form.Item name="expires_days" label="有效期 (天)">
<InputNumber min={1} placeholder="留空则永不过期" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="permissions" label="权限" rules={[{ required: true }]}>
<Select mode="multiple" placeholder="选择权限" options={[
{ value: 'relay:use', label: '中转使用' },
{ value: 'model:read', label: '模型读取' },
{ value: 'config:read', label: '配置读取' },
]} />
</Form.Item>
</Form>
</Modal>
<Modal
title="密钥创建成功"
open={!!newToken}
onOk={() => setNewToken(null)}
onCancel={() => setNewToken(null)}
>
<p></p>
<Input.TextArea
value={newToken || ''}
rows={3}
readOnly
addonAfter={<CopyOutlined onClick={copyToken} style={{ cursor: 'pointer' }} />}
/>
<Button type="primary" icon={<CopyOutlined />} onClick={copyToken} style={{ marginTop: 8 }}>
</Button>
</Modal>
</div>
)
}

View File

@@ -3,7 +3,7 @@
// ============================================================
import { useQuery } from '@tanstack/react-query'
import { Card, Col, Row, Statistic, Table, Tag, Typography, Spin, Alert } from 'antd'
import { Card, Col, Row, Statistic, Table, Tag, Spin } from 'antd'
import {
TeamOutlined,
CloudServerOutlined,
@@ -13,34 +13,18 @@ import {
} from '@ant-design/icons'
import { statsService } from '@/services/stats'
import { logService } from '@/services/logs'
import { PageHeader } from '@/components/PageHeader'
import { ErrorState } from '@/components/ErrorState'
import { actionLabels, actionColors } from '@/constants/status'
import type { OperationLog } from '@/types'
const { Title } = Typography
const actionLabels: Record<string, string> = {
login: '登录', logout: '登出',
create_account: '创建账号', update_account: '更新账号', delete_account: '删除账号',
create_provider: '创建服务商', update_provider: '更新服务商', delete_provider: '删除服务商',
create_model: '创建模型', update_model: '更新模型', delete_model: '删除模型',
create_token: '创建密钥', revoke_token: '撤销密钥',
update_config: '更新配置',
create_prompt: '创建提示词', update_prompt: '更新提示词', archive_prompt: '归档提示词',
desktop_audit: '桌面端审计',
}
const actionColors: Record<string, string> = {
login: 'green', logout: 'default',
create_account: 'blue', update_account: 'orange', delete_account: 'red',
create_provider: 'blue', update_provider: 'orange', delete_provider: 'red',
create_model: 'blue', update_model: 'orange', delete_model: 'red',
create_token: 'blue', revoke_token: 'red',
update_config: 'orange',
create_prompt: 'blue', update_prompt: 'orange', archive_prompt: 'red',
desktop_audit: 'default',
}
export default function Dashboard() {
const { data: stats, isLoading: statsLoading, error: statsError } = useQuery({
const {
data: stats,
isLoading: statsLoading,
error: statsError,
refetch: refetchStats,
} = useQuery({
queryKey: ['dashboard-stats'],
queryFn: ({ signal }) => statsService.dashboard(signal),
})
@@ -51,15 +35,28 @@ export default function Dashboard() {
})
if (statsError) {
return <Alert type="error" message="加载仪表盘数据失败" description={(statsError as Error).message} showIcon />
return (
<>
<PageHeader title="仪表盘" description="系统概览与最近活动" />
<ErrorState
message={(statsError as Error).message}
onRetry={() => refetchStats()}
/>
</>
)
}
const statCards = [
{ title: '总账号', value: stats?.total_accounts ?? 0, icon: <TeamOutlined />, color: '#1677ff' },
{ title: '活跃服务商', value: stats?.active_providers ?? 0, icon: <CloudServerOutlined />, color: '#52c41a' },
{ title: '活跃模型', value: stats?.active_models ?? 0, icon: <ApiOutlined />, color: '#722ed1' },
{ title: '今日请求', value: stats?.tasks_today ?? 0, icon: <ThunderboltOutlined />, color: '#fa8c16' },
{ title: '今日 Token', value: ((stats?.tokens_today_input ?? 0) + (stats?.tokens_today_output ?? 0)), icon: <ColumnWidthOutlined />, color: '#eb2f96' },
{ title: '总账号', value: stats?.total_accounts ?? 0, icon: <TeamOutlined />, color: '#863bff' },
{ title: '活跃服务商', value: stats?.active_providers ?? 0, icon: <CloudServerOutlined />, color: '#47bfff' },
{ title: '活跃模型', value: stats?.active_models ?? 0, icon: <ApiOutlined />, color: '#22c55e' },
{ title: '今日请求', value: stats?.tasks_today ?? 0, icon: <ThunderboltOutlined />, color: '#f59e0b' },
{
title: '今日 Token',
value: (stats?.tokens_today_input ?? 0) + (stats?.tokens_today_output ?? 0),
icon: <ColumnWidthOutlined />,
color: '#ef4444',
},
]
const logColumns = [
@@ -74,7 +71,13 @@ export default function Dashboard() {
</Tag>
),
},
{ title: '目标类型', dataIndex: 'target_type', key: 'target_type', width: 100, render: (v: string | null) => v || '-' },
{
title: '目标类型',
dataIndex: 'target_type',
key: 'target_type',
width: 100,
render: (v: string | null) => v || '-',
},
{
title: '时间',
dataIndex: 'created_at',
@@ -86,19 +89,34 @@ export default function Dashboard() {
return (
<div>
<Title level={4} style={{ marginBottom: 24 }}></Title>
<PageHeader title="仪表盘" description="系统概览与最近活动" />
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
{/* Stat Cards */}
<Row gutter={[16, 16]} className="mb-6">
{statsLoading ? (
<Col span={24}><Spin /></Col>
<Col span={24}>
<div className="flex justify-center py-8">
<Spin size="large" />
</div>
</Col>
) : (
statCards.map((card) => (
<Col xs={24} sm={12} md={8} lg={4} key={card.title}>
<Card>
<Card
className="hover:shadow-md transition-shadow duration-200"
styles={{ body: { padding: '20px 24px' } }}
>
<Statistic
title={card.title}
title={
<span className="text-neutral-500 dark:text-neutral-400 text-xs font-medium uppercase tracking-wide">
{card.title}
</span>
}
value={card.value}
prefix={<span style={{ color: card.color }}>{card.icon}</span>}
valueStyle={{ fontSize: 28, fontWeight: 600, color: card.color }}
prefix={
<span style={{ color: card.color, marginRight: 4 }}>{card.icon}</span>
}
/>
</Card>
</Col>
@@ -106,7 +124,16 @@ export default function Dashboard() {
)}
</Row>
<Card title="最近操作日志" size="small">
{/* Recent Logs */}
<Card
title={
<span className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
</span>
}
size="small"
styles={{ body: { padding: 0 } }}
>
<Table<OperationLog>
columns={logColumns}
dataSource={logsData?.items ?? []}

View File

@@ -6,13 +6,11 @@ import { useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { LoginForm, ProFormText } from '@ant-design/pro-components'
import { LockOutlined, UserOutlined, SafetyOutlined } from '@ant-design/icons'
import { message, Divider, Typography } from 'antd'
import { message } from 'antd'
import { authService } from '@/services/auth'
import { useAuthStore } from '@/stores/authStore'
import type { LoginRequest } from '@/types'
const { Title, Text } = Typography
export default function Login() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
@@ -50,51 +48,75 @@ export default function Login() {
}
return (
<div style={{ minHeight: '100vh', display: 'flex' }}>
{/* 左侧品牌区 */}
<div
style={{
flex: '1 1 0',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #001529 0%, #003a70 50%, #001529 100%)',
position: 'relative',
overflow: 'hidden',
}}
<div className="min-h-screen flex">
{/* Left Brand Panel — hidden on mobile */}
<div className="hidden md:flex flex-1 flex-col items-center justify-center relative overflow-hidden"
style={{ background: 'linear-gradient(135deg, #0c0a09 0%, #1c1917 40%, #292524 100%)' }}
>
<Title level={1} style={{ color: '#fff', marginBottom: 8, letterSpacing: 4 }}>
ZCLAW
</Title>
<Text style={{ color: 'rgba(255,255,255,0.65)', fontSize: 16 }}>AI Agent </Text>
<Divider style={{ borderColor: 'rgba(22,119,255,0.3)', width: 100, minWidth: 100 }} />
<Text style={{ color: 'rgba(255,255,255,0.45)', fontSize: 13, maxWidth: 320, textAlign: 'center' }}>
AI API
</Text>
{/* Decorative gradient orb */}
<div
className="absolute w-[400px] h-[400px] rounded-full opacity-20 blur-3xl"
style={{ background: 'linear-gradient(135deg, #863bff, #47bfff)', top: '20%', left: '10%' }}
/>
<div
className="absolute w-[300px] h-[300px] rounded-full opacity-10 blur-3xl"
style={{ background: 'linear-gradient(135deg, #47bfff, #863bff)', bottom: '10%', right: '15%' }}
/>
{/* Brand content */}
<div className="relative z-10 text-center px-8">
<div
className="inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-6"
style={{ background: 'linear-gradient(135deg, #863bff, #47bfff)' }}
>
<span className="text-white text-2xl font-bold">Z</span>
</div>
<h1 className="text-4xl font-bold text-white mb-3 tracking-tight">ZCLAW</h1>
<p className="text-white/50 text-base mb-8">AI Agent </p>
<div className="w-16 h-px mx-auto mb-8" style={{ background: 'linear-gradient(90deg, transparent, #863bff, #47bfff, transparent)' }} />
<p className="text-white/30 text-sm max-w-sm mx-auto leading-relaxed">
AI API
</p>
</div>
</div>
{/* 右侧登录表单 */}
<div
style={{
flex: '0 0 480px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 48,
}}
>
<div style={{ width: '100%', maxWidth: 360 }}>
<Title level={3} style={{ marginBottom: 4 }}></Title>
<Text type="secondary" style={{ display: 'block', marginBottom: 32 }}>
{/* Right Login Form */}
<div className="flex-1 md:flex-none md:w-[480px] flex items-center justify-center p-8 bg-white dark:bg-neutral-950">
<div className="w-full max-w-[360px]">
{/* Mobile logo (visible only on mobile) */}
<div className="md:hidden flex items-center gap-3 mb-10">
<div
className="flex items-center justify-center w-10 h-10 rounded-xl"
style={{ background: 'linear-gradient(135deg, #863bff, #47bfff)' }}
>
<span className="text-white font-bold">Z</span>
</div>
<span className="text-xl font-bold text-neutral-900 dark:text-white">ZCLAW</span>
</div>
<h2 className="text-2xl font-semibold text-neutral-900 dark:text-white mb-1">
</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-8">
</Text>
</p>
<LoginForm
onFinish={handleSubmit}
submitter={{
searchConfig: { submitText: '登录' },
submitButtonProps: { loading, block: true },
submitButtonProps: {
loading,
block: true,
style: {
height: 44,
borderRadius: 8,
fontWeight: 500,
fontSize: 15,
background: 'linear-gradient(135deg, #863bff, #47bfff)',
border: 'none',
},
},
}}
>
<ProFormText

View File

@@ -0,0 +1,427 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, InputNumber, Switch, Space, Popconfirm, Tabs, Table, Typography } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { providerService } from '@/services/providers'
import { modelService } from '@/services/models'
import type { Provider, ProviderKey, Model } from '@/types'
const { Text } = Typography
// ============================================================
// 子组件: 模型表格
// ============================================================
function ProviderModelsTable({ providerId }: { providerId: string }) {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['provider-models', providerId],
queryFn: ({ signal }) => modelService.list({ provider_id: providerId! }, signal),
})
const createMutation = useMutation({
mutationFn: (data: Partial<Omit<Model, 'id'>>) => modelService.create(data),
onSuccess: () => {
message.success('模型已创建')
queryClient.invalidateQueries({ queryKey: ['provider-models', providerId] })
setModalOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Omit<Model, 'id'>> }) =>
modelService.update(id, data),
onSuccess: () => {
message.success('模型已更新')
queryClient.invalidateQueries({ queryKey: ['provider-models', providerId] })
setModalOpen(false)
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => modelService.delete(id),
onSuccess: () => {
message.success('模型已删除')
queryClient.invalidateQueries({ queryKey: ['provider-models', providerId] })
},
onError: (err: Error) => message.error(err.message || '删除失败'),
})
const handleSave = async () => {
const values = await form.validateFields()
if (editingId) {
updateMutation.mutate({ id: editingId, data: values })
} else {
createMutation.mutate({ ...values, provider_id: providerId })
}
}
const columns: ProColumns<Model>[] = [
{ title: '模型 ID', dataIndex: 'model_id', width: 180, render: (_, r) => <Text code>{r.model_id}</Text> },
{ title: '别名', dataIndex: 'alias', width: 120 },
{ title: '上下文窗口', dataIndex: 'context_window', width: 100, render: (_, r) => r.context_window?.toLocaleString() },
{ title: '最大输出', dataIndex: 'max_output_tokens', width: 90, render: (_, r) => r.max_output_tokens?.toLocaleString() },
{ title: '流式', dataIndex: 'supports_streaming', width: 60, render: (_, r) => r.supports_streaming ? <Tag color="green"></Tag> : <Tag></Tag> },
{ title: '视觉', dataIndex: 'supports_vision', width: 60, render: (_, r) => r.supports_vision ? <Tag color="blue"></Tag> : <Tag></Tag> },
{ title: '状态', dataIndex: 'enabled', width: 60, render: (_, r) => r.enabled ? <Tag color="green"></Tag> : <Tag></Tag> },
{
title: '操作', width: 120, render: (_, record) => (
<Space>
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}></Button>
<Popconfirm title="确定删除此模型?" onConfirm={() => deleteMutation.mutate(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
</Space>
),
},
]
const models = data?.items ?? []
return (
<div>
<div style={{ marginBottom: 8 }}>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
</Button>
</div>
<Table<Model>
columns={columns}
dataSource={models}
loading={isLoading}
rowKey="id"
size="small"
pagination={false}
/>
<Modal
title={editingId ? '编辑模型' : '添加模型'}
open={modalOpen}
onOk={handleSave}
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
confirmLoading={createMutation.isPending || updateMutation.isPending}
width={560}
>
<Form form={form} layout="vertical">
<Form.Item name="model_id" label="模型 ID" rules={[{ required: true }]}>
<Input placeholder="如 gpt-4o" />
</Form.Item>
<Form.Item name="alias" label="别名">
<Input placeholder="可选" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="context_window" label="上下文窗口" style={{ flex: 1 }}>
<InputNumber min={0} placeholder="128000" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="max_output_tokens" label="最大输出 Token" style={{ flex: 1 }}>
<InputNumber min={0} placeholder="4096" style={{ width: '100%' }} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="enabled" label="启用" valuePropName="checked" style={{ flex: 1 }}>
<Switch />
</Form.Item>
<Form.Item name="supports_streaming" label="支持流式" valuePropName="checked" style={{ flex: 1 }}>
<Switch defaultChecked />
</Form.Item>
<Form.Item name="supports_vision" label="支持视觉" valuePropName="checked" style={{ flex: 1 }}>
<Switch />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="pricing_input" label="输入价格 (每百万Token)" style={{ flex: 1 }}>
<InputNumber min={0} step={0.01} placeholder="0" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="pricing_output" label="输出价格 (每百万Token)" style={{ flex: 1 }}>
<InputNumber min={0} step={0.01} placeholder="0" style={{ width: '100%' }} />
</Form.Item>
</div>
</Form>
</Modal>
</div>
)
}
// ============================================================
// 子组件: Key Pool 表格
// ============================================================
function ProviderKeysTable({ providerId }: { providerId: string }) {
const queryClient = useQueryClient()
const [addKeyForm] = Form.useForm()
const [addKeyOpen, setAddKeyOpen] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['provider-keys', providerId],
queryFn: ({ signal }) => providerService.listKeys(providerId!, signal),
})
const addKeyMutation = useMutation({
mutationFn: (data: { key_label: string; key_value: string; priority?: number; max_rpm?: number; max_tpm?: number }) =>
providerService.addKey(providerId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['provider-keys', providerId] })
message.success('密钥已添加')
setAddKeyOpen(false)
addKeyForm.resetFields()
},
onError: () => message.error('添加失败'),
})
const toggleKeyMutation = useMutation({
mutationFn: ({ keyId, active }: { keyId: string; active: boolean }) =>
providerService.toggleKey(providerId, keyId, active),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['provider-keys', providerId] })
message.success('状态已切换')
},
onError: () => message.error('切换失败'),
})
const deleteKeyMutation = useMutation({
mutationFn: (keyId: string) => providerService.deleteKey(providerId, keyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['provider-keys', providerId] })
message.success('密钥已删除')
},
onError: () => message.error('删除失败'),
})
const keyColumns: ProColumns<ProviderKey>[] = [
{ title: '标签', dataIndex: 'key_label', width: 120 },
{ title: '优先级', dataIndex: 'priority', width: 70 },
{ title: '请求数', dataIndex: 'total_requests', width: 80 },
{ title: 'Token 数', dataIndex: 'total_tokens', width: 90 },
{
title: '状态', dataIndex: 'is_active', width: 70,
render: (_, r) => r.is_active ? <Tag color="green"></Tag> : <Tag color="orange"></Tag>,
},
{
title: '操作', width: 120,
render: (_, record) => (
<Space>
<Popconfirm
title={record.is_active ? '确定禁用此密钥?' : '确定启用此密钥?'}
onConfirm={() => toggleKeyMutation.mutate({ keyId: record.id, active: !record.is_active })}
>
<Button size="small" type={record.is_active ? 'default' : 'primary'}>
{record.is_active ? '禁用' : '启用'}
</Button>
</Popconfirm>
<Popconfirm title="确定删除此密钥?此操作不可恢复。" onConfirm={() => deleteKeyMutation.mutate(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
</Space>
),
},
]
const keys = data ?? []
return (
<div>
<div style={{ marginBottom: 8 }}>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => { addKeyForm.resetFields(); setAddKeyOpen(true) }}>
</Button>
</div>
<Table<ProviderKey>
columns={keyColumns}
dataSource={keys}
loading={isLoading}
rowKey="id"
size="small"
pagination={false}
/>
<Modal
title="添加密钥"
open={addKeyOpen}
onOk={() => {
addKeyForm.validateFields().then((v) => addKeyMutation.mutate(v))
}}
onCancel={() => setAddKeyOpen(false)}
confirmLoading={addKeyMutation.isPending}
>
<Form form={addKeyForm} layout="vertical">
<Form.Item name="key_label" label="标签" rules={[{ required: true }]}>
<Input placeholder="如: my-openai-key" />
</Form.Item>
<Form.Item name="key_value" label="API Key" rules={[{ required: true }]}>
<Input.Password placeholder="sk-..." />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="priority" label="优先级" initialValue={0} style={{ flex: 1 }}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="max_rpm" label="最大 RPM" style={{ flex: 1 }}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="max_tpm" label="最大 TPM" style={{ flex: 1 }}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</div>
</Form>
</Modal>
</div>
)
}
// ============================================================
// 主页面: 模型服务
// ============================================================
export default function ModelServices() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['providers'],
queryFn: ({ signal }) => providerService.list(signal),
})
const createMutation = useMutation({
mutationFn: (data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>) =>
providerService.create(data),
onSuccess: () => {
message.success('服务商已创建')
queryClient.invalidateQueries({ queryKey: ['providers'] })
setModalOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>> }) =>
providerService.update(id, data),
onSuccess: () => {
message.success('服务商已更新')
queryClient.invalidateQueries({ queryKey: ['providers'] })
setModalOpen(false)
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => providerService.delete(id),
onSuccess: () => {
message.success('服务商已删除')
queryClient.invalidateQueries({ queryKey: ['providers'] })
},
onError: (err: Error) => message.error(err.message || '删除失败'),
})
const handleSave = async () => {
const values = await form.validateFields()
if (editingId) {
updateMutation.mutate({ id: editingId, data: values })
} else {
createMutation.mutate(values)
}
}
const columns: ProColumns<Provider>[] = [
{ title: '名称', dataIndex: 'display_name', width: 150 },
{ title: '标识', dataIndex: 'name', width: 120, render: (_, r) => <Text code>{r.name}</Text> },
{ title: 'Base URL', dataIndex: 'base_url', width: 260, ellipsis: true },
{ title: '协议', dataIndex: 'api_protocol', width: 90, hideInSearch: true },
{ title: 'RPM', dataIndex: 'rate_limit_rpm', width: 80, hideInSearch: true, render: (_, r) => r.rate_limit_rpm ?? '-' },
{
title: '状态', dataIndex: 'enabled', width: 70, hideInSearch: true,
render: (_, r) => r.enabled ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: '操作', width: 140, hideInSearch: true,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}></Button>
<Popconfirm title="确定删除此服务商?" onConfirm={() => deleteMutation.mutate(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
</Space>
),
},
]
return (
<div>
<ProTable<Provider>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={{}}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
</Button>,
]}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
expandable={{
expandedRowRender: (record) => (
<Tabs
size="small"
style={{ marginTop: 8 }}
items={[
{
key: 'models',
label: `模型`,
children: <ProviderModelsTable providerId={record.id} />,
},
{
key: 'keys',
label: 'Key Pool',
children: <ProviderKeysTable providerId={record.id} />,
},
]}
/>
),
}}
/>
<Modal
title={editingId? '编辑服务商' : '新建服务商'}
open={modalOpen}
onOk={handleSave}
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
confirmLoading={createMutation.isPending || updateMutation.isPending}
width={560}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="标识" rules={[{ required: true }]}>
<Input disabled={!!editingId} placeholder="如 openai, anthropic" />
</Form.Item>
<Form.Item name="display_name" label="显示名称" rules={[{ required: true }]}>
<Input placeholder="如 OpenAI" />
</Form.Item>
<Form.Item name="base_url" label="Base URL" rules={[{ required: true }]}>
<Input placeholder="https://api.openai.com/v1" />
</Form.Item>
<Form.Item name="api_protocol" label="API 协议">
<Input placeholder="openai" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="enabled" label="启用" valuePropName="checked" style={{ flex: 1 }}>
<Switch />
</Form.Item>
<Form.Item name="rate_limit_rpm" label="RPM 限制" style={{ flex: 1 }}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</div>
</Form>
</Modal>
</div>
)
}

View File

@@ -1,191 +0,0 @@
// ============================================================
// 模型管理
// ============================================================
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, InputNumber, Switch, Select, Space, Popconfirm } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { modelService } from '@/services/models'
import { providerService } from '@/services/providers'
import type { Model } from '@/types'
export default function Models() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['models'],
queryFn: ({ signal }) => modelService.list(signal),
})
const { data: providersData } = useQuery({
queryKey: ['providers-for-select'],
queryFn: ({ signal }) => providerService.list(signal),
})
const createMutation = useMutation({
mutationFn: (data: Partial<Omit<Model, 'id'>>) => modelService.create(data),
onSuccess: () => {
message.success('创建成功')
queryClient.invalidateQueries({ queryKey: ['models'] })
setModalOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Omit<Model, 'id'>> }) =>
modelService.update(id, data),
onSuccess: () => {
message.success('更新成功')
queryClient.invalidateQueries({ queryKey: ['models'] })
setModalOpen(false)
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => modelService.delete(id),
onSuccess: () => {
message.success('删除成功')
queryClient.invalidateQueries({ queryKey: ['models'] })
},
onError: (err: Error) => message.error(err.message || '删除失败'),
})
const columns: ProColumns<Model>[] = [
{ title: '模型 ID', dataIndex: 'model_id', width: 180, render: (_, r) => <code>{r.model_id}</code> },
{ title: '别名', dataIndex: 'alias', width: 140 },
{
title: '服务商',
dataIndex: 'provider_id',
width: 140,
hideInSearch: true,
render: (_, r) => {
const provider = providersData?.items?.find((p) => p.id === r.provider_id)
return provider?.display_name || r.provider_id.substring(0, 8)
},
},
{ title: '上下文窗口', dataIndex: 'context_window', width: 110, hideInSearch: true, render: (_, r) => r.context_window?.toLocaleString() },
{ title: '最大输出', dataIndex: 'max_output_tokens', width: 100, hideInSearch: true, render: (_, r) => r.max_output_tokens?.toLocaleString() },
{
title: '流式',
dataIndex: 'supports_streaming',
width: 70,
hideInSearch: true,
render: (_, r) => r.supports_streaming ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: '视觉',
dataIndex: 'supports_vision',
width: 70,
hideInSearch: true,
render: (_, r) => r.supports_vision ? <Tag color="blue"></Tag> : <Tag></Tag>,
},
{
title: '状态',
dataIndex: 'enabled',
width: 70,
hideInSearch: true,
render: (_, r) => r.enabled ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: '操作',
width: 160,
hideInSearch: true,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
</Button>
<Popconfirm title="确定删除此模型?" onConfirm={() => deleteMutation.mutate(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
</Space>
),
},
]
const handleSave = async () => {
const values = await form.validateFields()
if (editingId) {
updateMutation.mutate({ id: editingId, data: values })
} else {
createMutation.mutate(values)
}
}
return (
<div>
<ProTable<Model>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={{}}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
</Button>,
]}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
/>
<Modal
title={editingId ? '编辑模型' : '新建模型'}
open={modalOpen}
onOk={handleSave}
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
confirmLoading={createMutation.isPending || updateMutation.isPending}
width={600}
>
<Form form={form} layout="vertical">
<Form.Item name="provider_id" label="服务商" rules={[{ required: true }]}>
<Select
options={(providersData?.items ?? []).map((p) => ({ value: p.id, label: p.display_name }))}
placeholder="选择服务商"
/>
</Form.Item>
<Form.Item name="model_id" label="模型 ID" rules={[{ required: true }]}>
<Input placeholder="如 gpt-4o" />
</Form.Item>
<Form.Item name="alias" label="别名">
<Input />
</Form.Item>
<Form.Item name="context_window" label="上下文窗口">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="max_output_tokens" label="最大输出 Token">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="supports_streaming" label="支持流式" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="supports_vision" label="支持视觉" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="pricing_input" label="输入价格 (每百万 Token)">
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="pricing_output" label="输出价格 (每百万 Token)">
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</div>
)
}

View File

@@ -1,290 +0,0 @@
// ============================================================
// 服务商管理
// ============================================================
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, InputNumber, Switch, Space, Popconfirm, Typography } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { providerService } from '@/services/providers'
import type { Provider, ProviderKey } from '@/types'
const { Text } = Typography
export default function Providers() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [keyModalProviderId, setKeyModalProviderId] = useState<string | null>(null)
const [addKeyOpen, setAddKeyOpen] = useState(false)
const [addKeyForm] = Form.useForm()
const { data, isLoading } = useQuery({
queryKey: ['providers'],
queryFn: ({ signal }) => providerService.list(signal),
})
const { data: keysData, isLoading: keysLoading } = useQuery({
queryKey: ['provider-keys', keyModalProviderId],
queryFn: ({ signal }) => providerService.listKeys(keyModalProviderId!, signal),
enabled: !!keyModalProviderId,
})
const createMutation = useMutation({
mutationFn: (data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>) =>
providerService.create(data),
onSuccess: () => {
message.success('创建成功')
queryClient.invalidateQueries({ queryKey: ['providers'] })
setModalOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>> }) =>
providerService.update(id, data),
onSuccess: () => {
message.success('更新成功')
queryClient.invalidateQueries({ queryKey: ['providers'] })
setModalOpen(false)
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => providerService.delete(id),
onSuccess: () => {
message.success('删除成功')
queryClient.invalidateQueries({ queryKey: ['providers'] })
},
onError: (err: Error) => message.error(err.message || '删除失败'),
})
const addKeyMutation = useMutation({
mutationFn: ({ providerId, data }: { providerId: string; data: { key_label: string; key_value: string; priority?: number; max_rpm?: number; max_tpm?: number } }) =>
providerService.addKey(providerId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['provider-keys', keyModalProviderId] })
message.success('密钥已添加')
setAddKeyOpen(false)
addKeyForm.resetFields()
},
onError: () => message.error('添加失败'),
})
const toggleKeyMutation = useMutation({
mutationFn: ({ providerId, keyId, active }: { providerId: string; keyId: string; active: boolean }) =>
providerService.toggleKey(providerId, keyId, active),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['provider-keys', keyModalProviderId] })
message.success('状态已切换')
},
onError: () => message.error('切换失败'),
})
const deleteKeyMutation = useMutation({
mutationFn: ({ providerId, keyId }: { providerId: string; keyId: string }) =>
providerService.deleteKey(providerId, keyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['provider-keys', keyModalProviderId] })
message.success('密钥已删除')
},
onError: () => message.error('删除失败'),
})
const columns: ProColumns<Provider>[] = [
{ title: '名称', dataIndex: 'display_name', width: 140 },
{ title: '标识', dataIndex: 'name', width: 120, render: (_, r) => <Text code>{r.name}</Text> },
{ title: '协议', dataIndex: 'api_protocol', width: 100, hideInSearch: true },
{ title: 'RPM 限制', dataIndex: 'rate_limit_rpm', width: 100, hideInSearch: true, render: (_, r) => r.rate_limit_rpm ?? '-' },
{
title: '状态',
dataIndex: 'enabled',
width: 80,
hideInSearch: true,
render: (_, r) => r.enabled ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: '操作',
width: 260,
hideInSearch: true,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
</Button>
<Button size="small" onClick={() => setKeyModalProviderId(record.id)}>
Key Pool
</Button>
<Popconfirm title="确定删除此服务商?" onConfirm={() => deleteMutation.mutate(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
</Space>
),
},
]
const keyColumns: ProColumns<ProviderKey>[] = [
{ title: '标签', dataIndex: 'key_label', width: 120 },
{ title: '优先级', dataIndex: 'priority', width: 80 },
{ title: '请求数', dataIndex: 'total_requests', width: 80 },
{ title: 'Token 数', dataIndex: 'total_tokens', width: 100 },
{
title: '状态',
dataIndex: 'is_active',
width: 80,
render: (_, r) => r.is_active ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: '操作',
width: 160,
render: (_, record) => (
<Space>
<Popconfirm
title={record.is_active ? '确定禁用此密钥?' : '确定启用此密钥?'}
onConfirm={() => toggleKeyMutation.mutate({
providerId: keyModalProviderId!,
keyId: record.id,
active: !record.is_active,
})}
>
<Button size="small" type={record.is_active ? 'default' : 'primary'}>
{record.is_active ? '禁用' : '启用'}
</Button>
</Popconfirm>
<Popconfirm
title="确定删除此密钥?此操作不可恢复。"
onConfirm={() => deleteKeyMutation.mutate({
providerId: keyModalProviderId!,
keyId: record.id,
})}
>
<Button size="small" danger></Button>
</Popconfirm>
</Space>
),
},
]
const handleSave = async () => {
const values = await form.validateFields()
if (editingId) {
updateMutation.mutate({ id: editingId, data: values })
} else {
createMutation.mutate(values)
}
}
return (
<div>
<ProTable<Provider>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={{}}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
</Button>,
]}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
/>
<Modal
title={editingId ? '编辑服务商' : '新建服务商'}
open={modalOpen}
onOk={handleSave}
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
confirmLoading={createMutation.isPending || updateMutation.isPending}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="标识" rules={[{ required: true }]}>
<Input disabled={!!editingId} />
</Form.Item>
<Form.Item name="display_name" label="显示名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="base_url" label="Base URL" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="api_protocol" label="API 协议">
<Input placeholder="openai" />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="rate_limit_rpm" label="RPM 限制">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
<Modal
title="Key Pool"
open={!!keyModalProviderId}
onCancel={() => setKeyModalProviderId(null)}
footer={(_, { OkBtn, CancelBtn }) => (
<Space>
<CancelBtn />
<Button type="primary" onClick={() => { addKeyForm.resetFields(); setAddKeyOpen(true) }}>
</Button>
</Space>
)}
width={700}
>
<ProTable<ProviderKey>
columns={keyColumns}
dataSource={keysData ?? []}
loading={keysLoading}
rowKey="id"
search={false}
toolBarRender={false}
pagination={false}
size="small"
/>
</Modal>
<Modal
title="添加密钥"
open={addKeyOpen}
onOk={() => {
addKeyForm.validateFields().then((v) =>
addKeyMutation.mutate({ providerId: keyModalProviderId!, data: v })
)
}}
onCancel={() => setAddKeyOpen(false)}
confirmLoading={addKeyMutation.isPending}
>
<Form form={addKeyForm} layout="vertical">
<Form.Item name="key_label" label="标签" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="key_value" label="API Key" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
<Form.Item name="priority" label="优先级" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="max_rpm" label="最大 RPM (可选)">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="max_tpm" label="最大 TPM (可选)">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</div>
)
}

View File

@@ -2,16 +2,16 @@
// 中转任务
// ============================================================
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Tag, Select, Typography } from 'antd'
import { Tag, Select } from 'antd'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { relayService } from '@/services/relay'
import { useState } from 'react'
import { PageHeader } from '@/components/PageHeader'
import { ErrorState } from '@/components/ErrorState'
import type { RelayTask } from '@/types'
const { Title } = Typography
const statusLabels: Record<string, string> = {
queued: '排队中',
running: '运行中',
@@ -32,26 +32,57 @@ export default function Relay() {
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined)
const [page, setPage] = useState(1)
const { data, isLoading } = useQuery({
const {
data,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ['relay-tasks', page, statusFilter],
queryFn: ({ signal }) => relayService.list({ page, page_size: 20, status: statusFilter }, signal),
})
if (error) {
return (
<>
<PageHeader title="中转任务" description="查看和管理 AI 模型中转请求" />
<ErrorState message={(error as Error).message} onRetry={() => refetch()} />
</>
)
}
const columns: ProColumns<RelayTask>[] = [
{ title: 'ID', dataIndex: 'id', width: 120, render: (_, r) => <code>{r.id.substring(0, 8)}...</code> },
{
title: 'ID',
dataIndex: 'id',
width: 120,
render: (_, r) => (
<code className="text-xs px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800">
{r.id.substring(0, 8)}...
</code>
),
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (_, r) => <Tag color={statusColors[r.status] || 'default'}>{statusLabels[r.status] || r.status}</Tag>,
render: (_, r) => (
<Tag color={statusColors[r.status] || 'default'}>
{statusLabels[r.status] || r.status}
</Tag>
),
},
{ title: '模型', dataIndex: 'model_id', width: 160 },
{ title: '优先级', dataIndex: 'priority', width: 70 },
{ title: '尝试次数', dataIndex: 'attempt_count', width: 80 },
{
title: 'Token',
title: 'Token (入/出)',
width: 140,
render: (_, r) => `${r.input_tokens.toLocaleString()} / ${r.output_tokens.toLocaleString()}`,
render: (_, r) => (
<span className="text-sm">
{r.input_tokens.toLocaleString()} / {r.output_tokens.toLocaleString()}
</span>
),
},
{ title: '错误信息', dataIndex: 'error_message', width: 200, ellipsis: true },
{
@@ -64,30 +95,36 @@ export default function Relay() {
title: '完成时间',
dataIndex: 'completed_at',
width: 180,
render: (_, r) => r.completed_at ? new Date(r.completed_at).toLocaleString('zh-CN') : '-',
render: (_, r) => (r.completed_at ? new Date(r.completed_at).toLocaleString('zh-CN') : '-'),
},
]
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<Title level={4} style={{ margin: 0 }}></Title>
<Select
value={statusFilter}
onChange={(v) => { setStatusFilter(v === 'all' ? undefined : v); setPage(1) }}
placeholder="状态筛选"
style={{ width: 140 }}
allowClear
options={[
{ value: 'all', label: '全部' },
{ value: 'queued', label: '排队中' },
{ value: 'running', label: '运行中' },
{ value: 'completed', label: '已完成' },
{ value: 'failed', label: '失败' },
{ value: 'cancelled', label: '已取消' },
]}
/>
</div>
<PageHeader
title="中转任务"
description="查看和管理 AI 模型中转请求"
actions={
<Select
value={statusFilter}
onChange={(v) => {
setStatusFilter(v === 'all' ? undefined : v)
setPage(1)
}}
placeholder="状态筛选"
className="w-36"
allowClear
options={[
{ value: 'all', label: '全部' },
{ value: 'queued', label: '排队中' },
{ value: 'running', label: '运行中' },
{ value: 'completed', label: '已完成' },
{ value: 'failed', label: '失败' },
{ value: 'cancelled', label: '已取消' },
]}
/>
}
/>
<ProTable<RelayTask>
columns={columns}

View File

@@ -4,20 +4,24 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Card, Row, Col, Select, Spin, Alert, Statistic, Typography } from 'antd'
import { ColumnWidthOutlined, ThunderboltOutlined } from '@ant-design/icons'
import { Card, Col, Row, Select, Statistic } from 'antd'
import { ThunderboltOutlined, ColumnWidthOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { usageService } from '@/services/usage'
import { telemetryService } from '@/services/telemetry'
import { PageHeader } from '@/components/PageHeader'
import { ErrorState } from '@/components/ErrorState'
import type { DailyUsageStat, ModelUsageStat } from '@/types'
const { Title } = Typography
export default function Usage() {
const [days, setDays] = useState(30)
const { data: dailyData, isLoading: dailyLoading, error: dailyError } = useQuery({
const {
data: dailyData,
isLoading: dailyLoading,
error: dailyError,
refetch,
} = useQuery({
queryKey: ['usage-daily', days],
queryFn: ({ signal }) => telemetryService.dailyStats({ days }, signal),
})
@@ -28,7 +32,12 @@ export default function Usage() {
})
if (dailyError) {
return <Alert type="error" message="加载用量数据失败" description={(dailyError as Error).message} showIcon />
return (
<>
<PageHeader title="用量统计" description="查看模型使用情况和 Token 消耗" />
<ErrorState message={(dailyError as Error).message} onRetry={() => refetch()} />
</>
)
}
const totalRequests = dailyData?.reduce((s, d) => s + d.request_count, 0) ?? 0
@@ -36,22 +45,52 @@ export default function Usage() {
const dailyColumns: ProColumns<DailyUsageStat>[] = [
{ title: '日期', dataIndex: 'day', width: 120 },
{ title: '请求数', dataIndex: 'request_count', width: 100, render: (_, r) => r.request_count.toLocaleString() },
{ title: '输入 Token', dataIndex: 'input_tokens', width: 120, render: (_, r) => r.input_tokens.toLocaleString() },
{ title: '输出 Token', dataIndex: 'output_tokens', width: 120, render: (_, r) => r.output_tokens.toLocaleString() },
{
title: '请求数',
dataIndex: 'request_count',
width: 100,
render: (_, r) => r.request_count.toLocaleString(),
},
{
title: '输入 Token',
dataIndex: 'input_tokens',
width: 120,
render: (_, r) => r.input_tokens.toLocaleString(),
},
{
title: '输出 Token',
dataIndex: 'output_tokens',
width: 120,
render: (_, r) => r.output_tokens.toLocaleString(),
},
{ title: '设备数', dataIndex: 'unique_devices', width: 80 },
]
const modelColumns: ProColumns<ModelUsageStat>[] = [
{ title: '模型', dataIndex: 'model_id', width: 200 },
{ title: '请求数', dataIndex: 'request_count', width: 100, render: (_, r) => r.request_count.toLocaleString() },
{ title: '输入 Token', dataIndex: 'input_tokens', width: 120, render: (_, r) => r.input_tokens.toLocaleString() },
{ title: '输出 Token', dataIndex: 'output_tokens', width: 120, render: (_, r) => r.output_tokens.toLocaleString() },
{
title: '请求数',
dataIndex: 'request_count',
width: 100,
render: (_, r) => r.request_count.toLocaleString(),
},
{
title: '输入 Token',
dataIndex: 'input_tokens',
width: 120,
render: (_, r) => r.input_tokens.toLocaleString(),
},
{
title: '输出 Token',
dataIndex: 'output_tokens',
width: 120,
render: (_, r) => r.output_tokens.toLocaleString(),
},
{
title: '平均延迟',
dataIndex: 'avg_latency_ms',
width: 100,
render: (_, r) => r.avg_latency_ms ? `${Math.round(r.avg_latency_ms)}ms` : '-',
render: (_, r) => (r.avg_latency_ms ? `${Math.round(r.avg_latency_ms)}ms` : '-'),
},
{
title: '成功率',
@@ -63,34 +102,66 @@ export default function Usage() {
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<Title level={4} style={{ margin: 0 }}></Title>
<Select
value={days}
onChange={setDays}
options={[
{ value: 7, label: '最近 7 天' },
{ value: 30, label: '最近 30 天' },
{ value: 90, label: '最近 90 天' },
]}
style={{ width: 140 }}
/>
</div>
<PageHeader
title="用量统计"
description="查看模型使用情况和 Token 消耗"
actions={
<Select
value={days}
onChange={setDays}
options={[
{ value: 7, label: '最近 7 天' },
{ value: 30, label: '最近 30 天' },
{ value: 90, label: '最近 90 天' },
]}
className="w-36"
/>
}
/>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col span={12}>
<Card>
<Statistic title="总请求数" value={totalRequests} prefix={<ThunderboltOutlined />} />
{/* Summary Cards */}
<Row gutter={[16, 16]} className="mb-6">
<Col xs={24} sm={12}>
<Card className="hover:shadow-md transition-shadow duration-200">
<Statistic
title={
<span className="text-neutral-500 dark:text-neutral-400 text-xs font-medium uppercase tracking-wide">
</span>
}
value={totalRequests}
prefix={<ThunderboltOutlined style={{ color: '#863bff' }} />}
valueStyle={{ fontWeight: 600, color: '#863bff' }}
/>
</Card>
</Col>
<Col span={12}>
<Card>
<Statistic title="总 Token 数" value={totalTokens} prefix={<ColumnWidthOutlined />} />
<Col xs={24} sm={12}>
<Card className="hover:shadow-md transition-shadow duration-200">
<Statistic
title={
<span className="text-neutral-500 dark:text-neutral-400 text-xs font-medium uppercase tracking-wide">
Token
</span>
}
value={totalTokens}
prefix={<ColumnWidthOutlined style={{ color: '#47bfff' }} />}
valueStyle={{ fontWeight: 600, color: '#47bfff' }}
/>
</Card>
</Col>
</Row>
<Card title="每日统计" style={{ marginBottom: 24 }} size="small">
{/* Daily Stats */}
<Card
title={
<span className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
</span>
}
className="mb-6"
size="small"
styles={{ body: { padding: 0 } }}
>
<ProTable<DailyUsageStat>
columns={dailyColumns}
dataSource={dailyData ?? []}
@@ -103,7 +174,16 @@ export default function Usage() {
/>
</Card>
<Card title="按模型统计" size="small">
{/* Model Stats */}
<Card
title={
<span className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
</span>
}
size="small"
styles={{ body: { padding: 0 } }}
>
<ProTable<ModelUsageStat>
columns={modelColumns}
dataSource={modelData ?? []}

View File

@@ -21,10 +21,11 @@ export const router = createBrowserRouter([
children: [
{ index: true, lazy: () => import('@/pages/Dashboard').then((m) => ({ Component: m.default })) },
{ path: 'accounts', lazy: () => import('@/pages/Accounts').then((m) => ({ Component: m.default })) },
{ path: 'providers', lazy: () => import('@/pages/Providers').then((m) => ({ Component: m.default })) },
{ path: 'models', lazy: () => import('@/pages/Models').then((m) => ({ Component: m.default })) },
{ path: 'model-services', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
{ path: 'providers', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
{ path: 'models', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
{ path: 'agent-templates', lazy: () => import('@/pages/AgentTemplates').then((m) => ({ Component: m.default })) },
{ path: 'api-keys', lazy: () => import('@/pages/ApiKeys').then((m) => ({ Component: m.default })) },
{ path: 'api-keys', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
{ path: 'usage', lazy: () => import('@/pages/Usage').then((m) => ({ Component: m.default })) },
{ path: 'relay', lazy: () => import('@/pages/Relay').then((m) => ({ Component: m.default })) },
{ path: 'config', lazy: () => import('@/pages/Config').then((m) => ({ Component: m.default })) },

View File

@@ -0,0 +1,56 @@
import { create } from 'zustand'
type ThemeMode = 'light' | 'dark' | 'system'
interface ThemeState {
mode: ThemeMode
resolved: 'light' | 'dark'
}
function getSystemTheme(): 'light' | 'dark' {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function resolveTheme(mode: ThemeMode): 'light' | 'dark' {
return mode === 'system' ? getSystemTheme() : mode
}
function applyTheme(resolved: 'light' | 'dark') {
const html = document.documentElement
html.classList.toggle('dark', resolved === 'dark')
html.setAttribute('data-theme', resolved)
}
function getInitialMode(): ThemeMode {
const stored = localStorage.getItem('zclaw_admin_theme')
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored
return 'system'
}
const initialMode = getInitialMode()
const initialResolved = resolveTheme(initialMode)
applyTheme(initialResolved)
export const useThemeStore = create<ThemeState>(() => ({
mode: initialMode,
resolved: initialResolved,
}))
export function setThemeMode(mode: ThemeMode) {
const resolved = resolveTheme(mode)
localStorage.setItem('zclaw_admin_theme', mode)
applyTheme(resolved)
useThemeStore.setState({ mode, resolved })
}
// Listen for system theme changes
if (typeof window !== 'undefined') {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const { mode } = useThemeStore.getState()
if (mode === 'system') {
const resolved = getSystemTheme()
applyTheme(resolved)
useThemeStore.setState({ resolved })
}
})
}

View File

@@ -0,0 +1,235 @@
@import "tailwindcss";
/* ============================================================
ZCLAW Admin Design Tokens
DeerFlow-inspired warm neutral palette with brand accents
============================================================ */
@theme {
/* Brand Colors */
--color-brand-purple: #863bff;
--color-brand-blue: #47bfff;
--color-brand-gradient: linear-gradient(135deg, #863bff, #47bfff);
/* Neutral (warm stone palette) */
--color-neutral-50: #fafaf9;
--color-neutral-100: #f5f5f4;
--color-neutral-200: #e7e5e4;
--color-neutral-300: #d6d3d1;
--color-neutral-400: #a8a29e;
--color-neutral-500: #78716c;
--color-neutral-600: #57534e;
--color-neutral-700: #44403c;
--color-neutral-800: #292524;
--color-neutral-900: #1c1917;
--color-neutral-950: #0c0a09;
/* Semantic Colors */
--color-success: #22c55e;
--color-success-soft: #dcfce7;
--color-warning: #f59e0b;
--color-warning-soft: #fef3c7;
--color-error: #ef4444;
--color-error-soft: #fee2e2;
--color-info: #3b82f6;
--color-info-soft: #dbeafe;
/* Dark mode neutrals */
--color-dark-bg: #0c0a09;
--color-dark-surface: #1c1917;
--color-dark-card: #292524;
--color-dark-border: #44403c;
--color-dark-text: #fafaf9;
--color-dark-text-secondary: #a8a29e;
/* Spacing */
--spacing-sidebar-expanded: 16rem;
--spacing-sidebar-collapsed: 3rem;
--spacing-header-height: 3.5rem;
/* Border Radius */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
/* Shadows */
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
--shadow-dropdown: 0 4px 16px rgba(0, 0, 0, 0.12);
--shadow-modal: 0 8px 32px rgba(0, 0, 0, 0.16);
/* Typography */
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
/* Transitions */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* ============================================================
Base Styles
============================================================ */
html {
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
background-color: var(--color-neutral-50);
color: var(--color-neutral-900);
transition: background-color var(--transition-normal), color var(--transition-normal);
}
/* Dark mode overrides */
html.dark body {
background-color: var(--color-dark-bg);
color: var(--color-dark-text);
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--color-neutral-300);
border-radius: 3px;
}
html.dark ::-webkit-scrollbar-thumb {
background-color: var(--color-dark-border);
}
/* Focus visible */
:focus-visible {
outline: 2px solid var(--color-brand-purple);
outline-offset: 2px;
border-radius: 4px;
}
/* Skip to content (accessibility) */
.skip-to-content {
position: absolute;
top: -100%;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
padding: 8px 16px;
background: var(--color-brand-purple);
color: white;
border-radius: var(--radius-md);
font-size: 14px;
text-decoration: none;
transition: top var(--transition-fast);
}
.skip-to-content:focus {
top: 8px;
}
/* ============================================================
Ant Design Overrides (Light Mode)
============================================================ */
/* ProTable search area */
.ant-pro-table-search {
background-color: var(--color-neutral-50) !important;
border-bottom: 1px solid var(--color-neutral-200) !important;
}
/* Card styling */
.ant-card {
border-radius: var(--radius-lg) !important;
border: 1px solid var(--color-neutral-200) !important;
box-shadow: var(--shadow-card) !important;
}
.ant-card:hover {
box-shadow: var(--shadow-card-hover) !important;
}
/* Table styling */
.ant-table-wrapper .ant-table-thead > tr > th {
background-color: var(--color-neutral-50) !important;
font-weight: 600 !important;
color: var(--color-neutral-600) !important;
}
/* Modal styling */
.ant-modal .ant-modal-content {
border-radius: var(--radius-lg) !important;
}
/* Tag pill style */
.ant-tag {
border-radius: 9999px !important;
padding: 0 8px !important;
}
/* Form item */
.ant-form-item-label > label {
font-weight: 500 !important;
color: var(--color-neutral-700) !important;
}
/* ============================================================
Dark Mode — Ant Design Overrides
============================================================ */
html.dark .ant-card {
background-color: var(--color-dark-card) !important;
border-color: var(--color-dark-border) !important;
}
html.dark .ant-table-wrapper .ant-table-thead > tr > th {
background-color: var(--color-dark-surface) !important;
color: var(--color-dark-text-secondary) !important;
}
html.dark .ant-table-wrapper .ant-table-tbody > tr > td {
border-color: var(--color-dark-border) !important;
}
html.dark .ant-table-wrapper .ant-table-tbody > tr:hover > td {
background-color: rgba(134, 59, 255, 0.06) !important;
}
html.dark .ant-modal .ant-modal-content {
background-color: var(--color-dark-card) !important;
}
html.dark .ant-modal .ant-modal-header {
background-color: var(--color-dark-card) !important;
}
html.dark .ant-drawer .ant-drawer-content {
background-color: var(--color-dark-surface) !important;
}
html.dark .ant-form-item-label > label {
color: var(--color-dark-text-secondary) !important;
}
html.dark .ant-select-selector,
html.dark .ant-input,
html.dark .ant-input-number {
background-color: var(--color-dark-card) !important;
border-color: var(--color-dark-border) !important;
color: var(--color-dark-text) !important;
}
html.dark .ant-pro-table-search {
background-color: var(--color-dark-surface) !important;
border-color: var(--color-dark-border) !important;
}