feat: M1-01~03 admin pages — AI Gateway, Vector, Events deepening
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 7s

M1-01 AI Gateway:
- Tab-based layout: Overview, Routes CRUD, Provider toggle, Fallback logs

M1-02 Vector:
- New VectorAdmin page: collection stats, info panel, reindex trigger
- Route /vector + menu entry under 系统运维

M1-03 Events:
- Tab-based layout: Queue overview (with batch retry), Task statistics
- Worker status panel, 16 task type configs table

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
WangDL 2026-05-24 10:18:22 +08:00
parent 6020867060
commit f6917d63d3
5 changed files with 340 additions and 57 deletions

View File

@ -23,6 +23,7 @@ const AiGatewayPage = lazy(() => import("./pages/AiGateway"))
const CSPage = lazy(() => import("./pages/ContentSafety")) const CSPage = lazy(() => import("./pages/ContentSafety"))
const MetricsPage = lazy(() => import("./pages/Metrics")) const MetricsPage = lazy(() => import("./pages/Metrics"))
const EventsPage = lazy(() => import("./pages/Events")) const EventsPage = lazy(() => import("./pages/Events"))
const VectorAdminPage = lazy(() => import("./pages/VectorAdmin"))
const ServersPage = lazy(() => import("./pages/Servers")) const ServersPage = lazy(() => import("./pages/Servers"))
const AuditLogPage = lazy(() => import("./pages/AuditLog")) const AuditLogPage = lazy(() => import("./pages/AuditLog"))
const Dashboard = lazy(() => import('./pages/Dashboard')) const Dashboard = lazy(() => import('./pages/Dashboard'))
@ -141,6 +142,10 @@ function App() {
path="events" path="events"
element={<PermissionGuard requiredRole="SUPER_ADMIN"><Suspense fallback={<PageLoading />}><EventsPage /></Suspense></PermissionGuard>} element={<PermissionGuard requiredRole="SUPER_ADMIN"><Suspense fallback={<PageLoading />}><EventsPage /></Suspense></PermissionGuard>}
/> />
<Route
path="vector"
element={<PermissionGuard requiredRole="SUPER_ADMIN"><Suspense fallback={<PageLoading />}><VectorAdminPage /></Suspense></PermissionGuard>}
/>
<Route <Route
path="servers" path="servers"
element={ element={

View File

@ -37,6 +37,7 @@ export const adminMenuItems: AdminMenuItem[] = [
{ path: '/ai-gateway', name: 'AI Gateway' }, { path: '/ai-gateway', name: 'AI Gateway' },
{ path: '/servers', name: '服务器' }, { path: '/servers', name: '服务器' },
{ path: '/events', name: '事件队列' }, { path: '/events', name: '事件队列' },
{ path: '/vector', name: '向量检索' },
{ path: '/config', name: '配置管理' }, { path: '/config', name: '配置管理' },
{ path: '/safety', name: '内容安全' }, { path: '/safety', name: '内容安全' },
]}, ]},

View File

@ -1,26 +1,167 @@
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react'
import { Card, Row, Col, Statistic, Table, Tag, Button, Typography } from 'antd' import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { ReloadOutlined, CloudOutlined } from '@ant-design/icons' import { Card, Row, Col, Statistic, Table, Tag, Button, Typography, Tabs, Modal, Form, Input, InputNumber, Select, Switch, Space, App } from 'antd'
import { ReloadOutlined, CloudOutlined, PlusOutlined, EditOutlined, DeleteOutlined, StopOutlined, CheckCircleOutlined } from '@ant-design/icons'
import { api } from '@/services/http-client' import { api } from '@/services/http-client'
const { Title, Text } = Typography const { Title, Text } = Typography
export default function AiGatewayPage() { export default function AiGatewayPage() {
const qc = useQueryClient() const qc = useQueryClient()
const { data } = useQuery({ queryKey: ['ai-gateway', 'status'], queryFn: (): Promise<any> => api.get('/admin-api/ai-gateway/status'), staleTime: 30_000 }) const { message } = App.useApp()
const [activeTab, setActiveTab] = useState('overview')
const [routeModal, setRouteModal] = useState<{ open: boolean; editing?: any }>({ open: false })
const [form] = Form.useForm()
const tierCols = [ const { data: status } = useQuery({
{ title: '级别', dataIndex: 'name', width: 80 }, queryKey: ['ai-gateway', 'status'],
{ title: '主模型', dataIndex: 'provider', width: 120 }, queryFn: (): Promise<any> => api.get('/admin-api/ai-gateway/status'),
{ title: '模型', dataIndex: 'model', width: 180 }, staleTime: 30_000,
{ title: '备用', dataIndex: 'fallback', width: 180, render: (v: string) => v || '-' }, })
const { data: routes, refetch: refetchRoutes } = useQuery({
queryKey: ['ai-gateway', 'routes'],
queryFn: (): Promise<any> => api.get('/admin-api/ai-gateway/routes'),
enabled: activeTab === 'routes',
})
const { data: providers, refetch: refetchProviders } = useQuery({
queryKey: ['ai-gateway', 'providers'],
queryFn: (): Promise<any> => api.get('/admin-api/ai-gateway/providers'),
enabled: activeTab === 'providers',
})
const { data: fallbackEvents } = useQuery({
queryKey: ['ai-gateway', 'fallback-events'],
queryFn: (): Promise<any> => api.get('/admin-api/ai-gateway/fallback-events'),
enabled: activeTab === 'fallback',
refetchInterval: 30_000,
})
const createRoute = useMutation({
mutationFn: (values: any) => api.post('/admin-api/ai-gateway/routes', values),
onSuccess: () => { message.success('路由已创建'); refetchRoutes(); setRouteModal({ open: false }); form.resetFields() },
})
const deleteRoute = useMutation({
mutationFn: (id: string) => api.delete(`/admin-api/ai-gateway/routes/${id}`),
onSuccess: () => { message.success('路由已删除'); refetchRoutes() },
})
const toggleProvider = useMutation({
mutationFn: ({ name, enabled }: { name: string; enabled: boolean }) => api.put(`/admin-api/ai-gateway/providers/${name}`, { enabled }),
onSuccess: () => { message.success('Provider 已更新'); refetchProviders() },
})
const routeColumns = [
{ title: '级别', dataIndex: 'tier', width: 80, render: (v: string) => <Tag color={v==='strong'?'red':v==='primary'?'blue':'default'}>{v}</Tag> },
{ title: '任务类型', dataIndex: 'taskType', width: 100 },
{ title: '首选 Provider', dataIndex: 'preferredProvider', width: 120, render: (v: string) => <Tag>{v}</Tag> },
{ title: '首选模型', dataIndex: 'preferredModel', width: 180, ellipsis: true },
{ title: '备用 Provider', dataIndex: 'fallbackProvider', width: 120, render: (v: string) => <Tag>{v}</Tag> },
{ title: '备用模型', dataIndex: 'fallbackModel', width: 180, ellipsis: true },
{ title: '重试', dataIndex: 'maxRetries', width: 60, align: 'center' as const },
{ title: '状态', dataIndex: 'isActive', width: 70, render: (v: boolean) => v ? <Tag color="green"></Tag> : <Tag color="red"></Tag> },
{ title: '操作', width: 80, render: (_: any, r: any) => (
<Button type="link" danger size="small" icon={<DeleteOutlined />} onClick={() => deleteRoute.mutate(r.id)} />
)},
] ]
const tiers = data?.tiers ? [ const providerColumns = [
{ name: 'cheap', ...data.tiers.cheap, fallback: data.tiers.cheap.fallback?.model || '-' }, { title: 'Provider', dataIndex: 'name', width: 150, render: (v: string) => <Tag color={v==='deepseek'?'blue':v==='minimax'?'purple':'green'}>{v}</Tag> },
{ name: 'primary', ...data.tiers.primary, fallback: data.tiers.primary.fallback?.model || data.tiers.primary.fallback || '-' }, { title: '状态', dataIndex: 'enabled', width: 80, render: (v: boolean, r: any) => (
{ name: 'strong', ...data.tiers.strong, fallback: data.tiers.strong.fallback?.model || '-' }, <Switch checked={v} onChange={(checked) => toggleProvider.mutate({ name: r.name, enabled: checked })} />
] : [] )},
{ title: 'Base URL', dataIndex: 'baseUrl', ellipsis: true },
{ title: '更新时间', dataIndex: 'updatedAt', width: 180 },
]
const fallbackColumns = [
{ title: '时间', dataIndex: 'createdAt', width: 170 },
{ title: '级别', dataIndex: 'tier', width: 80 },
{ title: '来源', dataIndex: 'fromProvider', width: 100, render: (v: string, r: any) => <Text>{v}/{r.fromModel?.slice(0,30)}</Text> },
{ title: '切换到', dataIndex: 'toProvider', width: 100, render: (v: string, r: any) => <Text type="warning">{v}/{r.toModel?.slice(0,30)}</Text> },
{ title: '错误', dataIndex: 'errorMessage', ellipsis: true },
]
const tabItems = [
{
key: 'overview', label: '概览',
children: (
<>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col span={6}><Card size="small"><Statistic title="Provider" value={status?.providers?.length || 0} suffix="个" /></Card></Col>
<Col span={6}><Card size="small"><Statistic title="活跃路由" value={status?.activeRoutes || 0} suffix="条" /></Card></Col>
<Col span={6}><Card size="small"><Statistic title="降级事件" value={status?.fallbackEvents || 0} suffix="次" /></Card></Col>
<Col span={6}><Card size="small"><Statistic title="路由级别" value={3} suffix="层" /></Card></Col>
</Row>
<Row gutter={[16, 16]}>
<Col span={12}>
<Card size="small" title="Provider 列表">
<Table dataSource={(status?.providers || []).map((p: any) => (typeof p === 'string' ? { name: p, enabled: true } : p))} columns={[
{ title: '名称', dataIndex: 'name', render: (v: string) => <Tag color={v==='deepseek'?'blue':v==='minimax'?'purple':'green'}>{v}</Tag> },
{ title: '状态', dataIndex: 'enabled', render: (v: boolean) => v ? <Tag color="green"></Tag> : <Tag color="red"></Tag> },
]} rowKey="name" pagination={false} size="small" />
</Card>
</Col>
<Col span={12}>
<Card size="small" title="模型路由">
<Table dataSource={(status?.routes || []).map((r: any) => (
{ tier: r.tier, taskType: r.taskType || '*', preferredProvider: r.preferred?.split('/')[0], preferredModel: r.preferred?.split('/').slice(1).join('/'), fallbackProvider: r.fallback?.split('/')[0], fallbackModel: r.fallback?.split('/').slice(1).join('/'), maxRetries: r.maxRetries }
))} columns={[
{ title: '级别', dataIndex: 'tier', width: 70, render: (v: string) => <Tag>{v}</Tag> },
{ title: '首选', dataIndex: 'preferred', width: 200, ellipsis: true, render: (_: any, r: any) => <Text>{r.preferredProvider}/{r.preferredModel?.slice(0,25)}</Text> },
{ title: '备用', dataIndex: 'fallback', width: 200, ellipsis: true, render: (_: any, r: any) => <Text>{r.fallbackProvider}/{r.fallbackModel?.slice(0,25)}</Text> },
]} rowKey="tier" pagination={false} size="small" />
</Card>
</Col>
</Row>
</>
),
},
{
key: 'routes', label: '路由规则',
children: (
<>
<Button type="primary" icon={<PlusOutlined />} style={{ marginBottom: 16 }} onClick={() => { form.resetFields(); setRouteModal({ open: true }) }}></Button>
<Table dataSource={routes || []} columns={routeColumns} rowKey="id" pagination={false} size="small" />
<Modal title={routeModal.editing ? '编辑路由' : '新增路由'} open={routeModal.open} onCancel={() => setRouteModal({ open: false })} onOk={() => form.submit()} confirmLoading={createRoute.isPending}>
<Form form={form} layout="vertical" onFinish={(v) => createRoute.mutate(v)}>
<Form.Item name="tier" label="级别" rules={[{ required: true }]}>
<Select options={[{ value: 'cheap', label: 'Cheap' }, { value: 'primary', label: 'Primary' }, { value: 'strong', label: 'Strong' }]} />
</Form.Item>
<Form.Item name="taskType" label="任务类型" initialValue="*">
<Input placeholder="* 表示所有" />
</Form.Item>
<Form.Item name="preferredProvider" label="首选 Provider" rules={[{ required: true }]}>
<Select options={[{ value: 'deepseek', label: 'DeepSeek' }, { value: 'minimax', label: 'MiniMax' }, { value: 'siliconflow', label: 'SiliconFlow' }]} />
</Form.Item>
<Form.Item name="preferredModel" label="首选模型" rules={[{ required: true }]}>
<Input placeholder="deepseek-v4-pro" />
</Form.Item>
<Form.Item name="fallbackProvider" label="备用 Provider" rules={[{ required: true }]}>
<Select options={[{ value: 'deepseek', label: 'DeepSeek' }, { value: 'minimax', label: 'MiniMax' }, { value: 'siliconflow', label: 'SiliconFlow' }]} />
</Form.Item>
<Form.Item name="fallbackModel" label="备用模型" rules={[{ required: true }]}>
<Input placeholder="deepseek-v4-flash" />
</Form.Item>
<Form.Item name="maxRetries" label="最大重试次数" initialValue={2}>
<InputNumber min={0} max={10} />
</Form.Item>
</Form>
</Modal>
</>
),
},
{
key: 'providers', label: 'Provider 管理',
children: <Table dataSource={providers || []} columns={providerColumns} rowKey="name" pagination={false} size="small" />,
},
{
key: 'fallback', label: '降级日志',
children: <Table dataSource={fallbackEvents || []} columns={fallbackColumns} rowKey="id" pagination={{ pageSize: 20 }} size="small" />,
},
]
return ( return (
<div> <div>
@ -28,30 +169,7 @@ export default function AiGatewayPage() {
<Title level={5} style={{ margin: 0 }}><CloudOutlined /> AI Gateway</Title> <Title level={5} style={{ margin: 0 }}><CloudOutlined /> AI Gateway</Title>
<Button icon={<ReloadOutlined />} onClick={() => qc.invalidateQueries({ queryKey: ['ai-gateway'] })}></Button> <Button icon={<ReloadOutlined />} onClick={() => qc.invalidateQueries({ queryKey: ['ai-gateway'] })}></Button>
</div> </div>
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col span={6}><Card size="small"><Statistic title="Provider" value={data?.providers?.length || 0} suffix="个" /></Card></Col>
<Col span={6}><Card size="small"><Statistic title="Prompt 模板" value={data?.prompts?.length || 0} suffix="个" /></Card></Col>
<Col span={6}><Card size="small"><Statistic title="路由级别" value={3} suffix="层" /></Card></Col>
<Col span={6}><Card size="small"><Statistic title="重试次数" value={data?.retry?.primary || 3} suffix="次" /></Card></Col>
</Row>
<Row gutter={[16, 16]}>
<Col span={12}>
<Card size="small" title="Provider 列表">
<Table dataSource={(data?.providers || []).map((p: string) => ({ name: p }))} columns={[{ title: '名称', dataIndex: 'name', render: (v: string) => <Tag color={v==='deepseek'?'blue':v==='minimax'?'purple':'green'}>{v}</Tag> }]} rowKey="name" pagination={false} size="small" />
</Card>
</Col>
<Col span={12}>
<Card size="small" title="模型路由">
<Table dataSource={tiers} columns={tierCols} rowKey="name" pagination={false} size="small" />
</Card>
</Col>
</Row>
<Card size="small" title="Prompt 模板" style={{ marginTop: 16 }}>
<Table dataSource={(data?.prompts || []).map((p: string) => ({ name: p }))} columns={[{ title: '名称', dataIndex: 'name', render: (v: string) => <Text code>{v}</Text> }]} rowKey="name" pagination={false} size="small" />
</Card>
</div> </div>
) )
} }

View File

@ -1,8 +1,8 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Table, Button, Typography, App, Badge } from 'antd' import { Table, Button, Typography, App, Badge, Card, Row, Col, Statistic, Space, Tabs, Popconfirm } from 'antd'
import { ReloadOutlined, RetweetOutlined, CloudServerOutlined } from '@ant-design/icons' import { ReloadOutlined, RetweetOutlined, CloudServerOutlined, NodeIndexOutlined } from '@ant-design/icons'
import { getQueueOverview, getFailedJobs, retryJob } from '@/services/events-api' import { api } from '@/services/http-client'
const { Title, Text } = Typography const { Title, Text } = Typography
@ -10,20 +10,45 @@ function EventsPage() {
const { message } = App.useApp() const { message } = App.useApp()
const qc = useQueryClient() const qc = useQueryClient()
const [selectedQueue, setSelectedQueue] = useState<string | null>(null) const [selectedQueue, setSelectedQueue] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState('queues')
const { data: overview } = useQuery({
queryKey: ['events', 'overview'],
queryFn: (): Promise<any> => api.get('/admin-api/events'),
staleTime: 10_000,
})
const { data: stats } = useQuery({
queryKey: ['events', 'stats'],
queryFn: (): Promise<any> => api.get('/admin-api/events/stats'),
enabled: activeTab === 'stats',
})
const { data: workers } = useQuery({
queryKey: ['events', 'workers'],
queryFn: (): Promise<any> => api.get('/admin-api/events/workers'),
enabled: activeTab === 'stats',
refetchInterval: 15_000,
})
const { data: overview } = useQuery({ queryKey: ['events', 'overview'], queryFn: getQueueOverview, staleTime: 10_000 })
const { data: failed } = useQuery({ const { data: failed } = useQuery({
queryKey: ['events', 'failed', selectedQueue], queryKey: ['events', 'failed', selectedQueue],
queryFn: () => selectedQueue ? getFailedJobs(selectedQueue) : null, queryFn: () => selectedQueue ? api.get(`/admin-api/events/${selectedQueue}/failed`) : null,
enabled: !!selectedQueue, enabled: !!selectedQueue,
}) })
const handleRetry = async (queue: string, jobId: string) => { const handleRetry = async (queue: string, jobId: string) => {
await retryJob(queue, jobId) await api.post(`/admin-api/events/${queue}/jobs/${jobId}/retry`)
message.success('已重试') message.success('已重试')
qc.invalidateQueries({ queryKey: ['events'] }) qc.invalidateQueries({ queryKey: ['events'] })
} }
const handleBatchRetry = async (queue: string) => {
const res = await api.post(`/admin-api/events/${queue}/jobs/batch-retry`, { count: 50 })
message.success(`批量重试完成: ${res.data?.retried || 0}/${res.data?.total || 0}`)
qc.invalidateQueries({ queryKey: ['events'] })
}
const overviewColumns = [ const overviewColumns = [
{ title: '队列', dataIndex: 'name', width: 160 }, { title: '队列', dataIndex: 'name', width: 160 },
{ title: '总计', dataIndex: 'total', width: 60, align: 'center' as const }, { title: '总计', dataIndex: 'total', width: 60, align: 'center' as const },
@ -31,7 +56,7 @@ function EventsPage() {
{ title: '进行中', dataIndex: 'active', width: 70, align: 'center' as const, render: (v: number) => <Badge count={v} showZero color="processing" /> }, { title: '进行中', dataIndex: 'active', width: 70, align: 'center' as const, render: (v: number) => <Badge count={v} showZero color="processing" /> },
{ title: '完成', dataIndex: 'completed', width: 60, align: 'center' as const, render: (v: number) => <Text type="success">{v}</Text> }, { title: '完成', dataIndex: 'completed', width: 60, align: 'center' as const, render: (v: number) => <Text type="success">{v}</Text> },
{ title: '失败', dataIndex: 'failed', width: 60, align: 'center' as const, render: (v: number, r: any) => ( { title: '失败', dataIndex: 'failed', width: 60, align: 'center' as const, render: (v: number, r: any) => (
v > 0 ? <Button type="link" size="small" danger onClick={() => setSelectedQueue(r.name)}>{v}</Button> : <Text type="secondary">0</Text> v > 0 ? <Button type="link" size="small" danger onClick={() => { setSelectedQueue(r.name); setActiveTab('queues') }}>{v}</Button> : <Text type="secondary">0</Text>
)}, )},
{ title: '延迟', dataIndex: 'delayed', width: 60, align: 'center' as const }, { title: '延迟', dataIndex: 'delayed', width: 60, align: 'center' as const },
] ]
@ -46,24 +71,76 @@ function EventsPage() {
)}, )},
] ]
const statColumns = [
{ title: '任务类型', dataIndex: 'label', width: 140 },
{ title: '类型标识', dataIndex: 'type', width: 160, render: (v: string) => <Text code>{v}</Text> },
{ title: '最大重试', dataIndex: 'maxRetries', width: 80, align: 'center' as const },
{ title: '超时(ms)', dataIndex: 'timeoutMs', width: 100, align: 'center' as const },
{ title: '7天失败数', dataIndex: 'failureCount7d', width: 100, align: 'center' as const, render: (v: number) => v > 0 ? <Badge count={v} color="red" /> : <Text type="secondary">0</Text> },
]
const workerColumns = [
{ title: 'Worker 名称', dataIndex: 'name', width: 180 },
{ title: '状态', dataIndex: 'status', width: 80, render: (v: string) => <Badge status={v === 'online' ? 'success' : v === 'offline' ? 'error' : 'warning'} text={v} /> },
{ title: '最后心跳', dataIndex: 'lastSeen', width: 200 },
]
const totalFailed = overview?.queues?.reduce((s: number, q: any) => s + q.failed, 0) || 0
const totalWaiting = overview?.queues?.reduce((s: number, q: any) => s + q.waiting, 0) || 0
const totalCompleted = overview?.queues?.reduce((s: number, q: any) => s + q.completed, 0) || 0
const tabItems = [
{
key: 'queues', label: '队列概览',
children: (
<>
<Table dataSource={overview?.queues || []} columns={overviewColumns} rowKey="name" pagination={false} style={{ marginBottom: 24 }} />
{selectedQueue && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
<Title level={5} style={{ margin: 0, fontSize: 14 }}> · {selectedQueue}</Title>
<Space>
<Popconfirm title="确认重试所有失败任务?" onConfirm={() => handleBatchRetry(selectedQueue)}>
<Button size="small" type="primary" ghost icon={<RetweetOutlined />}></Button>
</Popconfirm>
<Button size="small" onClick={() => setSelectedQueue(null)}></Button>
</Space>
</div>
<Table dataSource={failed?.jobs || []} columns={failedColumns} rowKey="id" pagination={false} size="small" />
</>
)}
</>
),
},
{
key: 'stats', label: '任务统计',
children: (
<>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col span={6}><Card size="small"><Statistic title="失败任务" value={totalFailed} valueStyle={{ color: totalFailed > 0 ? '#ff4d4f' : undefined }} /></Card></Col>
<Col span={6}><Card size="small"><Statistic title="等待中" value={totalWaiting} /></Card></Col>
<Col span={6}><Card size="small"><Statistic title="已完成" value={totalCompleted} /></Card></Col>
<Col span={6}><Card size="small"><Statistic title="Worker 节点" value={workers?.count || 0} suffix="个" /></Card></Col>
</Row>
<Card size="small" title="任务类型配置" style={{ marginBottom: 16 }}>
<Table dataSource={stats?.taskStats || []} columns={statColumns} rowKey="type" pagination={false} size="small" />
</Card>
<Card size="small" title={<><NodeIndexOutlined /> Worker </>}>
<Table dataSource={workers?.workers || []} columns={workerColumns} rowKey="name" pagination={false} size="small" />
</Card>
</>
),
},
]
return ( return (
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Title level={5} style={{ margin: 0 }}><CloudServerOutlined /> </Title> <Title level={5} style={{ margin: 0 }}><CloudServerOutlined /> </Title>
<Button icon={<ReloadOutlined />} onClick={() => qc.invalidateQueries({ queryKey: ['events'] })}></Button> <Button icon={<ReloadOutlined />} onClick={() => qc.invalidateQueries({ queryKey: ['events'] })}></Button>
</div> </div>
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
<Table dataSource={overview?.queues || []} columns={overviewColumns} rowKey="name" pagination={false} style={{ marginBottom: 24 }} />
{selectedQueue && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
<Title level={5} style={{ margin: 0, fontSize: 14 }}> · {selectedQueue}</Title>
<Button size="small" onClick={() => setSelectedQueue(null)}></Button>
</div>
<Table dataSource={failed?.jobs || []} columns={failedColumns} rowKey="id" pagination={false} size="small" />
</>
)}
</div> </div>
) )
} }

82
src/pages/VectorAdmin.tsx Normal file
View File

@ -0,0 +1,82 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Card, Row, Col, Statistic, Button, Typography, Descriptions, App } from 'antd'
import { ReloadOutlined, DatabaseOutlined, NodeIndexOutlined } from '@ant-design/icons'
import { api } from '@/services/http-client'
const { Title } = Typography
export default function VectorAdminPage() {
const qc = useQueryClient()
const { message } = App.useApp()
const { data: coll, isLoading: collLoading } = useQuery({
queryKey: ['vector', 'collection'],
queryFn: (): Promise<any> => api.get('/admin-api/vector/collection'),
staleTime: 30_000,
})
const { data: count } = useQuery({
queryKey: ['vector', 'count'],
queryFn: (): Promise<any> => api.get('/admin-api/vector/count'),
staleTime: 30_000,
})
const handleReindex = async () => {
try {
await api.post('/admin-api/vector/reindex')
message.success('索引重建已提交')
qc.invalidateQueries({ queryKey: ['vector'] })
} catch {
message.error('操作失败')
}
}
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Title level={5} style={{ margin: 0 }}><DatabaseOutlined /> </Title>
<Button icon={<ReloadOutlined />} onClick={() => qc.invalidateQueries({ queryKey: ['vector'] })}></Button>
</div>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card size="small"><Statistic title="向量总数" value={count?.count ?? 0} loading={collLoading} suffix="条" /></Card>
</Col>
<Col span={6}>
<Card size="small"><Statistic title="向量维度" value={coll?.vectorSize || 1024} /></Card>
</Col>
<Col span={6}>
<Card size="small"><Statistic title="距离度量" value={coll?.distance || 'Cosine'} /></Card>
</Col>
<Col span={6}>
<Card size="small"><Statistic title="状态" value={coll?.status || '-'} valueStyle={{ color: coll?.status === 'green' ? '#52c41a' : '#faad14' }} /></Card>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col span={16}>
<Card size="small" title="Collection 信息">
<Descriptions column={2} size="small" bordered>
<Descriptions.Item label="名称">{coll?.name || 'zhixi_chunks'}</Descriptions.Item>
<Descriptions.Item label="向量维度">{coll?.vectorSize || 1024}</Descriptions.Item>
<Descriptions.Item label="距离度量">{coll?.distance || 'Cosine'}</Descriptions.Item>
<Descriptions.Item label="向量总数">{count?.count ?? 0}</Descriptions.Item>
<Descriptions.Item label="索引算法">HNSW (m=16, ef_construct=100)</Descriptions.Item>
<Descriptions.Item label="Payload 索引">userId (keyword), knowledgeBaseId (keyword), deleted (bool)</Descriptions.Item>
</Descriptions>
</Card>
</Col>
<Col span={8}>
<Card size="small" title="索引操作">
<Button icon={<NodeIndexOutlined />} block onClick={handleReindex} style={{ marginBottom: 8 }}>
</Button>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
线
</Typography.Text>
</Card>
</Col>
</Row>
</div>
)
}