449 lines
16 KiB
TypeScript
449 lines
16 KiB
TypeScript
"use client";
|
||
|
||
/** 批量测试已完成结果视图。 */
|
||
|
||
import { ChevronDown, ChevronRight, Settings2 } from "lucide-react";
|
||
import { useRef, useState } from "react";
|
||
|
||
import { BatchCaseDetailDrawer } from "@/components/batch-test/batch-case-detail-drawer";
|
||
import {
|
||
BatchCaseStatusBadge,
|
||
getBatchCaseStatusLabel,
|
||
} from "@/components/batch-test/batch-case-status";
|
||
import {
|
||
BATCH_ERROR_STRATEGY_LABEL,
|
||
BATCH_FAILURE_STRATEGY_LABEL,
|
||
countByStatus,
|
||
formatRunDuration,
|
||
formatRunTime,
|
||
type BatchRunSnapshot,
|
||
} from "@/data/batch-run";
|
||
import { Button } from "@/components/ui/button";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
type ResultFilter = "all" | "fail" | "error";
|
||
|
||
function stopReasonLabel(run: BatchRunSnapshot): string {
|
||
if (run.stopReason === "manual") return "已手动停止";
|
||
if (run.stopReason === "assertion_failure") return "因断言失败停止";
|
||
if (run.stopReason === "execution_error") return "因执行错误停止";
|
||
return "";
|
||
}
|
||
|
||
export function BatchRunCompletedView({
|
||
run,
|
||
onEditCase,
|
||
onRerunCase,
|
||
}: {
|
||
run: BatchRunSnapshot;
|
||
onEditCase: (caseId: string) => void;
|
||
onRerunCase: (caseId: string) => void;
|
||
}) {
|
||
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
||
const [filter, setFilter] = useState<ResultFilter>("all");
|
||
const detailRowRefs = useRef(new Map<string, HTMLTableRowElement>());
|
||
const selectedCase =
|
||
run.cases.find((item) => item.id === selectedCaseId) ?? null;
|
||
const detailOpen = Boolean(selectedCase);
|
||
|
||
const counts = countByStatus(run.cases);
|
||
const total = run.cases.length;
|
||
const outcome = getRunOutcome(run, counts);
|
||
const visibleCases =
|
||
filter === "fail"
|
||
? run.cases.filter((item) => item.status === "fail")
|
||
: filter === "error"
|
||
? run.cases.filter((item) => item.status === "error")
|
||
: run.cases;
|
||
|
||
function closeDetail() {
|
||
const currentId = selectedCaseId;
|
||
setSelectedCaseId(null);
|
||
if (!currentId) return;
|
||
window.requestAnimationFrame(() => {
|
||
detailRowRefs.current.get(currentId)?.focus();
|
||
});
|
||
}
|
||
|
||
function handleCaseRowKeyDown(
|
||
event: React.KeyboardEvent<HTMLTableRowElement>,
|
||
caseId: string,
|
||
) {
|
||
if (event.key !== "Enter" && event.key !== " ") return;
|
||
event.preventDefault();
|
||
setSelectedCaseId(caseId);
|
||
}
|
||
|
||
return (
|
||
<div className="flex h-full min-h-0 w-full overflow-hidden bg-background">
|
||
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-none px-4 py-4 sm:px-6 sm:py-6 lg:px-8">
|
||
<div
|
||
className={cn(
|
||
"flex flex-col gap-4",
|
||
detailOpen ? "w-full" : "mx-auto w-full max-w-[960px]",
|
||
)}
|
||
>
|
||
{/* 结果总览 */}
|
||
<section className="rounded-2xl border border-hairline bg-card p-5 shadow-sm">
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div>
|
||
<h2 className="text-base font-medium text-foreground">
|
||
{run.title}
|
||
</h2>
|
||
{run.finishedAt && (
|
||
<p className="mt-1.5 text-xs text-muted-soft">
|
||
完成于 {formatRunTime(run.finishedAt)}
|
||
{run.stopReason ? ` · ${stopReasonLabel(run)}` : ""}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<span
|
||
className={cn(
|
||
"inline-flex shrink-0 items-center rounded-full px-3 py-1 text-xs font-medium",
|
||
outcome.className,
|
||
)}
|
||
>
|
||
{outcome.label}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mt-5 rounded-xl border border-hairline bg-canvas-soft/60 p-4">
|
||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||
<div>
|
||
<p className="text-xs font-medium tracking-wide text-muted-soft uppercase">
|
||
测试结果
|
||
</p>
|
||
<p className="mt-1 font-display text-3xl text-ink">
|
||
<span className="tabular-nums">{counts.pass}</span>
|
||
<span className="mx-1.5 text-muted-soft">/</span>
|
||
<span className="tabular-nums">{total}</span>
|
||
<span className="ml-2 text-2xl">通过</span>
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-x-5 gap-y-2 text-sm sm:shrink-0">
|
||
<ResultCount tone="success" label="通过" count={counts.pass} />
|
||
<ResultCount
|
||
tone="destructive"
|
||
label="断言失败"
|
||
count={counts.fail}
|
||
/>
|
||
<ResultCount
|
||
tone="warning"
|
||
label="执行错误"
|
||
count={counts.error}
|
||
/>
|
||
{counts.skipped > 0 && (
|
||
<ResultCount
|
||
tone="muted"
|
||
label="未执行"
|
||
count={counts.skipped}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<ResultDistribution counts={counts} total={total} />
|
||
</div>
|
||
|
||
<dl
|
||
className={cn(
|
||
"mt-4 grid gap-x-6 gap-y-4 text-sm sm:grid-cols-2",
|
||
!detailOpen && "lg:grid-cols-4",
|
||
)}
|
||
>
|
||
<div>
|
||
<dt className="text-xs text-muted-foreground">被测助手</dt>
|
||
<dd className="mt-1 break-words font-medium text-foreground">
|
||
{run.assistantName}
|
||
</dd>
|
||
</div>
|
||
<div className="min-w-0">
|
||
<dt className="text-xs text-muted-foreground">评估模型</dt>
|
||
<dd
|
||
className="mt-1 break-words font-medium text-foreground"
|
||
title={run.config.evaluatorModelResourceName}
|
||
>
|
||
{run.config.evaluatorModelResourceName}
|
||
</dd>
|
||
{run.config.evaluatorModel && (
|
||
<dd
|
||
className="mt-0.5 break-all font-mono text-[11px] text-muted-soft"
|
||
title={run.config.evaluatorModel}
|
||
>
|
||
{run.config.evaluatorModel}
|
||
</dd>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<dt className="text-xs text-muted-foreground">测试范围</dt>
|
||
<dd className="mt-1 font-medium text-foreground">
|
||
{run.config.suiteCount} 个测试集 · {total} 个用例
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-xs text-muted-foreground">运行参数</dt>
|
||
<dd className="mt-1 font-medium text-foreground">
|
||
{run.config.concurrency} 并发 · {run.config.timeoutSecs} 秒超时
|
||
<span className="mx-1.5 text-muted-soft">·</span>
|
||
{formatRunDuration(run.startedAt, run.finishedAt)}
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
|
||
<details className="group mt-4 border-t border-hairline pt-3">
|
||
<summary className="flex w-fit cursor-pointer list-none items-center gap-2 rounded-full px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-canvas-soft hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 [&::-webkit-details-marker]:hidden">
|
||
<Settings2 size={13} />
|
||
查看运行设置
|
||
<ChevronDown
|
||
size={13}
|
||
className="transition-transform group-open:rotate-180"
|
||
/>
|
||
</summary>
|
||
<div className="mt-3 grid gap-3 rounded-xl border border-hairline bg-canvas-soft/50 px-4 py-3 text-xs leading-5 text-muted-foreground sm:grid-cols-2">
|
||
<p>
|
||
<span className="font-medium text-foreground">断言失败:</span>
|
||
{BATCH_FAILURE_STRATEGY_LABEL[run.config.failureStrategy]}
|
||
</p>
|
||
<p>
|
||
<span className="font-medium text-foreground">执行错误:</span>
|
||
重试 {run.config.errorRetryCount} 次,
|
||
{BATCH_ERROR_STRATEGY_LABEL[run.config.errorStrategy]}
|
||
</p>
|
||
</div>
|
||
</details>
|
||
</section>
|
||
|
||
{/* 用例列表 */}
|
||
<section className="rounded-2xl border border-hairline bg-card shadow-sm">
|
||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-hairline px-5 py-3.5">
|
||
<h3 className="text-sm font-medium text-foreground">用例列表</h3>
|
||
<div
|
||
className="flex items-center gap-2"
|
||
role="group"
|
||
aria-label="结果筛选"
|
||
>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant={filter === "all" ? "default" : "outline"}
|
||
className="rounded-full"
|
||
aria-pressed={filter === "all"}
|
||
onClick={() => setFilter("all")}
|
||
>
|
||
全部 {total}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant={filter === "fail" ? "default" : "outline"}
|
||
className="rounded-full"
|
||
aria-pressed={filter === "fail"}
|
||
onClick={() => setFilter("fail")}
|
||
>
|
||
断言失败 {counts.fail}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant={filter === "error" ? "default" : "outline"}
|
||
className="rounded-full"
|
||
aria-pressed={filter === "error"}
|
||
onClick={() => setFilter("error")}
|
||
>
|
||
执行错误 {counts.error}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full min-w-[560px] text-left text-sm">
|
||
<thead>
|
||
<tr className="border-b border-hairline text-xs text-muted-soft">
|
||
<th className="w-12 px-5 py-2.5 font-medium">#</th>
|
||
<th className="px-3 py-2.5 font-medium">测试用例</th>
|
||
<th className="w-28 px-3 py-2.5 font-medium">状态</th>
|
||
<th className="w-24 px-3 py-2.5 font-medium">详情</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{visibleCases.map((item) => {
|
||
const index = run.cases.findIndex(
|
||
(caseItem) => caseItem.id === item.id,
|
||
);
|
||
return (
|
||
<tr
|
||
key={item.id}
|
||
ref={(element) => {
|
||
if (element) {
|
||
detailRowRefs.current.set(item.id, element);
|
||
} else {
|
||
detailRowRefs.current.delete(item.id);
|
||
}
|
||
}}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label={`查看用例 ${item.name} 详情,状态:${getBatchCaseStatusLabel(item.status)}`}
|
||
aria-expanded={selectedCaseId === item.id}
|
||
aria-controls="batch-case-detail"
|
||
onClick={() => setSelectedCaseId(item.id)}
|
||
onKeyDown={(event) =>
|
||
handleCaseRowKeyDown(event, item.id)
|
||
}
|
||
className={cn(
|
||
"cursor-pointer border-b border-hairline transition-colors outline-none last:border-b-0 hover:bg-canvas-soft/70 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/40",
|
||
selectedCaseId === item.id && "bg-canvas-soft",
|
||
)}
|
||
>
|
||
<td className="w-12 px-5 py-3 tabular-nums text-muted-foreground">
|
||
{index + 1}
|
||
</td>
|
||
<td className="px-3 py-3 font-medium text-foreground">
|
||
{item.name}
|
||
</td>
|
||
<td className="w-28 px-3 py-3">
|
||
<BatchCaseStatusBadge status={item.status} />
|
||
</td>
|
||
<td className="w-24 px-3 py-2">
|
||
<span className="inline-flex items-center gap-1 text-sm text-muted-foreground">
|
||
查看
|
||
<ChevronRight size={14} />
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
{visibleCases.length === 0 && (
|
||
<tr>
|
||
<td
|
||
colSpan={4}
|
||
className="px-5 py-10 text-center text-sm text-muted-foreground"
|
||
>
|
||
{filter === "error"
|
||
? "本次运行没有执行错误。"
|
||
: "本次运行没有断言失败用例。"}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
{selectedCase && (
|
||
<BatchCaseDetailDrawer
|
||
item={selectedCase}
|
||
assistantName={run.assistantName}
|
||
onClose={closeDetail}
|
||
onEdit={() => onEditCase(selectedCase.id)}
|
||
onRerun={() => onRerunCase(selectedCase.id)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type BatchCounts = ReturnType<typeof countByStatus>;
|
||
|
||
function getRunOutcome(run: BatchRunSnapshot, counts: BatchCounts) {
|
||
if (run.status === "cancelled") {
|
||
return {
|
||
label: "运行已停止",
|
||
className: "bg-surface-strong text-muted-foreground",
|
||
};
|
||
}
|
||
if (counts.error > 0) {
|
||
return {
|
||
label: "存在执行错误",
|
||
className: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
|
||
};
|
||
}
|
||
if (counts.fail > 0) {
|
||
return {
|
||
label: "存在断言失败",
|
||
className: "bg-destructive/10 text-destructive",
|
||
};
|
||
}
|
||
if (counts.skipped > 0) {
|
||
return {
|
||
label: "部分未执行",
|
||
className: "bg-surface-strong text-muted-foreground",
|
||
};
|
||
}
|
||
if (counts.pass > 0) {
|
||
return {
|
||
label: "全部通过",
|
||
className: "bg-success/10 text-success",
|
||
};
|
||
}
|
||
return {
|
||
label: "暂无结果",
|
||
className: "bg-surface-strong text-muted-foreground",
|
||
};
|
||
}
|
||
|
||
function ResultCount({
|
||
tone,
|
||
label,
|
||
count,
|
||
}: {
|
||
tone: "success" | "destructive" | "warning" | "muted";
|
||
label: string;
|
||
count: number;
|
||
}) {
|
||
const dotClass = {
|
||
success: "bg-success",
|
||
destructive: "bg-destructive",
|
||
warning: "bg-amber-500",
|
||
muted: "bg-muted-soft",
|
||
}[tone];
|
||
|
||
return (
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<span
|
||
className={cn("size-2 shrink-0 rounded-full", dotClass)}
|
||
aria-hidden
|
||
/>
|
||
<span className="truncate text-muted-foreground">{label}</span>
|
||
<span className="ml-auto font-medium tabular-nums text-foreground">
|
||
{count}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ResultDistribution({
|
||
counts,
|
||
total,
|
||
}: {
|
||
counts: BatchCounts;
|
||
total: number;
|
||
}) {
|
||
const segments = [
|
||
{ key: "pass", count: counts.pass, className: "bg-success" },
|
||
{ key: "fail", count: counts.fail, className: "bg-destructive" },
|
||
{ key: "error", count: counts.error, className: "bg-amber-500" },
|
||
{ key: "skipped", count: counts.skipped, className: "bg-muted-soft" },
|
||
].filter((item) => item.count > 0);
|
||
|
||
return (
|
||
<div
|
||
className="mt-4 flex h-2 overflow-hidden rounded-full bg-surface-strong"
|
||
role="img"
|
||
aria-label={`结果分布:通过 ${counts.pass},断言失败 ${counts.fail},执行错误 ${counts.error},未执行 ${counts.skipped}`}
|
||
>
|
||
{total > 0 &&
|
||
segments.map((segment) => (
|
||
<span
|
||
key={segment.key}
|
||
className={segment.className}
|
||
style={{ flexBasis: 0, flexGrow: segment.count }}
|
||
aria-hidden
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|