Some checks failed
CI / test (push) Failing after 1m10s
Seven pages repeated the same load-on-mount + loading + try/finally +
reload-button skeleton, each re-implementing tab-active refresh, silent
polling, and (in two places) a hand-rolled requestId race guard. Extract two
composable hooks: useResource(fetcher, {tabPath, deps}) owning data/loading/
reload with a built-in race guard and auto tab-active refresh, and
usePolling(fn, ms, enabled) replacing the hand-written setInterval effects.
Migrate all seven pages onto them; Targets/Scenarios/ModelConfigs also gain a
uniform tab-active refresh they previously lacked. Verified via tsc --noEmit
and npm run build (no frontend test runner exists).
225 lines
7.8 KiB
TypeScript
225 lines
7.8 KiB
TypeScript
import { useState } from 'react'
|
||
import {
|
||
Button, Drawer, Form, Input, message, Popconfirm,
|
||
Space, Table, Tag, Select, Tooltip,
|
||
} from 'antd'
|
||
import { PlusOutlined, ApiOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'
|
||
import { targetsApi, type Target } from '../api'
|
||
import PageWrapper from '../components/PageWrapper'
|
||
import { useResource } from '../hooks/useResource'
|
||
import { colors } from '../tokens'
|
||
import { formatDateTime } from '../utils/date'
|
||
|
||
export default function TargetsPage() {
|
||
const { data: targets, loading, reload } = useResource(
|
||
() => targetsApi.list().then((r) => r.data),
|
||
{ tabPath: '/targets' },
|
||
)
|
||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||
const [editingTarget, setEditingTarget] = useState<Target | null>(null)
|
||
const [form] = Form.useForm()
|
||
|
||
const openCreate = () => {
|
||
setEditingTarget(null)
|
||
form.resetFields()
|
||
form.setFieldsValue({
|
||
platform: 'ai_digital_employee',
|
||
channel_type: 'tutu-api',
|
||
})
|
||
setDrawerOpen(true)
|
||
}
|
||
|
||
const openEdit = (target: Target) => {
|
||
setEditingTarget(target)
|
||
form.setFieldsValue({
|
||
name: target.name,
|
||
description: target.description,
|
||
platform: target.platform,
|
||
channel_type: target.channel_type,
|
||
base_url: (target.channel_config as any)?.base_url || '',
|
||
token: (target.channel_config as any)?.token || '',
|
||
tenant: (target.channel_config as any)?.tenant || '',
|
||
chat_channel_id: (target.channel_config as any)?.chat_channel_id || '',
|
||
chat_contact_id: (target.channel_config as any)?.chat_contact_id || '',
|
||
})
|
||
setDrawerOpen(true)
|
||
}
|
||
|
||
const handleSubmit = async () => {
|
||
const values = await form.validateFields()
|
||
const channel_config = {
|
||
base_url: values.base_url,
|
||
token: values.token,
|
||
tenant: values.tenant,
|
||
chat_channel_id: values.chat_channel_id,
|
||
chat_contact_id: values.chat_contact_id,
|
||
}
|
||
const payload = {
|
||
name: values.name,
|
||
description: values.description || '',
|
||
platform: values.platform,
|
||
channel_type: values.channel_type,
|
||
channel_config,
|
||
status: 'active',
|
||
}
|
||
|
||
if (editingTarget) {
|
||
await targetsApi.update(editingTarget.id, payload)
|
||
message.success('更新成功')
|
||
} else {
|
||
await targetsApi.create(payload)
|
||
message.success('创建成功')
|
||
}
|
||
setDrawerOpen(false)
|
||
reload()
|
||
}
|
||
|
||
const handleTest = async (id: string) => {
|
||
try {
|
||
const res = await targetsApi.test(id)
|
||
if (res.data.ok) {
|
||
message.success('通道连通正常')
|
||
} else {
|
||
message.warning(`通道异常: ${res.data.message}`)
|
||
}
|
||
} catch {
|
||
message.error('连通性测试失败')
|
||
}
|
||
}
|
||
|
||
const handleDelete = async (id: string) => {
|
||
await targetsApi.delete(id)
|
||
message.success('已删除')
|
||
reload()
|
||
}
|
||
|
||
const statusColor: Record<string, string> = {
|
||
active: 'success',
|
||
inactive: 'default',
|
||
error: 'error',
|
||
}
|
||
|
||
const columns = [
|
||
{ title: '名称', dataIndex: 'name', key: 'name', width: 180,
|
||
render: (v: string) => <span style={{ fontWeight: 500 }}>{v}</span>,
|
||
},
|
||
{ title: '平台', dataIndex: 'platform', key: 'platform', width: 160,
|
||
render: (p: string) => p === 'ai_digital_employee' ? 'AI 数字员工' : 'AI 助手',
|
||
},
|
||
{ title: '通道类型', dataIndex: 'channel_type', key: 'channel_type', width: 120 },
|
||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100,
|
||
render: (s: string) => <Tag color={statusColor[s] || 'default'}>{s}</Tag>,
|
||
},
|
||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180,
|
||
render: (t: string) => formatDateTime(t),
|
||
},
|
||
{ title: '操作', key: 'action', width: 160, fixed: 'right' as const,
|
||
render: (_: any, record: Target) => (
|
||
<Space size={4}>
|
||
<Tooltip title="连通测试">
|
||
<Button size="small" type="text" icon={<ApiOutlined />} onClick={() => handleTest(record.id)} />
|
||
</Tooltip>
|
||
<Tooltip title="编辑">
|
||
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => openEdit(record)} />
|
||
</Tooltip>
|
||
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||
<Tooltip title="删除">
|
||
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
|
||
</Tooltip>
|
||
</Popconfirm>
|
||
</Space>
|
||
),
|
||
},
|
||
]
|
||
|
||
return (
|
||
<PageWrapper
|
||
title="评测对象"
|
||
description="管理被评测的 AI 智能体及其通信通道"
|
||
inline
|
||
fullHeight
|
||
extra={
|
||
<Space>
|
||
<Tooltip title="刷新">
|
||
<Button size="middle" icon={<ReloadOutlined />} onClick={() => reload()} />
|
||
</Tooltip>
|
||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||
新增
|
||
</Button>
|
||
</Space>
|
||
}
|
||
>
|
||
{/* 表格区 — 撑满剩余高度 */}
|
||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||
<Table
|
||
dataSource={targets ?? []}
|
||
columns={columns}
|
||
rowKey="id"
|
||
loading={loading}
|
||
pagination={{ pageSize: 15, showSizeChanger: true, showTotal: (t) => `共 ${t} 个` }}
|
||
scroll={{ y: 'calc(100vh - 200px)' }}
|
||
style={{ height: '100%' }}
|
||
/>
|
||
</div>
|
||
|
||
<Drawer
|
||
title={editingTarget ? '编辑评测对象' : '新增评测对象'}
|
||
open={drawerOpen}
|
||
onClose={() => setDrawerOpen(false)}
|
||
width={480}
|
||
extra={
|
||
<Button type="primary" onClick={handleSubmit}>
|
||
{editingTarget ? '更新' : '创建'}
|
||
</Button>
|
||
}
|
||
>
|
||
<Form form={form} layout="vertical">
|
||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||
<Input placeholder="例如:社区医院AI客服" />
|
||
</Form.Item>
|
||
<Form.Item name="description" label="描述">
|
||
<Input.TextArea rows={2} placeholder="对象描述" />
|
||
</Form.Item>
|
||
<Form.Item name="platform" label="平台类型" rules={[{ required: true }]}>
|
||
<Select options={[
|
||
{ value: 'ai_digital_employee', label: 'AI 数字员工' },
|
||
{ value: 'ai_assistant', label: 'AI 助手' },
|
||
]} />
|
||
</Form.Item>
|
||
<Form.Item name="channel_type" label="通道类型" rules={[{ required: true }]}>
|
||
<Select options={[
|
||
{ value: 'tutu-api', label: 'Tutu API' },
|
||
{ value: 'http', label: 'HTTP 通用' },
|
||
{ value: 'openclaw', label: 'OpenClaw' },
|
||
]} />
|
||
</Form.Item>
|
||
|
||
<div style={{
|
||
border: `1px solid ${colors.border}`, borderRadius: 8,
|
||
padding: '12px 16px', marginBottom: 16,
|
||
}}>
|
||
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 12, color: colors.text }}>
|
||
通道配置
|
||
</div>
|
||
<Form.Item name="base_url" label="Base URL" rules={[{ required: true, message: '请输入 API 地址' }]}>
|
||
<Input placeholder="https://api.example.com" />
|
||
</Form.Item>
|
||
<Form.Item name="token" label="Token" rules={[{ required: true, message: '请输入 Token' }]}>
|
||
<Input.Password placeholder="Bearer Token" />
|
||
</Form.Item>
|
||
<Form.Item name="tenant" label="Tenant">
|
||
<Input placeholder="租户 ID" />
|
||
</Form.Item>
|
||
<Form.Item name="chat_channel_id" label="Chat Channel ID" rules={[{ required: true }]}>
|
||
<Input placeholder="聊天频道 ID" />
|
||
</Form.Item>
|
||
<Form.Item name="chat_contact_id" label="Chat Contact ID">
|
||
<Input placeholder="联系人 ID" />
|
||
</Form.Item>
|
||
</div>
|
||
</Form>
|
||
</Drawer>
|
||
</PageWrapper>
|
||
)
|
||
}
|