Update batch test page

This commit is contained in:
Xin Wang
2026-08-08 19:08:48 +08:00
parent 21a25874a9
commit 433ff0b255
9 changed files with 607 additions and 438 deletions
@@ -0,0 +1,211 @@
"use client";
/**
* 批量测试用例详情抽屉。
* 当前展示前端 mock 数据,后续可直接替换为真实执行事件与校验结果。
*/
import {
CheckCircle2,
Circle,
Loader2,
MessageSquareText,
Target,
TriangleAlert,
} from "lucide-react";
import { BatchCaseStatusBadge } from "@/components/batch-test/batch-case-status";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import type { BatchRunCase } from "@/data/batch-run";
import { cn } from "@/lib/utils";
type BatchCaseDetailDrawerProps = {
item: BatchRunCase | null;
assistantName: string;
onClose: () => void;
};
export function BatchCaseDetailDrawer({
item,
assistantName,
onClose,
}: BatchCaseDetailDrawerProps) {
return (
<Sheet
open={Boolean(item)}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<SheetContent
side="right"
className="w-[min(92vw,620px)] border-hairline bg-card p-0 sm:max-w-[620px]"
>
{item && (
<>
<SheetHeader className="border-b border-hairline px-6 py-5 pr-16">
<p className="text-xs font-medium tracking-[0.08em] text-muted-soft uppercase">
用例详情
</p>
<SheetTitle className="text-lg leading-7">
{item.name}
</SheetTitle>
<SheetDescription>
被测助手:{assistantName} · 当前为前端 Mock 结果
</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
<div className="space-y-6">
<section className="flex items-center justify-between rounded-2xl border border-hairline bg-canvas-soft px-4 py-3.5">
<div>
<p className="text-xs text-muted-soft">当前状态</p>
<p className="mt-1 text-sm text-muted-foreground">
{statusDescription(item)}
</p>
</div>
<BatchCaseStatusBadge status={item.status} />
</section>
<DetailSection
icon={<Target size={16} />}
title="预期结果"
value={item.expected}
/>
{(item.status === "waiting" || item.status === "running") && (
<ExecutionTimeline status={item.status} />
)}
{(item.status === "pass" || item.status === "fail") && (
<DetailSection
icon={<MessageSquareText size={16} />}
title="实际回复"
value={item.actual}
/>
)}
{item.status === "fail" && item.failReason && (
<DetailSection
icon={<TriangleAlert size={16} />}
title="失败原因(LLM 判断)"
value={item.failReason}
tone="destructive"
/>
)}
{item.status === "skipped" && (
<div className="rounded-2xl border border-dashed border-hairline-strong px-4 py-4 text-sm leading-6 text-muted-foreground">
该用例未执行,因此没有实际回复与判断结果。
</div>
)}
</div>
</div>
</>
)}
</SheetContent>
</Sheet>
);
}
function statusDescription(item: BatchRunCase): string {
const descriptions = {
waiting: "已加入执行队列,正在等待可用并发。",
running: "正在请求助手响应,结果尚未生成。",
pass: "执行与结果判断均已完成。",
fail: "执行完成,但实际回复未达到预期。",
skipped: "本轮运行已结束,该用例未执行。",
} as const;
return descriptions[item.status];
}
function DetailSection({
icon,
title,
value,
tone = "default",
}: {
icon: React.ReactNode;
title: string;
value: string;
tone?: "default" | "destructive";
}) {
return (
<section>
<div
className={cn(
"flex items-center gap-2 text-sm font-medium text-foreground",
tone === "destructive" && "text-destructive",
)}
>
{icon}
<h3>{title}</h3>
</div>
<p
className={cn(
"mt-2.5 rounded-2xl border border-hairline bg-canvas-soft px-4 py-3.5 text-sm leading-6 text-muted-foreground",
tone === "destructive" &&
"border-destructive/20 bg-destructive/5 text-foreground",
)}
>
{value}
</p>
</section>
);
}
function ExecutionTimeline({ status }: { status: "waiting" | "running" }) {
const isRunning = status === "running";
const steps = [
{
label: "载入测试上下文",
detail: isRunning ? "已完成" : "等待开始",
state: isRunning ? "done" : "pending",
},
{
label: "请求助手响应",
detail: isRunning ? "生成中(Mock)" : "尚未开始",
state: isRunning ? "active" : "pending",
},
{
label: "执行结果判断",
detail: "尚未开始",
state: "pending",
},
] as const;
return (
<section>
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<Loader2 size={16} className={cn(isRunning && "animate-spin")} />
<h3>执行进度</h3>
</div>
<ol className="mt-3 rounded-2xl border border-hairline px-4 py-1">
{steps.map((step) => (
<li
key={step.label}
className="flex items-center gap-3 border-b border-hairline py-3.5 last:border-b-0"
>
{step.state === "done" ? (
<CheckCircle2 size={17} className="shrink-0 text-success" />
) : step.state === "active" ? (
<Loader2 size={17} className="shrink-0 animate-spin text-primary" />
) : (
<Circle size={17} className="shrink-0 text-muted-soft" />
)}
<span className="min-w-0 flex-1 text-sm text-foreground">
{step.label}
</span>
<span className="text-xs text-muted-soft">{step.detail}</span>
</li>
))}
</ol>
</section>
);
}
@@ -4,9 +4,10 @@
* 批量测试 — 已完成结果视图(MVP mock)。
*/
import { ChevronDown, ChevronRight } from "lucide-react";
import { ChevronRight } from "lucide-react";
import { useState } from "react";
import { BatchCaseDetailDrawer } from "@/components/batch-test/batch-case-detail-drawer";
import {
BatchCaseStatusBadge,
BatchPhasePill,
@@ -19,10 +20,9 @@ import {
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 [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
const selectedCase =
run.cases.find((item) => item.id === selectedCaseId) ?? null;
const counts = countByStatus(run.cases);
const judged = counts.pass + counts.fail;
@@ -30,168 +30,130 @@ export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) {
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 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>
)}
<PassRateDonut pass={counts.pass} fail={counts.fail} />
<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>
<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 && (
<PassRateDonut pass={counts.pass} fail={counts.fail} />
<div className="space-y-2 text-sm">
<LegendRow
tone="muted"
label="未执行"
count={counts.skipped}
percent={
total === 0
? 0
: Math.round((counts.skipped / total) * 100)
}
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>
</div>
</section>
</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>
{/* 用例列表 */}
<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}
<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) => {
return (
<tr
key={item.id}
className="border-b border-hairline last:border-b-0"
>
<td colSpan={4} className="p-0">
<button
type="button"
onClick={() => setSelectedCaseId(item.id)}
aria-haspopup="dialog"
className={cn(
"flex w-full items-center gap-0 px-5 py-3 text-left transition-colors hover:bg-canvas-soft/70",
selectedCaseId === item.id && "bg-canvas-soft",
)}
>
<span className="w-12 shrink-0 pt-0.5 tabular-nums text-muted-soft">
{index + 1}
</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 className="min-w-0 flex-1 px-3">
<span className="block font-medium text-foreground">
{item.name}
</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>
</span>
<span className="w-28 shrink-0 px-3">
<BatchCaseStatusBadge status={item.status} />
</span>
<span className="flex w-24 shrink-0 items-center gap-1 px-3 text-muted-foreground">
查看
<ChevronRight size={14} />
</span>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
</div>
</section>
</div>
<BatchCaseDetailDrawer
item={selectedCase}
assistantName={run.assistantName}
onClose={() => setSelectedCaseId(null)}
/>
</>
);
}
@@ -284,14 +246,3 @@ function LegendRow({
</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>
);
}
@@ -4,9 +4,10 @@
* 批量测试 — 运行中视图(MVP mock)。
*/
import { ChevronDown, ChevronRight } from "lucide-react";
import { ChevronRight } from "lucide-react";
import { useState } from "react";
import { BatchCaseDetailDrawer } from "@/components/batch-test/batch-case-detail-drawer";
import {
BatchCaseStatusBadge,
BatchPhasePill,
@@ -18,142 +19,121 @@ import {
import { cn } from "@/lib/utils";
export function BatchRunRunningView({ run }: { run: BatchRunSnapshot }) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
const selectedCase =
run.cases.find((item) => item.id === selectedCaseId) ?? null;
const counts = countByStatus(run.cases);
const done = counts.pass + counts.fail + counts.skipped;
const total = run.cases.length;
const percent = total === 0 ? 0 : Math.round((done / total) * 100);
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-col gap-5 lg:flex-row lg:items-center lg:gap-8">
<div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-medium text-foreground">
{run.title}
</h2>
<BatchPhasePill phase="running" />
</div>
<div className="flex items-center gap-3">
<div className="h-2.5 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-strong">
<div
className="h-full rounded-full bg-primary transition-[width] duration-500 ease-out"
style={{ width: `${percent}%` }}
/>
<>
<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-col gap-5 lg:flex-row lg:items-center lg:gap-8">
<div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-medium text-foreground">
{run.title}
</h2>
<BatchPhasePill phase="running" />
</div>
<div className="shrink-0 text-right text-sm tabular-nums text-muted-foreground">
<span className="text-foreground">
{done} / {total}
</span>{" "}
已完成
<span className="ml-2 text-foreground">{percent}%</span>
<div className="flex items-center gap-3">
<div className="h-2.5 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-strong">
<div
className="h-full rounded-full bg-primary transition-[width] duration-500 ease-out"
style={{ width: `${percent}%` }}
/>
</div>
<div className="shrink-0 text-right text-sm tabular-nums text-muted-foreground">
<span className="text-foreground">
{done} / {total}
</span>{" "}
已完成
<span className="ml-2 text-foreground">{percent}%</span>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-3 border-t border-hairline pt-4 sm:grid-cols-4 lg:border-l lg:border-t-0 lg:pl-8 lg:pt-0">
<Stat label="通过" value={counts.pass} tone="success" />
<Stat label="失败" value={counts.fail} tone="destructive" />
<Stat label="运行中" value={counts.running} tone="primary" />
<Stat label="等待" value={counts.waiting} tone="muted" />
</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="grid grid-cols-2 gap-x-6 gap-y-3 border-t border-hairline pt-4 sm:grid-cols-4 lg:border-l lg:border-t-0 lg:pl-8 lg:pt-0">
<Stat label="通过" value={counts.pass} tone="success" />
<Stat label="失败" value={counts.fail} tone="destructive" />
<Stat label="运行中" value={counts.running} tone="primary" />
<Stat label="等待" value={counts.waiting} tone="muted" />
</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-[520px] 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-10 px-3 py-2.5" />
</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 grid gap-3 sm:grid-cols-2">
<DetailBlock
label="预期结果"
value={item.expected}
/>
<DetailBlock
label="实际结果"
value={item.actual}
/>
</span>
<div className="overflow-x-auto">
<table className="w-full min-w-[520px] 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) => {
return (
<tr
key={item.id}
className="border-b border-hairline last:border-b-0"
>
<td colSpan={4} className="p-0">
<button
type="button"
onClick={() => setSelectedCaseId(item.id)}
aria-haspopup="dialog"
className={cn(
"flex w-full items-center gap-0 px-5 py-3 text-left transition-colors hover:bg-canvas-soft/70",
selectedCaseId === item.id && "bg-canvas-soft",
)}
</span>
<span className="w-28 shrink-0 px-3 pt-0.5">
<BatchCaseStatusBadge status={item.status} />
</span>
<span className="flex w-10 shrink-0 justify-end pt-0.5 text-muted-soft">
{canExpand ? (
expanded ? (
<ChevronDown size={16} />
) : (
<ChevronRight size={16} />
)
) : null}
</span>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
</div>
>
<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>
</span>
<span className="w-28 shrink-0 px-3">
<BatchCaseStatusBadge status={item.status} />
</span>
<span className="flex w-24 shrink-0 items-center gap-1 px-3 text-muted-foreground">
查看
<ChevronRight size={14} />
</span>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
</div>
<BatchCaseDetailDrawer
item={selectedCase}
assistantName={run.assistantName}
onClose={() => setSelectedCaseId(null)}
/>
</>
);
}
@@ -182,14 +162,3 @@ function Stat({
</div>
);
}
function DetailBlock({ label, value }: { label: string; value: string }) {
return (
<span className="block rounded-xl bg-canvas-soft/80 px-3 py-2.5">
<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>
);
}