feat(admin): Admin V2 — Ant Design Pro 纯 SPA 重写
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
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
Next.js SSR/hydration 与 SWR fetch-on-mount 存在根本冲突: hydration 卸载组件时 abort 的请求仍占用后端 DB 连接, retry 循环耗尽 PostgreSQL 连接池导致后端完全卡死。 admin-v2 使用 Vite + React + antd 纯 SPA 彻底消除此问题: - 12 页面全部完成(Login, Dashboard, Accounts, Providers, Models, API Keys, Usage, Relay, Config, Prompts, Logs, Agent Templates) - ProTable + ProForm + ProLayout 统一 UI 模式 - TanStack Query + Axios + Zustand 数据层 - JWT 自动刷新 + 401 重试机制 - 全部 18 网络请求 200 OK,零 ERR_ABORTED 同时更新 troubleshooting 第 13 节和 SaaS 平台文档。
This commit is contained in:
170
admin-v2/src/pages/Accounts.tsx
Normal file
170
admin-v2/src/pages/Accounts.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
// ============================================================
|
||||
// 账号管理
|
||||
// ============================================================
|
||||
|
||||
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 type { AccountPublic } from '@/types'
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
super_admin: '超级管理员',
|
||||
admin: '管理员',
|
||||
user: '用户',
|
||||
}
|
||||
|
||||
const roleColors: Record<string, string> = {
|
||||
super_admin: 'red',
|
||||
admin: 'blue',
|
||||
user: 'default',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
active: '正常',
|
||||
disabled: '已禁用',
|
||||
suspended: '已封禁',
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
active: 'green',
|
||||
disabled: 'default',
|
||||
suspended: 'red',
|
||||
}
|
||||
|
||||
export default function Accounts() {
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['accounts'],
|
||||
queryFn: () => accountService.list(),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<AccountPublic> }) =>
|
||||
accountService.update(id, data),
|
||||
onSuccess: () => {
|
||||
message.success('更新成功')
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] })
|
||||
setModalOpen(false)
|
||||
},
|
||||
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||||
})
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: AccountPublic['status'] }) =>
|
||||
accountService.updateStatus(id, { status }),
|
||||
onSuccess: () => {
|
||||
message.success('状态更新成功')
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] })
|
||||
},
|
||||
onError: (err: Error) => message.error(err.message || '状态更新失败'),
|
||||
})
|
||||
|
||||
const columns: ProColumns<AccountPublic>[] = [
|
||||
{ title: '用户名', dataIndex: 'username', width: 120 },
|
||||
{ title: '显示名', dataIndex: 'display_name', width: 120 },
|
||||
{ title: '邮箱', dataIndex: 'email', width: 180 },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
width: 120,
|
||||
render: (_, record) => <Tag color={roleColors[record.role]}>{roleLabels[record.role] || record.role}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (_, record) => <Tag color={statusColors[record.status]}>{statusLabels[record.status] || record.status}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '2FA',
|
||||
dataIndex: 'totp_enabled',
|
||||
width: 80,
|
||||
render: (_, record) => record.totp_enabled ? <Tag color="green">已启用</Tag> : <Tag>未启用</Tag>,
|
||||
},
|
||||
{
|
||||
title: '最后登录',
|
||||
dataIndex: 'last_login_at',
|
||||
width: 180,
|
||||
render: (_, record) => record.last_login_at ? new Date(record.last_login_at).toLocaleString('zh-CN') : '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
|
||||
编辑
|
||||
</Button>
|
||||
{record.status === 'active' ? (
|
||||
<Popconfirm title="确定禁用此账号?" onConfirm={() => statusMutation.mutate({ id: record.id, status: 'disabled' })}>
|
||||
<Button size="small" danger>禁用</Button>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Popconfirm title="确定启用此账号?" onConfirm={() => statusMutation.mutate({ id: record.id, status: 'active' })}>
|
||||
<Button size="small" type="primary">启用</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields()
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, data: values })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ProTable<AccountPublic>
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
loading={isLoading}
|
||||
rowKey="id"
|
||||
search={false}
|
||||
toolBarRender={() => []}
|
||||
pagination={{
|
||||
total: data?.total ?? 0,
|
||||
pageSize: data?.page_size ?? 20,
|
||||
current: data?.page ?? 1,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="编辑账号"
|
||||
open={modalOpen}
|
||||
onOk={handleSave}
|
||||
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
|
||||
confirmLoading={updateMutation.isPending}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="display_name" label="显示名">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="email" label="邮箱">
|
||||
<Input type="email" />
|
||||
</Form.Item>
|
||||
<Form.Item name="role" label="角色">
|
||||
<Select options={[
|
||||
{ value: 'super_admin', label: '超级管理员' },
|
||||
{ value: 'admin', label: '管理员' },
|
||||
{ value: 'user', label: '用户' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
import { useState } from 'react'
|
||||
190
admin-v2/src/pages/AgentTemplates.tsx
Normal file
190
admin-v2/src/pages/AgentTemplates.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
// ============================================================
|
||||
// Agent 模板管理
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, message, Tag, Modal, Form, Input, Select, InputNumber, Space, Popconfirm, Descriptions } from 'antd'
|
||||
import { PlusOutlined } 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'
|
||||
import type { AgentTemplate } from '@/types'
|
||||
|
||||
const { TextArea } = Input
|
||||
|
||||
const sourceLabels: Record<string, string> = { builtin: '内置', custom: '自定义' }
|
||||
const visibilityLabels: Record<string, string> = { public: '公开', team: '团队', private: '私有' }
|
||||
const statusLabels: Record<string, string> = { active: '活跃', archived: '已归档' }
|
||||
const statusColors: Record<string, string> = { active: 'green', archived: 'default' }
|
||||
|
||||
export default function AgentTemplates() {
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [detailRecord, setDetailRecord] = useState<AgentTemplate | null>(null)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['agent-templates'],
|
||||
queryFn: () => agentTemplateService.list(),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Parameters<typeof agentTemplateService.create>[0]) =>
|
||||
agentTemplateService.create(data),
|
||||
onSuccess: () => {
|
||||
message.success('创建成功')
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-templates'] })
|
||||
setModalOpen(false)
|
||||
form.resetFields()
|
||||
},
|
||||
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||
})
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: (id: string) => agentTemplateService.archive(id),
|
||||
onSuccess: () => {
|
||||
message.success('已归档')
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-templates'] })
|
||||
},
|
||||
onError: (err: Error) => message.error(err.message || '归档失败'),
|
||||
})
|
||||
|
||||
const columns: ProColumns<AgentTemplate>[] = [
|
||||
{ title: '名称', dataIndex: 'name', width: 160 },
|
||||
{ title: '分类', dataIndex: 'category', width: 100 },
|
||||
{ title: '模型', dataIndex: 'model', width: 140, render: (_, r) => r.model || '-' },
|
||||
{
|
||||
title: '来源',
|
||||
dataIndex: 'source',
|
||||
width: 80,
|
||||
render: (_, r) => <Tag>{sourceLabels[r.source] || r.source}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '可见性',
|
||||
dataIndex: 'visibility',
|
||||
width: 80,
|
||||
render: (_, r) => <Tag color="blue">{visibilityLabels[r.visibility] || r.visibility}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (_, r) => <Tag color={statusColors[r.status]}>{statusLabels[r.status] || r.status}</Tag>,
|
||||
},
|
||||
{ title: '版本', dataIndex: 'current_version', width: 70 },
|
||||
{
|
||||
title: '操作',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => setDetailRecord(record)}>详情</Button>
|
||||
{record.status === 'active' && (
|
||||
<Popconfirm title="确定归档此模板?" onConfirm={() => archiveMutation.mutate(record.id)}>
|
||||
<Button size="small" danger>归档</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields()
|
||||
createMutation.mutate(values)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ProTable<AgentTemplate>
|
||||
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="新建 Agent 模板"
|
||||
open={modalOpen}
|
||||
onOk={handleCreate}
|
||||
onCancel={() => { setModalOpen(false); form.resetFields() }}
|
||||
confirmLoading={createMutation.isPending}
|
||||
width={640}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="category" label="分类">
|
||||
<Input placeholder="如 assistant, tool" />
|
||||
</Form.Item>
|
||||
<Form.Item name="model" label="默认模型">
|
||||
<Input placeholder="如 gpt-4o" />
|
||||
</Form.Item>
|
||||
<Form.Item name="system_prompt" label="系统提示词">
|
||||
<TextArea rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item name="temperature" label="Temperature">
|
||||
<InputNumber min={0} max={2} step={0.1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="max_tokens" label="最大 Token">
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="visibility" label="可见性">
|
||||
<Select options={[
|
||||
{ value: 'public', label: '公开' },
|
||||
{ value: 'team', label: '团队' },
|
||||
{ value: 'private', label: '私有' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="模板详情"
|
||||
open={!!detailRecord}
|
||||
onCancel={() => setDetailRecord(null)}
|
||||
footer={null}
|
||||
width={640}
|
||||
>
|
||||
{detailRecord && (
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="名称">{detailRecord.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="分类">{detailRecord.category}</Descriptions.Item>
|
||||
<Descriptions.Item label="模型">{detailRecord.model || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="来源">{sourceLabels[detailRecord.source]}</Descriptions.Item>
|
||||
<Descriptions.Item label="可见性">{visibilityLabels[detailRecord.visibility]}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{statusLabels[detailRecord.status]}</Descriptions.Item>
|
||||
<Descriptions.Item label="描述" span={2}>{detailRecord.description || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="系统提示词" span={2}>
|
||||
<div style={{ whiteSpace: 'pre-wrap', maxHeight: 200, overflow: 'auto' }}>
|
||||
{detailRecord.system_prompt || '-'}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="工具" span={2}>
|
||||
{detailRecord.tools?.map((t) => <Tag key={t}>{t}</Tag>) || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="能力" span={2}>
|
||||
{detailRecord.capabilities?.map((c) => <Tag key={c} color="blue">{c}</Tag>) || '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
165
admin-v2/src/pages/ApiKeys.tsx
Normal file
165
admin-v2/src/pages/ApiKeys.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
// ============================================================
|
||||
// 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: () => apiKeyService.list(),
|
||||
})
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
110
admin-v2/src/pages/Config.tsx
Normal file
110
admin-v2/src/pages/Config.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
// ============================================================
|
||||
// 系统配置
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Card, Tabs, message, Tag, Input, Button, Space, Typography } from 'antd'
|
||||
import type { ProColumns } from '@ant-design/pro-components'
|
||||
import { ProTable } from '@ant-design/pro-components'
|
||||
import { configService } from '@/services/config'
|
||||
import type { ConfigItem } from '@/types'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
export default function Config() {
|
||||
const queryClient = useQueryClient()
|
||||
const [category, setCategory] = useState<string>('general')
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['config', category],
|
||||
queryFn: () => configService.list({ category }),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, value }: { id: string; value: string }) =>
|
||||
configService.update(id, { value }),
|
||||
onSuccess: () => {
|
||||
message.success('配置已更新')
|
||||
queryClient.invalidateQueries({ queryKey: ['config', category] })
|
||||
setEditingId(null)
|
||||
},
|
||||
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||||
})
|
||||
|
||||
const columns: ProColumns<ConfigItem>[] = [
|
||||
{ title: '配置路径', dataIndex: 'key_path', width: 200, render: (_, r) => <code>{r.key_path}</code> },
|
||||
{
|
||||
title: '当前值',
|
||||
dataIndex: 'current_value',
|
||||
width: 250,
|
||||
render: (_, record) => {
|
||||
if (editingId === record.id) {
|
||||
return (
|
||||
<Space>
|
||||
<Input
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
style={{ width: 180 }}
|
||||
onPressEnter={() => updateMutation.mutate({ id: record.id, value: editValue })}
|
||||
/>
|
||||
<Button size="small" type="primary" onClick={() => updateMutation.mutate({ id: record.id, value: editValue })}>
|
||||
保存
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setEditingId(null)}>取消</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span
|
||||
onClick={() => { setEditingId(record.id); setEditValue(record.current_value || '') }}
|
||||
style={{ cursor: 'pointer', color: '#1677ff' }}
|
||||
>
|
||||
{record.current_value || <Tag>未设置</Tag>}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{ title: '默认值', dataIndex: 'default_value', width: 200, render: (_, r) => r.default_value || '-' },
|
||||
{ title: '类型', dataIndex: 'value_type', width: 80, render: (_, r) => <Tag>{r.value_type}</Tag> },
|
||||
{ title: '描述', dataIndex: 'description', width: 200, ellipsis: true },
|
||||
{
|
||||
title: '需要重启',
|
||||
dataIndex: 'requires_restart',
|
||||
width: 90,
|
||||
render: (_, r) => r.requires_restart ? <Tag color="orange">是</Tag> : <Tag>否</Tag>,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={4} style={{ marginBottom: 24 }}>系统配置</Title>
|
||||
|
||||
<Tabs
|
||||
activeKey={category}
|
||||
onChange={(key) => { setCategory(key); setEditingId(null) }}
|
||||
items={[
|
||||
{ key: 'general', label: '通用' },
|
||||
{ key: 'auth', label: '认证' },
|
||||
{ key: 'relay', label: '中转' },
|
||||
{ key: 'model', label: '模型' },
|
||||
{ key: 'rate_limit', label: '限流' },
|
||||
{ key: 'log', label: '日志' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<ProTable<ConfigItem>
|
||||
columns={columns}
|
||||
dataSource={data ?? []}
|
||||
loading={isLoading}
|
||||
rowKey="id"
|
||||
search={false}
|
||||
toolBarRender={false}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
121
admin-v2/src/pages/Dashboard.tsx
Normal file
121
admin-v2/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
// ============================================================
|
||||
// 仪表盘页面
|
||||
// ============================================================
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Card, Col, Row, Statistic, Table, Tag, Typography, Spin, Alert } from 'antd'
|
||||
import {
|
||||
TeamOutlined,
|
||||
CloudServerOutlined,
|
||||
ApiOutlined,
|
||||
ThunderboltOutlined,
|
||||
ColumnWidthOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { statsService } from '@/services/stats'
|
||||
import { logService } from '@/services/logs'
|
||||
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({
|
||||
queryKey: ['dashboard-stats'],
|
||||
queryFn: () => statsService.dashboard(),
|
||||
})
|
||||
|
||||
const { data: logsData, isLoading: logsLoading } = useQuery({
|
||||
queryKey: ['recent-logs'],
|
||||
queryFn: () => logService.list({ page: 1, page_size: 10 }),
|
||||
})
|
||||
|
||||
if (statsError) {
|
||||
return <Alert type="error" message="加载仪表盘数据失败" description={(statsError as Error).message} showIcon />
|
||||
}
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
const logColumns = [
|
||||
{
|
||||
title: '操作类型',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 140,
|
||||
render: (action: string) => (
|
||||
<Tag color={actionColors[action] || 'default'}>
|
||||
{actionLabels[action] || action}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '目标类型', dataIndex: 'target_type', key: 'target_type', width: 100, render: (v: string | null) => v || '-' },
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={4} style={{ marginBottom: 24 }}>仪表盘</Title>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
{statsLoading ? (
|
||||
<Col span={24}><Spin /></Col>
|
||||
) : (
|
||||
statCards.map((card) => (
|
||||
<Col xs={24} sm={12} md={8} lg={4} key={card.title}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={card.title}
|
||||
value={card.value}
|
||||
prefix={<span style={{ color: card.color }}>{card.icon}</span>}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))
|
||||
)}
|
||||
</Row>
|
||||
|
||||
<Card title="最近操作日志" size="small">
|
||||
<Table<OperationLog>
|
||||
columns={logColumns}
|
||||
dataSource={logsData?.items ?? []}
|
||||
loading={logsLoading}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
138
admin-v2/src/pages/Login.tsx
Normal file
138
admin-v2/src/pages/Login.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
// ============================================================
|
||||
// 登录页面
|
||||
// ============================================================
|
||||
|
||||
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 { 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()
|
||||
const loginStore = useAuthStore((s) => s.login)
|
||||
const [needTotp, setNeedTotp] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (values: Record<string, string>) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data: LoginRequest = {
|
||||
username: values.username?.trim() || '',
|
||||
password: values.password || '',
|
||||
totp_code: values.totp_code?.trim() || undefined,
|
||||
}
|
||||
|
||||
const res = await authService.login(data)
|
||||
loginStore(res.token, res.refresh_token, res.account)
|
||||
|
||||
message.success('登录成功')
|
||||
const from = searchParams.get('from') || '/'
|
||||
navigate(from, { replace: true })
|
||||
} catch (err: unknown) {
|
||||
const error = err as { message?: string; status?: number }
|
||||
const msg = error.message || ''
|
||||
if (msg.includes('TOTP') || msg.includes('totp') || msg.includes('2FA') || msg.includes('验证码') || error.status === 403) {
|
||||
setNeedTotp(true)
|
||||
message.warning(msg || '请输入两步验证码')
|
||||
} else {
|
||||
message.error(msg || '登录失败,请检查用户名和密码')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</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 }}>
|
||||
输入您的账号信息以继续
|
||||
</Text>
|
||||
|
||||
<LoginForm
|
||||
onFinish={handleSubmit}
|
||||
submitter={{
|
||||
searchConfig: { submitText: '登录' },
|
||||
submitButtonProps: { loading, block: true },
|
||||
}}
|
||||
>
|
||||
<ProFormText
|
||||
name="username"
|
||||
fieldProps={{
|
||||
size: 'large',
|
||||
prefix: <UserOutlined />,
|
||||
autoComplete: 'username',
|
||||
}}
|
||||
placeholder="请输入用户名"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
/>
|
||||
<ProFormText.Password
|
||||
name="password"
|
||||
fieldProps={{
|
||||
size: 'large',
|
||||
prefix: <LockOutlined />,
|
||||
autoComplete: 'current-password',
|
||||
}}
|
||||
placeholder="请输入密码"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
/>
|
||||
{needTotp && (
|
||||
<ProFormText
|
||||
name="totp_code"
|
||||
fieldProps={{
|
||||
size: 'large',
|
||||
prefix: <SafetyOutlined />,
|
||||
maxLength: 6,
|
||||
autoComplete: 'one-time-code',
|
||||
}}
|
||||
placeholder="请输入 6 位验证码"
|
||||
rules={[{ required: true, message: '请输入验证码' }]}
|
||||
/>
|
||||
)}
|
||||
</LoginForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
112
admin-v2/src/pages/Logs.tsx
Normal file
112
admin-v2/src/pages/Logs.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
// ============================================================
|
||||
// 操作日志
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Tag, Select, Typography } from 'antd'
|
||||
import type { ProColumns } from '@ant-design/pro-components'
|
||||
import { ProTable } from '@ant-design/pro-components'
|
||||
import { logService } from '@/services/logs'
|
||||
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',
|
||||
}
|
||||
|
||||
const actionOptions = Object.entries(actionLabels).map(([value, label]) => ({ value, label }))
|
||||
|
||||
export default function Logs() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [actionFilter, setActionFilter] = useState<string | undefined>(undefined)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['logs', page, actionFilter],
|
||||
queryFn: () => logService.list({ page, page_size: 20, action: actionFilter }),
|
||||
})
|
||||
|
||||
const columns: ProColumns<OperationLog>[] = [
|
||||
{
|
||||
title: '操作类型',
|
||||
dataIndex: 'action',
|
||||
width: 140,
|
||||
render: (_, r) => (
|
||||
<Tag color={actionColors[r.action] || 'default'}>
|
||||
{actionLabels[r.action] || r.action}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '目标类型', dataIndex: 'target_type', width: 100, render: (_, r) => r.target_type || '-' },
|
||||
{ title: '目标 ID', dataIndex: 'target_id', width: 120, render: (_, r) => r.target_id ? <code>{r.target_id.substring(0, 8)}...</code> : '-' },
|
||||
{
|
||||
title: '详情',
|
||||
dataIndex: 'details',
|
||||
width: 250,
|
||||
ellipsis: true,
|
||||
render: (_, r) => {
|
||||
if (!r.details) return '-'
|
||||
if (typeof r.details === 'string') return r.details
|
||||
return JSON.stringify(r.details)
|
||||
},
|
||||
},
|
||||
{ title: 'IP 地址', dataIndex: 'ip_address', width: 130, render: (_, r) => <code>{r.ip_address || '-'}</code> },
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 180,
|
||||
render: (_, r) => new Date(r.created_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={actionFilter}
|
||||
onChange={(v) => { setActionFilter(v === 'all' ? undefined : v); setPage(1) }}
|
||||
placeholder="操作类型筛选"
|
||||
style={{ width: 160 }}
|
||||
allowClear
|
||||
options={[{ value: 'all', label: '全部操作' }, ...actionOptions]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ProTable<OperationLog>
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
loading={isLoading}
|
||||
rowKey="id"
|
||||
search={false}
|
||||
toolBarRender={false}
|
||||
pagination={{
|
||||
total: data?.total ?? 0,
|
||||
pageSize: 20,
|
||||
current: page,
|
||||
onChange: setPage,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
186
admin-v2/src/pages/Models.tsx
Normal file
186
admin-v2/src/pages/Models.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
// ============================================================
|
||||
// 模型管理
|
||||
// ============================================================
|
||||
|
||||
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: () => modelService.list(),
|
||||
})
|
||||
|
||||
const { data: providersData } = useQuery({
|
||||
queryKey: ['providers-for-select'],
|
||||
queryFn: () => providerService.list(),
|
||||
})
|
||||
|
||||
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,
|
||||
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, render: (_, r) => r.context_window?.toLocaleString() },
|
||||
{ title: '最大输出', dataIndex: 'max_output_tokens', width: 100, render: (_, r) => r.max_output_tokens?.toLocaleString() },
|
||||
{
|
||||
title: '流式',
|
||||
dataIndex: 'supports_streaming',
|
||||
width: 70,
|
||||
render: (_, r) => r.supports_streaming ? <Tag color="green">是</Tag> : <Tag>否</Tag>,
|
||||
},
|
||||
{
|
||||
title: '视觉',
|
||||
dataIndex: 'supports_vision',
|
||||
width: 70,
|
||||
render: (_, r) => r.supports_vision ? <Tag color="blue">是</Tag> : <Tag>否</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'enabled',
|
||||
width: 70,
|
||||
render: (_, r) => r.enabled ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
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={false}
|
||||
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>
|
||||
)
|
||||
}
|
||||
228
admin-v2/src/pages/Prompts.tsx
Normal file
228
admin-v2/src/pages/Prompts.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
// ============================================================
|
||||
// 提示词管理
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, message, Tag, Modal, Form, Input, Select, Space, Popconfirm, Descriptions, Tabs, 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 { promptService } from '@/services/prompts'
|
||||
import type { PromptTemplate, PromptVersion } from '@/types'
|
||||
|
||||
const { TextArea } = Input
|
||||
const { Text } = Typography
|
||||
|
||||
const sourceLabels: Record<string, string> = { builtin: '内置', custom: '自定义' }
|
||||
const statusLabels: Record<string, string> = { active: '活跃', deprecated: '已废弃', archived: '已归档' }
|
||||
const statusColors: Record<string, string> = { active: 'green', deprecated: 'orange', archived: 'default' }
|
||||
|
||||
export default function Prompts() {
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [detailName, setDetailName] = useState<string | null>(null)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['prompts'],
|
||||
queryFn: () => promptService.list(),
|
||||
})
|
||||
|
||||
const { data: detailData } = useQuery({
|
||||
queryKey: ['prompt-detail', detailName],
|
||||
queryFn: () => promptService.get(detailName!),
|
||||
enabled: !!detailName,
|
||||
})
|
||||
|
||||
const { data: versionsData } = useQuery({
|
||||
queryKey: ['prompt-versions', detailName],
|
||||
queryFn: () => promptService.listVersions(detailName!),
|
||||
enabled: !!detailName,
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Parameters<typeof promptService.create>[0]) => promptService.create(data),
|
||||
onSuccess: () => {
|
||||
message.success('创建成功')
|
||||
queryClient.invalidateQueries({ queryKey: ['prompts'] })
|
||||
setCreateOpen(false)
|
||||
form.resetFields()
|
||||
},
|
||||
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||
})
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: (name: string) => promptService.archive(name),
|
||||
onSuccess: () => {
|
||||
message.success('已归档')
|
||||
queryClient.invalidateQueries({ queryKey: ['prompts'] })
|
||||
},
|
||||
onError: (err: Error) => message.error(err.message || '归档失败'),
|
||||
})
|
||||
|
||||
const rollbackMutation = useMutation({
|
||||
mutationFn: ({ name, version }: { name: string; version: number }) =>
|
||||
promptService.rollback(name, version),
|
||||
onSuccess: () => {
|
||||
message.success('回滚成功')
|
||||
queryClient.invalidateQueries({ queryKey: ['prompts'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['prompt-detail', detailName] })
|
||||
queryClient.invalidateQueries({ queryKey: ['prompt-versions', detailName] })
|
||||
},
|
||||
onError: (err: Error) => message.error(err.message || '回滚失败'),
|
||||
})
|
||||
|
||||
const columns: ProColumns<PromptTemplate>[] = [
|
||||
{ title: '名称', dataIndex: 'name', width: 200, render: (_, r) => <Text code>{r.name}</Text> },
|
||||
{ title: '分类', dataIndex: 'category', width: 100 },
|
||||
{ title: '描述', dataIndex: 'description', width: 200, ellipsis: true },
|
||||
{
|
||||
title: '来源',
|
||||
dataIndex: 'source',
|
||||
width: 80,
|
||||
render: (_, r) => <Tag>{sourceLabels[r.source]}</Tag>,
|
||||
},
|
||||
{ title: '版本', dataIndex: 'current_version', width: 70 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (_, r) => <Tag color={statusColors[r.status]}>{statusLabels[r.status]}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => setDetailName(record.name)}>详情</Button>
|
||||
{record.status === 'active' && (
|
||||
<Popconfirm title="确定归档此提示词?" onConfirm={() => archiveMutation.mutate(record.name)}>
|
||||
<Button size="small" danger>归档</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields()
|
||||
createMutation.mutate(values)
|
||||
}
|
||||
|
||||
const versionColumns: ProColumns<PromptVersion>[] = [
|
||||
{ title: '版本', dataIndex: 'version', width: 60 },
|
||||
{ title: '更新说明', dataIndex: 'changelog', width: 200, ellipsis: true },
|
||||
{ title: '最低版本', dataIndex: 'min_app_version', width: 100, render: (_, r) => r.min_app_version || '-' },
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 180,
|
||||
render: (_, r) => new Date(r.created_at).toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, record) => (
|
||||
<Popconfirm
|
||||
title={`确定回滚到版本 ${record.version}?`}
|
||||
onConfirm={() => detailName && rollbackMutation.mutate({ name: detailName, version: record.version })}
|
||||
>
|
||||
<Button size="small">回滚</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ProTable<PromptTemplate>
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
loading={isLoading}
|
||||
rowKey="id"
|
||||
search={false}
|
||||
toolBarRender={() => [
|
||||
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setCreateOpen(true) }}>
|
||||
新建提示词
|
||||
</Button>,
|
||||
]}
|
||||
pagination={{
|
||||
total: data?.total ?? 0,
|
||||
pageSize: data?.page_size ?? 20,
|
||||
current: data?.page ?? 1,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="新建提示词"
|
||||
open={createOpen}
|
||||
onOk={handleCreate}
|
||||
onCancel={() => { setCreateOpen(false); form.resetFields() }}
|
||||
confirmLoading={createMutation.isPending}
|
||||
width={640}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input placeholder="唯一标识" />
|
||||
</Form.Item>
|
||||
<Form.Item name="category" label="分类" rules={[{ required: true }]}>
|
||||
<Input placeholder="如 system, tool" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="system_prompt" label="系统提示词" rules={[{ required: true }]}>
|
||||
<TextArea rows={6} />
|
||||
</Form.Item>
|
||||
<Form.Item name="user_prompt_template" label="用户提示词模板">
|
||||
<TextArea rows={4} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`提示词详情: ${detailName || ''}`}
|
||||
open={!!detailName}
|
||||
onCancel={() => setDetailName(null)}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
<Tabs items={[
|
||||
{
|
||||
key: 'info',
|
||||
label: '基本信息',
|
||||
children: detailData ? (
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="名称">{detailData.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="分类">{detailData.category}</Descriptions.Item>
|
||||
<Descriptions.Item label="来源">{sourceLabels[detailData.source]}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{statusLabels[detailData.status]}</Descriptions.Item>
|
||||
<Descriptions.Item label="当前版本">{detailData.current_version}</Descriptions.Item>
|
||||
<Descriptions.Item label="描述" span={2}>{detailData.description || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
key: 'versions',
|
||||
label: '版本历史',
|
||||
children: (
|
||||
<ProTable<PromptVersion>
|
||||
columns={versionColumns}
|
||||
dataSource={versionsData ?? []}
|
||||
rowKey="id"
|
||||
search={false}
|
||||
toolBarRender={false}
|
||||
pagination={false}
|
||||
size="small"
|
||||
loading={!versionsData}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
188
admin-v2/src/pages/Providers.tsx
Normal file
188
admin-v2/src/pages/Providers.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
// ============================================================
|
||||
// 服务商管理
|
||||
// ============================================================
|
||||
|
||||
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 { data, isLoading } = useQuery({
|
||||
queryKey: ['providers'],
|
||||
queryFn: () => providerService.list(),
|
||||
})
|
||||
|
||||
const { data: keysData, isLoading: keysLoading } = useQuery({
|
||||
queryKey: ['provider-keys', keyModalProviderId],
|
||||
queryFn: () => providerService.listKeys(keyModalProviderId!),
|
||||
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 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 },
|
||||
{ title: 'RPM 限制', dataIndex: 'rate_limit_rpm', width: 100, render: (_, r) => r.rate_limit_rpm ?? '-' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'enabled',
|
||||
width: 80,
|
||||
render: (_, r) => r.enabled ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 260,
|
||||
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>,
|
||||
},
|
||||
]
|
||||
|
||||
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={false}
|
||||
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={null}
|
||||
width={700}
|
||||
>
|
||||
<ProTable<ProviderKey>
|
||||
columns={keyColumns}
|
||||
dataSource={keysData ?? []}
|
||||
loading={keysLoading}
|
||||
rowKey="id"
|
||||
search={false}
|
||||
toolBarRender={false}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
109
admin-v2/src/pages/Relay.tsx
Normal file
109
admin-v2/src/pages/Relay.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
// ============================================================
|
||||
// 中转任务
|
||||
// ============================================================
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Tag, Select, Typography } 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 type { RelayTask } from '@/types'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
queued: '排队中',
|
||||
running: '运行中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
cancelled: '已取消',
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
queued: 'default',
|
||||
running: 'processing',
|
||||
completed: 'green',
|
||||
failed: 'red',
|
||||
cancelled: 'default',
|
||||
}
|
||||
|
||||
export default function Relay() {
|
||||
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['relay-tasks', page, statusFilter],
|
||||
queryFn: () => relayService.list({ page, page_size: 20, status: statusFilter }),
|
||||
})
|
||||
|
||||
const columns: ProColumns<RelayTask>[] = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 120, render: (_, r) => <code>{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>,
|
||||
},
|
||||
{ title: '模型', dataIndex: 'model_id', width: 160 },
|
||||
{ title: '优先级', dataIndex: 'priority', width: 70 },
|
||||
{ title: '尝试次数', dataIndex: 'attempt_count', width: 80 },
|
||||
{
|
||||
title: 'Token',
|
||||
width: 140,
|
||||
render: (_, r) => `${r.input_tokens.toLocaleString()} / ${r.output_tokens.toLocaleString()}`,
|
||||
},
|
||||
{ title: '错误信息', dataIndex: 'error_message', width: 200, ellipsis: true },
|
||||
{
|
||||
title: '排队时间',
|
||||
dataIndex: 'queued_at',
|
||||
width: 180,
|
||||
render: (_, r) => new Date(r.queued_at).toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
title: '完成时间',
|
||||
dataIndex: 'completed_at',
|
||||
width: 180,
|
||||
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>
|
||||
|
||||
<ProTable<RelayTask>
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
loading={isLoading}
|
||||
rowKey="id"
|
||||
search={false}
|
||||
toolBarRender={false}
|
||||
pagination={{
|
||||
total: data?.total ?? 0,
|
||||
pageSize: 20,
|
||||
current: page,
|
||||
onChange: setPage,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
120
admin-v2/src/pages/Usage.tsx
Normal file
120
admin-v2/src/pages/Usage.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
// ============================================================
|
||||
// 用量统计
|
||||
// ============================================================
|
||||
|
||||
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 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 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({
|
||||
queryKey: ['usage-daily', days],
|
||||
queryFn: () => telemetryService.dailyStats({ days }),
|
||||
})
|
||||
|
||||
const { data: modelData, isLoading: modelLoading } = useQuery({
|
||||
queryKey: ['usage-model', days],
|
||||
queryFn: () => telemetryService.modelStats({}),
|
||||
})
|
||||
|
||||
if (dailyError) {
|
||||
return <Alert type="error" message="加载用量数据失败" description={(dailyError as Error).message} showIcon />
|
||||
}
|
||||
|
||||
const totalRequests = dailyData?.reduce((s, d) => s + d.request_count, 0) ?? 0
|
||||
const totalTokens = dailyData?.reduce((s, d) => s + d.input_tokens + d.output_tokens, 0) ?? 0
|
||||
|
||||
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: '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: 'avg_latency_ms',
|
||||
width: 100,
|
||||
render: (_, r) => r.avg_latency_ms ? `${Math.round(r.avg_latency_ms)}ms` : '-',
|
||||
},
|
||||
{
|
||||
title: '成功率',
|
||||
dataIndex: 'success_rate',
|
||||
width: 100,
|
||||
render: (_, r) => `${(r.success_rate * 100).toFixed(1)}%`,
|
||||
},
|
||||
]
|
||||
|
||||
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>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col span={12}>
|
||||
<Card>
|
||||
<Statistic title="总请求数" value={totalRequests} prefix={<ThunderboltOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card>
|
||||
<Statistic title="总 Token 数" value={totalTokens} prefix={<ColumnWidthOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="每日统计" style={{ marginBottom: 24 }} size="small">
|
||||
<ProTable<DailyUsageStat>
|
||||
columns={dailyColumns}
|
||||
dataSource={dailyData ?? []}
|
||||
loading={dailyLoading}
|
||||
rowKey="day"
|
||||
search={false}
|
||||
toolBarRender={false}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="按模型统计" size="small">
|
||||
<ProTable<ModelUsageStat>
|
||||
columns={modelColumns}
|
||||
dataSource={modelData ?? []}
|
||||
loading={modelLoading}
|
||||
rowKey="model_id"
|
||||
search={false}
|
||||
toolBarRender={false}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user