Some checks failed
CI / test (push) Failing after 2m50s
启用登录鉴权后,导出 HTML/MD/JSON 用 window.open 直连 API 无法携带 X-Auth-Token,服务端返回 401 导致导出失效。改为经 axios 拉取 blob (拦截器自动附加凭据)后触发浏览器下载。
459 lines
17 KiB
TypeScript
459 lines
17 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import {
|
||
Alert, Button, Empty, message, Progress, Select, Space, Tabs, Tooltip, Typography,
|
||
} from 'antd'
|
||
import {
|
||
FileTextOutlined, PlayCircleOutlined, ReloadOutlined, StopOutlined,
|
||
} from '@ant-design/icons'
|
||
import { reportsApi, runsApi, scenariosApi, targetsApi, type Run, type Scenario, type Target } from '../api'
|
||
import RunList from '../components/RunList'
|
||
import CaseBlock from '../components/CaseBlock'
|
||
import RuleOverview from '../components/RuleOverview'
|
||
import CaseDetail from '../components/CaseDetail'
|
||
import { useRunSession } from '../hooks/useRunSession'
|
||
import { useTicker } from '../hooks/useTicker'
|
||
import { useOnTabActive } from '../hooks/useOnTabActive'
|
||
import { colors, statusColors, statusLabels } from '../tokens'
|
||
import { formatDateTime, elapsedStr } from '../utils/date'
|
||
import { passRateColor } from '../utils/colors'
|
||
|
||
export default function RunsPage() {
|
||
const navigate = useNavigate()
|
||
|
||
const [runs, setRuns] = useState<Run[]>([])
|
||
const [targets, setTargets] = useState<Target[]>([])
|
||
const [scenarios, setScenarios] = useState<Scenario[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [targetId, setTargetId] = useState<string>()
|
||
const [scenarioId, setScenarioId] = useState<string>()
|
||
const [starting, setStarting] = useState(false)
|
||
const [tabKey, setTabKey] = useState<string | null>(null)
|
||
const [tabOverrideId, setTabOverrideId] = useState<string | null>(null)
|
||
const [focusCaseId, setFocusCaseId] = useState<string | null>(null)
|
||
|
||
const session = useRunSession()
|
||
|
||
const loadLists = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const [r, t, s] = await Promise.all([
|
||
runsApi.list(),
|
||
targetsApi.list(),
|
||
scenariosApi.list(),
|
||
])
|
||
// API 已按 started_at DESC 排序,直接使用,最新的在前
|
||
setRuns(r.data)
|
||
setTargets(t.data)
|
||
setScenarios(s.data)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => { loadLists() }, [])
|
||
|
||
// Keep-alive tabs never remount: refresh the list whenever the tab is
|
||
// re-activated so runs triggered elsewhere (e.g. AI assistant) show up.
|
||
useOnTabActive('/runs', loadLists)
|
||
|
||
useEffect(() => {
|
||
if (!session.isLive) return
|
||
const id = window.setInterval(() => {
|
||
runsApi.list().then((r) => setRuns(r.data)).catch(() => {})
|
||
}, 3000)
|
||
return () => window.clearInterval(id)
|
||
}, [session.isLive])
|
||
|
||
useEffect(() => {
|
||
if (session.completed) {
|
||
runsApi.list().then((r) => setRuns(r.data)).catch(() => {})
|
||
}
|
||
}, [session.completed])
|
||
|
||
// 提取为共享函数,handleStart 和 onRerun 共用
|
||
const startRun = async (tId: string, sId: string) => {
|
||
setStarting(true)
|
||
try {
|
||
const res = await runsApi.start(tId, sId)
|
||
await loadLists()
|
||
session.select(res.data, { live: true })
|
||
setTabOverrideId(null)
|
||
setFocusCaseId(null)
|
||
} finally {
|
||
setStarting(false)
|
||
}
|
||
}
|
||
|
||
const handleStart = async () => {
|
||
if (!targetId || !scenarioId) {
|
||
message.warning('请选择评测对象和场景')
|
||
return
|
||
}
|
||
await startRun(targetId, scenarioId)
|
||
message.success('评测已启动')
|
||
}
|
||
|
||
const handleSelect = (r: Run) => {
|
||
session.select(r)
|
||
setTabOverrideId(null)
|
||
setFocusCaseId(null)
|
||
}
|
||
|
||
const handleOpenReport = (runId: string) => {
|
||
navigate(`/reports?run=${runId}`)
|
||
}
|
||
|
||
return (
|
||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||
{/* 页头 */}
|
||
<div style={{ padding: '10px 16px 8px', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: colors.text }}>评测执行</h2>
|
||
<span style={{ width: 1, height: 18, background: '#d9d9d9', display: 'inline-block' }} />
|
||
<span style={{ fontSize: 13, color: colors.textSecondary }}>启动评测任务并查看实时执行详情</span>
|
||
</div>
|
||
|
||
{/* 主体 grid — 贴边铺满 */}
|
||
<div style={{ flex: 1, minHeight: 0 }}>
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'minmax(360px, 28vw) 1fr',
|
||
gridTemplateRows: '1fr',
|
||
gap: 0,
|
||
height: '100%',
|
||
minHeight: 480,
|
||
}}>
|
||
{/* LEFT: launcher + list */}
|
||
<div style={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
borderRight: `1px solid ${colors.border}`,
|
||
background: '#fff',
|
||
minHeight: 0,
|
||
overflow: 'hidden',
|
||
}}>
|
||
{/* 启动区:两行布局 */}
|
||
<div style={{
|
||
padding: '12px 12px 10px',
|
||
borderBottom: `1px solid ${colors.border}`,
|
||
background: colors.bgSubtle,
|
||
flexShrink: 0,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: 8,
|
||
}}>
|
||
<Select
|
||
value={targetId}
|
||
onChange={setTargetId}
|
||
allowClear
|
||
showSearch
|
||
filterOption={(input, opt) =>
|
||
(opt?.label as string ?? '').toLowerCase().includes(input.toLowerCase())}
|
||
placeholder="选择评测对象"
|
||
options={targets.map((t) => ({ label: t.name, value: t.id }))}
|
||
style={{ width: '100%' }}
|
||
size="middle"
|
||
/>
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<Select
|
||
value={scenarioId}
|
||
onChange={setScenarioId}
|
||
allowClear
|
||
showSearch
|
||
filterOption={(input, opt) =>
|
||
(opt?.label as string ?? '').toLowerCase().includes(input.toLowerCase())}
|
||
placeholder="选择评测场景"
|
||
options={scenarios.map((s) => ({ label: `${s.name}(v${s.version ?? 1})`, value: s.id }))}
|
||
style={{ flex: 1 }}
|
||
size="middle"
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
icon={<PlayCircleOutlined />}
|
||
onClick={handleStart}
|
||
loading={starting}
|
||
>
|
||
启动
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ flex: 1, minHeight: 0 }}>
|
||
<RunList
|
||
runs={runs}
|
||
loading={loading}
|
||
targets={targets}
|
||
scenarios={scenarios}
|
||
selectedId={session.run?.id}
|
||
onSelect={handleSelect}
|
||
onOpenReport={handleOpenReport}
|
||
liveRunId={session.isLive ? session.run?.id : undefined}
|
||
liveProgress={session.isLive ? session.progress : null}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* RIGHT: detail workspace */}
|
||
<div style={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
background: '#fff',
|
||
minHeight: 0,
|
||
overflow: 'hidden',
|
||
}}>
|
||
<DetailWorkspace
|
||
run={session.run}
|
||
cases={session.cases}
|
||
progress={session.progress}
|
||
snapshots={session.scenarioSnapshot}
|
||
isLive={session.isLive}
|
||
finalStatus={session.finalStatus}
|
||
errorInfo={session.errorInfo}
|
||
tabKey={tabKey}
|
||
tabOverrideId={tabOverrideId}
|
||
onTabChange={(k) => {
|
||
setTabKey(k)
|
||
setTabOverrideId(session.run?.id ?? null)
|
||
}}
|
||
focusCaseId={focusCaseId}
|
||
onFocusCase={(id) => { setFocusCaseId(id); setTabKey('detail'); setTabOverrideId(session.run?.id ?? null) }}
|
||
onCancel={async () => {
|
||
try {
|
||
await session.cancel()
|
||
message.success('已发送停止请求')
|
||
} catch {
|
||
message.error('停止失败')
|
||
}
|
||
}}
|
||
onOpenReport={handleOpenReport}
|
||
onExportJson={(runId) => reportsApi.download(runId, 'json')}
|
||
onRerun={async () => {
|
||
if (!session.run) return
|
||
await startRun(session.run.target_id, session.run.scenario_id)
|
||
message.success('已重新启动')
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Detail Workspace ─────────────────────────────────────────────────────────
|
||
|
||
interface DetailWorkspaceProps {
|
||
run: Run | null
|
||
cases: ReturnType<typeof useRunSession>['cases']
|
||
progress: ReturnType<typeof useRunSession>['progress']
|
||
snapshots: ReturnType<typeof useRunSession>['scenarioSnapshot']
|
||
isLive: boolean
|
||
finalStatus: ReturnType<typeof useRunSession>['finalStatus']
|
||
errorInfo: ReturnType<typeof useRunSession>['errorInfo']
|
||
tabKey: string | null
|
||
tabOverrideId: string | null
|
||
onTabChange: (k: string) => void
|
||
focusCaseId: string | null
|
||
onFocusCase: (id: string) => void
|
||
onCancel: () => void
|
||
onOpenReport: (runId: string) => void
|
||
onExportJson: (runId: string) => void
|
||
onRerun: () => void
|
||
}
|
||
|
||
function DetailWorkspace({
|
||
run, cases, progress, snapshots, isLive, finalStatus, errorInfo,
|
||
tabKey, tabOverrideId, onTabChange, focusCaseId, onFocusCase,
|
||
onCancel, onOpenReport, onExportJson, onRerun,
|
||
}: DetailWorkspaceProps) {
|
||
useTicker(isLive)
|
||
|
||
const status = run?.status ?? 'pending'
|
||
const autoTab = useMemo(() => {
|
||
if (isLive) return 'log'
|
||
if (status === 'completed') return 'overview'
|
||
if (status === 'failed') return 'detail'
|
||
return 'log'
|
||
}, [isLive, status])
|
||
|
||
if (!run) {
|
||
return (
|
||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<Empty
|
||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||
description={<span style={{ color: colors.textMuted }}>选择左侧记录或启动新评测</span>}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const summary = (run.summary || {}) as Record<string, unknown>
|
||
const passRate = summary.pass_rate as number | undefined
|
||
const totalCases = summary.total_cases as number | undefined
|
||
const passedCases = summary.passed_cases as number | undefined
|
||
const totalRules = summary.total_rules as number | undefined
|
||
const passedRules = summary.passed_rules as number | undefined
|
||
|
||
const activeKey = tabOverrideId === run.id && tabKey ? tabKey : autoTab
|
||
|
||
const finalAlert = (() => {
|
||
if (finalStatus === 'completed') return <Alert type="success" showIcon message="评测完成" style={{ padding: '4px 12px' }} />
|
||
if (finalStatus === 'cancelled') return <Alert type="warning" showIcon message={errorInfo?.message || '评测已手动停止'} style={{ padding: '4px 12px' }} />
|
||
if (finalStatus === 'failed') return <Alert type="error" showIcon message={`评测失败:${errorInfo?.message ?? '未知原因'}`} style={{ padding: '4px 12px' }} />
|
||
return null
|
||
})()
|
||
|
||
return (
|
||
<>
|
||
{/* KPI bar */}
|
||
<div style={{
|
||
padding: '12px 16px',
|
||
borderBottom: `1px solid ${colors.border}`,
|
||
background: colors.bgSubtle,
|
||
flexShrink: 0,
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 20, flexWrap: 'wrap' }}>
|
||
<StatusBadge status={status} finalStatus={finalStatus} />
|
||
|
||
<KpiItem
|
||
label="通过率"
|
||
value={passRate != null ? `${Math.round(passRate * 100)}%` : '-'}
|
||
valueColor={passRate != null ? passRateColor(passRate) : colors.textMuted}
|
||
subtitle={
|
||
passedCases != null && totalCases != null
|
||
? `${passedCases}/${totalCases} 用例通过 · ${passedRules ?? 0}/${totalRules ?? 0} 规则通过`
|
||
: '等待评测结果'
|
||
}
|
||
/>
|
||
|
||
<KpiItem
|
||
label={isLive ? '已运行' : '用时'}
|
||
value={elapsedStr(run.started_at, run.completed_at)}
|
||
subtitle={`${formatDateTime(run.started_at)}${run.completed_at ? ' → ' + formatDateTime(run.completed_at) : ''}`}
|
||
/>
|
||
|
||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}>
|
||
<Tooltip title={run.id}>
|
||
<Typography.Text code copyable={{ text: run.id }} style={{ fontSize: 11 }}>
|
||
{run.id.slice(0, 8)}…
|
||
</Typography.Text>
|
||
</Tooltip>
|
||
{isLive ? (
|
||
<Button danger size="small" icon={<StopOutlined />} onClick={onCancel}>停止</Button>
|
||
) : (
|
||
<Space size={4}>
|
||
<Button size="small" icon={<ReloadOutlined />} onClick={onRerun}>重跑</Button>
|
||
{status === 'completed' && (
|
||
<>
|
||
<Button size="small" icon={<FileTextOutlined />} onClick={() => onOpenReport(run.id)}>报告</Button>
|
||
<Button size="small" onClick={() => onExportJson(run.id)}>导出</Button>
|
||
</>
|
||
)}
|
||
</Space>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{progress && progress.total > 0 && (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 8 }}>
|
||
<span style={{ fontSize: 11, color: colors.textMuted, minWidth: 88 }}>
|
||
进度 {progress.done}/{progress.total}{progress.currentIndex ? ` · 第 ${progress.currentIndex} 个` : ''}
|
||
</span>
|
||
<Progress
|
||
percent={progress.total ? Math.round((progress.done / progress.total) * 100) : 0}
|
||
size="small"
|
||
strokeColor={colors.primary}
|
||
showInfo={false}
|
||
style={{ flex: 1, margin: 0 }}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{finalAlert && <div style={{ marginTop: 8 }}>{finalAlert}</div>}
|
||
</div>
|
||
|
||
{/* Tab 导航栏(只渲染头部,内容区由下方 flex 面板独立控制) */}
|
||
<div style={{ borderBottom: `1px solid ${colors.border}`, flexShrink: 0 }}>
|
||
<Tabs
|
||
className="nav-only-tabs"
|
||
activeKey={activeKey}
|
||
onChange={onTabChange}
|
||
tabBarStyle={{ margin: '0 16px', marginBottom: 0 }}
|
||
items={[
|
||
{ key: 'log', label: `实时对话${cases.length > 0 ? ` (${cases.length})` : ''}` },
|
||
{ key: 'overview', label: '规则总览' },
|
||
{ key: 'detail', label: '用例明细' },
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
{/* 实时对话 — 独立 flex 子元素,直接 overflowY: auto */}
|
||
<div style={{
|
||
display: activeKey === 'log' ? 'block' : 'none',
|
||
flex: 1, minHeight: 0, overflowY: 'auto',
|
||
padding: '10px 16px 16px',
|
||
}}>
|
||
{cases.length === 0 ? (
|
||
<Empty
|
||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||
description={isLive ? '等待用例开始…' : '暂无对话记录'}
|
||
style={{ marginTop: 60 }}
|
||
/>
|
||
) : (
|
||
cases.map((c) => (
|
||
<CaseBlock key={c.caseId} cs={c} snapshot={snapshots[c.caseId]} />
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
{/* 规则总览 */}
|
||
<div style={{
|
||
display: activeKey === 'overview' ? 'block' : 'none',
|
||
flex: 1, minHeight: 0, overflowY: 'auto',
|
||
padding: '10px 16px 16px',
|
||
}}>
|
||
<RuleOverview cases={cases} onSelectCase={(id) => onFocusCase(id)} />
|
||
</div>
|
||
|
||
{/* 用例明细 — CaseDetail 内部两栏各自独立滚动 */}
|
||
<div style={{
|
||
display: activeKey === 'detail' ? 'flex' : 'none',
|
||
flex: 1, minHeight: 0, overflow: 'hidden',
|
||
padding: '10px 16px 16px',
|
||
}}>
|
||
<CaseDetail cases={cases} snapshots={snapshots} focusCaseId={focusCaseId} />
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function StatusBadge({ status, finalStatus }: { status: string; finalStatus: ReturnType<typeof useRunSession>['finalStatus'] }) {
|
||
const effective = finalStatus ?? status
|
||
const color = statusColors[effective] ?? colors.textMuted
|
||
const label = statusLabels[effective] ?? effective
|
||
const isRunning = effective === 'running' || effective === 'pending'
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
{isRunning
|
||
? <span className="pulse-dot" style={{ background: color }} />
|
||
: <span style={{ width: 8, height: 8, borderRadius: '50%', background: color, display: 'inline-block' }} />}
|
||
<span style={{ fontSize: 13, fontWeight: 600, color }}>{label}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function KpiItem({ label, value, subtitle, valueColor }: {
|
||
label: string
|
||
value: string
|
||
subtitle?: string
|
||
valueColor?: string
|
||
}) {
|
||
return (
|
||
<div style={{ minWidth: 120 }}>
|
||
<div style={{ fontSize: 11, color: colors.textMuted, marginBottom: 2 }}>{label}</div>
|
||
<div style={{ fontSize: 20, fontWeight: 600, lineHeight: 1.2, color: valueColor ?? colors.text }}>{value}</div>
|
||
{subtitle && <div style={{ fontSize: 11, color: colors.textMuted, marginTop: 2 }}>{subtitle}</div>}
|
||
</div>
|
||
)
|
||
}
|