feature: 增加功能 导出详细报告

This commit is contained in:
baozaotumao2025
2026-07-19 00:29:06 +08:00
parent 789d4fc4e9
commit 12d8293250
12 changed files with 92 additions and 15 deletions
+7 -5
View File
@@ -5,6 +5,7 @@ import { logger } from '../logging'
export type RequestOptions<T> = Omit<RequestInit, 'body'> & {
body?: unknown
schema?: ZodType<T>
responseType?: 'json' | 'blob'
}
export class ApiClient {
@@ -22,26 +23,27 @@ export class ApiClient {
private async execute<T>(path: string, options: RequestOptions<T>, replayed: boolean): Promise<T> {
const token = this.getToken()
const headers = new Headers(options.headers)
headers.set('Accept', 'application/json')
headers.set('Accept', options.responseType === 'blob' ? 'text/markdown' : 'application/json')
if (options.body !== undefined) headers.set('Content-Type', 'application/json')
if (token) headers.set('Authorization', `Bearer ${token}`)
try {
const { responseType, schema, ...requestOptions } = options
const response = await fetch(`${this.baseUrl}${path}`, {
...options,
...requestOptions,
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
})
if (response.status === 204) return undefined as T
const body = await response.json().catch(() => null)
const body = response.ok && responseType === 'blob' ? await response.blob() : await response.json().catch(() => null)
if (!response.ok) {
if (response.status === 401 && !replayed && await this.refreshAuth?.(true)) {
return this.execute(path, options, true)
}
throw { status: response.status, body }
}
if (!options.schema) return body as T
const parsed = options.schema.safeParse(body)
if (!schema) return body as T
const parsed = schema.safeParse(body)
if (!parsed.success) {
throw new AppError('服务响应格式异常', 'validation', response.status, 'INVALID_RESPONSE', undefined, {
issues: parsed.error.issues,
+21 -3
View File
@@ -43,6 +43,24 @@ export function RunDetailPage() {
message.error(status === undefined || (typeof status === 'number' && status >= 500) ? '重新执行提交失败,请稍后重试' : error.message)
},
})
const exportReport = useMutation({
mutationFn: () => services.runs.export(id, {
executionStatus: executionFilter === 'all' ? undefined : executionFilter as 'completed' | 'error',
verdict: verdictFilter === 'all' ? undefined : verdictFilter === 'unjudged' ? 'none' : verdictFilter as 'pass' | 'fail' | 'needs_human_review' | 'judge_format_error',
}),
onSuccess: (blob) => {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `run_${id}_results.md`
link.click()
URL.revokeObjectURL(url)
},
onError: (error) => {
message.error(error.message)
if ('status' in error && error.status === 409) refresh()
},
})
const action = useMutation({
mutationFn: async (name: 'cancel' | 'resume' | 'retry' | 'delete') => {
if (name === 'cancel') return services.runs.cancel(id)
@@ -83,16 +101,16 @@ export function RunDetailPage() {
<div className="stat-strip"><span><strong>{data.processed_count}</strong></span><span><strong>{data.completed_count}</strong></span><span><strong>{data.error_count}</strong></span><span><strong>{data.selected_count}</strong></span></div>
</Card>
<Tabs items={[
{ key: 'results', label: `逐条结果 (${filtered.length} / ${results.data?.length || 0})`, children: <ResultsPanel rows={filtered} executionFilter={executionFilter} verdictFilter={verdictFilter} executionIdQuery={executionIdQuery} onExecutionFilter={setExecutionFilter} onVerdictFilter={setVerdictFilter} onExecutionIdQuery={setExecutionIdQuery} loading={results.isLoading} canRetry={['completed', 'completed_with_errors'].includes(data.status)} retryingExecutionId={activeRetryingExecutionId} followedExecutionId={followedExecutionId} submittingExecutionId={retryResult.isPending ? retryResult.variables : null} onStopFollowing={() => setFollowedExecutionId(null)} onRetry={(executionId) => Modal.confirm({ title: `确定重新执行 ${executionId} 吗?`, content: '当前测试结果将被新结果替换。', onOk: () => retryResult.mutate(executionId) })} /> },
{ key: 'results', label: `逐条结果 (${filtered.length} / ${results.data?.length || 0})`, children: <ResultsPanel rows={filtered} executionFilter={executionFilter} verdictFilter={verdictFilter} executionIdQuery={executionIdQuery} onExecutionFilter={setExecutionFilter} onVerdictFilter={setVerdictFilter} onExecutionIdQuery={setExecutionIdQuery} loading={results.isLoading} canRetry={['completed', 'completed_with_errors'].includes(data.status)} canExport={data.terminal} exporting={exportReport.isPending} retryingExecutionId={activeRetryingExecutionId} followedExecutionId={followedExecutionId} submittingExecutionId={retryResult.isPending ? retryResult.variables : null} onExport={() => exportReport.mutate()} onStopFollowing={() => setFollowedExecutionId(null)} onRetry={(executionId) => Modal.confirm({ title: `确定重新执行 ${executionId} 吗?`, content: '当前测试结果将被新结果替换。', onOk: () => retryResult.mutate(executionId) })} /> },
{ key: 'report', label: '汇总报告', children: report.data ? <ReportSummary summary={report.data.summary} /> : <Empty description={data.terminal ? '暂无报告' : '运行结束后生成报告'} /> },
]} />
</>
)
}
function ResultsPanel({ rows, executionFilter, verdictFilter, executionIdQuery, onExecutionFilter, onVerdictFilter, onExecutionIdQuery, loading, canRetry, retryingExecutionId, followedExecutionId, submittingExecutionId, onStopFollowing, onRetry }: { rows: ResultItem[]; executionFilter: string; verdictFilter: string; executionIdQuery: string; onExecutionFilter: (value: string) => void; onVerdictFilter: (value: string) => void; onExecutionIdQuery: (value: string) => void; loading: boolean; canRetry: boolean; retryingExecutionId: string | null; followedExecutionId: string | null; submittingExecutionId: string | null; onStopFollowing: () => void; onRetry: (executionId: string) => void }) {
function ResultsPanel({ rows, executionFilter, verdictFilter, executionIdQuery, onExecutionFilter, onVerdictFilter, onExecutionIdQuery, loading, canRetry, canExport, exporting, retryingExecutionId, followedExecutionId, submittingExecutionId, onExport, onStopFollowing, onRetry }: { rows: ResultItem[]; executionFilter: string; verdictFilter: string; executionIdQuery: string; onExecutionFilter: (value: string) => void; onVerdictFilter: (value: string) => void; onExecutionIdQuery: (value: string) => void; loading: boolean; canRetry: boolean; canExport: boolean; exporting: boolean; retryingExecutionId: string | null; followedExecutionId: string | null; submittingExecutionId: string | null; onExport: () => void; onStopFollowing: () => void; onRetry: (executionId: string) => void }) {
if (loading) return <LoadingState />
return <div className="results-panel"><div className="action-group"><Select aria-label="执行状态" value={executionFilter} onChange={onExecutionFilter} labelRender={({ label }) => `执行状态:${label}`} options={[{ value: 'all', label: '全部' }, { value: 'completed', label: '已完成' }, { value: 'error', label: '执行错误' }]} /><Select aria-label="仲裁结论" value={verdictFilter} onChange={onVerdictFilter} labelRender={({ label }) => `仲裁结论:${label}`} options={[{ value: 'all', label: '全部' }, { value: 'pass', label: '通过' }, { value: 'fail', label: '不通过' }, { value: 'needs_human_review', label: '人工复核' }, { value: 'judge_format_error', label: '裁判格式错误' }, { value: 'unjudged', label: '未仲裁' }]} /><Input type="search" allowClear aria-label="执行 ID" placeholder="根据执行 ID 搜索,如 R0029" value={executionIdQuery} onChange={(event) => onExecutionIdQuery(event.target.value)} /></div>
return <div className="results-panel"><div className="action-group"><Select aria-label="执行状态" value={executionFilter} onChange={onExecutionFilter} labelRender={({ label }) => `执行状态:${label}`} options={[{ value: 'all', label: '全部' }, { value: 'completed', label: '已完成' }, { value: 'error', label: '执行错误' }]} /><Select aria-label="仲裁结论" value={verdictFilter} onChange={onVerdictFilter} labelRender={({ label }) => `仲裁结论:${label}`} options={[{ value: 'all', label: '全部' }, { value: 'pass', label: '通过' }, { value: 'fail', label: '不通过' }, { value: 'needs_human_review', label: '人工复核' }, { value: 'judge_format_error', label: '裁判格式错误' }, { value: 'unjudged', label: '未仲裁' }]} /><Input type="search" allowClear aria-label="执行 ID" placeholder="根据执行 ID 搜索,如 R0029" value={executionIdQuery} onChange={(event) => onExecutionIdQuery(event.target.value)} /><Button type="primary" disabled={!canExport} loading={exporting} title={canExport ? undefined : '运行结束后可导出'} onClick={onExport}></Button></div>
{followedExecutionId && <Alert type="info" showIcon message={retryingExecutionId ? `${followedExecutionId} 重新执行中…` : `正在跟踪 ${followedExecutionId};即使不符合当前筛选也会置顶显示。`} action={<Button size="small" onClick={onStopFollowing}></Button>} />}
{!rows.length ? <Empty description={followedExecutionId ? '正在等待跟踪结果…' : '暂无符合条件的结果'} /> : rows.map((row) => <Card key={row.execution_id} className="result-card"><div className="result-title"><strong>{row.execution_id}</strong><span className="result-tags" aria-label="结果标识">{resultTags({ caseKind: row.case_kind, mode: row.interaction_mode, executionStatus: row.execution_status, verdict: row.verdict }).map((tag) => <Tag key={tag.label} className={`result-tag-${tag.color}${tag.emphasis ? ' result-verdict' : ''}`}>{tag.label}</Tag>)}</span><Button disabled={!canRetry || Boolean(retryingExecutionId) || Boolean(submittingExecutionId)} loading={submittingExecutionId === row.execution_id} onClick={() => onRetry(row.execution_id)} aria-label={submittingExecutionId === row.execution_id ? '提交中…' : retryingExecutionId === row.execution_id ? '重新执行中…' : `重新执行 ${row.execution_id}`}>{submittingExecutionId === row.execution_id ? '提交中…' : retryingExecutionId === row.execution_id ? '重新执行中…' : '重新执行'}</Button></div><div className="result-columns"><section><h3></h3><pre>{JSON.stringify(row.model_input, null, 2)}</pre></section><section><h3></h3><pre>{row.model_response || '-'}</pre></section></div>{Object.keys(row.judge_result).length > 0 && <section className="judge-result"><h3>Judge </h3><pre>{JSON.stringify(row.judge_result, null, 2)}</pre></section>}{row.error_message && <Alert type="error" message={row.error_message} />}</Card>)}</div>
}
+9
View File
@@ -2,6 +2,8 @@ import type { ApiClient } from '../api/client'
import { createIdempotencyKey } from '../idempotencyKey'
import { resultListSchema, retryErrorsSchema, retryResultSchema, runDetailSchema, runListSchema, runResponseSchema, startRunSchema, resumeRunSchema, type StartRunInput } from '../schemas/runs'
type ExportFilters = { executionStatus?: 'completed' | 'error'; verdict?: 'pass' | 'fail' | 'needs_human_review' | 'judge_format_error' | 'none' }
export const runService = (api: ApiClient) => ({
list: (limit = 50) => api.request(`/runs?limit=${limit}`, { schema: runListSchema }),
get: (id: number) => api.request(`/runs/${id}`, { schema: runDetailSchema }),
@@ -13,4 +15,11 @@ export const runService = (api: ApiClient) => ({
retryErrors: (id: number) => api.request(`/runs/${id}/retry-errors`, { method: 'POST', schema: retryErrorsSchema }),
retryResult: (id: number, executionId: string) => api.request(`/runs/${id}/results/${encodeURIComponent(executionId)}/retry`, { method: 'POST', schema: retryResultSchema }),
results: (id: number) => api.request(`/runs/${id}/results`, { schema: resultListSchema }),
export: (id: number, filters: ExportFilters = {}) => {
const params = new URLSearchParams()
if (filters.executionStatus) params.set('execution_status', filters.executionStatus)
if (filters.verdict) params.set('verdict', filters.verdict)
const query = params.toString()
return api.request<Blob>(`/runs/${id}/export${query ? `?${query}` : ''}`, { responseType: 'blob' })
},
})