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).
379 lines
13 KiB
TypeScript
379 lines
13 KiB
TypeScript
import { useEffect, useState } from 'react'
|
||
import {
|
||
Button, Card, Drawer, Form, Input, message, Modal, Popconfirm,
|
||
Select, Space, Table, Tag, Tooltip,
|
||
} from 'antd'
|
||
import {
|
||
PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined,
|
||
ReloadOutlined, AppstoreAddOutlined,
|
||
} from '@ant-design/icons'
|
||
import Editor from '@monaco-editor/react'
|
||
import { modelConfigsApi, scenariosApi, type Scenario } from '../api'
|
||
import PageWrapper from '../components/PageWrapper'
|
||
import { useResource } from '../hooks/useResource'
|
||
import { colors } from '../tokens'
|
||
import { formatDateTime } from '../utils/date'
|
||
import { MODEL_CONFIGS_CHANGED_EVENT } from '../utils/modelConfigEvents'
|
||
|
||
const YAML_TEMPLATE = `# 场景用例示例(JSON 格式)
|
||
[
|
||
{
|
||
"id": "case-1",
|
||
"type": "single",
|
||
"messages": ["你好,请问挂号怎么操作?"],
|
||
"eval_rules": [
|
||
{ "type": "response_time", "params": { "max_ms": 30000 } }
|
||
]
|
||
},
|
||
{
|
||
"id": "case-dynamic-1",
|
||
"type": "dynamic",
|
||
"prompt": "你是一个来社区医院咨询的患者,请围绕挂号流程提几个问题",
|
||
"turns": 3,
|
||
"eval_rules": [
|
||
{ "type": "response_time", "params": { "max_ms": 30000 } }
|
||
]
|
||
}
|
||
]`
|
||
|
||
export default function ScenariosPage() {
|
||
const { data: scenarios, loading, reload } = useResource(
|
||
() => scenariosApi.list().then((r) => r.data),
|
||
{ tabPath: '/scenarios' },
|
||
)
|
||
const {
|
||
data: modelConfigs,
|
||
loading: modelConfigsLoading,
|
||
reload: reloadModelConfigs,
|
||
} = useResource(() => modelConfigsApi.list({ enabled: true }).then((r) => r.data))
|
||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||
const [previewOpen, setPreviewOpen] = useState(false)
|
||
const [previewData, setPreviewData] = useState<any>(null)
|
||
const [editingScenario, setEditingScenario] = useState<Scenario | null>(null)
|
||
const [casesJson, setCasesJson] = useState(YAML_TEMPLATE)
|
||
const [modelBindings, setModelBindings] = useState<Record<string, string>>({})
|
||
const [form] = Form.useForm()
|
||
|
||
// Template picker
|
||
const [tplModalOpen, setTplModalOpen] = useState(false)
|
||
const [templates, setTemplates] = useState<any[]>([])
|
||
const [tplLoading, setTplLoading] = useState(false)
|
||
|
||
useEffect(() => {
|
||
const onChanged = () => void reloadModelConfigs()
|
||
window.addEventListener(MODEL_CONFIGS_CHANGED_EVENT, onChanged)
|
||
return () => window.removeEventListener(MODEL_CONFIGS_CHANGED_EVENT, onChanged)
|
||
}, [reloadModelConfigs])
|
||
|
||
const openCreate = () => {
|
||
reloadModelConfigs()
|
||
setEditingScenario(null)
|
||
form.resetFields()
|
||
setCasesJson(YAML_TEMPLATE)
|
||
setModelBindings({})
|
||
setDrawerOpen(true)
|
||
}
|
||
|
||
const openTemplateModal = async () => {
|
||
setTplModalOpen(true)
|
||
if (templates.length === 0) {
|
||
setTplLoading(true)
|
||
try {
|
||
const res = await scenariosApi.listTemplates()
|
||
setTemplates(res.data)
|
||
} catch { /* handled by interceptor */ }
|
||
finally { setTplLoading(false) }
|
||
}
|
||
}
|
||
|
||
const applyTemplate = (tpl: any) => {
|
||
reloadModelConfigs()
|
||
setTplModalOpen(false)
|
||
setEditingScenario(null)
|
||
form.resetFields()
|
||
form.setFieldsValue({ name: `${tpl.name}(副本)`, description: tpl.description, tags: tpl.tags.join(', ') })
|
||
setCasesJson(JSON.stringify(tpl.cases, null, 2))
|
||
setModelBindings(tpl.model_bindings || {})
|
||
setDrawerOpen(true)
|
||
}
|
||
|
||
const openEdit = (scenario: Scenario) => {
|
||
reloadModelConfigs()
|
||
setEditingScenario(scenario)
|
||
form.setFieldsValue({
|
||
name: scenario.name,
|
||
description: scenario.description,
|
||
tags: scenario.tags.join(', '),
|
||
})
|
||
setCasesJson(JSON.stringify(scenario.cases, null, 2))
|
||
setModelBindings(scenario.model_bindings || {})
|
||
setDrawerOpen(true)
|
||
}
|
||
|
||
const openPreview = (scenario: Scenario) => {
|
||
setPreviewData(scenario)
|
||
setPreviewOpen(true)
|
||
}
|
||
|
||
const handleSubmit = async () => {
|
||
const values = await form.validateFields()
|
||
let cases: any[]
|
||
try {
|
||
cases = JSON.parse(casesJson)
|
||
} catch {
|
||
message.error('用例 JSON 格式错误')
|
||
return
|
||
}
|
||
|
||
const tags = values.tags
|
||
? values.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
|
||
: []
|
||
|
||
const payload = {
|
||
name: values.name,
|
||
description: values.description || '',
|
||
tags,
|
||
cases,
|
||
model_bindings: modelBindings,
|
||
}
|
||
|
||
if (editingScenario) {
|
||
await scenariosApi.update(editingScenario.id, payload)
|
||
message.success('更新成功')
|
||
} else {
|
||
await scenariosApi.create(payload)
|
||
message.success('创建成功')
|
||
}
|
||
setDrawerOpen(false)
|
||
reload()
|
||
}
|
||
|
||
const handleDelete = async (id: string) => {
|
||
await scenariosApi.delete(id)
|
||
message.success('已删除')
|
||
reload()
|
||
}
|
||
|
||
const columns = [
|
||
{ title: '名称', dataIndex: 'name', key: 'name', width: 200,
|
||
render: (v: string) => <span style={{ fontWeight: 500 }}>{v}</span>,
|
||
},
|
||
{ title: '版本', dataIndex: 'version', key: 'version', width: 70,
|
||
render: (v: number) => <Tag color="geekblue">v{v ?? 1}</Tag>,
|
||
},
|
||
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
|
||
{ title: '标签', dataIndex: 'tags', key: 'tags', width: 200,
|
||
render: (tags: string[]) => tags.map((t) => <Tag key={t}>{t}</Tag>),
|
||
},
|
||
{ title: '用例数', key: 'cases', width: 80,
|
||
render: (_: any, r: Scenario) => r.cases.length,
|
||
},
|
||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180,
|
||
render: (t: string) => formatDateTime(t),
|
||
},
|
||
{ title: '操作', key: 'action', width: 140, fixed: 'right' as const,
|
||
render: (_: any, record: Scenario) => (
|
||
<Space size={4}>
|
||
<Tooltip title="预览">
|
||
<Button size="small" type="text" icon={<EyeOutlined />} onClick={() => openPreview(record)} />
|
||
</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="管理评测用例集合与评测规则"
|
||
inline
|
||
fullHeight
|
||
extra={
|
||
<Space>
|
||
<Tooltip title="刷新">
|
||
<Button
|
||
size="middle"
|
||
icon={<ReloadOutlined />}
|
||
onClick={() => {
|
||
reload()
|
||
reloadModelConfigs()
|
||
}}
|
||
/>
|
||
</Tooltip>
|
||
<Button icon={<AppstoreAddOutlined />} onClick={openTemplateModal}>
|
||
从模板新建
|
||
</Button>
|
||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||
新增
|
||
</Button>
|
||
</Space>
|
||
}
|
||
>
|
||
{/* 表格区 — 撑满剩余高度 */}
|
||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||
<Table
|
||
dataSource={scenarios ?? []}
|
||
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={editingScenario ? `编辑评测场景(当前 v${editingScenario.version ?? 1})` : '新增评测场景'}
|
||
open={drawerOpen}
|
||
onClose={() => setDrawerOpen(false)}
|
||
width={720}
|
||
extra={
|
||
<Button type="primary" onClick={handleSubmit}>
|
||
{editingScenario ? '更新' : '创建'}
|
||
</Button>
|
||
}
|
||
>
|
||
<Form form={form} layout="vertical">
|
||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入场景名称' }]}>
|
||
<Input placeholder="例如:健康咨询基础场景" />
|
||
</Form.Item>
|
||
<Form.Item name="description" label="描述">
|
||
<Input.TextArea rows={2} placeholder="场景描述" />
|
||
</Form.Item>
|
||
<Form.Item name="tags" label="标签(逗号分隔)">
|
||
<Input placeholder="例如:health, basic, single-turn" />
|
||
</Form.Item>
|
||
</Form>
|
||
|
||
<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>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||
{[
|
||
{ purpose: 'generator', label: '动态用例生成', capability: 'chat' },
|
||
{ purpose: 'judge', label: 'LLM 评分', capability: 'chat' },
|
||
{ purpose: 'embedding', label: '语义相似度', capability: 'embedding' },
|
||
{ purpose: 'moderation', label: '内容审核', capability: 'moderation' },
|
||
].map((item) => (
|
||
<div key={item.purpose}>
|
||
<label style={{ fontSize: 12, color: '#666', display: 'block', marginBottom: 4 }}>{item.label}</label>
|
||
<Select
|
||
allowClear
|
||
showSearch
|
||
loading={modelConfigsLoading}
|
||
optionFilterProp="label"
|
||
style={{ width: '100%' }}
|
||
placeholder="未绑定"
|
||
value={modelBindings[item.purpose]}
|
||
onChange={(value) => {
|
||
const next = { ...modelBindings }
|
||
if (value) next[item.purpose] = value
|
||
else delete next[item.purpose]
|
||
setModelBindings(next)
|
||
}}
|
||
options={(modelConfigs ?? [])
|
||
.filter((config) => config.capability === item.capability)
|
||
.map((config) => ({ value: config.id, label: `${config.name} · ${config.model_name || config.capability}` }))}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ marginBottom: 8, fontWeight: 500, fontSize: 13, color: '#666' }}>用例定义(JSON)</div>
|
||
<div style={{ border: '1px solid #d9d9d9', borderRadius: 8, overflow: 'hidden' }}>
|
||
<Editor
|
||
height="400px"
|
||
defaultLanguage="json"
|
||
theme="vs-dark"
|
||
value={casesJson}
|
||
onChange={(v) => setCasesJson(v || '')}
|
||
options={{
|
||
minimap: { enabled: false },
|
||
fontSize: 13,
|
||
lineNumbers: 'on',
|
||
scrollBeyondLastLine: false,
|
||
automaticLayout: true,
|
||
}}
|
||
/>
|
||
</div>
|
||
</Drawer>
|
||
|
||
<Modal
|
||
title={`场景预览: ${previewData?.name || ''}${previewData?.version != null ? `(v${previewData.version})` : ''}`}
|
||
open={previewOpen}
|
||
onCancel={() => setPreviewOpen(false)}
|
||
footer={null}
|
||
width={700}
|
||
>
|
||
{previewData && (
|
||
<div>
|
||
<p style={{ marginBottom: 8 }}><strong>描述:</strong>{previewData.description || '无'}</p>
|
||
<p style={{ marginBottom: 8 }}><strong>标签:</strong>{previewData.tags?.join(', ') || '无'}</p>
|
||
<p style={{ marginBottom: 12 }}><strong>用例数:</strong>{previewData.cases?.length || 0}</p>
|
||
<div style={{ background: '#1e1e2e', borderRadius: 8, padding: 16, maxHeight: 400, overflow: 'auto' }}>
|
||
<pre style={{ color: '#d4d4d4', fontSize: 12, margin: 0 }}>
|
||
{JSON.stringify(previewData.cases, null, 2)}
|
||
</pre>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
|
||
{/* Template picker */}
|
||
<Modal
|
||
title="从模板新建场景"
|
||
open={tplModalOpen}
|
||
onCancel={() => setTplModalOpen(false)}
|
||
footer={null}
|
||
width={680}
|
||
>
|
||
{tplLoading ? (
|
||
<div style={{ textAlign: 'center', padding: 40, color: colors.textMuted }}>加载模板…</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
{templates.map((tpl) => (
|
||
<Card
|
||
key={tpl.id}
|
||
size="small"
|
||
hoverable
|
||
onClick={() => applyTemplate(tpl)}
|
||
style={{ cursor: 'pointer', borderColor: colors.border }}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||
<div>
|
||
<div style={{ fontWeight: 600, fontSize: 14, color: colors.text, marginBottom: 4 }}>
|
||
{tpl.name}
|
||
</div>
|
||
<div style={{ fontSize: 12, color: colors.textSecondary, marginBottom: 6 }}>
|
||
{tpl.description}
|
||
</div>
|
||
<Space size={4} wrap>
|
||
{tpl.tags.map((t: string) => <Tag key={t} style={{ margin: 0 }}>{t}</Tag>)}
|
||
</Space>
|
||
</div>
|
||
<div style={{ fontSize: 12, color: colors.textMuted, flexShrink: 0, marginLeft: 12 }}>
|
||
{tpl.cases?.length ?? 0} 个用例
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
</PageWrapper>
|
||
)
|
||
}
|