feat(frontend): implement batch testing views and status components

Add components for batch testing, including BatchRunCompletedView and BatchRunRunningView, to display test results and progress. Introduce BatchCaseStatusBadge and BatchPhasePill for visual status representation. Implement mock data handling for batch run snapshots and case statuses, enhancing the user experience for managing batch tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xin Wang
2026-08-08 17:37:48 +08:00
co-authored by Cursor
parent 398b20778d
commit 21a25874a9
10 changed files with 1679 additions and 94 deletions
@@ -0,0 +1,297 @@
"use client";
/**
* 批量测试 — 已完成结果视图(MVP mock)。
*/
import { ChevronDown, ChevronRight } from "lucide-react";
import { useState } from "react";
import {
BatchCaseStatusBadge,
BatchPhasePill,
} from "@/components/batch-test/batch-case-status";
import {
countByStatus,
formatRunTime,
type BatchRunSnapshot,
} from "@/data/batch-run";
import { cn } from "@/lib/utils";
export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => {
const firstFail = run.cases.find((item) => item.status === "fail");
return firstFail ? new Set([firstFail.id]) : new Set();
});
const counts = countByStatus(run.cases);
const judged = counts.pass + counts.fail;
const total = run.cases.length;
const passRate = judged === 0 ? 0 : Math.round((counts.pass / judged) * 100);
const failRate = judged === 0 ? 0 : 100 - passRate;
function toggleExpanded(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<div className="mx-auto flex w-full max-w-[960px] flex-col gap-4">
{/* 结果总览 */}
<section className="rounded-2xl border border-hairline bg-card p-5 shadow-sm">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-medium text-foreground">{run.title}</h2>
<BatchPhasePill phase="completed" />
</div>
{run.finishedAt && (
<p className="mt-1.5 text-xs text-muted-soft">
完成时间:{formatRunTime(run.finishedAt)}
{run.stopped ? " · 已手动停止" : ""}
</p>
)}
<div className="mt-5 flex flex-col items-center gap-6 sm:flex-row sm:justify-between sm:gap-8">
<div className="text-center sm:text-left">
<div className="font-display text-3xl text-ink">
{counts.pass} / {judged || total} 通过
</div>
<div className="mt-1 text-sm font-medium text-success">
通过率 {passRate}%
</div>
</div>
<PassRateDonut pass={counts.pass} fail={counts.fail} />
<div className="space-y-2 text-sm">
<LegendRow
tone="success"
label="通过"
count={counts.pass}
percent={passRate}
/>
<LegendRow
tone="destructive"
label="失败"
count={counts.fail}
percent={failRate}
/>
{counts.skipped > 0 && (
<LegendRow
tone="muted"
label="未执行"
count={counts.skipped}
percent={
total === 0
? 0
: Math.round((counts.skipped / total) * 100)
}
/>
)}
</div>
</div>
</section>
{/* 用例列表 */}
<section className="rounded-2xl border border-hairline bg-card shadow-sm">
<div className="border-b border-hairline px-5 py-3.5">
<h3 className="text-sm font-medium text-foreground">
用例列表(共 {total} 个)
</h3>
</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>
{run.cases.map((item, index) => {
const expanded = expandedIds.has(item.id);
const canExpand =
item.status === "fail" || item.status === "pass";
return (
<tr
key={item.id}
className="border-b border-hairline last:border-b-0"
>
<td colSpan={4} className="p-0">
<button
type="button"
disabled={!canExpand}
onClick={() => toggleExpanded(item.id)}
className={cn(
"flex w-full items-start gap-0 px-5 py-3 text-left transition-colors",
canExpand && "hover:bg-canvas-soft/70",
!canExpand && "cursor-default",
)}
>
<span className="w-12 shrink-0 pt-0.5 tabular-nums text-muted-soft">
{index + 1}
</span>
<span className="min-w-0 flex-1 px-3">
<span className="block font-medium text-foreground">
{item.name}
</span>
{expanded && (
<span className="mt-3 block rounded-xl bg-canvas-soft/80 px-3 py-3">
<span className="grid gap-3 sm:grid-cols-2">
<DetailField
label="预期结果"
value={item.expected}
/>
<DetailField
label="实际结果"
value={item.actual}
/>
</span>
{item.status === "fail" && item.failReason && (
<span className="mt-3 block border-t border-hairline pt-3">
<DetailField
label="失败原因(LLM 判断)"
value={item.failReason}
/>
</span>
)}
</span>
)}
</span>
<span className="w-28 shrink-0 px-3 pt-0.5">
<BatchCaseStatusBadge status={item.status} />
</span>
<span className="flex w-24 shrink-0 items-center gap-1 px-3 pt-0.5 text-muted-foreground">
{canExpand ? (
<>
查看
{expanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
</>
) : (
<span className="text-muted-soft">—</span>
)}
</span>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
</div>
);
}
function PassRateDonut({ pass, fail }: { pass: number; fail: number }) {
const total = pass + fail;
const passRatio = total === 0 ? 0 : pass / total;
const size = 112;
const stroke = 14;
const radius = (size - stroke) / 2;
const circumference = 2 * Math.PI * radius;
const passLength = circumference * passRatio;
const failLength = circumference - passLength;
return (
<div
className="relative shrink-0"
style={{ width: size, height: size }}
aria-hidden
>
<svg width={size} height={size} className="-rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
className="text-surface-strong"
/>
{total > 0 && (
<>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeDasharray={`${passLength} ${circumference}`}
strokeLinecap="butt"
className="text-success"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeDasharray={`${failLength} ${circumference}`}
strokeDashoffset={-passLength}
strokeLinecap="butt"
className="text-destructive"
/>
</>
)}
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-lg font-medium tabular-nums text-foreground">
{total === 0 ? 0 : Math.round((pass / total) * 100)}%
</span>
</div>
</div>
);
}
function LegendRow({
tone,
label,
count,
percent,
}: {
tone: "success" | "destructive" | "muted";
label: string;
count: number;
percent: number;
}) {
const dotClass = {
success: "bg-success",
destructive: "bg-destructive",
muted: "bg-muted-soft",
}[tone];
return (
<div className="flex items-center gap-2 text-muted-foreground">
<span className={cn("size-2.5 rounded-full", dotClass)} />
<span>
{label} {count}({percent}%)
</span>
</div>
);
}
function DetailField({ label, value }: { label: string; value: string }) {
return (
<span className="block">
<span className="block text-xs text-muted-soft">{label}</span>
<span className="mt-1 block text-sm leading-6 text-muted-foreground">
{value}
</span>
</span>
);
}