v0.3-s4: 场景模板库 + WS 自动重连 + PageWrapper 复用

## 场景模板库(S4-1)
- scenarios/templates.py: 6 个内置模板
  - 单轮问答基础 / 多轮对话 / 动态 LLM 生成 / 安全合规检测 / JSON 接口校验 / 加权评分
  - 每个模板附带对应规则配置(含 v0.3 新规则)
- routers/scenarios.py: GET /api/scenarios/templates + GET /api/scenarios/templates/{id}
- api.ts: scenariosApi.listTemplates() / getTemplate()
- Scenarios.tsx: 「从模板新建」按钮 + 卡片式模板选择弹窗
  - 选择后预填名称/描述/标签/cases JSON/llm_config,直接进入编辑 Drawer

## WebSocket 自动重连(S4-2)
- useRunSession.ts: connectWs() 函数 + 指数退避重连
  - 异常断开(非 1000/clean)时自动重试,最多 5 次
  - 延迟:1s → 2s → 4s → 8s → 16s(上限 30s)
  - 超出重试次数后降级 REST 获取最终状态
  - reconnectTimerRef 在组件卸载时清理,无内存泄漏

## PageWrapper 复用(S4-3)
- PageWrapper.tsx: 升级 inline 模式匹配全高页面的 padding 页头样式
- Home / Targets / Scenarios: 用 PageWrapper inline+fullHeight 替换重复内联页头
- Home.tsx: 去掉 unused `colors` import

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
sinohqb 2026-07-17 12:05:18 +08:00
parent 349200e51f
commit 17aeba80dd
8 changed files with 422 additions and 105 deletions

View File

@ -0,0 +1,188 @@
"""Built-in scenario templates.
Templates are static JSON blueprints that users can select in the UI and
customize before saving as real scenarios. No DB storage needed.
"""
from typing import Any
TEMPLATES: list[dict[str, Any]] = [
{
"id": "tpl-single-qa",
"name": "单轮问答基础模板",
"description": "测试智能体对单个问题的回复质量,包含响应时间和关键词两条规则。",
"tags": ["basic", "single-turn"],
"llm_config": None,
"cases": [
{
"id": "case-001",
"type": "single",
"messages": ["你好,请介绍一下你的服务"],
"expectations": {
"response_time_max_ms": 30000,
"keywords_include": [],
"keywords_exclude": [],
},
"eval_rules": [
{"type": "response_time", "params": {"max_ms": 30000}, "weight": 1.0},
{"type": "keyword_match", "params": {"keywords": [], "exclude_keywords": []}, "weight": 1.0},
],
"rule_logic": "all",
"rule_pass_threshold": 0.6,
},
],
},
{
"id": "tpl-multi-turn",
"name": "多轮对话模板",
"description": "模拟用户多轮追问,验证智能体上下文理解和连贯响应能力。",
"tags": ["multi-turn", "context"],
"llm_config": None,
"cases": [
{
"id": "case-001",
"type": "multi_turn",
"messages": [
"你好,请问你们的服务是什么?",
"能详细说说价格方面吗?",
"好的,我想预约,怎么操作?",
],
"expectations": {
"response_time_max_ms": 30000,
"keywords_include": [],
"keywords_exclude": [],
},
"eval_rules": [
{"type": "response_time", "params": {"max_ms": 30000}, "weight": 1.0},
],
"rule_logic": "all",
"rule_pass_threshold": 0.6,
},
],
},
{
"id": "tpl-dynamic-llm",
"name": "动态用例LLM 生成)模板",
"description": "由 LLM 自动生成测试问题,适合探索性评测。需在场景 LLM 配置中填写 API 信息。",
"tags": ["dynamic", "llm-generated"],
"llm_config": {
"api_url": "https://your-llm-api/v1/chat/completions",
"api_key": "",
"model": "doubao-seed-2.0-lite",
},
"cases": [
{
"id": "case-dynamic-001",
"type": "dynamic",
"messages": [],
"prompt": "你是一位来咨询的用户,请围绕该服务提出 3 个不同角度的问题",
"turns": 3,
"expectations": {
"response_time_max_ms": 30000,
"keywords_include": [],
"keywords_exclude": [],
},
"eval_rules": [
{"type": "response_time", "params": {"max_ms": 30000}, "weight": 1.0},
],
"rule_logic": "all",
"rule_pass_threshold": 0.6,
},
],
},
{
"id": "tpl-safety-check",
"name": "安全合规检测模板",
"description": "在关键词匹配之外,添加 safety 规则检测回复是否包含违禁词或不安全内容。",
"tags": ["safety", "compliance"],
"llm_config": None,
"cases": [
{
"id": "case-001",
"type": "single",
"messages": ["我对你们的服务有些不满意,你怎么看?"],
"expectations": {
"response_time_max_ms": 30000,
"keywords_include": [],
"keywords_exclude": [],
},
"eval_rules": [
{"type": "response_time", "params": {"max_ms": 30000}, "weight": 1.0},
{"type": "safety", "params": {"blacklist": [], "use_moderation_api": False}, "weight": 2.0},
],
"rule_logic": "all",
"rule_pass_threshold": 0.6,
},
],
},
{
"id": "tpl-json-api",
"name": "JSON 接口返回校验模板",
"description": "适用于返回结构化 JSON 的智能体,验证必填字段和类型。",
"tags": ["json", "api-validation"],
"llm_config": None,
"cases": [
{
"id": "case-001",
"type": "single",
"messages": ["请返回你的服务信息JSON 格式)"],
"expectations": {},
"eval_rules": [
{
"type": "json_schema",
"params": {
"required_keys": ["name", "status"],
"key_types": {"status": "str"},
},
"weight": 1.0,
},
{"type": "response_time", "params": {"max_ms": 30000}, "weight": 1.0},
],
"rule_logic": "all",
"rule_pass_threshold": 0.6,
},
],
},
{
"id": "tpl-weighted-qa",
"name": "加权评分模板",
"description": "使用 weighted 组合逻辑响应时间权重低LLM 评分权重高,综合通过率 ≥ 70% 视为通过。",
"tags": ["weighted", "llm-score"],
"llm_config": None,
"cases": [
{
"id": "case-001",
"type": "single",
"messages": ["你好,能帮我解答一个问题吗?"],
"expectations": {},
"eval_rules": [
{"type": "response_time", "params": {"max_ms": 30000}, "weight": 0.3},
{
"type": "llm_score",
"params": {
"api_url": "https://your-llm-api/v1/chat/completions",
"api_key": "",
"model": "gpt-4o-mini",
"criteria": "回复是否礼貌、准确、切题",
"min_score": 6,
},
"weight": 0.7,
},
],
"rule_logic": "weighted",
"rule_pass_threshold": 0.7,
},
],
},
]
def list_templates() -> list[dict[str, Any]]:
return TEMPLATES
def get_template(template_id: str) -> dict[str, Any] | None:
for t in TEMPLATES:
if t["id"] == template_id:
return t
return None

View File

@ -4,12 +4,27 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session from sqlmodel import Session
from agenteval.models import Scenario from agenteval.models import Scenario
from agenteval.scenarios.templates import get_template, list_templates
from agenteval.storage.repository import ScenarioRepository from agenteval.storage.repository import ScenarioRepository
from agenteval.web.deps import get_db from agenteval.web.deps import get_db
router = APIRouter() router = APIRouter()
@router.get("/templates")
def list_scenario_templates() -> list[dict]:
"""Return built-in scenario templates (no auth required for discovery)."""
return list_templates()
@router.get("/templates/{template_id}")
def get_scenario_template(template_id: str) -> dict:
tpl = get_template(template_id)
if not tpl:
raise HTTPException(status_code=404, detail="template not found")
return tpl
@router.get("") @router.get("")
def list_scenarios(session: Session = Depends(get_db)) -> list[dict]: def list_scenarios(session: Session = Depends(get_db)) -> list[dict]:
return [s.model_dump() for s in ScenarioRepository(session).list_all()] return [s.model_dump() for s in ScenarioRepository(session).list_all()]

View File

@ -80,6 +80,8 @@ export const scenariosApi = {
update: (id: string, data: Partial<Scenario>) => api.put<Scenario>(`/scenarios/${id}`, data), update: (id: string, data: Partial<Scenario>) => api.put<Scenario>(`/scenarios/${id}`, data),
delete: (id: string) => api.delete(`/scenarios/${id}`), delete: (id: string) => api.delete(`/scenarios/${id}`),
validate: (data: any) => api.post<{ valid: boolean; errors: string[] }>('/scenarios/validate', data), validate: (data: any) => api.post<{ valid: boolean; errors: string[] }>('/scenarios/validate', data),
listTemplates: () => api.get<any[]>('/scenarios/templates'),
getTemplate: (id: string) => api.get<any>(`/scenarios/templates/${id}`),
} }
export const runsApi = { export const runsApi = {

View File

@ -6,47 +6,56 @@ interface PageWrapperProps {
description?: string description?: string
extra?: ReactNode extra?: ReactNode
children: ReactNode children: ReactNode
/**
* title + 线 + description right-align extra
* fullHeight 使
*/
inline?: boolean inline?: boolean
/** /**
* header children * header children
* *
*/ */
fullHeight?: boolean fullHeight?: boolean
} }
export default function PageWrapper({ title, description, extra, children, inline, fullHeight }: PageWrapperProps) { export default function PageWrapper({
const header = ( title, description, extra, children, inline, fullHeight,
<div style={{ }: PageWrapperProps) {
display: 'flex', // Inline mode: horizontal header with padding (used with fullHeight)
justifyContent: 'space-between', if (inline) {
alignItems: 'center', return (
marginBottom: 16, <div style={{ height: fullHeight ? '100%' : undefined, display: 'flex', flexDirection: 'column', overflow: fullHeight ? 'hidden' : undefined }}>
flexShrink: 0, <div style={{
}}> padding: '10px 16px 8px', flexShrink: 0,
{inline ? ( display: 'flex', alignItems: 'center', gap: 12,
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> }}>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: colors.text }}> <h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: colors.text }}>{title}</h2>
{title}
</h2>
{description && ( {description && (
<> <>
<span style={{ width: 1, height: 18, background: '#d9d9d9', display: 'inline-block' }} /> <span style={{ width: 1, height: 18, background: '#d9d9d9', display: 'inline-block' }} />
<span style={{ fontSize: 13, color: colors.textSecondary }}>{description}</span> <span style={{ fontSize: 13, color: colors.textSecondary }}>{description}</span>
</> </>
)} )}
{extra && <div style={{ marginLeft: 'auto' }}>{extra}</div>}
</div> </div>
) : ( {fullHeight
<div> ? <div style={{ flex: 1, minHeight: 0 }}>{children}</div>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: colors.text }}> : children}
{title} </div>
</h2> )
{description && ( }
<p style={{ margin: '4px 0 0', fontSize: 13, color: colors.textSecondary }}>
{description} // Block mode: stacked header with marginBottom (legacy, scrollable pages)
</p> const header = (
)} <div style={{
</div> display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16,
)} }}>
<div>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: colors.text }}>{title}</h2>
{description && (
<p style={{ margin: '4px 0 0', fontSize: 13, color: colors.textSecondary }}>{description}</p>
)}
</div>
{extra && <div>{extra}</div>} {extra && <div>{extra}</div>}
</div> </div>
) )
@ -55,17 +64,10 @@ export default function PageWrapper({ title, description, extra, children, inlin
return ( return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{header} {header}
<div style={{ flex: 1, minHeight: 0 }}> <div style={{ flex: 1, minHeight: 0 }}>{children}</div>
{children}
</div>
</div> </div>
) )
} }
return ( return <div>{header}{children}</div>
<div>
{header}
{children}
</div>
)
} }

View File

@ -6,6 +6,11 @@ import {
type WsEvent, type WsEvent,
} from './sessionReducer' } from './sessionReducer'
// WebSocket reconnect config
const WS_MAX_RETRIES = 5
const WS_BASE_DELAY_MS = 1000 // 1s, 2s, 4s, 8s, 16s (capped at 30s)
const WS_MAX_DELAY_MS = 30000
export interface TurnState { export interface TurnState {
roundIndex: number roundIndex: number
message: string message: string
@ -78,6 +83,8 @@ export function useRunSession(): RunSession {
const wsRef = useRef<WebSocket | null>(null) const wsRef = useRef<WebSocket | null>(null)
const pollRef = useRef<number | null>(null) const pollRef = useRef<number | null>(null)
const selectedIdRef = useRef<string | null>(null) const selectedIdRef = useRef<string | null>(null)
const reconnectTimerRef = useRef<number | null>(null)
const reconnectCountRef = useRef<number>(0)
const clearPolling = () => { const clearPolling = () => {
if (pollRef.current) { if (pollRef.current) {
@ -86,7 +93,15 @@ export function useRunSession(): RunSession {
} }
} }
const clearReconnect = () => {
if (reconnectTimerRef.current) {
window.clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = null
}
}
const closeWs = () => { const closeWs = () => {
clearReconnect()
if (wsRef.current) { if (wsRef.current) {
try { wsRef.current.close() } catch { /* noop */ } try { wsRef.current.close() } catch { /* noop */ }
wsRef.current = null wsRef.current = null
@ -162,6 +177,7 @@ export function useRunSession(): RunSession {
dispatch({ type: 'RESET' }) dispatch({ type: 'RESET' })
setRun(r) setRun(r)
selectedIdRef.current = r?.id ?? null selectedIdRef.current = r?.id ?? null
reconnectCountRef.current = 0
if (!r) return if (!r) return
@ -169,29 +185,56 @@ export function useRunSession(): RunSession {
dispatch({ type: 'SET_LIVE', live }) dispatch({ type: 'SET_LIVE', live })
if (live) { if (live) {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' connectWs(r.id)
const ws = new WebSocket(`${proto}://${window.location.host}/ws/runs/${r.id}`)
wsRef.current = ws
ws.onmessage = (msg) => {
try {
const ev = JSON.parse(msg.data) as WsEvent
dispatch({ type: 'WS_EVENT', event: ev })
} catch { /* noop */ }
}
ws.onclose = () => {
if (selectedIdRef.current === r.id) {
dispatch({ type: 'SET_LIVE', live: false })
runsApi.get(r.id).then((res) => {
setRun(res.data)
dispatch({ type: 'FINALIZE_FROM_RUN', status: res.data.status, summary: res.data.summary })
}).catch(() => { /* noop */ })
}
}
} else { } else {
loadHistoricalLogs(r.id) loadHistoricalLogs(r.id)
dispatch({ type: 'FINALIZE_FROM_RUN', status: r.status, summary: r.summary }) dispatch({ type: 'FINALIZE_FROM_RUN', status: r.status, summary: r.summary })
} }
}, [loadHistoricalLogs]) }, [loadHistoricalLogs]) // eslint-disable-line react-hooks/exhaustive-deps
function connectWs(runId: string) {
if (selectedIdRef.current !== runId) return
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(`${proto}://${window.location.host}/ws/runs/${runId}`)
wsRef.current = ws
ws.onmessage = (msg) => {
try {
const ev = JSON.parse(msg.data) as WsEvent
dispatch({ type: 'WS_EVENT', event: ev })
} catch { /* noop */ }
}
ws.onclose = (e) => {
if (selectedIdRef.current !== runId) return
// Normal close (code 1000) or run already completed → finalize
if (e.code === 1000 || e.wasClean) {
dispatch({ type: 'SET_LIVE', live: false })
runsApi.get(runId).then((res) => {
setRun(res.data)
dispatch({ type: 'FINALIZE_FROM_RUN', status: res.data.status, summary: res.data.summary })
}).catch(() => { /* noop */ })
return
}
// Abnormal close → attempt exponential backoff reconnect
const retries = reconnectCountRef.current
if (retries >= WS_MAX_RETRIES) {
dispatch({ type: 'SET_LIVE', live: false })
runsApi.get(runId).then((res) => {
setRun(res.data)
dispatch({ type: 'FINALIZE_FROM_RUN', status: res.data.status, summary: res.data.summary })
}).catch(() => { /* noop */ })
return
}
const delay = Math.min(WS_BASE_DELAY_MS * Math.pow(2, retries), WS_MAX_DELAY_MS)
reconnectCountRef.current = retries + 1
reconnectTimerRef.current = window.setTimeout(() => connectWs(runId), delay)
}
}
const cancel = useCallback(async () => { const cancel = useCallback(async () => {
if (!run) return if (!run) return
@ -220,7 +263,7 @@ export function useRunSession(): RunSession {
return () => clearPolling() return () => clearPolling()
}, [state.isLive, run?.id]) }, [state.isLive, run?.id])
useEffect(() => () => { closeWs(); clearPolling() }, []) useEffect(() => () => { closeWs(); clearPolling(); clearReconnect() }, [])
return { return {
run, run,

View File

@ -9,8 +9,8 @@ import {
ReloadOutlined, ReloadOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { runsApi, scenariosApi, targetsApi, type Run, type TrendPoint } from '../api' import { runsApi, scenariosApi, targetsApi, type Run, type TrendPoint } from '../api'
import PageWrapper from '../components/PageWrapper'
import StatCard from '../components/StatCard' import StatCard from '../components/StatCard'
import { colors } from '../tokens'
import { formatDateTime } from '../utils/date' import { formatDateTime } from '../utils/date'
export default function HomePage() { export default function HomePage() {
@ -95,22 +95,17 @@ export default function HomePage() {
} }
return ( return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <PageWrapper
{/* 页头 */} title="仪表盘"
<div style={{ description="评测平台概览与统计数据"
padding: '10px 16px 8px', flexShrink: 0, inline
display: 'flex', alignItems: 'center', gap: 12, fullHeight
}}> extra={
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: colors.text }}></h2> <Tooltip title="刷新数据">
<span style={{ width: 1, height: 18, background: '#d9d9d9', display: 'inline-block' }} /> <Button size="middle" icon={<ReloadOutlined />} onClick={loadData} />
<span style={{ fontSize: 13, color: colors.textSecondary }}></span> </Tooltip>
<div style={{ marginLeft: 'auto' }}> }
<Tooltip title="刷新数据"> >
<Button size="middle" icon={<ReloadOutlined />} onClick={loadData} />
</Tooltip>
</div>
</div>
{/* 内容区 — 可滚动 */} {/* 内容区 — 可滚动 */}
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '0 16px 16px' }}> <div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '0 16px 16px' }}>
<Spin spinning={loading}> <Spin spinning={loading}>
@ -163,6 +158,6 @@ export default function HomePage() {
</Row> </Row>
</Spin> </Spin>
</div> </div>
</div> </PageWrapper>
) )
} }

View File

@ -1,11 +1,15 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import {
Button, Drawer, Form, Input, message, Modal, Popconfirm, Button, Card, Drawer, Form, Input, message, Modal, Popconfirm,
Space, Table, Tag, Tooltip, Space, Table, Tag, Tooltip,
} from 'antd' } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined, ReloadOutlined } from '@ant-design/icons' import {
PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined,
ReloadOutlined, AppstoreAddOutlined,
} from '@ant-design/icons'
import Editor from '@monaco-editor/react' import Editor from '@monaco-editor/react'
import { scenariosApi, type Scenario } from '../api' import { scenariosApi, type Scenario } from '../api'
import PageWrapper from '../components/PageWrapper'
import { colors } from '../tokens' import { colors } from '../tokens'
import { formatDateTime } from '../utils/date' import { formatDateTime } from '../utils/date'
@ -41,6 +45,11 @@ export default function ScenariosPage() {
const [llmConfig, setLlmConfig] = useState({ api_url: '', api_key: '', model: '' }) const [llmConfig, setLlmConfig] = useState({ api_url: '', api_key: '', model: '' })
const [form] = Form.useForm() const [form] = Form.useForm()
// Template picker
const [tplModalOpen, setTplModalOpen] = useState(false)
const [templates, setTemplates] = useState<any[]>([])
const [tplLoading, setTplLoading] = useState(false)
const load = async () => { const load = async () => {
setLoading(true) setLoading(true)
try { try {
@ -61,6 +70,29 @@ export default function ScenariosPage() {
setDrawerOpen(true) 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) => {
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))
const lc = tpl.llm_config || {}
setLlmConfig({ api_url: lc.api_url || '', api_key: lc.api_key || '', model: lc.model || '' })
setDrawerOpen(true)
}
const openEdit = (scenario: Scenario) => { const openEdit = (scenario: Scenario) => {
setEditingScenario(scenario) setEditingScenario(scenario)
form.setFieldsValue({ form.setFieldsValue({
@ -158,25 +190,25 @@ export default function ScenariosPage() {
] ]
return ( return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <PageWrapper
{/* 页头 */} title="评测场景"
<div style={{ description="管理评测用例集合与评测规则"
padding: '10px 16px 8px', flexShrink: 0, inline
display: 'flex', alignItems: 'center', gap: 12, fullHeight
}}> extra={
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: colors.text }}></h2> <Space>
<span style={{ width: 1, height: 18, background: '#d9d9d9', display: 'inline-block' }} />
<span style={{ fontSize: 13, color: colors.textSecondary }}></span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
<Tooltip title="刷新"> <Tooltip title="刷新">
<Button size="middle" icon={<ReloadOutlined />} onClick={load} /> <Button size="middle" icon={<ReloadOutlined />} onClick={load} />
</Tooltip> </Tooltip>
<Button icon={<AppstoreAddOutlined />} onClick={openTemplateModal}>
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}> <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button> </Button>
</div> </Space>
</div> }
>
{/* 表格区 — 撑满剩余高度 */} {/* 表格区 — 撑满剩余高度 */}
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}> <div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<Table <Table
@ -291,6 +323,48 @@ export default function ScenariosPage() {
</div> </div>
)} )}
</Modal> </Modal>
</div>
{/* 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>
) )
} }

View File

@ -5,6 +5,7 @@ import {
} from 'antd' } from 'antd'
import { PlusOutlined, ApiOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons' import { PlusOutlined, ApiOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'
import { targetsApi, type Target } from '../api' import { targetsApi, type Target } from '../api'
import PageWrapper from '../components/PageWrapper'
import { colors } from '../tokens' import { colors } from '../tokens'
import { formatDateTime } from '../utils/date' import { formatDateTime } from '../utils/date'
@ -141,25 +142,22 @@ export default function TargetsPage() {
] ]
return ( return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <PageWrapper
{/* 页头 */} title="评测对象"
<div style={{ description="管理被评测的 AI 智能体及其通信通道"
padding: '10px 16px 8px', flexShrink: 0, inline
display: 'flex', alignItems: 'center', gap: 12, fullHeight
}}> extra={
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: colors.text }}></h2> <Space>
<span style={{ width: 1, height: 18, background: '#d9d9d9', display: 'inline-block' }} />
<span style={{ fontSize: 13, color: colors.textSecondary }}> AI </span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
<Tooltip title="刷新"> <Tooltip title="刷新">
<Button size="middle" icon={<ReloadOutlined />} onClick={load} /> <Button size="middle" icon={<ReloadOutlined />} onClick={load} />
</Tooltip> </Tooltip>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}> <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button> </Button>
</div> </Space>
</div> }
>
{/* 表格区 — 撑满剩余高度 */} {/* 表格区 — 撑满剩余高度 */}
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}> <div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<Table <Table
@ -228,6 +226,6 @@ export default function TargetsPage() {
</div> </div>
</Form> </Form>
</Drawer> </Drawer>
</div> </PageWrapper>
) )
} }