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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user