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

View File

@@ -70,7 +70,13 @@ export function EditorBackButton({
); );
} }
export function AssistantIdentity({ assistantId }: { assistantId: string | null }) { export function AssistantIdentity({
assistantId,
entityLabel = "助手",
}: {
assistantId: string | null;
entityLabel?: string;
}) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
async function copyId() { async function copyId() {
@@ -90,7 +96,9 @@ export function AssistantIdentity({ assistantId }: { assistantId: string | null
type="button" type="button"
onClick={() => void copyId()} onClick={() => void copyId()}
className="ml-1 flex h-7 w-7 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground" className="ml-1 flex h-7 w-7 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
aria-label={copied ? "助手 ID 已复制" : "复制助手 ID"} aria-label={
copied ? `${entityLabel} ID 已复制` : `复制${entityLabel} ID`
}
title={copied ? "已复制" : "复制 ID"} title={copied ? "已复制" : "复制 ID"}
> >
{copied ? <Check size={13} /> : <Copy size={13} />} {copied ? <Check size={13} /> : <Copy size={13} />}

View File

@@ -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>
);
}

View File

@@ -4,9 +4,10 @@
* 批量测试 — 已完成结果视图MVP mock * 批量测试 — 已完成结果视图MVP mock
*/ */
import { ChevronDown, ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { BatchCaseDetailDrawer } from "@/components/batch-test/batch-case-detail-drawer";
import { import {
BatchCaseStatusBadge, BatchCaseStatusBadge,
BatchPhasePill, BatchPhasePill,
@@ -19,10 +20,9 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) { export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => { const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
const firstFail = run.cases.find((item) => item.status === "fail"); const selectedCase =
return firstFail ? new Set([firstFail.id]) : new Set(); run.cases.find((item) => item.id === selectedCaseId) ?? null;
});
const counts = countByStatus(run.cases); const counts = countByStatus(run.cases);
const judged = counts.pass + counts.fail; 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 passRate = judged === 0 ? 0 : Math.round((counts.pass / judged) * 100);
const failRate = judged === 0 ? 0 : 100 - passRate; 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 ( return (
<div className="mx-auto flex w-full max-w-[960px] flex-col gap-4"> <>
{/* 结果总览 */} <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"> <section className="rounded-2xl border border-hairline bg-card p-5 shadow-sm">
<h2 className="text-base font-medium text-foreground">{run.title}</h2> <div className="flex flex-wrap items-center gap-2">
<BatchPhasePill phase="completed" /> <h2 className="text-base font-medium text-foreground">{run.title}</h2>
</div> <BatchPhasePill phase="completed" />
{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> </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"> <PassRateDonut pass={counts.pass} fail={counts.fail} />
<LegendRow
tone="success" <div className="space-y-2 text-sm">
label="通过"
count={counts.pass}
percent={passRate}
/>
<LegendRow
tone="destructive"
label="失败"
count={counts.fail}
percent={failRate}
/>
{counts.skipped > 0 && (
<LegendRow <LegendRow
tone="muted" tone="success"
label="未执行" label="通过"
count={counts.skipped} count={counts.pass}
percent={ percent={passRate}
total === 0
? 0
: Math.round((counts.skipped / total) * 100)
}
/> />
)} <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>
</div> </section>
</section>
{/* 用例列表 */} {/* 用例列表 */}
<section className="rounded-2xl border border-hairline bg-card shadow-sm"> <section className="rounded-2xl border border-hairline bg-card shadow-sm">
<div className="border-b border-hairline px-5 py-3.5"> <div className="border-b border-hairline px-5 py-3.5">
<h3 className="text-sm font-medium text-foreground"> <h3 className="text-sm font-medium text-foreground">
{total} {total}
</h3> </h3>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[560px] text-left text-sm"> <table className="w-full min-w-[560px] text-left text-sm">
<thead> <thead>
<tr className="border-b border-hairline text-xs text-muted-soft"> <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="w-12 px-5 py-2.5 font-medium">#</th>
<th className="px-3 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-28 px-3 py-2.5 font-medium"></th>
<th className="w-24 px-3 py-2.5 font-medium"></th> <th className="w-24 px-3 py-2.5 font-medium"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{run.cases.map((item, index) => { {run.cases.map((item, index) => {
const expanded = expandedIds.has(item.id); return (
const canExpand = <tr
item.status === "fail" || item.status === "pass"; key={item.id}
className="border-b border-hairline last:border-b-0"
return ( >
<tr <td colSpan={4} className="p-0">
key={item.id} <button
className="border-b border-hairline last:border-b-0" type="button"
> onClick={() => setSelectedCaseId(item.id)}
<td colSpan={4} className="p-0"> aria-haspopup="dialog"
<button className={cn(
type="button" "flex w-full items-center gap-0 px-5 py-3 text-left transition-colors hover:bg-canvas-soft/70",
disabled={!canExpand} selectedCaseId === item.id && "bg-canvas-soft",
onClick={() => toggleExpanded(item.id)} )}
className={cn( >
"flex w-full items-start gap-0 px-5 py-3 text-left transition-colors", <span className="w-12 shrink-0 pt-0.5 tabular-nums text-muted-soft">
canExpand && "hover:bg-canvas-soft/70", {index + 1}
!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> </span>
{expanded && ( <span className="min-w-0 flex-1 px-3">
<span className="mt-3 block rounded-xl bg-canvas-soft/80 px-3 py-3"> <span className="block font-medium text-foreground">
<span className="grid gap-3 sm:grid-cols-2"> {item.name}
<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>
</span> <span className="w-28 shrink-0 px-3">
<span className="w-28 shrink-0 px-3 pt-0.5"> <BatchCaseStatusBadge status={item.status} />
<BatchCaseStatusBadge status={item.status} /> </span>
</span> <span className="flex w-24 shrink-0 items-center gap-1 px-3 text-muted-foreground">
<span className="flex w-24 shrink-0 items-center gap-1 px-3 pt-0.5 text-muted-foreground">
{canExpand ? ( <ChevronRight size={14} />
<> </span>
</button>
{expanded ? ( </td>
<ChevronDown size={14} /> </tr>
) : ( );
<ChevronRight size={14} /> })}
)} </tbody>
</> </table>
) : ( </div>
<span className="text-muted-soft"></span> </section>
)}
</span>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div> </div>
</section>
</div> <BatchCaseDetailDrawer
item={selectedCase}
assistantName={run.assistantName}
onClose={() => setSelectedCaseId(null)}
/>
</>
); );
} }
@@ -284,14 +246,3 @@ function LegendRow({
</div> </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>
);
}

View File

@@ -4,9 +4,10 @@
* 批量测试 — 运行中视图MVP mock * 批量测试 — 运行中视图MVP mock
*/ */
import { ChevronDown, ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { BatchCaseDetailDrawer } from "@/components/batch-test/batch-case-detail-drawer";
import { import {
BatchCaseStatusBadge, BatchCaseStatusBadge,
BatchPhasePill, BatchPhasePill,
@@ -18,142 +19,121 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function BatchRunRunningView({ run }: { run: BatchRunSnapshot }) { 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 counts = countByStatus(run.cases);
const done = counts.pass + counts.fail + counts.skipped; const done = counts.pass + counts.fail + counts.skipped;
const total = run.cases.length; const total = run.cases.length;
const percent = total === 0 ? 0 : Math.round((done / total) * 100); 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 ( return (
<div className="mx-auto flex w-full max-w-[960px] flex-col gap-4"> <>
{/* 进度总览 */} <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"> <section className="rounded-2xl border border-hairline bg-card p-5 shadow-sm">
<div className="min-w-0 flex-1 space-y-3"> <div className="flex flex-col gap-5 lg:flex-row lg:items-center lg:gap-8">
<div className="flex flex-wrap items-center gap-2"> <div className="min-w-0 flex-1 space-y-3">
<h2 className="text-base font-medium text-foreground"> <div className="flex flex-wrap items-center gap-2">
{run.title} <h2 className="text-base font-medium text-foreground">
</h2> {run.title}
<BatchPhasePill phase="running" /> </h2>
</div> <BatchPhasePill phase="running" />
<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>
<div className="shrink-0 text-right text-sm tabular-nums text-muted-foreground">
<span className="text-foreground"> <div className="flex items-center gap-3">
{done} / {total} <div className="h-2.5 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-strong">
</span>{" "} <div
className="h-full rounded-full bg-primary transition-[width] duration-500 ease-out"
<span className="ml-2 text-foreground">{percent}%</span> 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> </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>
<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"> <div className="overflow-x-auto">
<Stat label="通过" value={counts.pass} tone="success" /> <table className="w-full min-w-[520px] text-left text-sm">
<Stat label="失败" value={counts.fail} tone="destructive" /> <thead>
<Stat label="运行中" value={counts.running} tone="primary" /> <tr className="border-b border-hairline text-xs text-muted-soft">
<Stat label="等待" value={counts.waiting} tone="muted" /> <th className="w-12 px-5 py-2.5 font-medium">#</th>
</div> <th className="px-3 py-2.5 font-medium"></th>
</div> <th className="w-28 px-3 py-2.5 font-medium"></th>
</section> <th className="w-24 px-3 py-2.5 font-medium"></th>
</tr>
{/* 用例列表 */} </thead>
<section className="rounded-2xl border border-hairline bg-card shadow-sm"> <tbody>
<div className="border-b border-hairline px-5 py-3.5"> {run.cases.map((item, index) => {
<h3 className="text-sm font-medium text-foreground"> return (
{total} <tr
</h3> key={item.id}
</div> className="border-b border-hairline last:border-b-0"
>
<div className="overflow-x-auto"> <td colSpan={4} className="p-0">
<table className="w-full min-w-[520px] text-left text-sm"> <button
<thead> type="button"
<tr className="border-b border-hairline text-xs text-muted-soft"> onClick={() => setSelectedCaseId(item.id)}
<th className="w-12 px-5 py-2.5 font-medium">#</th> aria-haspopup="dialog"
<th className="px-3 py-2.5 font-medium"></th> className={cn(
<th className="w-28 px-3 py-2.5 font-medium"></th> "flex w-full items-center gap-0 px-5 py-3 text-left transition-colors hover:bg-canvas-soft/70",
<th className="w-10 px-3 py-2.5" /> selectedCaseId === item.id && "bg-canvas-soft",
</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"> <span className="w-12 shrink-0 pt-0.5 tabular-nums text-muted-soft">
<BatchCaseStatusBadge status={item.status} /> {index + 1}
</span> </span>
<span className="flex w-10 shrink-0 justify-end pt-0.5 text-muted-soft"> <span className="min-w-0 flex-1 px-3">
{canExpand ? ( <span className="block font-medium text-foreground">
expanded ? ( {item.name}
<ChevronDown size={16} /> </span>
) : ( </span>
<ChevronRight size={16} /> <span className="w-28 shrink-0 px-3">
) <BatchCaseStatusBadge status={item.status} />
) : null} </span>
</span> <span className="flex w-24 shrink-0 items-center gap-1 px-3 text-muted-foreground">
</button>
</td> <ChevronRight size={14} />
</tr> </span>
); </button>
})} </td>
</tbody> </tr>
</table> );
</div> })}
</section> </tbody>
</div> </table>
</div>
</section>
</div>
<BatchCaseDetailDrawer
item={selectedCase}
assistantName={run.assistantName}
onClose={() => setSelectedCaseId(null)}
/>
</>
); );
} }
@@ -182,14 +162,3 @@ function Stat({
</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>
);
}

View File

@@ -85,6 +85,20 @@ function buildRunTitle(
return "批量测试"; return "批量测试";
} }
function loadTestScope() {
const suites = listTestSuites();
const casesBySuite: Record<string, TestCase[]> = {};
const caseIds: string[] = [];
for (const suite of suites) {
const items = listTestCases(suite.id);
casesBySuite[suite.id] = items;
for (const item of items) caseIds.push(item.id);
}
return { suites, casesBySuite, caseIds };
}
export function BatchTestPage() { export function BatchTestPage() {
const [phase, setPhase] = useState<BatchRunPhase>("config"); const [phase, setPhase] = useState<BatchRunPhase>("config");
const [run, setRun] = useState<BatchRunSnapshot | null>(null); const [run, setRun] = useState<BatchRunSnapshot | null>(null);
@@ -93,15 +107,13 @@ export function BatchTestPage() {
const [loadingAssistants, setLoadingAssistants] = useState(true); const [loadingAssistants, setLoadingAssistants] = useState(true);
const [assistantId, setAssistantId] = useState(""); const [assistantId, setAssistantId] = useState("");
const [suites, setSuites] = useState<TestSuite[]>([]); const [{ suites, casesBySuite, caseIds: initialCaseIds }] =
const [casesBySuite, setCasesBySuite] = useState<Record<string, TestCase[]>>( useState(loadTestScope);
{},
);
const [expandedSuiteIds, setExpandedSuiteIds] = useState<Set<string>>( const [expandedSuiteIds, setExpandedSuiteIds] = useState<Set<string>>(
new Set(), new Set(),
); );
const [selectedCaseIds, setSelectedCaseIds] = useState<Set<string>>( const [selectedCaseIds, setSelectedCaseIds] = useState<Set<string>>(
new Set(), () => new Set(initialCaseIds),
); );
const [concurrency, setConcurrency] = useState<string>("3"); const [concurrency, setConcurrency] = useState<string>("3");
@@ -116,8 +128,6 @@ export function BatchTestPage() {
settings: null, settings: null,
}); });
const selectedAnchorRef = useRef<BatchSectionId | null>(null); const selectedAnchorRef = useRef<BatchSectionId | null>(null);
const stopOnFailRef = useRef(failStrategy === "stop_on_fail");
stopOnFailRef.current = failStrategy === "stop_on_fail";
useEffect(() => { useEffect(() => {
if (phase !== "config") return; if (phase !== "config") return;
@@ -207,19 +217,6 @@ export function BatchTestPage() {
} }
useEffect(() => { 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 () => { void (async () => {
try { try {
const list = await assistantsApi.list(); const list = await assistantsApi.list();
@@ -235,16 +232,13 @@ export function BatchTestPage() {
// mock 执行引擎:按并发推进用例状态 // mock 执行引擎:按并发推进用例状态
useEffect(() => { useEffect(() => {
if (phase !== "running" || !run) return; if (phase !== "running" || !run?.startedAt) return;
const limit = Number(concurrency) || 3; const limit = Number(concurrency) || 3;
let finished = false;
function tick(settleRunning: boolean) { function tick(settleRunning: boolean) {
if (finished) return;
setRun((prev) => { setRun((prev) => {
if (!prev || finished) return prev; if (!prev || prev.finishedAt) return prev;
let cases = prev.cases.map((item) => ({ ...item })); let cases = prev.cases.map((item) => ({ ...item }));
@@ -259,14 +253,12 @@ export function BatchTestPage() {
} }
// 失败即停:剩余全部标记未执行 // 失败即停:剩余全部标记未执行
if (sawFail && stopOnFailRef.current) { if (sawFail && failStrategy === "stop_on_fail") {
cases = cases.map((item) => cases = cases.map((item) =>
item.status === "waiting" || item.status === "running" item.status === "waiting" || item.status === "running"
? { ...item, status: "skipped" as const } ? { ...item, status: "skipped" as const }
: item, : item,
); );
finished = true;
window.setTimeout(() => setPhase("completed"), 0);
return { return {
...prev, ...prev,
cases, cases,
@@ -288,8 +280,6 @@ export function BatchTestPage() {
(item) => item.status === "waiting" || item.status === "running", (item) => item.status === "waiting" || item.status === "running",
); );
if (!pending) { if (!pending) {
finished = true;
window.setTimeout(() => setPhase("completed"), 0);
return { return {
...prev, ...prev,
cases, cases,
@@ -306,7 +296,14 @@ export function BatchTestPage() {
tick(false); tick(false);
const timer = window.setInterval(() => tick(true), TICK_MS); const timer = window.setInterval(() => tick(true), TICK_MS);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [phase, concurrency, run?.startedAt]); }, [phase, concurrency, failStrategy, run?.startedAt]);
// 执行引擎只负责写入完成快照;页面阶段在快照稳定后统一切换。
useEffect(() => {
if (phase !== "running" || !run?.finishedAt) return;
const timer = window.setTimeout(() => setPhase("completed"), 0);
return () => window.clearTimeout(timer);
}, [phase, run?.finishedAt]);
const allCaseIds = useMemo( const allCaseIds = useMemo(
() => () =>

View File

@@ -16,6 +16,7 @@ import {
Rocket, Rocket,
Save, Save,
Trash2, Trash2,
X,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
@@ -53,13 +54,11 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { import {
createTestCase, createTestCase,
createTestSuite, createTestSuite,
duplicateTestCase, duplicateTestCase,
duplicateTestSuite, duplicateTestSuite,
formatSuiteResult,
getTestSuite, getTestSuite,
listTestCases, listTestCases,
listTestSuites, listTestSuites,
@@ -91,27 +90,23 @@ export type TestCasesPageProps =
export function TestCasesPage(props: TestCasesPageProps) { export function TestCasesPage(props: TestCasesPageProps) {
if (props.mode === "list") return <SuiteListView />; if (props.mode === "list") return <SuiteListView />;
if (props.mode === "create") return <SuiteCreateView />; if (props.mode === "create") return <SuiteCreateView />;
return <SuiteDetailView suiteId={props.suiteId} />; return <SuiteDetailView key={props.suiteId} suiteId={props.suiteId} />;
} }
// ─── Suite 列表 ────────────────────────────────────────────────────────────── // ─── Suite 列表 ──────────────────────────────────────────────────────────────
function SuiteListView() { function SuiteListView() {
const router = useRouter(); const router = useRouter();
const [suites, setSuites] = useState<TestSuite[]>([]); const [suites, setSuites] = useState<TestSuite[]>(() => listTestSuites());
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
useEffect(() => {
setSuites(listTestSuites());
}, []);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const keyword = search.trim().toLowerCase(); const keyword = search.trim().toLowerCase();
return suites.filter((suite) => { return suites.filter((suite) => {
if (!keyword) return true; if (!keyword) return true;
return [suite.name, suite.description, suite.assistantName, suite.id] return [suite.name, suite.assistantName, suite.id]
.join(" ") .join(" ")
.toLowerCase() .toLowerCase()
.includes(keyword); .includes(keyword);
@@ -209,8 +204,8 @@ function SuiteListView() {
<div className="truncate font-medium text-foreground"> <div className="truncate font-medium text-foreground">
{suite.name} {suite.name}
</div> </div>
<div className="mt-1 truncate text-xs text-muted-soft"> <div className="mt-1 truncate font-mono text-xs text-muted-soft">
{suite.description || suite.id} {suite.id}
</div> </div>
</> </>
), ),
@@ -229,13 +224,6 @@ function SuiteListView() {
cellClassName: "tabular-nums text-muted-foreground", cellClassName: "tabular-nums text-muted-foreground",
cell: (suite) => suiteCaseStats(suite.id).total, cell: (suite) => suiteCaseStats(suite.id).total,
}, },
{
key: "lastResult",
header: "最近结果",
width: "md:w-[112px]",
cellClassName: "tabular-nums text-muted-foreground",
cell: (suite) => formatSuiteResult(suite.id),
},
{ {
key: "actions", key: "actions",
header: "操作", header: "操作",
@@ -316,7 +304,6 @@ function SuiteCreateView() {
const [assistants, setAssistants] = useState<Assistant[]>([]); const [assistants, setAssistants] = useState<Assistant[]>([]);
const [loadingAssistants, setLoadingAssistants] = useState(true); const [loadingAssistants, setLoadingAssistants] = useState(true);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [assistantName, setAssistantName] = useState(""); const [assistantName, setAssistantName] = useState("");
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
@@ -339,7 +326,7 @@ function SuiteCreateView() {
setCreating(true); setCreating(true);
const saved = createTestSuite({ const saved = createTestSuite({
name, name,
description, description: "",
assistantName, assistantName,
}); });
router.push(`/test/cases/${saved.id}`); router.push(`/test/cases/${saved.id}`);
@@ -377,49 +364,36 @@ function SuiteCreateView() {
</ListPageSection> </ListPageSection>
<ListPageSection> <ListPageSection>
<div className="space-y-5"> <div>
<label className="block"> <div className="mb-2 text-sm font-medium text-foreground">
<div className="mb-2 text-sm font-medium text-foreground"></div>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="用途说明(可选)"
rows={4}
className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</label>
<div>
<div className="mb-2 text-sm font-medium text-foreground">
</div>
{loadingAssistants ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
) : assistants.length > 0 ? (
<Select value={assistantName} onValueChange={setAssistantName}>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<SelectValue placeholder="选择关联助手" />
</SelectTrigger>
<SelectContent>
{assistants.map((item) => (
<SelectItem key={item.id} value={item.name}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={assistantName}
onChange={(event) => setAssistantName(event.target.value)}
placeholder="助手名称"
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
)}
</div> </div>
{loadingAssistants ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
) : assistants.length > 0 ? (
<Select value={assistantName} onValueChange={setAssistantName}>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<SelectValue placeholder="选择关联助手" />
</SelectTrigger>
<SelectContent>
{assistants.map((item) => (
<SelectItem key={item.id} value={item.name}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={assistantName}
onChange={(event) => setAssistantName(event.target.value)}
placeholder="助手名称"
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
)}
</div> </div>
</ListPageSection> </ListPageSection>
@@ -468,15 +442,41 @@ function draftsEqual(a: CaseEditorDraft, b: CaseEditorDraft) {
return JSON.stringify(a) === JSON.stringify(b); return JSON.stringify(a) === JSON.stringify(b);
} }
function createEmptyCaseDraft(): CaseEditorDraft {
return {
name: "未命名用例",
kind: SUPPORTED_TEST_CASE_KIND,
contextTurns: [],
userInput: "",
assertionType: "keyword",
keywords: [],
keywordMatchMode: "any",
llmCriteria: "",
};
}
function SuiteDetailView({ suiteId }: { suiteId: string }) { function SuiteDetailView({ suiteId }: { suiteId: string }) {
const router = useRouter(); const router = useRouter();
const [suite, setSuite] = useState<TestSuite | null>(null); const [initial] = useState(() => {
const [cases, setCases] = useState<TestCase[]>([]); const initialCases = listTestCases(suiteId);
const [selectedId, setSelectedId] = useState<string | null>(null); const initialCase = initialCases[0] ?? null;
const initialDraft = initialCase ? caseToDraft(initialCase) : null;
return {
suite: getTestSuite(suiteId),
cases: initialCases,
selectedId: initialCase?.id ?? null,
draft: initialDraft,
};
});
const [suite, setSuite] = useState<TestSuite | null>(initial.suite);
const [cases, setCases] = useState<TestCase[]>(initial.cases);
const [selectedId, setSelectedId] = useState<string | null>(
initial.selectedId,
);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [draft, setDraft] = useState<CaseEditorDraft | null>(null); const [draft, setDraft] = useState<CaseEditorDraft | null>(initial.draft);
const [savedSnapshot, setSavedSnapshot] = useState<CaseEditorDraft | null>( const [savedSnapshot, setSavedSnapshot] = useState<CaseEditorDraft | null>(
null, initial.draft,
); );
const [statusMessage, setStatusMessage] = useState(""); const [statusMessage, setStatusMessage] = useState("");
const [selectionMode, setSelectionMode] = useState(false); const [selectionMode, setSelectionMode] = useState(false);
@@ -512,18 +512,11 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
} }
} }
useEffect(() => {
setSelectionMode(false);
setCheckedIds(new Set());
reload();
// eslint-disable-next-line react-hooks/exhaustive-deps -- route-driven load
}, [suiteId]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const keyword = search.trim().toLowerCase(); const keyword = search.trim().toLowerCase();
return cases.filter((item) => { return cases.filter((item) => {
if (!keyword) return true; if (!keyword) return true;
return [item.name, item.description, TEST_CASE_KIND_LABEL[item.kind]] return [item.name, TEST_CASE_KIND_LABEL[item.kind]]
.join(" ") .join(" ")
.toLowerCase() .toLowerCase()
.includes(keyword); .includes(keyword);
@@ -532,8 +525,9 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
const dirty = const dirty =
draft !== null && draft !== null &&
savedSnapshot !== null && (selectedId === null ||
!draftsEqual(draft, savedSnapshot); savedSnapshot === null ||
!draftsEqual(draft, savedSnapshot));
function selectCase(item: TestCase) { function selectCase(item: TestCase) {
if (dirty && !window.confirm("当前用例有未保存修改,切换将丢弃。继续?")) { if (dirty && !window.confirm("当前用例有未保存修改,切换将丢弃。继续?")) {
@@ -550,18 +544,15 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
if (dirty && !window.confirm("当前用例有未保存修改,新建将丢弃。继续?")) { if (dirty && !window.confirm("当前用例有未保存修改,新建将丢弃。继续?")) {
return; return;
} }
const created = createTestCase({ setSelectedId(null);
suiteId, setDraft(createEmptyCaseDraft());
name: "未命名用例", setSavedSnapshot(null);
});
if (!created) return;
reload(created.id);
setStatusMessage(""); setStatusMessage("");
} }
function handleSave() { function handleSave() {
if (!selectedId || !draft) return; if (!draft) return;
const saved = updateTestCase(selectedId, { const patch = {
name: draft.name.trim() || "未命名用例", name: draft.name.trim() || "未命名用例",
kind: draft.kind, kind: draft.kind,
contextTurns: draft.contextTurns, contextTurns: draft.contextTurns,
@@ -570,9 +561,21 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
keywords: draft.keywords, keywords: draft.keywords,
keywordMatchMode: draft.keywordMatchMode, keywordMatchMode: draft.keywordMatchMode,
llmCriteria: draft.llmCriteria, llmCriteria: draft.llmCriteria,
}); };
let saved: TestCase | null;
if (selectedId) {
saved = updateTestCase(selectedId, patch);
} else {
const created = createTestCase({
suiteId,
name: patch.name,
});
saved = created ? updateTestCase(created.id, patch) : null;
}
if (!saved) return; if (!saved) return;
const nextDraft = caseToDraft(saved); const nextDraft = caseToDraft(saved);
setSelectedId(saved.id);
setCases(listTestCases(suiteId)); setCases(listTestCases(suiteId));
setSuite(getTestSuite(suiteId)); setSuite(getTestSuite(suiteId));
setDraft(nextDraft); setDraft(nextDraft);
@@ -581,6 +584,18 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
window.setTimeout(() => setStatusMessage(""), 2000); window.setTimeout(() => setStatusMessage(""), 2000);
} }
function handleCancelNewCase() {
reload();
setStatusMessage("");
}
function handleBackToList() {
if (dirty && !window.confirm("当前用例有未保存修改,离开将丢弃。继续?")) {
return;
}
router.push("/test/cases");
}
function handleDuplicateCase(item: TestCase) { function handleDuplicateCase(item: TestCase) {
if (dirty && !window.confirm("当前用例有未保存修改,复制将丢弃。继续?")) { if (dirty && !window.confirm("当前用例有未保存修改,复制将丢弃。继续?")) {
return; return;
@@ -696,7 +711,7 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
<div className="flex h-full min-w-0 flex-1 items-center gap-2 sm:-ml-2 lg:-ml-4"> <div className="flex h-full min-w-0 flex-1 items-center gap-2 sm:-ml-2 lg:-ml-4">
<EditorBackButton <EditorBackButton
ariaLabel="返回测试集列表" ariaLabel="返回测试集列表"
onClick={() => router.push("/test/cases")} onClick={handleBackToList}
/> />
<EditableTitle <EditableTitle
value={suite.name} value={suite.name}
@@ -704,7 +719,7 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
placeholder="未命名测试集" placeholder="未命名测试集"
editLabel="测试集名称" editLabel="测试集名称"
/> />
<AssistantIdentity assistantId={suite.id} /> <AssistantIdentity assistantId={suite.id} entityLabel="测试集" />
</div> </div>
</TopbarPortal> </TopbarPortal>
@@ -765,8 +780,17 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{TEST_CASE_KIND_OPTIONS.map((option) => ( {TEST_CASE_KIND_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem
{option.label} key={option.value}
value={option.value}
disabled={!option.available}
>
<span>{option.label}</span>
{!option.available && (
<span className="ml-auto text-[11px] font-normal text-muted-soft">
</span>
)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -792,11 +816,17 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="border-hairline-strong text-muted-foreground hover:text-destructive" className={
onClick={handleDeleteSelected} selectedId
? "border-hairline-strong text-muted-foreground hover:text-destructive"
: "border-hairline-strong text-muted-foreground hover:text-foreground"
}
onClick={
selectedId ? handleDeleteSelected : handleCancelNewCase
}
> >
<Trash2 size={14} /> {selectedId ? <Trash2 size={14} /> : <X size={14} />}
{selectedId ? "删除" : "取消"}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -379,6 +379,7 @@ export function NextReplyEditorBody({
<button <button
key={option.value} key={option.value}
type="button" type="button"
aria-pressed={active}
onClick={() => onClick={() =>
onChange({ onChange({
...draft, ...draft,
@@ -491,6 +492,7 @@ export function NextReplyEditorBody({
<button <button
key={option.value} key={option.value}
type="button" type="button"
aria-pressed={active}
onClick={() => onClick={() =>
onChange({ onChange({
...draft, ...draft,

View File

@@ -398,9 +398,6 @@ function SortableCaseCard({
)} )}
</div> </div>
</div> </div>
<p className="mt-1.5 line-clamp-2 text-xs leading-5 text-muted-soft">
{item.description || "暂无说明"}
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -63,12 +63,16 @@ export const TEST_CASE_KIND_LABEL: Record<TestCaseKind, string> = {
voice_run: "语音运行", voice_run: "语音运行",
}; };
export const TEST_CASE_KIND_OPTIONS: { value: TestCaseKind; label: string }[] = [ export const TEST_CASE_KIND_OPTIONS: {
{ value: "next_reply", label: "单步回复" }, value: TestCaseKind;
{ value: "tool_call", label: "工具调用" }, label: string;
{ value: "fixed_dialogue", label: "固定对话" }, available: boolean;
{ value: "user_simulation", label: "用户模拟" }, }[] = [
{ value: "voice_run", label: "语音运行" }, { value: "next_reply", label: "单步回复", available: true },
{ value: "tool_call", label: "工具调用", available: false },
{ value: "fixed_dialogue", label: "固定对话", available: false },
{ value: "user_simulation", label: "用户模拟", available: false },
{ value: "voice_run", label: "语音运行", available: false },
]; ];
export const ASSERTION_TYPE_LABEL: Record<AssertionType, string> = { export const ASSERTION_TYPE_LABEL: Record<AssertionType, string> = {