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:
84
frontend/src/components/batch-test/batch-case-status.tsx
Normal file
84
frontend/src/components/batch-test/batch-case-status.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CheckCircle2, Circle, Loader2, XCircle } from "lucide-react";
|
||||||
|
|
||||||
|
import type { BatchCaseStatus } from "@/data/batch-run";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const STATUS_META: Record<
|
||||||
|
BatchCaseStatus,
|
||||||
|
{ label: string; className: string; Icon: typeof CheckCircle2 }
|
||||||
|
> = {
|
||||||
|
pass: {
|
||||||
|
label: "通过",
|
||||||
|
className: "text-success",
|
||||||
|
Icon: CheckCircle2,
|
||||||
|
},
|
||||||
|
fail: {
|
||||||
|
label: "失败",
|
||||||
|
className: "text-destructive",
|
||||||
|
Icon: XCircle,
|
||||||
|
},
|
||||||
|
running: {
|
||||||
|
label: "运行中",
|
||||||
|
className: "text-primary",
|
||||||
|
Icon: Loader2,
|
||||||
|
},
|
||||||
|
waiting: {
|
||||||
|
label: "等待",
|
||||||
|
className: "text-muted-soft",
|
||||||
|
Icon: Circle,
|
||||||
|
},
|
||||||
|
skipped: {
|
||||||
|
label: "未执行",
|
||||||
|
className: "text-muted-soft",
|
||||||
|
Icon: Circle,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BatchCaseStatusBadge({
|
||||||
|
status,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
status: BatchCaseStatus;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const meta = STATUS_META[status];
|
||||||
|
const Icon = meta.Icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 text-sm font-medium",
|
||||||
|
meta.className,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={16}
|
||||||
|
className={cn(status === "running" && "animate-spin")}
|
||||||
|
/>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BatchPhasePill({
|
||||||
|
phase,
|
||||||
|
}: {
|
||||||
|
phase: "running" | "completed";
|
||||||
|
}) {
|
||||||
|
if (phase === "running") {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary">
|
||||||
|
运行中
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-success/15 px-2.5 py-0.5 text-xs font-medium text-success">
|
||||||
|
已完成
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
297
frontend/src/components/batch-test/batch-run-completed.tsx
Normal file
297
frontend/src/components/batch-test/batch-run-completed.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
195
frontend/src/components/batch-test/batch-run-running.tsx
Normal file
195
frontend/src/components/batch-test/batch-run-running.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"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,
|
||||||
|
type BatchRunSnapshot,
|
||||||
|
} from "@/data/batch-run";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function BatchRunRunningView({ run }: { run: BatchRunSnapshot }) {
|
||||||
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||||
|
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>
|
||||||
|
<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="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>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
tone: "success" | "destructive" | "primary" | "muted";
|
||||||
|
}) {
|
||||||
|
const toneClass = {
|
||||||
|
success: "text-success",
|
||||||
|
destructive: "text-destructive",
|
||||||
|
primary: "text-primary",
|
||||||
|
muted: "text-muted-foreground",
|
||||||
|
}[tone];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-[4.5rem]">
|
||||||
|
<div className="text-xs text-muted-soft">{label}</div>
|
||||||
|
<div className={cn("mt-0.5 text-xl font-medium tabular-nums", toneClass)}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ const monitorSubItems: NavItem[] = [
|
|||||||
|
|
||||||
const testSubItems: NavItem[] = [
|
const testSubItems: NavItem[] = [
|
||||||
{ href: "/test/cases", label: "测试用例", icon: ClipboardList },
|
{ href: "/test/cases", label: "测试用例", icon: ClipboardList },
|
||||||
|
{ href: "/test/batch", label: "批量测试", icon: PlayCircle },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
export function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ export function ListPageLayout({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
action,
|
action,
|
||||||
|
topbarAction,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
action?: ReactNode;
|
action?: ReactNode;
|
||||||
|
/** 右上角 topbar 操作区(如主 CTA) */
|
||||||
|
topbarAction?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -24,7 +27,7 @@ export function ListPageLayout({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TopbarPortal>
|
<TopbarPortal>
|
||||||
<PageTopbar title={title} />
|
<PageTopbar title={title} action={topbarAction} />
|
||||||
</TopbarPortal>
|
</TopbarPortal>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,10 +1,784 @@
|
|||||||
import { PlaceholderPage } from "./PlaceholderPage";
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量测试页(MVP)
|
||||||
|
* 三阶段:配置 → 运行中 → 已完成。执行进度为前端 mock,后续可换真实引擎。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
FolderOpen,
|
||||||
|
Loader2,
|
||||||
|
Play,
|
||||||
|
RotateCcw,
|
||||||
|
Settings2,
|
||||||
|
Square,
|
||||||
|
Target,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { BatchRunCompletedView } from "@/components/batch-test/batch-run-completed";
|
||||||
|
import { BatchRunRunningView } from "@/components/batch-test/batch-run-running";
|
||||||
|
import { SectionAnchorTabs } from "@/components/editor/section-anchor-tabs";
|
||||||
|
import { SectionCard } from "@/components/editor/section-card";
|
||||||
|
import { ListPageLayout } from "@/components/layout/list-page-layout";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
createBatchRunSnapshot,
|
||||||
|
type BatchRunPhase,
|
||||||
|
type BatchRunSnapshot,
|
||||||
|
} from "@/data/batch-run";
|
||||||
|
import {
|
||||||
|
listTestCases,
|
||||||
|
listTestSuites,
|
||||||
|
suiteCaseStats,
|
||||||
|
type TestCase,
|
||||||
|
type TestSuite,
|
||||||
|
} from "@/data/test-suites";
|
||||||
|
import { assistantsApi, type Assistant } from "@/lib/api";
|
||||||
|
|
||||||
|
const CONCURRENCY_OPTIONS = ["1", "2", "3", "5", "10"] as const;
|
||||||
|
|
||||||
|
const FAIL_STRATEGY_OPTIONS = [
|
||||||
|
{ value: "continue", label: "继续执行全部用例" },
|
||||||
|
{ value: "stop_on_fail", label: "遇失败立即停止" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type FailStrategy = (typeof FAIL_STRATEGY_OPTIONS)[number]["value"];
|
||||||
|
|
||||||
|
type BatchSectionId = "target" | "scope" | "settings";
|
||||||
|
|
||||||
|
const BATCH_SECTIONS = [
|
||||||
|
{ id: "target", label: "运行目标" },
|
||||||
|
{ id: "scope", label: "测试范围" },
|
||||||
|
{ id: "settings", label: "运行设置" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TICK_MS = 900;
|
||||||
|
|
||||||
|
function getAppScrollContainer(): HTMLElement | null {
|
||||||
|
return document.querySelector<HTMLElement>(".app-content");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRunTitle(
|
||||||
|
selected: TestCase[],
|
||||||
|
suites: TestSuite[],
|
||||||
|
): string {
|
||||||
|
const suiteIds = [...new Set(selected.map((item) => item.suiteId))];
|
||||||
|
if (suiteIds.length === 1) {
|
||||||
|
return (
|
||||||
|
suites.find((suite) => suite.id === suiteIds[0])?.name ?? "批量测试"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (suiteIds.length > 1) {
|
||||||
|
return `${suiteIds.length} 个测试集 · ${selected.length} 个用例`;
|
||||||
|
}
|
||||||
|
return "批量测试";
|
||||||
|
}
|
||||||
|
|
||||||
export function BatchTestPage() {
|
export function BatchTestPage() {
|
||||||
|
const [phase, setPhase] = useState<BatchRunPhase>("config");
|
||||||
|
const [run, setRun] = useState<BatchRunSnapshot | null>(null);
|
||||||
|
|
||||||
|
const [assistants, setAssistants] = useState<Assistant[]>([]);
|
||||||
|
const [loadingAssistants, setLoadingAssistants] = useState(true);
|
||||||
|
const [assistantId, setAssistantId] = useState("");
|
||||||
|
|
||||||
|
const [suites, setSuites] = useState<TestSuite[]>([]);
|
||||||
|
const [casesBySuite, setCasesBySuite] = useState<Record<string, TestCase[]>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const [expandedSuiteIds, setExpandedSuiteIds] = useState<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
const [selectedCaseIds, setSelectedCaseIds] = useState<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [concurrency, setConcurrency] = useState<string>("3");
|
||||||
|
const [timeoutSecs, setTimeoutSecs] = useState("30");
|
||||||
|
const [failStrategy, setFailStrategy] = useState<FailStrategy>("continue");
|
||||||
|
|
||||||
|
const [activeSection, setActiveSection] =
|
||||||
|
useState<BatchSectionId>("target");
|
||||||
|
const sectionRefs = useRef<Record<BatchSectionId, HTMLElement | null>>({
|
||||||
|
target: null,
|
||||||
|
scope: null,
|
||||||
|
settings: null,
|
||||||
|
});
|
||||||
|
const selectedAnchorRef = useRef<BatchSectionId | null>(null);
|
||||||
|
const stopOnFailRef = useRef(failStrategy === "stop_on_fail");
|
||||||
|
stopOnFailRef.current = failStrategy === "stop_on_fail";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== "config") return;
|
||||||
|
|
||||||
|
const scrollContainer = getAppScrollContainer();
|
||||||
|
if (!scrollContainer) return;
|
||||||
|
|
||||||
|
let animationFrame = 0;
|
||||||
|
|
||||||
|
function updateActiveSection() {
|
||||||
|
if (selectedAnchorRef.current) {
|
||||||
|
setActiveSection(selectedAnchorRef.current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerTop = scrollContainer!.getBoundingClientRect().top;
|
||||||
|
const activationLine = containerTop + 24;
|
||||||
|
let nextSection: BatchSectionId = BATCH_SECTIONS[0].id;
|
||||||
|
|
||||||
|
for (const section of BATCH_SECTIONS) {
|
||||||
|
const element = sectionRefs.current[section.id];
|
||||||
|
if (element && element.getBoundingClientRect().top <= activationLine) {
|
||||||
|
nextSection = section.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reachedBottom =
|
||||||
|
scrollContainer!.scrollHeight > scrollContainer!.clientHeight + 8 &&
|
||||||
|
scrollContainer!.scrollHeight -
|
||||||
|
scrollContainer!.scrollTop -
|
||||||
|
scrollContainer!.clientHeight <
|
||||||
|
8;
|
||||||
|
if (reachedBottom) {
|
||||||
|
nextSection = BATCH_SECTIONS[BATCH_SECTIONS.length - 1].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveSection((current) =>
|
||||||
|
current === nextSection ? current : nextSection,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleUpdate() {
|
||||||
|
window.cancelAnimationFrame(animationFrame);
|
||||||
|
animationFrame = window.requestAnimationFrame(updateActiveSection);
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseSelectedAnchor() {
|
||||||
|
selectedAnchorRef.current = null;
|
||||||
|
scheduleUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleUpdate();
|
||||||
|
scrollContainer.addEventListener("scroll", scheduleUpdate, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
scrollContainer.addEventListener("wheel", releaseSelectedAnchor, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
scrollContainer.addEventListener("touchstart", releaseSelectedAnchor, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
window.addEventListener("resize", scheduleUpdate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(animationFrame);
|
||||||
|
scrollContainer.removeEventListener("scroll", scheduleUpdate);
|
||||||
|
scrollContainer.removeEventListener("wheel", releaseSelectedAnchor);
|
||||||
|
scrollContainer.removeEventListener("touchstart", releaseSelectedAnchor);
|
||||||
|
window.removeEventListener("resize", scheduleUpdate);
|
||||||
|
};
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
|
function scrollToSection(sectionId: BatchSectionId) {
|
||||||
|
const scrollContainer = getAppScrollContainer();
|
||||||
|
const section = sectionRefs.current[sectionId];
|
||||||
|
if (!scrollContainer || !section) return;
|
||||||
|
|
||||||
|
const containerTop = scrollContainer.getBoundingClientRect().top;
|
||||||
|
const sectionTop = section.getBoundingClientRect().top;
|
||||||
|
|
||||||
|
selectedAnchorRef.current = sectionId;
|
||||||
|
setActiveSection(sectionId);
|
||||||
|
scrollContainer.scrollTo({
|
||||||
|
top: scrollContainer.scrollTop + (sectionTop - containerTop) - 12,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextSuites = listTestSuites();
|
||||||
|
setSuites(nextSuites);
|
||||||
|
|
||||||
|
const map: Record<string, TestCase[]> = {};
|
||||||
|
const allCaseIds = new Set<string>();
|
||||||
|
for (const suite of nextSuites) {
|
||||||
|
const items = listTestCases(suite.id);
|
||||||
|
map[suite.id] = items;
|
||||||
|
for (const item of items) allCaseIds.add(item.id);
|
||||||
|
}
|
||||||
|
setCasesBySuite(map);
|
||||||
|
setSelectedCaseIds(allCaseIds);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const list = await assistantsApi.list();
|
||||||
|
setAssistants(list);
|
||||||
|
if (list[0]) setAssistantId(list[0].id);
|
||||||
|
} catch {
|
||||||
|
// 无助手时仍可浏览配置页
|
||||||
|
} finally {
|
||||||
|
setLoadingAssistants(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// mock 执行引擎:按并发推进用例状态
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== "running" || !run) return;
|
||||||
|
|
||||||
|
const limit = Number(concurrency) || 3;
|
||||||
|
let finished = false;
|
||||||
|
|
||||||
|
function tick(settleRunning: boolean) {
|
||||||
|
if (finished) return;
|
||||||
|
|
||||||
|
setRun((prev) => {
|
||||||
|
if (!prev || finished) return prev;
|
||||||
|
|
||||||
|
let cases = prev.cases.map((item) => ({ ...item }));
|
||||||
|
|
||||||
|
// 1) 结算上一拍仍在运行的用例(首拍只启动,不结算)
|
||||||
|
let sawFail = false;
|
||||||
|
if (settleRunning) {
|
||||||
|
for (const item of cases) {
|
||||||
|
if (item.status !== "running") continue;
|
||||||
|
item.status = item.failReason ? "fail" : "pass";
|
||||||
|
if (item.status === "fail") sawFail = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 失败即停:剩余全部标记未执行
|
||||||
|
if (sawFail && stopOnFailRef.current) {
|
||||||
|
cases = cases.map((item) =>
|
||||||
|
item.status === "waiting" || item.status === "running"
|
||||||
|
? { ...item, status: "skipped" as const }
|
||||||
|
: item,
|
||||||
|
);
|
||||||
|
finished = true;
|
||||||
|
window.setTimeout(() => setPhase("completed"), 0);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
cases,
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
stopped: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 按并发补齐运行中
|
||||||
|
let running = cases.filter((item) => item.status === "running").length;
|
||||||
|
for (const item of cases) {
|
||||||
|
if (running >= limit) break;
|
||||||
|
if (item.status !== "waiting") continue;
|
||||||
|
item.status = "running";
|
||||||
|
running += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = cases.some(
|
||||||
|
(item) => item.status === "waiting" || item.status === "running",
|
||||||
|
);
|
||||||
|
if (!pending) {
|
||||||
|
finished = true;
|
||||||
|
window.setTimeout(() => setPhase("completed"), 0);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
cases,
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
stopped: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...prev, cases };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立刻拉起第一批,再按节拍结算/推进
|
||||||
|
tick(false);
|
||||||
|
const timer = window.setInterval(() => tick(true), TICK_MS);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [phase, concurrency, run?.startedAt]);
|
||||||
|
|
||||||
|
const allCaseIds = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.values(casesBySuite)
|
||||||
|
.flat()
|
||||||
|
.map((item) => item.id),
|
||||||
|
[casesBySuite],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedCases = useMemo(() => {
|
||||||
|
const ordered: TestCase[] = [];
|
||||||
|
for (const suite of suites) {
|
||||||
|
for (const item of casesBySuite[suite.id] ?? []) {
|
||||||
|
if (selectedCaseIds.has(item.id)) ordered.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
}, [suites, casesBySuite, selectedCaseIds]);
|
||||||
|
|
||||||
|
const selectedCount = selectedCaseIds.size;
|
||||||
|
const allSelected =
|
||||||
|
allCaseIds.length > 0 && allCaseIds.every((id) => selectedCaseIds.has(id));
|
||||||
|
const someSelected = selectedCount > 0 && !allSelected;
|
||||||
|
|
||||||
|
const timeoutValue = Number(timeoutSecs);
|
||||||
|
const timeoutValid =
|
||||||
|
Number.isFinite(timeoutValue) && timeoutValue > 0 && timeoutValue <= 600;
|
||||||
|
|
||||||
|
const canStart =
|
||||||
|
Boolean(assistantId) &&
|
||||||
|
selectedCount > 0 &&
|
||||||
|
timeoutValid &&
|
||||||
|
!loadingAssistants &&
|
||||||
|
phase === "config";
|
||||||
|
|
||||||
|
function toggleSelectAll() {
|
||||||
|
if (allSelected) {
|
||||||
|
setSelectedCaseIds(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedCaseIds(new Set(allCaseIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSuite(suiteId: string) {
|
||||||
|
const suiteCaseIds = (casesBySuite[suiteId] ?? []).map((item) => item.id);
|
||||||
|
if (suiteCaseIds.length === 0) return;
|
||||||
|
|
||||||
|
const allInSuiteSelected = suiteCaseIds.every((id) =>
|
||||||
|
selectedCaseIds.has(id),
|
||||||
|
);
|
||||||
|
setSelectedCaseIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (allInSuiteSelected) {
|
||||||
|
for (const id of suiteCaseIds) next.delete(id);
|
||||||
|
} else {
|
||||||
|
for (const id of suiteCaseIds) next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCase(caseId: string) {
|
||||||
|
setSelectedCaseIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(caseId)) next.delete(caseId);
|
||||||
|
else next.add(caseId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpanded(suiteId: string) {
|
||||||
|
setExpandedSuiteIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(suiteId)) next.delete(suiteId);
|
||||||
|
else next.add(suiteId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRun(cases: TestCase[]) {
|
||||||
|
const assistantName =
|
||||||
|
assistants.find((item) => item.id === assistantId)?.name ?? "助手";
|
||||||
|
const snapshot = createBatchRunSnapshot({
|
||||||
|
title: buildRunTitle(cases, suites),
|
||||||
|
assistantName,
|
||||||
|
cases,
|
||||||
|
});
|
||||||
|
setRun(snapshot);
|
||||||
|
setPhase("running");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStart() {
|
||||||
|
if (!canStart) return;
|
||||||
|
startRun(selectedCases);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStop() {
|
||||||
|
setRun((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
stopped: true,
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
cases: prev.cases.map((item) =>
|
||||||
|
item.status === "waiting" || item.status === "running"
|
||||||
|
? { ...item, status: "skipped" }
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPhase("completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRerun() {
|
||||||
|
if (selectedCases.length === 0) {
|
||||||
|
setPhase("config");
|
||||||
|
setRun(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startRun(selectedCases);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackToConfig() {
|
||||||
|
setPhase("config");
|
||||||
|
setRun(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "running" && run) {
|
||||||
|
return (
|
||||||
|
<ListPageLayout
|
||||||
|
title="批量测试 / 运行中"
|
||||||
|
description="批量运行测试用例,实时查看执行进度与结果。"
|
||||||
|
className="max-w-[960px]"
|
||||||
|
topbarAction={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 rounded-full border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
onClick={handleStop}
|
||||||
|
>
|
||||||
|
<Square size={14} className="fill-current" />
|
||||||
|
停止运行
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<BatchRunRunningView run={run} />
|
||||||
|
</ListPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "completed" && run) {
|
||||||
|
return (
|
||||||
|
<ListPageLayout
|
||||||
|
title="批量测试 / 已完成"
|
||||||
|
description="批量测试已完成,您可以查看结果或重新运行。"
|
||||||
|
className="max-w-[960px]"
|
||||||
|
topbarAction={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-full border-hairline-strong"
|
||||||
|
onClick={handleBackToConfig}
|
||||||
|
>
|
||||||
|
返回配置
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="gap-2 rounded-full px-4"
|
||||||
|
onClick={handleRerun}
|
||||||
|
>
|
||||||
|
<RotateCcw size={15} />
|
||||||
|
重新运行
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<BatchRunCompletedView run={run} />
|
||||||
|
</ListPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlaceholderPage
|
<ListPageLayout
|
||||||
title="批量测试"
|
title="批量测试"
|
||||||
description="按用例集批量跑测助手,汇总通过率与失败详情。"
|
description="配置一次批量运行:选择被测助手、测试范围与基础运行参数。"
|
||||||
/>
|
className="max-w-[880px]"
|
||||||
|
topbarAction={
|
||||||
|
<Button
|
||||||
|
className="gap-2 rounded-full px-4"
|
||||||
|
disabled={!canStart}
|
||||||
|
onClick={handleStart}
|
||||||
|
>
|
||||||
|
<Play size={16} />
|
||||||
|
开始批量测试
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SectionAnchorTabs
|
||||||
|
ariaLabel="批量测试分区"
|
||||||
|
sections={BATCH_SECTIONS}
|
||||||
|
activeSectionId={activeSection}
|
||||||
|
onSelect={(sectionId) =>
|
||||||
|
scrollToSection(sectionId as BatchSectionId)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<section
|
||||||
|
ref={(element) => {
|
||||||
|
sectionRefs.current.target = element;
|
||||||
|
}}
|
||||||
|
className="scroll-mt-3"
|
||||||
|
>
|
||||||
|
<SectionCard
|
||||||
|
icon={<Target size={15} />}
|
||||||
|
title="运行目标"
|
||||||
|
description="选择本次批量测试要对齐的助手配置"
|
||||||
|
>
|
||||||
|
<label className="block space-y-2">
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
被测助手
|
||||||
|
</span>
|
||||||
|
{loadingAssistants ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
正在加载助手…
|
||||||
|
</div>
|
||||||
|
) : assistants.length === 0 ? (
|
||||||
|
<p className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft/50 px-3 py-4 text-sm text-muted-foreground">
|
||||||
|
暂无可用助手,请先在「创建助手」中新建。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Select value={assistantId} onValueChange={setAssistantId}>
|
||||||
|
<SelectTrigger className="w-full border-hairline-strong bg-background">
|
||||||
|
<SelectValue placeholder="选择被测助手" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{assistants.map((item) => (
|
||||||
|
<SelectItem key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</SectionCard>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
ref={(element) => {
|
||||||
|
sectionRefs.current.scope = element;
|
||||||
|
}}
|
||||||
|
className="scroll-mt-3"
|
||||||
|
>
|
||||||
|
<SectionCard
|
||||||
|
icon={<FolderOpen size={15} />}
|
||||||
|
title="选择测试范围"
|
||||||
|
description="可全选,或展开测试集勾选单个用例"
|
||||||
|
action={
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
|
已选 {selectedCount} 个用例
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="inline-flex cursor-pointer items-center gap-2 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="size-3.5 accent-primary"
|
||||||
|
checked={allSelected}
|
||||||
|
ref={(element) => {
|
||||||
|
if (!element) return;
|
||||||
|
element.indeterminate = someSelected;
|
||||||
|
}}
|
||||||
|
onChange={toggleSelectAll}
|
||||||
|
disabled={allCaseIds.length === 0}
|
||||||
|
/>
|
||||||
|
全选测试集
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{suites.length === 0 ? (
|
||||||
|
<p className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft/50 px-3 py-6 text-center text-xs text-muted-soft">
|
||||||
|
暂无测试集,请先在「测试用例」中创建。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{suites.map((suite) => {
|
||||||
|
const suiteCases = casesBySuite[suite.id] ?? [];
|
||||||
|
const suiteCaseIds = suiteCases.map((item) => item.id);
|
||||||
|
const selectedInSuite = suiteCaseIds.filter((id) =>
|
||||||
|
selectedCaseIds.has(id),
|
||||||
|
).length;
|
||||||
|
const suiteAllSelected =
|
||||||
|
suiteCaseIds.length > 0 &&
|
||||||
|
selectedInSuite === suiteCaseIds.length;
|
||||||
|
const suiteSomeSelected =
|
||||||
|
selectedInSuite > 0 && !suiteAllSelected;
|
||||||
|
const expanded = expandedSuiteIds.has(suite.id);
|
||||||
|
const total = suiteCaseStats(suite.id).total;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={suite.id}
|
||||||
|
className="rounded-2xl border border-hairline bg-card"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
||||||
|
aria-label={
|
||||||
|
expanded
|
||||||
|
? `收起 ${suite.name}`
|
||||||
|
: `展开 ${suite.name}`
|
||||||
|
}
|
||||||
|
onClick={() => toggleExpanded(suite.id)}
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="size-3.5 shrink-0 accent-primary"
|
||||||
|
checked={suiteAllSelected}
|
||||||
|
ref={(element) => {
|
||||||
|
if (!element) return;
|
||||||
|
element.indeterminate = suiteSomeSelected;
|
||||||
|
}}
|
||||||
|
onChange={() => toggleSuite(suite.id)}
|
||||||
|
aria-label={`选择测试集 ${suite.name}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-w-0 flex-1 text-left"
|
||||||
|
onClick={() => toggleExpanded(suite.id)}
|
||||||
|
>
|
||||||
|
<div className="truncate text-sm font-medium text-foreground">
|
||||||
|
{suite.name}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 truncate text-xs text-muted-soft">
|
||||||
|
{suite.description || suite.id} · {total} 个用例
|
||||||
|
{selectedInSuite > 0
|
||||||
|
? ` · 已选 ${selectedInSuite}`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<ul className="space-y-1 border-t border-hairline px-3 py-2 pl-12">
|
||||||
|
{suiteCases.length === 0 ? (
|
||||||
|
<li className="py-2 text-xs text-muted-soft">
|
||||||
|
该测试集暂无用例
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
suiteCases.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<label className="flex cursor-pointer items-start gap-2 rounded-xl px-2 py-2 transition-colors hover:bg-canvas-soft/80">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5 size-3.5 shrink-0 accent-primary"
|
||||||
|
checked={selectedCaseIds.has(item.id)}
|
||||||
|
onChange={() => toggleCase(item.id)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm text-foreground">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
{item.description && (
|
||||||
|
<span className="mt-0.5 block truncate text-xs text-muted-soft">
|
||||||
|
{item.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
ref={(element) => {
|
||||||
|
sectionRefs.current.settings = element;
|
||||||
|
}}
|
||||||
|
className="scroll-mt-3"
|
||||||
|
>
|
||||||
|
<SectionCard
|
||||||
|
icon={<Settings2 size={15} />}
|
||||||
|
title="运行设置"
|
||||||
|
description="MVP 仅保留并发、超时与失败策略"
|
||||||
|
>
|
||||||
|
<div className="grid gap-5 sm:grid-cols-3">
|
||||||
|
<label className="block space-y-2">
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
并发数
|
||||||
|
</span>
|
||||||
|
<Select value={concurrency} onValueChange={setConcurrency}>
|
||||||
|
<SelectTrigger className="w-full border-hairline-strong bg-background">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CONCURRENCY_OPTIONS.map((value) => (
|
||||||
|
<SelectItem key={value} value={value}>
|
||||||
|
{value}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block space-y-2">
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
单用例超时时间
|
||||||
|
</span>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={600}
|
||||||
|
value={timeoutSecs}
|
||||||
|
onChange={(event) => setTimeoutSecs(event.target.value)}
|
||||||
|
className="border-hairline-strong bg-background pr-10"
|
||||||
|
/>
|
||||||
|
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-xs text-muted-soft">
|
||||||
|
秒
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!timeoutValid && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
请输入 1–600 的秒数
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block space-y-2 sm:col-span-1">
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
失败策略
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
value={failStrategy}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFailStrategy(value as FailStrategy)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full border-hairline-strong bg-background">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FAIL_STRATEGY_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</ListPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
|
Copy,
|
||||||
Loader2,
|
Loader2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -56,6 +57,8 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import {
|
import {
|
||||||
createTestCase,
|
createTestCase,
|
||||||
createTestSuite,
|
createTestSuite,
|
||||||
|
duplicateTestCase,
|
||||||
|
duplicateTestSuite,
|
||||||
formatSuiteResult,
|
formatSuiteResult,
|
||||||
getTestSuite,
|
getTestSuite,
|
||||||
listTestCases,
|
listTestCases,
|
||||||
@@ -126,6 +129,12 @@ function SuiteListView() {
|
|||||||
router.push(`/test/cases/${suite.id}`);
|
router.push(`/test/cases/${suite.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function duplicateSuite(suite: TestSuite) {
|
||||||
|
const copied = duplicateTestSuite(suite.id);
|
||||||
|
if (!copied) return;
|
||||||
|
setSuites(listTestSuites());
|
||||||
|
}
|
||||||
|
|
||||||
function removeSuite(suite: TestSuite) {
|
function removeSuite(suite: TestSuite) {
|
||||||
if (
|
if (
|
||||||
!window.confirm(
|
!window.confirm(
|
||||||
@@ -265,6 +274,13 @@ function SuiteListView() {
|
|||||||
align="end"
|
align="end"
|
||||||
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
|
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
|
||||||
>
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="rounded-lg"
|
||||||
|
onSelect={() => duplicateSuite(suite)}
|
||||||
|
>
|
||||||
|
<Copy size={14} />
|
||||||
|
复制
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="rounded-lg"
|
className="rounded-lg"
|
||||||
@@ -565,6 +581,16 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
|||||||
window.setTimeout(() => setStatusMessage(""), 2000);
|
window.setTimeout(() => setStatusMessage(""), 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDuplicateCase(item: TestCase) {
|
||||||
|
if (dirty && !window.confirm("当前用例有未保存修改,复制将丢弃。继续?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const copied = duplicateTestCase(item.id);
|
||||||
|
if (!copied) return;
|
||||||
|
reload(copied.id);
|
||||||
|
setStatusMessage("");
|
||||||
|
}
|
||||||
|
|
||||||
function handleDeleteCase(item: TestCase) {
|
function handleDeleteCase(item: TestCase) {
|
||||||
if (!window.confirm(`确定删除测试用例“${item.name}”吗?`)) return;
|
if (!window.confirm(`确定删除测试用例“${item.name}”吗?`)) return;
|
||||||
removeTestCase(item.id);
|
removeTestCase(item.id);
|
||||||
@@ -696,6 +722,7 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
|||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
onSelectCase={selectCase}
|
onSelectCase={selectCase}
|
||||||
onCreateCase={handleCreateCase}
|
onCreateCase={handleCreateCase}
|
||||||
|
onDuplicateCase={handleDuplicateCase}
|
||||||
onDeleteCase={handleDeleteCase}
|
onDeleteCase={handleDeleteCase}
|
||||||
onEnterSelectionMode={handleEnterSelectionMode}
|
onEnterSelectionMode={handleEnterSelectionMode}
|
||||||
onExitSelectionMode={handleExitSelectionMode}
|
onExitSelectionMode={handleExitSelectionMode}
|
||||||
|
|||||||
@@ -23,7 +23,15 @@ import {
|
|||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { CheckSquare, GripVertical, MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react";
|
import {
|
||||||
|
CheckSquare,
|
||||||
|
Copy,
|
||||||
|
GripVertical,
|
||||||
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -49,6 +57,7 @@ export function SuiteCaseList({
|
|||||||
onSearchChange,
|
onSearchChange,
|
||||||
onSelectCase,
|
onSelectCase,
|
||||||
onCreateCase,
|
onCreateCase,
|
||||||
|
onDuplicateCase,
|
||||||
onDeleteCase,
|
onDeleteCase,
|
||||||
onEnterSelectionMode,
|
onEnterSelectionMode,
|
||||||
onExitSelectionMode,
|
onExitSelectionMode,
|
||||||
@@ -66,6 +75,7 @@ export function SuiteCaseList({
|
|||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
onSelectCase: (item: TestCase) => void;
|
onSelectCase: (item: TestCase) => void;
|
||||||
onCreateCase: () => void;
|
onCreateCase: () => void;
|
||||||
|
onDuplicateCase: (item: TestCase) => void;
|
||||||
onDeleteCase: (item: TestCase) => void;
|
onDeleteCase: (item: TestCase) => void;
|
||||||
onEnterSelectionMode: () => void;
|
onEnterSelectionMode: () => void;
|
||||||
onExitSelectionMode: () => void;
|
onExitSelectionMode: () => void;
|
||||||
@@ -109,71 +119,73 @@ export function SuiteCaseList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex min-h-0 w-full shrink-0 flex-col border-b border-hairline bg-background lg:w-[32%] lg:border-b-0 lg:border-r">
|
<aside className="flex min-h-0 w-full shrink-0 flex-col border-b border-hairline bg-background lg:w-[32%] lg:border-b-0 lg:border-r">
|
||||||
{selectionMode ? (
|
<div className="flex h-14 shrink-0 items-center gap-2 px-4 sm:px-5">
|
||||||
<div className="flex shrink-0 items-center gap-2 border-b border-hairline px-3 py-2.5 sm:px-4">
|
{selectionMode ? (
|
||||||
<label className="flex shrink-0 cursor-pointer items-center gap-2 text-xs text-muted-foreground">
|
<>
|
||||||
<input
|
<label className="flex shrink-0 cursor-pointer items-center gap-2 text-xs text-muted-foreground">
|
||||||
type="checkbox"
|
<input
|
||||||
className="size-3.5 accent-primary"
|
type="checkbox"
|
||||||
checked={allFilteredChecked}
|
className="size-3.5 accent-primary"
|
||||||
ref={(element) => {
|
checked={allFilteredChecked}
|
||||||
if (!element) return;
|
ref={(element) => {
|
||||||
element.indeterminate =
|
if (!element) return;
|
||||||
checkedCount > 0 && checkedCount < filtered.length;
|
element.indeterminate =
|
||||||
}}
|
checkedCount > 0 && checkedCount < filtered.length;
|
||||||
onChange={onToggleSelectAll}
|
}}
|
||||||
aria-label="全选当前列表"
|
onChange={onToggleSelectAll}
|
||||||
/>
|
aria-label="全选当前列表"
|
||||||
全选
|
/>
|
||||||
</label>
|
全选
|
||||||
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
|
</label>
|
||||||
已选择 {checkedCount} 项
|
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
|
||||||
</span>
|
已选择 {checkedCount} 项
|
||||||
<Button
|
</span>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
||||||
disabled={checkedCount === 0}
|
|
||||||
onClick={onBulkDelete}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 border-hairline-strong text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={onExitSelectionMode}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex shrink-0 items-center justify-between gap-3 px-4 py-3 sm:px-5">
|
|
||||||
<p className="min-w-0 truncate text-sm text-muted-foreground">
|
|
||||||
{cases.length} 个测试用例
|
|
||||||
</p>
|
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 gap-1.5 border-hairline-strong text-muted-foreground hover:text-foreground"
|
className="h-8 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
disabled={cases.length === 0}
|
disabled={checkedCount === 0}
|
||||||
onClick={onEnterSelectionMode}
|
onClick={onBulkDelete}
|
||||||
>
|
>
|
||||||
<CheckSquare size={14} />
|
删除
|
||||||
选择
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 shrink-0 gap-1.5"
|
className="h-8 border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||||
onClick={onCreateCase}
|
onClick={onExitSelectionMode}
|
||||||
>
|
>
|
||||||
<Plus size={14} />
|
取消
|
||||||
新建
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<>
|
||||||
|
<p className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
|
||||||
|
{cases.length} 个测试用例
|
||||||
|
</p>
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||||
|
disabled={cases.length === 0}
|
||||||
|
onClick={onEnterSelectionMode}
|
||||||
|
>
|
||||||
|
<CheckSquare size={14} />
|
||||||
|
选择
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-8 shrink-0 gap-1.5"
|
||||||
|
onClick={onCreateCase}
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
新建
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 px-4 pb-3 sm:px-5">
|
<div className="shrink-0 px-4 pb-3 sm:px-5">
|
||||||
<SearchInput
|
<SearchInput
|
||||||
@@ -217,6 +229,7 @@ export function SuiteCaseList({
|
|||||||
dragEnabled={dragEnabled}
|
dragEnabled={dragEnabled}
|
||||||
onSelect={() => onSelectCase(item)}
|
onSelect={() => onSelectCase(item)}
|
||||||
onToggleChecked={() => onToggleChecked(item.id)}
|
onToggleChecked={() => onToggleChecked(item.id)}
|
||||||
|
onDuplicate={() => onDuplicateCase(item)}
|
||||||
onDelete={() => onDeleteCase(item)}
|
onDelete={() => onDeleteCase(item)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -237,6 +250,7 @@ function SortableCaseCard({
|
|||||||
dragEnabled,
|
dragEnabled,
|
||||||
onSelect,
|
onSelect,
|
||||||
onToggleChecked,
|
onToggleChecked,
|
||||||
|
onDuplicate,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
item: TestCase;
|
item: TestCase;
|
||||||
@@ -246,6 +260,7 @@ function SortableCaseCard({
|
|||||||
dragEnabled: boolean;
|
dragEnabled: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
onToggleChecked: () => void;
|
onToggleChecked: () => void;
|
||||||
|
onDuplicate: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
@@ -291,30 +306,32 @@ function SortableCaseCard({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-1.5">
|
<div className="flex items-start gap-1.5">
|
||||||
{selectionMode ? (
|
<div className="mt-0.5 flex h-6 w-5 shrink-0 items-center justify-center">
|
||||||
<input
|
{selectionMode ? (
|
||||||
type="checkbox"
|
<input
|
||||||
className="mt-1 size-3.5 shrink-0 accent-primary"
|
type="checkbox"
|
||||||
checked={checked}
|
className="size-3.5 accent-primary"
|
||||||
onChange={onToggleChecked}
|
checked={checked}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onChange={onToggleChecked}
|
||||||
aria-label={`选择 ${item.name}`}
|
onClick={(event) => event.stopPropagation()}
|
||||||
/>
|
aria-label={`选择 ${item.name}`}
|
||||||
) : (
|
/>
|
||||||
<button
|
) : (
|
||||||
type="button"
|
<button
|
||||||
className={cn(
|
type="button"
|
||||||
"mt-0.5 flex h-6 w-5 shrink-0 cursor-grab items-center justify-center rounded text-muted-soft opacity-0 transition-opacity active:cursor-grabbing group-hover:opacity-100",
|
className={cn(
|
||||||
isDragging && "opacity-100",
|
"flex h-6 w-5 cursor-grab items-center justify-center rounded text-muted-soft opacity-0 transition-opacity active:cursor-grabbing group-hover:opacity-100",
|
||||||
)}
|
isDragging && "opacity-100",
|
||||||
aria-label={`拖拽排序 ${item.name}`}
|
)}
|
||||||
onClick={(event) => event.stopPropagation()}
|
aria-label={`拖拽排序 ${item.name}`}
|
||||||
{...attributes}
|
onClick={(event) => event.stopPropagation()}
|
||||||
{...listeners}
|
{...attributes}
|
||||||
>
|
{...listeners}
|
||||||
<GripVertical size={14} />
|
>
|
||||||
</button>
|
<GripVertical size={14} />
|
||||||
)}
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
@@ -327,12 +344,15 @@ function SortableCaseCard({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!selectionMode && (
|
<div
|
||||||
<div
|
className={cn(
|
||||||
className="flex shrink-0 items-center"
|
"flex h-7 w-7 shrink-0 items-center",
|
||||||
onClick={(event) => event.stopPropagation()}
|
selectionMode && "invisible",
|
||||||
onKeyDown={(event) => event.stopPropagation()}
|
)}
|
||||||
>
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
{!selectionMode && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -355,6 +375,13 @@ function SortableCaseCard({
|
|||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
编辑
|
编辑
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="rounded-lg"
|
||||||
|
onSelect={onDuplicate}
|
||||||
|
>
|
||||||
|
<Copy size={14} />
|
||||||
|
复制
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="rounded-lg"
|
className="rounded-lg"
|
||||||
@@ -368,8 +395,8 @@ function SortableCaseCard({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1.5 line-clamp-2 text-xs leading-5 text-muted-soft">
|
<p className="mt-1.5 line-clamp-2 text-xs leading-5 text-muted-soft">
|
||||||
{item.description || "暂无说明"}
|
{item.description || "暂无说明"}
|
||||||
|
|||||||
115
frontend/src/data/batch-run.ts
Normal file
115
frontend/src/data/batch-run.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* 批量测试运行 — 前端 mock。
|
||||||
|
* 真实执行引擎接入前,用本地状态模拟进度与结果。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TestCase } from "@/data/test-suites";
|
||||||
|
|
||||||
|
export type BatchCaseStatus =
|
||||||
|
| "waiting"
|
||||||
|
| "running"
|
||||||
|
| "pass"
|
||||||
|
| "fail"
|
||||||
|
| "skipped";
|
||||||
|
|
||||||
|
export type BatchRunCase = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: BatchCaseStatus;
|
||||||
|
expected: string;
|
||||||
|
actual: string;
|
||||||
|
failReason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BatchRunPhase = "config" | "running" | "completed";
|
||||||
|
|
||||||
|
export type BatchRunSnapshot = {
|
||||||
|
title: string;
|
||||||
|
assistantName: string;
|
||||||
|
cases: BatchRunCase[];
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
stopped: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 从测试用例生成期望文案(展示用) */
|
||||||
|
export function buildExpectedResult(item: TestCase): string {
|
||||||
|
if (item.assertionType === "llm") {
|
||||||
|
return item.llmCriteria.trim() || "回复应符合 LLM 判断标准。";
|
||||||
|
}
|
||||||
|
if (item.keywords.length === 0) {
|
||||||
|
return "回复应包含预期关键词。";
|
||||||
|
}
|
||||||
|
const mode =
|
||||||
|
item.keywordMatchMode === "all" ? "同时包含" : "至少包含其一";
|
||||||
|
return `回复应${mode}:${item.keywords.join("、")}。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预定部分用例失败,方便演示失败展开态 */
|
||||||
|
function shouldFail(index: number, item: TestCase): boolean {
|
||||||
|
if (item.lastResult === "fail") return true;
|
||||||
|
return index % 4 === 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockFailActual(): string {
|
||||||
|
return "已为您转接人工处理。";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockFailReason(item: TestCase): string {
|
||||||
|
if (item.assertionType === "llm") {
|
||||||
|
return "未按照预期确认关键信息,且错误转接人工。";
|
||||||
|
}
|
||||||
|
return `回复未命中预期关键词(${item.keywords.slice(0, 3).join("、") || "无"})。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockPassActual(item: TestCase): string {
|
||||||
|
if (item.assertionType === "keyword" && item.keywords[0]) {
|
||||||
|
return `好的,请继续描述事故经过,并确认是否有人受伤。`;
|
||||||
|
}
|
||||||
|
return "好的,我已记录,我们继续处理。";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBatchRunSnapshot(input: {
|
||||||
|
title: string;
|
||||||
|
assistantName: string;
|
||||||
|
cases: TestCase[];
|
||||||
|
}): BatchRunSnapshot {
|
||||||
|
return {
|
||||||
|
title: input.title,
|
||||||
|
assistantName: input.assistantName,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
finishedAt: null,
|
||||||
|
stopped: false,
|
||||||
|
cases: input.cases.map((item, index) => {
|
||||||
|
const fail = shouldFail(index, item);
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
status: "waiting" as const,
|
||||||
|
expected: buildExpectedResult(item),
|
||||||
|
actual: fail ? mockFailActual() : mockPassActual(item),
|
||||||
|
failReason: fail ? mockFailReason(item) : "",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countByStatus(cases: BatchRunCase[]) {
|
||||||
|
const counts = {
|
||||||
|
pass: 0,
|
||||||
|
fail: 0,
|
||||||
|
running: 0,
|
||||||
|
waiting: 0,
|
||||||
|
skipped: 0,
|
||||||
|
};
|
||||||
|
for (const item of cases) {
|
||||||
|
counts[item.status] += 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRunTime(iso: string): string {
|
||||||
|
const date = new Date(iso);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||||
|
}
|
||||||
@@ -300,6 +300,38 @@ export function removeTestSuite(id: string): boolean {
|
|||||||
return suites.length < before;
|
return suites.length < before;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 复制测试集及其全部用例;名称加「(副本)」 */
|
||||||
|
export function duplicateTestSuite(id: string): TestSuite | null {
|
||||||
|
const source = getTestSuite(id);
|
||||||
|
if (!source) return null;
|
||||||
|
|
||||||
|
const copied = createTestSuite({
|
||||||
|
name: `${source.name}(副本)`,
|
||||||
|
description: source.description,
|
||||||
|
assistantName: source.assistantName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourceCases = listTestCases(id);
|
||||||
|
const clonedCases: TestCase[] = sourceCases.map((item, index) => ({
|
||||||
|
id: nextCaseId(),
|
||||||
|
suiteId: copied.id,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
kind: item.kind,
|
||||||
|
lastResult: "not_run",
|
||||||
|
contextTurns: item.contextTurns.map((turn) => ({ ...turn })),
|
||||||
|
userInput: item.userInput,
|
||||||
|
assertionType: item.assertionType,
|
||||||
|
keywords: [...item.keywords],
|
||||||
|
keywordMatchMode: item.keywordMatchMode,
|
||||||
|
llmCriteria: item.llmCriteria,
|
||||||
|
sortOrder: index,
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
}));
|
||||||
|
cases = [...cases, ...clonedCases];
|
||||||
|
return copied;
|
||||||
|
}
|
||||||
|
|
||||||
export function createTestCase(input: {
|
export function createTestCase(input: {
|
||||||
suiteId: string;
|
suiteId: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -330,6 +362,36 @@ export function createTestCase(input: {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 复制单条用例;名称加「(副本)」,排在同套件末尾 */
|
||||||
|
export function duplicateTestCase(id: string): TestCase | null {
|
||||||
|
const source = getTestCase(id);
|
||||||
|
if (!source || !getTestSuite(source.suiteId)) return null;
|
||||||
|
|
||||||
|
const maxOrder = cases
|
||||||
|
.filter((item) => item.suiteId === source.suiteId)
|
||||||
|
.reduce((max, item) => Math.max(max, item.sortOrder), -1);
|
||||||
|
|
||||||
|
const copied: TestCase = {
|
||||||
|
id: nextCaseId(),
|
||||||
|
suiteId: source.suiteId,
|
||||||
|
name: `${source.name}(副本)`,
|
||||||
|
description: source.description,
|
||||||
|
kind: source.kind,
|
||||||
|
lastResult: "not_run",
|
||||||
|
contextTurns: source.contextTurns.map((turn) => ({ ...turn })),
|
||||||
|
userInput: source.userInput,
|
||||||
|
assertionType: source.assertionType,
|
||||||
|
keywords: [...source.keywords],
|
||||||
|
keywordMatchMode: source.keywordMatchMode,
|
||||||
|
llmCriteria: source.llmCriteria,
|
||||||
|
sortOrder: maxOrder + 1,
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
};
|
||||||
|
cases = [...cases, copied];
|
||||||
|
updateTestSuite(source.suiteId, {});
|
||||||
|
return copied;
|
||||||
|
}
|
||||||
|
|
||||||
export type TestCasePatch = Partial<
|
export type TestCasePatch = Partial<
|
||||||
Pick<
|
Pick<
|
||||||
TestCase,
|
TestCase,
|
||||||
|
|||||||
Reference in New Issue
Block a user