feature: 增加功能 导出详细报告
This commit is contained in:
+7
-5
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user