Update batch test page
This commit is contained in:
@@ -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);
|
||||
|
||||
async function copyId() {
|
||||
@@ -90,7 +96,9 @@ export function AssistantIdentity({ assistantId }: { assistantId: string | null
|
||||
type="button"
|
||||
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"
|
||||
aria-label={copied ? "助手 ID 已复制" : "复制助手 ID"}
|
||||
aria-label={
|
||||
copied ? `${entityLabel} ID 已复制` : `复制${entityLabel} ID`
|
||||
}
|
||||
title={copied ? "已复制" : "复制 ID"}
|
||||
>
|
||||
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||
|
||||
211
frontend/src/components/batch-test/batch-case-detail-drawer.tsx
Normal file
211
frontend/src/components/batch-test/batch-case-detail-drawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -4,9 +4,10 @@
|
||||
* 批量测试 — 已完成结果视图(MVP mock)。
|
||||
*/
|
||||
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { BatchCaseDetailDrawer } from "@/components/batch-test/batch-case-detail-drawer";
|
||||
import {
|
||||
BatchCaseStatusBadge,
|
||||
BatchPhasePill,
|
||||
@@ -19,10 +20,9 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) {
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => {
|
||||
const firstFail = run.cases.find((item) => item.status === "fail");
|
||||
return firstFail ? new Set([firstFail.id]) : new Set();
|
||||
});
|
||||
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
||||
const selectedCase =
|
||||
run.cases.find((item) => item.id === selectedCaseId) ?? null;
|
||||
|
||||
const counts = countByStatus(run.cases);
|
||||
const judged = counts.pass + counts.fail;
|
||||
@@ -30,168 +30,130 @@ export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) {
|
||||
const passRate = judged === 0 ? 0 : Math.round((counts.pass / judged) * 100);
|
||||
const failRate = judged === 0 ? 0 : 100 - passRate;
|
||||
|
||||
function toggleExpanded(id: string) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[960px] flex-col gap-4">
|
||||
{/* 结果总览 */}
|
||||
<section className="rounded-2xl border border-hairline bg-card p-5 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-base font-medium text-foreground">{run.title}</h2>
|
||||
<BatchPhasePill phase="completed" />
|
||||
</div>
|
||||
{run.finishedAt && (
|
||||
<p className="mt-1.5 text-xs text-muted-soft">
|
||||
完成时间:{formatRunTime(run.finishedAt)}
|
||||
{run.stopped ? " · 已手动停止" : ""}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex flex-col items-center gap-6 sm:flex-row sm:justify-between sm:gap-8">
|
||||
<div className="text-center sm:text-left">
|
||||
<div className="font-display text-3xl text-ink">
|
||||
{counts.pass} / {judged || total} 通过
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium text-success">
|
||||
通过率 {passRate}%
|
||||
</div>
|
||||
<>
|
||||
<div className="mx-auto flex w-full max-w-[960px] flex-col gap-4">
|
||||
{/* 结果总览 */}
|
||||
<section className="rounded-2xl border border-hairline bg-card p-5 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-base font-medium text-foreground">{run.title}</h2>
|
||||
<BatchPhasePill phase="completed" />
|
||||
</div>
|
||||
{run.finishedAt && (
|
||||
<p className="mt-1.5 text-xs text-muted-soft">
|
||||
完成时间:{formatRunTime(run.finishedAt)}
|
||||
{run.stopped ? " · 已手动停止" : ""}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<PassRateDonut pass={counts.pass} fail={counts.fail} />
|
||||
<div className="mt-5 flex flex-col items-center gap-6 sm:flex-row sm:justify-between sm:gap-8">
|
||||
<div className="text-center sm:text-left">
|
||||
<div className="font-display text-3xl text-ink">
|
||||
{counts.pass} / {judged || total} 通过
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium text-success">
|
||||
通过率 {passRate}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<LegendRow
|
||||
tone="success"
|
||||
label="通过"
|
||||
count={counts.pass}
|
||||
percent={passRate}
|
||||
/>
|
||||
<LegendRow
|
||||
tone="destructive"
|
||||
label="失败"
|
||||
count={counts.fail}
|
||||
percent={failRate}
|
||||
/>
|
||||
{counts.skipped > 0 && (
|
||||
<PassRateDonut pass={counts.pass} fail={counts.fail} />
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<LegendRow
|
||||
tone="muted"
|
||||
label="未执行"
|
||||
count={counts.skipped}
|
||||
percent={
|
||||
total === 0
|
||||
? 0
|
||||
: Math.round((counts.skipped / total) * 100)
|
||||
}
|
||||
tone="success"
|
||||
label="通过"
|
||||
count={counts.pass}
|
||||
percent={passRate}
|
||||
/>
|
||||
)}
|
||||
<LegendRow
|
||||
tone="destructive"
|
||||
label="失败"
|
||||
count={counts.fail}
|
||||
percent={failRate}
|
||||
/>
|
||||
{counts.skipped > 0 && (
|
||||
<LegendRow
|
||||
tone="muted"
|
||||
label="未执行"
|
||||
count={counts.skipped}
|
||||
percent={
|
||||
total === 0
|
||||
? 0
|
||||
: Math.round((counts.skipped / total) * 100)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{/* 用例列表 */}
|
||||
<section className="rounded-2xl border border-hairline bg-card shadow-sm">
|
||||
<div className="border-b border-hairline px-5 py-3.5">
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
用例列表(共 {total} 个)
|
||||
</h3>
|
||||
</div>
|
||||
{/* 用例列表 */}
|
||||
<section className="rounded-2xl border border-hairline bg-card shadow-sm">
|
||||
<div className="border-b border-hairline px-5 py-3.5">
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
用例列表(共 {total} 个)
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[560px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-hairline text-xs text-muted-soft">
|
||||
<th className="w-12 px-5 py-2.5 font-medium">#</th>
|
||||
<th className="px-3 py-2.5 font-medium">测试用例</th>
|
||||
<th className="w-28 px-3 py-2.5 font-medium">状态</th>
|
||||
<th className="w-24 px-3 py-2.5 font-medium">详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{run.cases.map((item, index) => {
|
||||
const expanded = expandedIds.has(item.id);
|
||||
const canExpand =
|
||||
item.status === "fail" || item.status === "pass";
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-b border-hairline last:border-b-0"
|
||||
>
|
||||
<td colSpan={4} className="p-0">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canExpand}
|
||||
onClick={() => toggleExpanded(item.id)}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-0 px-5 py-3 text-left transition-colors",
|
||||
canExpand && "hover:bg-canvas-soft/70",
|
||||
!canExpand && "cursor-default",
|
||||
)}
|
||||
>
|
||||
<span className="w-12 shrink-0 pt-0.5 tabular-nums text-muted-soft">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 px-3">
|
||||
<span className="block font-medium text-foreground">
|
||||
{item.name}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[560px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-hairline text-xs text-muted-soft">
|
||||
<th className="w-12 px-5 py-2.5 font-medium">#</th>
|
||||
<th className="px-3 py-2.5 font-medium">测试用例</th>
|
||||
<th className="w-28 px-3 py-2.5 font-medium">状态</th>
|
||||
<th className="w-24 px-3 py-2.5 font-medium">详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{run.cases.map((item, index) => {
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-b border-hairline last:border-b-0"
|
||||
>
|
||||
<td colSpan={4} className="p-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedCaseId(item.id)}
|
||||
aria-haspopup="dialog"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-0 px-5 py-3 text-left transition-colors hover:bg-canvas-soft/70",
|
||||
selectedCaseId === item.id && "bg-canvas-soft",
|
||||
)}
|
||||
>
|
||||
<span className="w-12 shrink-0 pt-0.5 tabular-nums text-muted-soft">
|
||||
{index + 1}
|
||||
</span>
|
||||
{expanded && (
|
||||
<span className="mt-3 block rounded-xl bg-canvas-soft/80 px-3 py-3">
|
||||
<span className="grid gap-3 sm:grid-cols-2">
|
||||
<DetailField
|
||||
label="预期结果"
|
||||
value={item.expected}
|
||||
/>
|
||||
<DetailField
|
||||
label="实际结果"
|
||||
value={item.actual}
|
||||
/>
|
||||
</span>
|
||||
{item.status === "fail" && item.failReason && (
|
||||
<span className="mt-3 block border-t border-hairline pt-3">
|
||||
<DetailField
|
||||
label="失败原因(LLM 判断)"
|
||||
value={item.failReason}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 px-3">
|
||||
<span className="block font-medium text-foreground">
|
||||
{item.name}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="w-28 shrink-0 px-3 pt-0.5">
|
||||
<BatchCaseStatusBadge status={item.status} />
|
||||
</span>
|
||||
<span className="flex w-24 shrink-0 items-center gap-1 px-3 pt-0.5 text-muted-foreground">
|
||||
{canExpand ? (
|
||||
<>
|
||||
查看
|
||||
{expanded ? (
|
||||
<ChevronDown size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-soft">—</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</span>
|
||||
<span className="w-28 shrink-0 px-3">
|
||||
<BatchCaseStatusBadge status={item.status} />
|
||||
</span>
|
||||
<span className="flex w-24 shrink-0 items-center gap-1 px-3 text-muted-foreground">
|
||||
查看
|
||||
<ChevronRight size={14} />
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<BatchCaseDetailDrawer
|
||||
item={selectedCase}
|
||||
assistantName={run.assistantName}
|
||||
onClose={() => setSelectedCaseId(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -284,14 +246,3 @@ function LegendRow({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailField({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<span className="block">
|
||||
<span className="block text-xs text-muted-soft">{label}</span>
|
||||
<span className="mt-1 block text-sm leading-6 text-muted-foreground">
|
||||
{value}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
* 批量测试 — 运行中视图(MVP mock)。
|
||||
*/
|
||||
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { BatchCaseDetailDrawer } from "@/components/batch-test/batch-case-detail-drawer";
|
||||
import {
|
||||
BatchCaseStatusBadge,
|
||||
BatchPhasePill,
|
||||
@@ -18,142 +19,121 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function BatchRunRunningView({ run }: { run: BatchRunSnapshot }) {
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
||||
const selectedCase =
|
||||
run.cases.find((item) => item.id === selectedCaseId) ?? null;
|
||||
const counts = countByStatus(run.cases);
|
||||
const done = counts.pass + counts.fail + counts.skipped;
|
||||
const total = run.cases.length;
|
||||
const percent = total === 0 ? 0 : Math.round((done / total) * 100);
|
||||
|
||||
function toggleExpanded(id: string) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[960px] flex-col gap-4">
|
||||
{/* 进度总览 */}
|
||||
<section className="rounded-2xl border border-hairline bg-card p-5 shadow-sm">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-center lg:gap-8">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-base font-medium text-foreground">
|
||||
{run.title}
|
||||
</h2>
|
||||
<BatchPhasePill phase="running" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-2.5 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-strong">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-500 ease-out"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
<>
|
||||
<div className="mx-auto flex w-full max-w-[960px] flex-col gap-4">
|
||||
{/* 进度总览 */}
|
||||
<section className="rounded-2xl border border-hairline bg-card p-5 shadow-sm">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-center lg:gap-8">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-base font-medium text-foreground">
|
||||
{run.title}
|
||||
</h2>
|
||||
<BatchPhasePill phase="running" />
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-sm tabular-nums text-muted-foreground">
|
||||
<span className="text-foreground">
|
||||
{done} / {total}
|
||||
</span>{" "}
|
||||
已完成
|
||||
<span className="ml-2 text-foreground">{percent}%</span>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-2.5 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-strong">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-500 ease-out"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-sm tabular-nums text-muted-foreground">
|
||||
<span className="text-foreground">
|
||||
{done} / {total}
|
||||
</span>{" "}
|
||||
已完成
|
||||
<span className="ml-2 text-foreground">{percent}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 border-t border-hairline pt-4 sm:grid-cols-4 lg:border-l lg:border-t-0 lg:pl-8 lg:pt-0">
|
||||
<Stat label="通过" value={counts.pass} tone="success" />
|
||||
<Stat label="失败" value={counts.fail} tone="destructive" />
|
||||
<Stat label="运行中" value={counts.running} tone="primary" />
|
||||
<Stat label="等待" value={counts.waiting} tone="muted" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 用例列表 */}
|
||||
<section className="rounded-2xl border border-hairline bg-card shadow-sm">
|
||||
<div className="border-b border-hairline px-5 py-3.5">
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
测试用例(共 {total} 个)
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 border-t border-hairline pt-4 sm:grid-cols-4 lg:border-l lg:border-t-0 lg:pl-8 lg:pt-0">
|
||||
<Stat label="通过" value={counts.pass} tone="success" />
|
||||
<Stat label="失败" value={counts.fail} tone="destructive" />
|
||||
<Stat label="运行中" value={counts.running} tone="primary" />
|
||||
<Stat label="等待" value={counts.waiting} tone="muted" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 用例列表 */}
|
||||
<section className="rounded-2xl border border-hairline bg-card shadow-sm">
|
||||
<div className="border-b border-hairline px-5 py-3.5">
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
测试用例(共 {total} 个)
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[520px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-hairline text-xs text-muted-soft">
|
||||
<th className="w-12 px-5 py-2.5 font-medium">#</th>
|
||||
<th className="px-3 py-2.5 font-medium">测试用例</th>
|
||||
<th className="w-28 px-3 py-2.5 font-medium">状态</th>
|
||||
<th className="w-10 px-3 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{run.cases.map((item, index) => {
|
||||
const expanded = expandedIds.has(item.id);
|
||||
const canExpand =
|
||||
item.status === "fail" || item.status === "pass";
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-b border-hairline last:border-b-0"
|
||||
>
|
||||
<td colSpan={4} className="p-0">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canExpand}
|
||||
onClick={() => toggleExpanded(item.id)}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-0 px-5 py-3 text-left transition-colors",
|
||||
canExpand && "hover:bg-canvas-soft/70",
|
||||
!canExpand && "cursor-default",
|
||||
)}
|
||||
>
|
||||
<span className="w-12 shrink-0 pt-0.5 tabular-nums text-muted-soft">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 px-3">
|
||||
<span className="block font-medium text-foreground">
|
||||
{item.name}
|
||||
</span>
|
||||
{expanded && (
|
||||
<span className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<DetailBlock
|
||||
label="预期结果"
|
||||
value={item.expected}
|
||||
/>
|
||||
<DetailBlock
|
||||
label="实际结果"
|
||||
value={item.actual}
|
||||
/>
|
||||
</span>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[520px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-hairline text-xs text-muted-soft">
|
||||
<th className="w-12 px-5 py-2.5 font-medium">#</th>
|
||||
<th className="px-3 py-2.5 font-medium">测试用例</th>
|
||||
<th className="w-28 px-3 py-2.5 font-medium">状态</th>
|
||||
<th className="w-24 px-3 py-2.5 font-medium">详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{run.cases.map((item, index) => {
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-b border-hairline last:border-b-0"
|
||||
>
|
||||
<td colSpan={4} className="p-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedCaseId(item.id)}
|
||||
aria-haspopup="dialog"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-0 px-5 py-3 text-left transition-colors hover:bg-canvas-soft/70",
|
||||
selectedCaseId === item.id && "bg-canvas-soft",
|
||||
)}
|
||||
</span>
|
||||
<span className="w-28 shrink-0 px-3 pt-0.5">
|
||||
<BatchCaseStatusBadge status={item.status} />
|
||||
</span>
|
||||
<span className="flex w-10 shrink-0 justify-end pt-0.5 text-muted-soft">
|
||||
{canExpand ? (
|
||||
expanded ? (
|
||||
<ChevronDown size={16} />
|
||||
) : (
|
||||
<ChevronRight size={16} />
|
||||
)
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
>
|
||||
<span className="w-12 shrink-0 pt-0.5 tabular-nums text-muted-soft">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 px-3">
|
||||
<span className="block font-medium text-foreground">
|
||||
{item.name}
|
||||
</span>
|
||||
</span>
|
||||
<span className="w-28 shrink-0 px-3">
|
||||
<BatchCaseStatusBadge status={item.status} />
|
||||
</span>
|
||||
<span className="flex w-24 shrink-0 items-center gap-1 px-3 text-muted-foreground">
|
||||
查看
|
||||
<ChevronRight size={14} />
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<BatchCaseDetailDrawer
|
||||
item={selectedCase}
|
||||
assistantName={run.assistantName}
|
||||
onClose={() => setSelectedCaseId(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -182,14 +162,3 @@ function Stat({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailBlock({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<span className="block rounded-xl bg-canvas-soft/80 px-3 py-2.5">
|
||||
<span className="block text-xs text-muted-soft">{label}</span>
|
||||
<span className="mt-1 block text-sm leading-6 text-muted-foreground">
|
||||
{value}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,6 +85,20 @@ function buildRunTitle(
|
||||
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() {
|
||||
const [phase, setPhase] = useState<BatchRunPhase>("config");
|
||||
const [run, setRun] = useState<BatchRunSnapshot | null>(null);
|
||||
@@ -93,15 +107,13 @@ export function BatchTestPage() {
|
||||
const [loadingAssistants, setLoadingAssistants] = useState(true);
|
||||
const [assistantId, setAssistantId] = useState("");
|
||||
|
||||
const [suites, setSuites] = useState<TestSuite[]>([]);
|
||||
const [casesBySuite, setCasesBySuite] = useState<Record<string, TestCase[]>>(
|
||||
{},
|
||||
);
|
||||
const [{ suites, casesBySuite, caseIds: initialCaseIds }] =
|
||||
useState(loadTestScope);
|
||||
const [expandedSuiteIds, setExpandedSuiteIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [selectedCaseIds, setSelectedCaseIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
() => new Set(initialCaseIds),
|
||||
);
|
||||
|
||||
const [concurrency, setConcurrency] = useState<string>("3");
|
||||
@@ -116,8 +128,6 @@ export function BatchTestPage() {
|
||||
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;
|
||||
@@ -207,19 +217,6 @@ export function BatchTestPage() {
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -235,16 +232,13 @@ export function BatchTestPage() {
|
||||
|
||||
// mock 执行引擎:按并发推进用例状态
|
||||
useEffect(() => {
|
||||
if (phase !== "running" || !run) return;
|
||||
if (phase !== "running" || !run?.startedAt) return;
|
||||
|
||||
const limit = Number(concurrency) || 3;
|
||||
let finished = false;
|
||||
|
||||
function tick(settleRunning: boolean) {
|
||||
if (finished) return;
|
||||
|
||||
setRun((prev) => {
|
||||
if (!prev || finished) return prev;
|
||||
if (!prev || prev.finishedAt) return prev;
|
||||
|
||||
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) =>
|
||||
item.status === "waiting" || item.status === "running"
|
||||
? { ...item, status: "skipped" as const }
|
||||
: item,
|
||||
);
|
||||
finished = true;
|
||||
window.setTimeout(() => setPhase("completed"), 0);
|
||||
return {
|
||||
...prev,
|
||||
cases,
|
||||
@@ -288,8 +280,6 @@ export function BatchTestPage() {
|
||||
(item) => item.status === "waiting" || item.status === "running",
|
||||
);
|
||||
if (!pending) {
|
||||
finished = true;
|
||||
window.setTimeout(() => setPhase("completed"), 0);
|
||||
return {
|
||||
...prev,
|
||||
cases,
|
||||
@@ -306,7 +296,14 @@ export function BatchTestPage() {
|
||||
tick(false);
|
||||
const timer = window.setInterval(() => tick(true), TICK_MS);
|
||||
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(
|
||||
() =>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Rocket,
|
||||
Save,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -53,13 +54,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
createTestCase,
|
||||
createTestSuite,
|
||||
duplicateTestCase,
|
||||
duplicateTestSuite,
|
||||
formatSuiteResult,
|
||||
getTestSuite,
|
||||
listTestCases,
|
||||
listTestSuites,
|
||||
@@ -91,27 +90,23 @@ export type TestCasesPageProps =
|
||||
export function TestCasesPage(props: TestCasesPageProps) {
|
||||
if (props.mode === "list") return <SuiteListView />;
|
||||
if (props.mode === "create") return <SuiteCreateView />;
|
||||
return <SuiteDetailView suiteId={props.suiteId} />;
|
||||
return <SuiteDetailView key={props.suiteId} suiteId={props.suiteId} />;
|
||||
}
|
||||
|
||||
// ─── Suite 列表 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function SuiteListView() {
|
||||
const router = useRouter();
|
||||
const [suites, setSuites] = useState<TestSuite[]>([]);
|
||||
const [suites, setSuites] = useState<TestSuite[]>(() => listTestSuites());
|
||||
const [search, setSearch] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSuites(listTestSuites());
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
return suites.filter((suite) => {
|
||||
if (!keyword) return true;
|
||||
return [suite.name, suite.description, suite.assistantName, suite.id]
|
||||
return [suite.name, suite.assistantName, suite.id]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(keyword);
|
||||
@@ -209,8 +204,8 @@ function SuiteListView() {
|
||||
<div className="truncate font-medium text-foreground">
|
||||
{suite.name}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-muted-soft">
|
||||
{suite.description || suite.id}
|
||||
<div className="mt-1 truncate font-mono text-xs text-muted-soft">
|
||||
{suite.id}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
@@ -229,13 +224,6 @@ function SuiteListView() {
|
||||
cellClassName: "tabular-nums text-muted-foreground",
|
||||
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",
|
||||
header: "操作",
|
||||
@@ -316,7 +304,6 @@ function SuiteCreateView() {
|
||||
const [assistants, setAssistants] = useState<Assistant[]>([]);
|
||||
const [loadingAssistants, setLoadingAssistants] = useState(true);
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [assistantName, setAssistantName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
@@ -339,7 +326,7 @@ function SuiteCreateView() {
|
||||
setCreating(true);
|
||||
const saved = createTestSuite({
|
||||
name,
|
||||
description,
|
||||
description: "",
|
||||
assistantName,
|
||||
});
|
||||
router.push(`/test/cases/${saved.id}`);
|
||||
@@ -377,49 +364,36 @@ function SuiteCreateView() {
|
||||
</ListPageSection>
|
||||
|
||||
<ListPageSection>
|
||||
<div className="space-y-5">
|
||||
<label className="block">
|
||||
<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 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>
|
||||
</ListPageSection>
|
||||
|
||||
@@ -468,15 +442,41 @@ function draftsEqual(a: CaseEditorDraft, b: CaseEditorDraft) {
|
||||
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 }) {
|
||||
const router = useRouter();
|
||||
const [suite, setSuite] = useState<TestSuite | null>(null);
|
||||
const [cases, setCases] = useState<TestCase[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [initial] = useState(() => {
|
||||
const initialCases = listTestCases(suiteId);
|
||||
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 [draft, setDraft] = useState<CaseEditorDraft | null>(null);
|
||||
const [draft, setDraft] = useState<CaseEditorDraft | null>(initial.draft);
|
||||
const [savedSnapshot, setSavedSnapshot] = useState<CaseEditorDraft | null>(
|
||||
null,
|
||||
initial.draft,
|
||||
);
|
||||
const [statusMessage, setStatusMessage] = useState("");
|
||||
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 keyword = search.trim().toLowerCase();
|
||||
return cases.filter((item) => {
|
||||
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(" ")
|
||||
.toLowerCase()
|
||||
.includes(keyword);
|
||||
@@ -532,8 +525,9 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
||||
|
||||
const dirty =
|
||||
draft !== null &&
|
||||
savedSnapshot !== null &&
|
||||
!draftsEqual(draft, savedSnapshot);
|
||||
(selectedId === null ||
|
||||
savedSnapshot === null ||
|
||||
!draftsEqual(draft, savedSnapshot));
|
||||
|
||||
function selectCase(item: TestCase) {
|
||||
if (dirty && !window.confirm("当前用例有未保存修改,切换将丢弃。继续?")) {
|
||||
@@ -550,18 +544,15 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
||||
if (dirty && !window.confirm("当前用例有未保存修改,新建将丢弃。继续?")) {
|
||||
return;
|
||||
}
|
||||
const created = createTestCase({
|
||||
suiteId,
|
||||
name: "未命名用例",
|
||||
});
|
||||
if (!created) return;
|
||||
reload(created.id);
|
||||
setSelectedId(null);
|
||||
setDraft(createEmptyCaseDraft());
|
||||
setSavedSnapshot(null);
|
||||
setStatusMessage("");
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!selectedId || !draft) return;
|
||||
const saved = updateTestCase(selectedId, {
|
||||
if (!draft) return;
|
||||
const patch = {
|
||||
name: draft.name.trim() || "未命名用例",
|
||||
kind: draft.kind,
|
||||
contextTurns: draft.contextTurns,
|
||||
@@ -570,9 +561,21 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
||||
keywords: draft.keywords,
|
||||
keywordMatchMode: draft.keywordMatchMode,
|
||||
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;
|
||||
const nextDraft = caseToDraft(saved);
|
||||
setSelectedId(saved.id);
|
||||
setCases(listTestCases(suiteId));
|
||||
setSuite(getTestSuite(suiteId));
|
||||
setDraft(nextDraft);
|
||||
@@ -581,6 +584,18 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
||||
window.setTimeout(() => setStatusMessage(""), 2000);
|
||||
}
|
||||
|
||||
function handleCancelNewCase() {
|
||||
reload();
|
||||
setStatusMessage("");
|
||||
}
|
||||
|
||||
function handleBackToList() {
|
||||
if (dirty && !window.confirm("当前用例有未保存修改,离开将丢弃。继续?")) {
|
||||
return;
|
||||
}
|
||||
router.push("/test/cases");
|
||||
}
|
||||
|
||||
function handleDuplicateCase(item: TestCase) {
|
||||
if (dirty && !window.confirm("当前用例有未保存修改,复制将丢弃。继续?")) {
|
||||
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">
|
||||
<EditorBackButton
|
||||
ariaLabel="返回测试集列表"
|
||||
onClick={() => router.push("/test/cases")}
|
||||
onClick={handleBackToList}
|
||||
/>
|
||||
<EditableTitle
|
||||
value={suite.name}
|
||||
@@ -704,7 +719,7 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
||||
placeholder="未命名测试集"
|
||||
editLabel="测试集名称"
|
||||
/>
|
||||
<AssistantIdentity assistantId={suite.id} />
|
||||
<AssistantIdentity assistantId={suite.id} entityLabel="测试集" />
|
||||
</div>
|
||||
</TopbarPortal>
|
||||
|
||||
@@ -765,8 +780,17 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TEST_CASE_KIND_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
<SelectItem
|
||||
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>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -792,11 +816,17 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-hairline-strong text-muted-foreground hover:text-destructive"
|
||||
onClick={handleDeleteSelected}
|
||||
className={
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -379,6 +379,7 @@ export function NextReplyEditorBody({
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...draft,
|
||||
@@ -491,6 +492,7 @@ export function NextReplyEditorBody({
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...draft,
|
||||
|
||||
@@ -398,9 +398,6 @@ function SortableCaseCard({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1.5 line-clamp-2 text-xs leading-5 text-muted-soft">
|
||||
{item.description || "暂无说明"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,12 +63,16 @@ export const TEST_CASE_KIND_LABEL: Record<TestCaseKind, string> = {
|
||||
voice_run: "语音运行",
|
||||
};
|
||||
|
||||
export const TEST_CASE_KIND_OPTIONS: { value: TestCaseKind; label: string }[] = [
|
||||
{ value: "next_reply", label: "单步回复" },
|
||||
{ value: "tool_call", label: "工具调用" },
|
||||
{ value: "fixed_dialogue", label: "固定对话" },
|
||||
{ value: "user_simulation", label: "用户模拟" },
|
||||
{ value: "voice_run", label: "语音运行" },
|
||||
export const TEST_CASE_KIND_OPTIONS: {
|
||||
value: TestCaseKind;
|
||||
label: string;
|
||||
available: boolean;
|
||||
}[] = [
|
||||
{ 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> = {
|
||||
|
||||
Reference in New Issue
Block a user