## 新增功能 - 文件管理模块:分类树 + 文件上传/下载/删除 - 文件上传支持拖拽(Dragger)+ 手动上传(customRequest 模式) ## 页面布局统一(参照评测执行页) - 仪表盘/评测对象/评测场景/评测报告 全部改为全高 flex 布局 - 统一内联页头样式(h2 + 竖线分隔 + 描述) - 表格撑满高度、overflow 处理 - 每页添加刷新按钮 ## Bug 修复 - 分类树操作按钮 hover 不可见(CSS 规则缺失) - 文件上传失败(multipart boundary 缺失) - LLM API 响应 content blocks 数组格式支持(_extract_content_from_api_response) - response_time_max_ms 被静默忽略(隐式规则传空 params) - 空 messages 导致 IndexError 崩溃 - poll_reply 异常中止整个 run(缺 try/catch) - engine finally 未关闭 session - 3 个页面 UTC 时间戳解析偏差 8 小时 ## 后端 - EvalEngine: poll_reply 异常保护、空 dialog 保护、session 关闭 - LLM API 响应解析支持 content-block-array 格式 - 隐式 response_time 规则正确传递 max_ms 参数 ## 前端 - api.ts: 移除手动 Content-Type(让浏览器自动添加 boundary) - Files.tsx: customRequest 替代 beforeUpload、布局优化 - index.css: 分类树 hover 规则 - Targets/Scenarios/Home/Reports: 全高布局改造 - 3 个页面时间戳改用 formatDateTime()(修复 UTC 偏差) Co-Authored-By: Claude <noreply@anthropic.com>
175 lines
6.4 KiB
TypeScript
175 lines
6.4 KiB
TypeScript
import { useState } from 'react'
|
||
import { Tag, Tooltip } from 'antd'
|
||
import {
|
||
CaretDownOutlined, CaretRightOutlined,
|
||
CheckCircleFilled, CloseCircleFilled,
|
||
ThunderboltOutlined,
|
||
} from '@ant-design/icons'
|
||
import type { CaseState } from '../hooks/useRunSession'
|
||
import type { CaseSnapshot } from '../api'
|
||
import GeneratedMessages from './GeneratedMessages'
|
||
import TurnList from './TurnList'
|
||
import { colors, statusColors } from '../tokens'
|
||
import { formatRuleScore, ruleTypeLabel } from '../utils/ruleLabels'
|
||
|
||
interface CaseBlockProps {
|
||
cs: CaseState
|
||
snapshot?: CaseSnapshot
|
||
defaultCollapsed?: boolean
|
||
}
|
||
|
||
export default function CaseBlock({ cs, snapshot, defaultCollapsed }: CaseBlockProps) {
|
||
const [collapsed, setCollapsed] = useState(!!defaultCollapsed)
|
||
|
||
const allPassed = cs.ruleResults.length > 0 && cs.ruleResults.every((r) => r.passed)
|
||
const hasFailed = cs.ruleResults.some((r) => !r.passed)
|
||
|
||
const color =
|
||
cs.status === 'running' ? statusColors.running
|
||
: cs.status === 'done' && allPassed ? statusColors.completed
|
||
: cs.status === 'done' && hasFailed ? statusColors.failed
|
||
: statusColors.pending
|
||
|
||
const rulePassed = cs.ruleResults.filter((r) => r.passed).length
|
||
const ruleTotal = cs.ruleResults.length
|
||
|
||
const typeLabel =
|
||
snapshot?.type === 'dynamic' ? 'AI 动态'
|
||
: snapshot?.type === 'multi_turn' ? '多轮'
|
||
: snapshot?.type === 'single' ? '单轮'
|
||
: cs.isDynamic ? 'AI 动态' : null
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
border: `1px solid ${color}33`,
|
||
borderRadius: 8,
|
||
marginBottom: 10,
|
||
overflow: 'hidden',
|
||
background: '#fff',
|
||
}}
|
||
>
|
||
<div
|
||
onClick={() => setCollapsed((v) => !v)}
|
||
style={{
|
||
background: `${color}0d`,
|
||
padding: '8px 12px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
cursor: 'pointer',
|
||
borderBottom: collapsed ? 'none' : `1px solid ${color}22`,
|
||
}}
|
||
>
|
||
{collapsed ? <CaretRightOutlined style={{ fontSize: 10, color: colors.textMuted }} />
|
||
: <CaretDownOutlined style={{ fontSize: 10, color: colors.textMuted }} />}
|
||
<StatusDot status={cs.status} allPassed={allPassed} hasFailed={hasFailed} />
|
||
<span style={{ fontWeight: 600, fontSize: 13 }}>{cs.caseId}</span>
|
||
{typeLabel && (
|
||
<Tag
|
||
color={typeLabel === 'AI 动态' ? 'orange' : typeLabel === '多轮' ? 'blue' : 'default'}
|
||
style={{ fontSize: 11, lineHeight: '18px', margin: 0 }}
|
||
>
|
||
{typeLabel === 'AI 动态' && <ThunderboltOutlined style={{ marginRight: 2 }} />}
|
||
{typeLabel}
|
||
</Tag>
|
||
)}
|
||
<span style={{ marginLeft: 'auto', fontSize: 11, color: colors.textMuted }}>
|
||
{cs.turns.length} 轮
|
||
{ruleTotal > 0 && (
|
||
<>
|
||
{' · '}
|
||
<span style={{ color: allPassed ? statusColors.completed : hasFailed ? statusColors.failed : colors.textMuted }}>
|
||
{rulePassed}/{ruleTotal} 规则通过
|
||
</span>
|
||
</>
|
||
)}
|
||
</span>
|
||
</div>
|
||
|
||
{!collapsed && (
|
||
<div style={{ padding: '10px 12px' }}>
|
||
{/* Expectations panel */}
|
||
{snapshot && <ExpectationsPanel snapshot={snapshot} />}
|
||
|
||
{/* AI generated messages */}
|
||
{cs.generatedMessages && cs.generatedMessages.length > 0 && (
|
||
<GeneratedMessages messages={cs.generatedMessages} compact />
|
||
)}
|
||
|
||
{/* Turns + case-level errors */}
|
||
<TurnList turns={cs.turns} errors={cs.errors} />
|
||
|
||
{/* Rule results footer */}
|
||
{cs.ruleResults.length > 0 && (
|
||
<div style={{
|
||
borderTop: `1px solid ${colors.border}`,
|
||
marginTop: 8,
|
||
paddingTop: 8,
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: 6,
|
||
}}>
|
||
{cs.ruleResults.map((r, i) => (
|
||
<Tooltip key={i} title={r.reason || ruleTypeLabel(r.rule_type)}>
|
||
<Tag
|
||
color={r.passed ? 'success' : 'error'}
|
||
style={{ fontSize: 11, margin: 0 }}
|
||
>
|
||
{r.passed ? '✓' : '✗'} {ruleTypeLabel(r.rule_type)}
|
||
{r.score != null && <span style={{ marginLeft: 4, opacity: 0.75 }}>{formatRuleScore(r.rule_type, r.score)}</span>}
|
||
</Tag>
|
||
</Tooltip>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function StatusDot({ status, allPassed, hasFailed }: { status: CaseState['status']; allPassed: boolean; hasFailed: boolean }) {
|
||
if (status === 'running') {
|
||
return <span className="pulse-dot" style={{ background: statusColors.running }} />
|
||
}
|
||
if (status === 'done') {
|
||
if (hasFailed) return <CloseCircleFilled style={{ color: statusColors.failed, fontSize: 14 }} />
|
||
if (allPassed) return <CheckCircleFilled style={{ color: statusColors.completed, fontSize: 14 }} />
|
||
return <CheckCircleFilled style={{ color: colors.textMuted, fontSize: 14 }} />
|
||
}
|
||
return <span style={{ width: 8, height: 8, borderRadius: '50%', background: colors.textMuted, display: 'inline-block' }} />
|
||
}
|
||
|
||
function ExpectationsPanel({ snapshot }: { snapshot: CaseSnapshot }) {
|
||
const e = snapshot.expectations
|
||
const items: { label: string; value: string }[] = []
|
||
if (e.keywords_include?.length) items.push({ label: '需包含', value: e.keywords_include.join('、') })
|
||
if (e.keywords_exclude?.length) items.push({ label: '需排除', value: e.keywords_exclude.join('、') })
|
||
if (e.response_time_max_ms) items.push({ label: '响应上限', value: `${e.response_time_max_ms} ms` })
|
||
if (e.coherence_min_score != null) items.push({ label: '连贯性最低分', value: String(e.coherence_min_score) })
|
||
if (e.intent) items.push({ label: '目标意图', value: e.intent })
|
||
if (snapshot.type === 'dynamic' && snapshot.prompt) items.push({ label: '生成提示', value: snapshot.prompt })
|
||
|
||
if (items.length === 0) return null
|
||
|
||
return (
|
||
<div style={{
|
||
background: colors.bgSubtle,
|
||
border: `1px solid ${colors.border}`,
|
||
borderRadius: 6,
|
||
padding: '6px 10px',
|
||
marginBottom: 8,
|
||
fontSize: 11,
|
||
color: colors.textSecondary,
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: '2px 12px',
|
||
}}>
|
||
{items.map((it, i) => (
|
||
<span key={i}><b style={{ color: colors.text }}>{it.label}:</b>{it.value}</span>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|