Compare commits
4 Commits
b2e99baa19
...
21a25874a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21a25874a9 | ||
|
|
398b20778d | ||
|
|
2f755969d3 | ||
|
|
36117b976e |
56
frontend/package-lock.json
generated
56
frontend/package-lock.json
generated
@@ -9,6 +9,9 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@daily-co/daily-js": "^0.90.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@pipecat-ai/client-js": "^1.12.0",
|
||||
"@pipecat-ai/small-webrtc-transport": "^1.10.5",
|
||||
"@xyflow/react": "^12.11.0",
|
||||
@@ -477,6 +480,59 @@
|
||||
"node": ">=22.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dotenvx/dotenvx": {
|
||||
"version": "1.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.71.0.tgz",
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@daily-co/daily-js": "^0.90.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@pipecat-ai/client-js": "^1.12.0",
|
||||
"@pipecat-ai/small-webrtc-transport": "^1.10.5",
|
||||
"@xyflow/react": "^12.11.0",
|
||||
|
||||
5
frontend/src/app/test/batch/page.tsx
Normal file
5
frontend/src/app/test/batch/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { BatchTestPage } from "@/components/pages/BatchTestPage";
|
||||
|
||||
export default function Page() {
|
||||
return <BatchTestPage />;
|
||||
}
|
||||
10
frontend/src/app/test/cases/[id]/page.tsx
Normal file
10
frontend/src/app/test/cases/[id]/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { TestCasesPage } from "@/components/pages/TestCasesPage";
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
return <TestCasesPage mode="detail" suiteId={id} />;
|
||||
}
|
||||
5
frontend/src/app/test/cases/new/page.tsx
Normal file
5
frontend/src/app/test/cases/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { TestCasesPage } from "@/components/pages/TestCasesPage";
|
||||
|
||||
export default function Page() {
|
||||
return <TestCasesPage mode="create" />;
|
||||
}
|
||||
5
frontend/src/app/test/cases/page.tsx
Normal file
5
frontend/src/app/test/cases/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { TestCasesPage } from "@/components/pages/TestCasesPage";
|
||||
|
||||
export default function Page() {
|
||||
return <TestCasesPage mode="list" />;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TestPage } from "@/components/pages/TestPage";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Page() {
|
||||
return <TestPage />;
|
||||
redirect("/test/cases");
|
||||
}
|
||||
|
||||
1196
frontend/src/components/assistant-editor/debug-auto-test.tsx
Normal file
1196
frontend/src/components/assistant-editor/debug-auto-test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,12 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
AutoTestLivePanel,
|
||||
AutoTestRunBar,
|
||||
DebugModeTabs,
|
||||
useAutoTestRunner,
|
||||
} from "@/components/assistant-editor/debug-auto-test";
|
||||
import { NetworkQualityIndicator } from "@/components/network-quality-indicator";
|
||||
import { ClientMessageDialog } from "@/components/client-message-dialog";
|
||||
import { AuraVisualizer } from "@/components/ui/aura-visualizer";
|
||||
@@ -67,6 +73,7 @@ type VizStyle = "aura" | "nebula" | "bars" | "wave";
|
||||
// 调试面板顶部主视图:聊天记录 / 视频流
|
||||
type DebugView = "chat" | "video";
|
||||
type DebugInputMode = "mic" | "text";
|
||||
type DebugMode = "manual" | "auto";
|
||||
type PendingDebugImage = {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
@@ -175,6 +182,9 @@ export function DebugDrawer({
|
||||
}) {
|
||||
const preview = useVoicePreview(assistantId, onNodeActive);
|
||||
const camera = useCameraPreview();
|
||||
const autoTest = useAutoTestRunner();
|
||||
const stopAutoTest = autoTest.stop;
|
||||
const [debugMode, setDebugMode] = useState<DebugMode>("manual");
|
||||
const [showTranscript, setShowTranscript] = useState(false);
|
||||
const [vizStyle, setVizStyle] = useState<VizStyle>("aura");
|
||||
const [view, setView] = useState<DebugView>("chat");
|
||||
@@ -218,6 +228,20 @@ export function DebugDrawer({
|
||||
[camera, preview],
|
||||
);
|
||||
|
||||
// 切到自动测试时停掉手动会话,避免两边抢占麦克风/对话区
|
||||
const handleModeChange = useCallback(
|
||||
(mode: DebugMode) => {
|
||||
setDebugMode(mode);
|
||||
if (mode === "auto" && recording) {
|
||||
preview.disconnect();
|
||||
}
|
||||
if (mode === "manual") {
|
||||
stopAutoTest();
|
||||
}
|
||||
},
|
||||
[preview, recording, stopAutoTest],
|
||||
);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={overlay
|
||||
@@ -240,14 +264,18 @@ export function DebugDrawer({
|
||||
<div className="shrink-0 text-sm font-medium text-foreground">
|
||||
调试与预览
|
||||
</div>
|
||||
<NetworkQualityIndicator
|
||||
quality={preview.networkQuality}
|
||||
status={preview.status}
|
||||
/>
|
||||
<DebugConnectionStatus
|
||||
status={preview.status}
|
||||
micWarning={preview.micWarning}
|
||||
/>
|
||||
{debugMode === "manual" && (
|
||||
<>
|
||||
<NetworkQualityIndicator
|
||||
quality={preview.networkQuality}
|
||||
status={preview.status}
|
||||
/>
|
||||
<DebugConnectionStatus
|
||||
status={preview.status}
|
||||
micWarning={preview.micWarning}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ClientToolsPopover tools={preview.clientTools} />
|
||||
@@ -261,7 +289,7 @@ export function DebugDrawer({
|
||||
onChange={setDynamicVariableValues}
|
||||
/>
|
||||
)}
|
||||
{SHOW_VOICE_VIZ && view === "chat" && (
|
||||
{debugMode === "manual" && SHOW_VOICE_VIZ && view === "chat" && (
|
||||
<>
|
||||
{!showTranscript && (
|
||||
<SegmentedIconGroup label="可视化样式">
|
||||
@@ -297,28 +325,72 @@ export function DebugDrawer({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 border-b border-hairline px-5 py-2.5">
|
||||
<div className="flex h-10 min-w-0 items-center rounded-[1.4rem] border border-hairline-strong bg-background px-2">
|
||||
<CameraDeviceField camera={camera} onSelect={selectCamera} />
|
||||
</div>
|
||||
<div className="shrink-0 space-y-2.5 border-b border-hairline px-5 py-2.5">
|
||||
<DebugModeTabs mode={debugMode} onChange={handleModeChange} />
|
||||
{debugMode === "manual" && (
|
||||
<div className="flex h-10 min-w-0 items-center rounded-[1.4rem] border border-hairline-strong bg-background px-2">
|
||||
<CameraDeviceField camera={camera} onSelect={selectCamera} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DebugVoicePanel
|
||||
view={view}
|
||||
onViewChange={setView}
|
||||
showTranscript={showTranscript}
|
||||
vizStyle={vizStyle}
|
||||
assistantId={assistantId}
|
||||
preview={preview}
|
||||
camera={camera}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
vision={vision}
|
||||
dynamicVariables={resolvedDynamicVariables}
|
||||
dynamicVariablesError={dynamicVariablesError}
|
||||
/>
|
||||
{debugMode === "auto" ? (
|
||||
<AutoTestPanel assistantId={assistantId} autoTest={autoTest} />
|
||||
) : (
|
||||
<DebugVoicePanel
|
||||
view={view}
|
||||
onViewChange={setView}
|
||||
showTranscript={showTranscript}
|
||||
vizStyle={vizStyle}
|
||||
assistantId={assistantId}
|
||||
preview={preview}
|
||||
camera={camera}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
vision={vision}
|
||||
dynamicVariables={resolvedDynamicVariables}
|
||||
dynamicVariablesError={dynamicVariablesError}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function AutoTestPanel({
|
||||
assistantId,
|
||||
autoTest,
|
||||
}: {
|
||||
assistantId: string | null;
|
||||
autoTest: ReturnType<typeof useAutoTestRunner>;
|
||||
}) {
|
||||
const {
|
||||
state,
|
||||
selectedCase,
|
||||
selectCase,
|
||||
clearCase,
|
||||
start,
|
||||
stop,
|
||||
rerun,
|
||||
} = autoTest;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<AutoTestLivePanel testCase={selectedCase} state={state} />
|
||||
</div>
|
||||
<div className="shrink-0 border-t border-hairline bg-card p-3">
|
||||
<AutoTestRunBar
|
||||
state={state}
|
||||
assistantId={assistantId}
|
||||
onSelectCase={selectCase}
|
||||
onClearCase={clearCase}
|
||||
onStart={start}
|
||||
onStop={stop}
|
||||
onRerun={rerun}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientToolsPopover({ tools }: { tools: ClientToolDefinition[] }) {
|
||||
const [copiedToolName, setCopiedToolName] = useState<string | null>(null);
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -25,12 +25,15 @@ export function SectionCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
icon?: ReactNode;
|
||||
title?: string;
|
||||
description?: string;
|
||||
/** 标题行右侧操作区(如添加按钮) */
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
@@ -52,12 +55,13 @@ export function SectionCard({
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<CardTitle className="text-sm font-medium leading-none">
|
||||
{title}
|
||||
</CardTitle>
|
||||
{description && <HelpHint text={description} />}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
</CardHeader>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Brain,
|
||||
ChevronLeft,
|
||||
ChevronUp,
|
||||
ClipboardList,
|
||||
Clock3,
|
||||
Database,
|
||||
HelpCircle,
|
||||
@@ -51,6 +52,11 @@ const monitorSubItems: NavItem[] = [
|
||||
{ href: "/dashboard", label: "数据看板", icon: Database },
|
||||
];
|
||||
|
||||
const testSubItems: NavItem[] = [
|
||||
{ href: "/test/cases", label: "测试用例", icon: ClipboardList },
|
||||
{ href: "/test/batch", label: "批量测试", icon: PlayCircle },
|
||||
];
|
||||
|
||||
export function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
@@ -62,6 +68,7 @@ export function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
|
||||
const componentActive = componentSubItems.some((item) => isActive(item.href));
|
||||
const monitorActive = monitorSubItems.some((item) => isActive(item.href));
|
||||
const testActive = testSubItems.some((item) => isActive(item.href));
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
@@ -201,13 +208,43 @@ export function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<NavButton
|
||||
active={isActive("/test")}
|
||||
collapsed={collapsed}
|
||||
icon={PlayCircle}
|
||||
label="测试助手"
|
||||
href="/test"
|
||||
/>
|
||||
{collapsed ? (
|
||||
<div
|
||||
className="flex h-8 items-center justify-center"
|
||||
aria-hidden="true"
|
||||
title="测试助手"
|
||||
>
|
||||
<span className="h-px w-6 rounded-full bg-hairline-strong" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={[
|
||||
"flex h-11 w-full items-center gap-3 rounded-full px-3 text-sm",
|
||||
testActive ? "text-foreground" : "text-muted-foreground",
|
||||
].join(" ")}
|
||||
>
|
||||
<PlayCircle size={18} />
|
||||
<span className="font-medium">测试助手</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={[
|
||||
"mt-1 space-y-1 transition-[padding] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]",
|
||||
collapsed ? "pl-0" : "pl-5",
|
||||
].join(" ")}
|
||||
>
|
||||
{testSubItems.map((item) => (
|
||||
<NavButton
|
||||
key={item.href}
|
||||
active={isActive(item.href)}
|
||||
collapsed={collapsed}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
href={item.href}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -10,12 +10,15 @@ export function ListPageLayout({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
topbarAction,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
action?: ReactNode;
|
||||
/** 右上角 topbar 操作区(如主 CTA) */
|
||||
topbarAction?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
@@ -24,7 +27,7 @@ export function ListPageLayout({
|
||||
return (
|
||||
<>
|
||||
<TopbarPortal>
|
||||
<PageTopbar title={title} />
|
||||
<PageTopbar title={title} action={topbarAction} />
|
||||
</TopbarPortal>
|
||||
|
||||
<div
|
||||
|
||||
784
frontend/src/components/pages/BatchTestPage.tsx
Normal file
784
frontend/src/components/pages/BatchTestPage.tsx
Normal file
@@ -0,0 +1,784 @@
|
||||
"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() {
|
||||
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 (
|
||||
<ListPageLayout
|
||||
title="批量测试"
|
||||
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>
|
||||
);
|
||||
}
|
||||
822
frontend/src/components/pages/TestCasesPage.tsx
Normal file
822
frontend/src/components/pages/TestCasesPage.tsx
Normal file
@@ -0,0 +1,822 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 测试用例管理:
|
||||
* - 列表页:Test Suite 一级容器
|
||||
* - 详情页:左列表 + 右「单步回复」编辑器(只编辑,不运行)
|
||||
*/
|
||||
|
||||
import {
|
||||
ChevronLeft,
|
||||
Copy,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Rocket,
|
||||
Save,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
AssistantIdentity,
|
||||
EditableTitle,
|
||||
EditorBackButton,
|
||||
} from "@/components/assistant-editor/editor-controls";
|
||||
import {
|
||||
ListPageLayout,
|
||||
ListPageSection,
|
||||
} from "@/components/layout/list-page-layout";
|
||||
import { TopbarPortal } from "@/components/layout/topbar-portal";
|
||||
import {
|
||||
NextReplyEditorBody,
|
||||
type CaseEditorDraft,
|
||||
} from "@/components/test-cases/next-reply-editor";
|
||||
import { SuiteCaseList } from "@/components/test-cases/suite-case-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DataList } from "@/components/ui/data-list";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ListToolbar } from "@/components/ui/list-toolbar";
|
||||
import { SearchInput } from "@/components/ui/search-input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
createTestCase,
|
||||
createTestSuite,
|
||||
duplicateTestCase,
|
||||
duplicateTestSuite,
|
||||
formatSuiteResult,
|
||||
getTestSuite,
|
||||
listTestCases,
|
||||
listTestSuites,
|
||||
removeTestCase,
|
||||
removeTestCases,
|
||||
removeTestSuite,
|
||||
reorderTestCases,
|
||||
suiteCaseStats,
|
||||
SUPPORTED_TEST_CASE_KIND,
|
||||
TEST_CASE_KIND_LABEL,
|
||||
TEST_CASE_KIND_OPTIONS,
|
||||
updateTestCase,
|
||||
updateTestSuite,
|
||||
type TestCase,
|
||||
type TestCaseKind,
|
||||
type TestSuite,
|
||||
} from "@/data/test-suites";
|
||||
import { assistantsApi, type Assistant } from "@/lib/api";
|
||||
|
||||
// 路由驱动:
|
||||
// /test/cases → list
|
||||
// /test/cases/new → create suite
|
||||
// /test/cases/[id] → suite detail (cases)
|
||||
export type TestCasesPageProps =
|
||||
| { mode: "list" }
|
||||
| { mode: "create" }
|
||||
| { mode: "detail"; suiteId: string };
|
||||
|
||||
export function TestCasesPage(props: TestCasesPageProps) {
|
||||
if (props.mode === "list") return <SuiteListView />;
|
||||
if (props.mode === "create") return <SuiteCreateView />;
|
||||
return <SuiteDetailView suiteId={props.suiteId} />;
|
||||
}
|
||||
|
||||
// ─── Suite 列表 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function SuiteListView() {
|
||||
const router = useRouter();
|
||||
const [suites, setSuites] = useState<TestSuite[]>([]);
|
||||
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]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(keyword);
|
||||
});
|
||||
}, [suites, search]);
|
||||
|
||||
const pageSize = 5;
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const safeCurrentPage = Math.min(currentPage, totalPages);
|
||||
const pageStart = (safeCurrentPage - 1) * pageSize;
|
||||
const pageEnd = pageStart + pageSize;
|
||||
const paginated = filtered.slice(pageStart, pageEnd);
|
||||
|
||||
function openSuite(suite: TestSuite) {
|
||||
router.push(`/test/cases/${suite.id}`);
|
||||
}
|
||||
|
||||
function duplicateSuite(suite: TestSuite) {
|
||||
const copied = duplicateTestSuite(suite.id);
|
||||
if (!copied) return;
|
||||
setSuites(listTestSuites());
|
||||
}
|
||||
|
||||
function removeSuite(suite: TestSuite) {
|
||||
if (
|
||||
!window.confirm(
|
||||
`确定删除测试集“${suite.name}”及其全部测试用例吗?`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setDeletingId(suite.id);
|
||||
removeTestSuite(suite.id);
|
||||
setSuites(listTestSuites());
|
||||
setDeletingId(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<ListPageLayout
|
||||
title="测试用例"
|
||||
description="管理用于助手调试的单步回复测试场景。"
|
||||
action={
|
||||
<Button
|
||||
className="w-full shrink-0 gap-2 sm:w-auto"
|
||||
onClick={() => router.push("/test/cases/new")}
|
||||
>
|
||||
<Plus size={16} />
|
||||
新建测试集
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ListPageSection>
|
||||
<ListToolbar
|
||||
className="lg:justify-end"
|
||||
search={
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={(value) => {
|
||||
setSearch(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="搜索测试集..."
|
||||
className="lg:w-[320px]"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataList<TestSuite>
|
||||
rows={paginated}
|
||||
rowKey={(suite) => suite.id}
|
||||
onRowClick={openSuite}
|
||||
empty={{
|
||||
title: suites.length === 0 ? "暂无测试集" : "未找到匹配的测试集",
|
||||
description:
|
||||
suites.length === 0
|
||||
? "点击右上角「新建测试集」开始。"
|
||||
: "请调整关键词后再试。",
|
||||
}}
|
||||
pagination={{
|
||||
page: safeCurrentPage,
|
||||
totalPages,
|
||||
onPageChange: setCurrentPage,
|
||||
summary:
|
||||
filtered.length === 0
|
||||
? "没有数据"
|
||||
: `显示 ${pageStart + 1}-${Math.min(pageEnd, filtered.length)} / 共 ${filtered.length} 个测试集`,
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
key: "name",
|
||||
header: "测试集名称",
|
||||
width: "md:w-[320px]",
|
||||
cell: (suite) => (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "assistant",
|
||||
header: "关联助手",
|
||||
width: "md:w-[160px]",
|
||||
cellClassName: "text-muted-foreground",
|
||||
cell: (suite) => suite.assistantName || "—",
|
||||
},
|
||||
{
|
||||
key: "caseCount",
|
||||
header: "用例数",
|
||||
width: "md:w-[96px]",
|
||||
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: "操作",
|
||||
align: "right",
|
||||
cell: (suite) => (
|
||||
<div
|
||||
className="flex justify-end gap-2"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openSuite(suite);
|
||||
}}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
编辑
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
aria-label={`${suite.name} 更多操作`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal size={15} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
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
|
||||
variant="destructive"
|
||||
className="rounded-lg"
|
||||
disabled={deletingId === suite.id}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
window.setTimeout(() => removeSuite(suite), 0);
|
||||
}}
|
||||
>
|
||||
{deletingId === suite.id ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ListPageSection>
|
||||
</ListPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 新建测试集 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function SuiteCreateView() {
|
||||
const router = useRouter();
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const list = await assistantsApi.list();
|
||||
setAssistants(list);
|
||||
if (list[0]) setAssistantName(list[0].name);
|
||||
} catch {
|
||||
setAssistantName("视频快处助手");
|
||||
} finally {
|
||||
setLoadingAssistants(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
function confirmCreate() {
|
||||
if (!name.trim() || creating) return;
|
||||
setCreating(true);
|
||||
const saved = createTestSuite({
|
||||
name,
|
||||
description,
|
||||
assistantName,
|
||||
});
|
||||
router.push(`/test/cases/${saved.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<ListPageLayout
|
||||
title="新建测试集"
|
||||
className="max-w-[1180px]"
|
||||
description="测试集是单步回复用例的业务分组。确认后进入用例编辑。"
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground sm:w-auto"
|
||||
onClick={() => router.push("/test/cases")}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
返回列表
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ListPageSection>
|
||||
<label className="block">
|
||||
<div className="mb-2 text-sm font-medium text-foreground">
|
||||
测试集名称
|
||||
</div>
|
||||
<Input
|
||||
value={name}
|
||||
autoFocus
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="例如:事故基础流程"
|
||||
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
||||
/>
|
||||
</label>
|
||||
</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>
|
||||
</ListPageSection>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
disabled={creating}
|
||||
onClick={() => router.push("/test/cases")}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={!name.trim() || creating}
|
||||
onClick={confirmCreate}
|
||||
>
|
||||
{creating ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Rocket size={16} />
|
||||
)}
|
||||
创建测试集
|
||||
</Button>
|
||||
</div>
|
||||
</ListPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Suite 详情:左列表 + 右编辑 ─────────────────────────────────────────────
|
||||
|
||||
function caseToDraft(item: TestCase): CaseEditorDraft {
|
||||
return {
|
||||
name: item.name,
|
||||
kind: item.kind,
|
||||
contextTurns: item.contextTurns.map((turn) => ({ ...turn })),
|
||||
userInput: item.userInput,
|
||||
assertionType: item.assertionType,
|
||||
keywords: [...item.keywords],
|
||||
keywordMatchMode: item.keywordMatchMode,
|
||||
llmCriteria: item.llmCriteria,
|
||||
};
|
||||
}
|
||||
|
||||
function draftsEqual(a: CaseEditorDraft, b: CaseEditorDraft) {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
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 [search, setSearch] = useState("");
|
||||
const [draft, setDraft] = useState<CaseEditorDraft | null>(null);
|
||||
const [savedSnapshot, setSavedSnapshot] = useState<CaseEditorDraft | null>(
|
||||
null,
|
||||
);
|
||||
const [statusMessage, setStatusMessage] = useState("");
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
function reload(preferId?: string | null) {
|
||||
const nextSuite = getTestSuite(suiteId);
|
||||
const nextCases = listTestCases(suiteId);
|
||||
setSuite(nextSuite);
|
||||
setCases(nextCases);
|
||||
|
||||
const nextSelected =
|
||||
(preferId && nextCases.some((item) => item.id === preferId)
|
||||
? preferId
|
||||
: null) ??
|
||||
(selectedId && nextCases.some((item) => item.id === selectedId)
|
||||
? selectedId
|
||||
: null) ??
|
||||
nextCases[0]?.id ??
|
||||
null;
|
||||
|
||||
setSelectedId(nextSelected);
|
||||
if (nextSelected) {
|
||||
const item = nextCases.find((caseItem) => caseItem.id === nextSelected);
|
||||
if (item) {
|
||||
const nextDraft = caseToDraft(item);
|
||||
setDraft(nextDraft);
|
||||
setSavedSnapshot(nextDraft);
|
||||
}
|
||||
} else {
|
||||
setDraft(null);
|
||||
setSavedSnapshot(null);
|
||||
}
|
||||
}
|
||||
|
||||
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]]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(keyword);
|
||||
});
|
||||
}, [cases, search]);
|
||||
|
||||
const dirty =
|
||||
draft !== null &&
|
||||
savedSnapshot !== null &&
|
||||
!draftsEqual(draft, savedSnapshot);
|
||||
|
||||
function selectCase(item: TestCase) {
|
||||
if (dirty && !window.confirm("当前用例有未保存修改,切换将丢弃。继续?")) {
|
||||
return;
|
||||
}
|
||||
setSelectedId(item.id);
|
||||
const nextDraft = caseToDraft(item);
|
||||
setDraft(nextDraft);
|
||||
setSavedSnapshot(nextDraft);
|
||||
setStatusMessage("");
|
||||
}
|
||||
|
||||
function handleCreateCase() {
|
||||
if (dirty && !window.confirm("当前用例有未保存修改,新建将丢弃。继续?")) {
|
||||
return;
|
||||
}
|
||||
const created = createTestCase({
|
||||
suiteId,
|
||||
name: "未命名用例",
|
||||
});
|
||||
if (!created) return;
|
||||
reload(created.id);
|
||||
setStatusMessage("");
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!selectedId || !draft) return;
|
||||
const saved = updateTestCase(selectedId, {
|
||||
name: draft.name.trim() || "未命名用例",
|
||||
kind: draft.kind,
|
||||
contextTurns: draft.contextTurns,
|
||||
userInput: draft.userInput,
|
||||
assertionType: draft.assertionType,
|
||||
keywords: draft.keywords,
|
||||
keywordMatchMode: draft.keywordMatchMode,
|
||||
llmCriteria: draft.llmCriteria,
|
||||
});
|
||||
if (!saved) return;
|
||||
const nextDraft = caseToDraft(saved);
|
||||
setCases(listTestCases(suiteId));
|
||||
setSuite(getTestSuite(suiteId));
|
||||
setDraft(nextDraft);
|
||||
setSavedSnapshot(nextDraft);
|
||||
setStatusMessage("已保存");
|
||||
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) {
|
||||
if (!window.confirm(`确定删除测试用例“${item.name}”吗?`)) return;
|
||||
removeTestCase(item.id);
|
||||
setCheckedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(item.id);
|
||||
return next;
|
||||
});
|
||||
reload(selectedId === item.id ? null : selectedId);
|
||||
}
|
||||
|
||||
function handleDeleteSelected() {
|
||||
if (!selectedId || !draft) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
`确定删除测试用例“${draft.name.trim() || "未命名用例"}”吗?`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
removeTestCase(selectedId);
|
||||
reload(null);
|
||||
}
|
||||
|
||||
function handleEnterSelectionMode() {
|
||||
setSelectionMode(true);
|
||||
setCheckedIds(new Set());
|
||||
}
|
||||
|
||||
function handleExitSelectionMode() {
|
||||
setSelectionMode(false);
|
||||
setCheckedIds(new Set());
|
||||
}
|
||||
|
||||
function handleToggleChecked(id: string) {
|
||||
setCheckedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleToggleSelectAll() {
|
||||
const allChecked =
|
||||
filtered.length > 0 && filtered.every((item) => checkedIds.has(item.id));
|
||||
if (allChecked) {
|
||||
setCheckedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const item of filtered) next.delete(item.id);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setCheckedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const item of filtered) next.add(item.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleBulkDelete() {
|
||||
const ids = filtered
|
||||
.filter((item) => checkedIds.has(item.id))
|
||||
.map((item) => item.id);
|
||||
if (ids.length === 0) return;
|
||||
if (!window.confirm(`确定删除选中的 ${ids.length} 个测试用例吗?`)) return;
|
||||
removeTestCases(ids);
|
||||
handleExitSelectionMode();
|
||||
reload(selectedId && ids.includes(selectedId) ? null : selectedId);
|
||||
}
|
||||
|
||||
function handleReorder(orderedIds: string[]) {
|
||||
reorderTestCases(suiteId, orderedIds);
|
||||
setCases(listTestCases(suiteId));
|
||||
}
|
||||
|
||||
function renameSuite(nextName: string) {
|
||||
if (!suite || nextName === suite.name) return;
|
||||
const saved = updateTestSuite(suite.id, { name: nextName });
|
||||
if (saved) setSuite(saved);
|
||||
}
|
||||
|
||||
if (!suite) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1280px] flex-col gap-4 py-16">
|
||||
<div className="font-medium text-destructive">测试集不存在</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
onClick={() => router.push("/test/cases")}
|
||||
>
|
||||
返回列表
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopbarPortal>
|
||||
<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")}
|
||||
/>
|
||||
<EditableTitle
|
||||
value={suite.name}
|
||||
onChange={renameSuite}
|
||||
placeholder="未命名测试集"
|
||||
editLabel="测试集名称"
|
||||
/>
|
||||
<AssistantIdentity assistantId={suite.id} />
|
||||
</div>
|
||||
</TopbarPortal>
|
||||
|
||||
<div
|
||||
data-app-content="full-bleed"
|
||||
className="flex h-full min-h-0 flex-col overflow-hidden bg-background lg:flex-row"
|
||||
>
|
||||
<SuiteCaseList
|
||||
cases={cases}
|
||||
filtered={filtered}
|
||||
selectedId={selectedId}
|
||||
search={search}
|
||||
selectionMode={selectionMode}
|
||||
checkedIds={checkedIds}
|
||||
onSearchChange={setSearch}
|
||||
onSelectCase={selectCase}
|
||||
onCreateCase={handleCreateCase}
|
||||
onDuplicateCase={handleDuplicateCase}
|
||||
onDeleteCase={handleDeleteCase}
|
||||
onEnterSelectionMode={handleEnterSelectionMode}
|
||||
onExitSelectionMode={handleExitSelectionMode}
|
||||
onToggleChecked={handleToggleChecked}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
onReorder={handleReorder}
|
||||
/>
|
||||
|
||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
|
||||
{!draft ? (
|
||||
<div className="flex flex-1 items-center justify-center px-6 text-sm text-muted-foreground">
|
||||
请选择左侧用例,或新建一个测试用例。
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-hairline px-4 py-3 sm:gap-3 sm:px-6">
|
||||
<EditableTitle
|
||||
value={draft.name}
|
||||
onChange={(value) =>
|
||||
setDraft({ ...draft, name: value || "未命名用例" })
|
||||
}
|
||||
placeholder="未命名用例"
|
||||
editLabel="用例名称"
|
||||
variant="panel"
|
||||
allowEmpty
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={draft.kind}
|
||||
onValueChange={(value) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
kind: value as TestCaseKind,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[132px] shrink-0 border-hairline-strong bg-background text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TEST_CASE_KIND_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
{dirty ? (
|
||||
<span className="text-xs text-amber-600">未保存</span>
|
||||
) : statusMessage ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{statusMessage}
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
disabled={!dirty || !draft.name.trim()}
|
||||
onClick={handleSave}
|
||||
>
|
||||
<Save size={14} />
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-hairline-strong text-muted-foreground hover:text-destructive"
|
||||
onClick={handleDeleteSelected}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{draft.kind === SUPPORTED_TEST_CASE_KIND ? (
|
||||
<NextReplyEditorBody draft={draft} onChange={setDraft} />
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{TEST_CASE_KIND_LABEL[draft.kind]} · 开发中
|
||||
</div>
|
||||
<p className="max-w-sm text-xs leading-5 text-muted-foreground">
|
||||
当前仅支持「单步回复」编辑器,其它类型稍后接入。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { PlaceholderPage } from "./PlaceholderPage";
|
||||
|
||||
export function TestPage() {
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="测试助手"
|
||||
description="在发布前通过实时视频对话测试助手的表现与交互体验。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
555
frontend/src/components/test-cases/next-reply-editor.tsx
Normal file
555
frontend/src/components/test-cases/next-reply-editor.tsx
Normal file
@@ -0,0 +1,555 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 单步回复 / Next Reply Test 表单主体。
|
||||
* 带与 prompt mode 相同的锚点导航 + 滚动高亮;标题栏由外层负责。
|
||||
*/
|
||||
|
||||
import {
|
||||
MessageSquareText,
|
||||
MessagesSquare,
|
||||
Plus,
|
||||
Sparkles,
|
||||
Target,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { SectionCard } from "@/components/editor/section-card";
|
||||
import { SectionAnchorTabs } from "@/components/editor/section-anchor-tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
ASSERTION_TYPE_LABEL,
|
||||
type AssertionType,
|
||||
type ContextTurn,
|
||||
type KeywordMatchMode,
|
||||
type TestCaseKind,
|
||||
} from "@/data/test-suites";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type CaseEditorDraft = {
|
||||
name: string;
|
||||
kind: TestCaseKind;
|
||||
contextTurns: ContextTurn[];
|
||||
userInput: string;
|
||||
assertionType: AssertionType;
|
||||
keywords: string[];
|
||||
keywordMatchMode: KeywordMatchMode;
|
||||
llmCriteria: string;
|
||||
};
|
||||
|
||||
type NextReplySectionId = "context" | "user_input" | "expectation";
|
||||
|
||||
const NEXT_REPLY_SECTIONS: {
|
||||
id: NextReplySectionId;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ id: "context", label: "对话上下文" },
|
||||
{ id: "user_input", label: "当前用户输入" },
|
||||
{ id: "expectation", label: "结果预期" },
|
||||
];
|
||||
|
||||
/** 单步回复表单主体(含锚点导航) */
|
||||
export function NextReplyEditorBody({
|
||||
draft,
|
||||
onChange,
|
||||
}: {
|
||||
draft: CaseEditorDraft;
|
||||
onChange: (next: CaseEditorDraft) => void;
|
||||
}) {
|
||||
const [keywordDraft, setKeywordDraft] = useState("");
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const sectionRefs = useRef<Record<NextReplySectionId, HTMLElement | null>>({
|
||||
context: null,
|
||||
user_input: null,
|
||||
expectation: null,
|
||||
});
|
||||
const selectedAnchorRef = useRef<NextReplySectionId | null>(null);
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<NextReplySectionId>("context");
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
const scrollContainer: HTMLDivElement = container;
|
||||
|
||||
let animationFrame = 0;
|
||||
|
||||
function updateActiveSection() {
|
||||
if (selectedAnchorRef.current) {
|
||||
setActiveSection(selectedAnchorRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
const containerTop = scrollContainer.getBoundingClientRect().top;
|
||||
const activationLine = containerTop + 24;
|
||||
let nextSection: NextReplySectionId = NEXT_REPLY_SECTIONS[0].id;
|
||||
|
||||
for (const section of NEXT_REPLY_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 = NEXT_REPLY_SECTIONS[NEXT_REPLY_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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function scrollToSection(sectionId: NextReplySectionId) {
|
||||
const container = scrollContainerRef.current;
|
||||
const section = sectionRefs.current[sectionId];
|
||||
if (!container || !section) return;
|
||||
|
||||
const containerTop = container.getBoundingClientRect().top;
|
||||
const sectionTop = section.getBoundingClientRect().top;
|
||||
|
||||
selectedAnchorRef.current = sectionId;
|
||||
setActiveSection(sectionId);
|
||||
container.scrollTo({
|
||||
top: container.scrollTop + sectionTop - containerTop,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
function updateContext(index: number, patch: Partial<ContextTurn>) {
|
||||
onChange({
|
||||
...draft,
|
||||
contextTurns: draft.contextTurns.map((turn, turnIndex) =>
|
||||
turnIndex === index ? { ...turn, ...patch } : turn,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function addContextTurn(role: "agent" | "user") {
|
||||
onChange({
|
||||
...draft,
|
||||
contextTurns: [...draft.contextTurns, { role, content: "" }],
|
||||
});
|
||||
}
|
||||
|
||||
function removeContextTurn(index: number) {
|
||||
onChange({
|
||||
...draft,
|
||||
contextTurns: draft.contextTurns.filter(
|
||||
(_, turnIndex) => turnIndex !== index,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function addKeyword(raw: string) {
|
||||
const value = raw.trim();
|
||||
if (!value) return;
|
||||
if (draft.keywords.includes(value)) {
|
||||
setKeywordDraft("");
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
...draft,
|
||||
keywords: [...draft.keywords, value],
|
||||
});
|
||||
setKeywordDraft("");
|
||||
}
|
||||
|
||||
function removeKeyword(index: number) {
|
||||
onChange({
|
||||
...draft,
|
||||
keywords: draft.keywords.filter((_, itemIndex) => itemIndex !== index),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<SectionAnchorTabs
|
||||
ariaLabel="测试用例分区"
|
||||
sections={NEXT_REPLY_SECTIONS}
|
||||
activeSectionId={activeSection}
|
||||
onSelect={(sectionId) =>
|
||||
scrollToSection(sectionId as NextReplySectionId)
|
||||
}
|
||||
className="bg-background px-4 pt-3 sm:px-6 sm:pt-4 lg:px-8"
|
||||
contentClassName="mx-auto w-full max-w-3xl"
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="scrollbar-subtle min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-none bg-background px-4 pb-6 pt-3 sm:px-6 sm:pb-8 lg:px-8 lg:pb-10"
|
||||
>
|
||||
<div className="mx-auto max-w-3xl space-y-3">
|
||||
{/* 对话上下文 */}
|
||||
<section
|
||||
ref={(element) => {
|
||||
sectionRefs.current.context = element;
|
||||
}}
|
||||
className="scroll-mt-3 space-y-3"
|
||||
>
|
||||
<SectionCard
|
||||
icon={<MessagesSquare size={15} />}
|
||||
title="对话上下文"
|
||||
description="可选:运行前的历史轮次,默认为空"
|
||||
action={
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 border-hairline-strong text-xs text-muted-foreground"
|
||||
onClick={() => addContextTurn("agent")}
|
||||
>
|
||||
<Plus size={13} />
|
||||
Agent
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 border-hairline-strong text-xs text-muted-foreground"
|
||||
onClick={() => addContextTurn("user")}
|
||||
>
|
||||
<Plus size={13} />
|
||||
User
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{draft.contextTurns.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">
|
||||
默认无上下文,可按需添加 Agent / User 轮次
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{draft.contextTurns.map((turn, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<Select
|
||||
value={turn.role}
|
||||
onValueChange={(value) =>
|
||||
updateContext(index, {
|
||||
role: value as "agent" | "user",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[96px] shrink-0 border-hairline-strong bg-background text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="agent">Agent</SelectItem>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={turn.content}
|
||||
onChange={(event) =>
|
||||
updateContext(index, {
|
||||
content: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={
|
||||
turn.role === "agent"
|
||||
? "请您简单描述一下事发经过。"
|
||||
: "用户说的话…"
|
||||
}
|
||||
className="border-hairline-strong bg-background"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0 text-muted-soft hover:text-destructive"
|
||||
onClick={() => removeContextTurn(index)}
|
||||
aria-label="删除上下文"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
</section>
|
||||
|
||||
{/* 当前用户输入 */}
|
||||
<section
|
||||
ref={(element) => {
|
||||
sectionRefs.current.user_input = element;
|
||||
}}
|
||||
className="scroll-mt-3 space-y-3"
|
||||
>
|
||||
<SectionCard
|
||||
icon={<MessageSquareText size={15} />}
|
||||
title="当前用户输入"
|
||||
description="本轮要测的用户原话"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="mt-2 w-14 shrink-0 text-xs font-medium text-muted-foreground">
|
||||
User
|
||||
</span>
|
||||
<Input
|
||||
value={draft.userInput}
|
||||
onChange={(event) =>
|
||||
onChange({ ...draft, userInput: event.target.value })
|
||||
}
|
||||
placeholder="喂"
|
||||
className="border-hairline-strong bg-background"
|
||||
/>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</section>
|
||||
|
||||
{/* 结果预期 */}
|
||||
<section
|
||||
ref={(element) => {
|
||||
sectionRefs.current.expectation = element;
|
||||
}}
|
||||
className="scroll-mt-3 space-y-3"
|
||||
>
|
||||
<SectionCard
|
||||
icon={<Target size={15} />}
|
||||
title="结果预期"
|
||||
description="用关键词或 LLM 标准判断 Agent 下一句回复是否合格"
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(
|
||||
[
|
||||
{
|
||||
value: "keyword" as const,
|
||||
label: ASSERTION_TYPE_LABEL.keyword,
|
||||
hint: "匹配回复中的词",
|
||||
},
|
||||
{
|
||||
value: "llm" as const,
|
||||
label: ASSERTION_TYPE_LABEL.llm,
|
||||
hint: "用自然语言标准评判",
|
||||
},
|
||||
] as const
|
||||
).map((option) => {
|
||||
const active = draft.assertionType === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...draft,
|
||||
assertionType: option.value as AssertionType,
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
"flex min-w-[140px] flex-1 flex-col items-start gap-0.5 rounded-2xl border px-3.5 py-3 text-left transition-colors",
|
||||
active
|
||||
? "border-hairline-strong bg-surface-strong"
|
||||
: "border-hairline bg-background hover:bg-canvas-soft/80",
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
{option.value === "llm" ? (
|
||||
<Sparkles size={14} className="text-muted-foreground" />
|
||||
) : (
|
||||
<Target size={14} className="text-muted-foreground" />
|
||||
)}
|
||||
{option.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-soft">
|
||||
{option.hint}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{draft.assertionType === "keyword" ? (
|
||||
<div className="space-y-4 border-t border-hairline pt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
关键词
|
||||
</div>
|
||||
<div className="flex min-h-11 flex-wrap items-center gap-2 rounded-2xl border border-hairline-strong bg-background px-3 py-2">
|
||||
{draft.keywords.map((keyword, index) => (
|
||||
<span
|
||||
key={`${keyword}-${index}`}
|
||||
className="inline-flex h-7 items-center gap-1 rounded-full bg-surface-strong px-2.5 text-xs text-foreground"
|
||||
>
|
||||
{keyword}
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-soft hover:text-destructive"
|
||||
onClick={() => removeKeyword(index)}
|
||||
aria-label={`删除关键词 ${keyword}`}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<Input
|
||||
value={keywordDraft}
|
||||
onChange={(event) =>
|
||||
setKeywordDraft(event.target.value)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Enter" ||
|
||||
event.key === "," ||
|
||||
event.key === ","
|
||||
) {
|
||||
event.preventDefault();
|
||||
addKeyword(keywordDraft);
|
||||
} else if (
|
||||
event.key === "Backspace" &&
|
||||
!keywordDraft &&
|
||||
draft.keywords.length > 0
|
||||
) {
|
||||
removeKeyword(draft.keywords.length - 1);
|
||||
}
|
||||
}}
|
||||
onBlur={() => addKeyword(keywordDraft)}
|
||||
placeholder={
|
||||
draft.keywords.length === 0
|
||||
? "输入后回车,例如:我在"
|
||||
: "继续添加…"
|
||||
}
|
||||
className="h-7 min-w-[140px] flex-1 border-0 bg-transparent px-1 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-soft">
|
||||
回车或逗号添加;期望 Agent 回复中出现这些词。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
匹配方式
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(
|
||||
[
|
||||
{
|
||||
value: "any" as const,
|
||||
label: "任一匹配",
|
||||
hint: "命中其中一个即可",
|
||||
},
|
||||
{
|
||||
value: "all" as const,
|
||||
label: "全部匹配",
|
||||
hint: "需要全部出现",
|
||||
},
|
||||
] as const
|
||||
).map((option) => {
|
||||
const active =
|
||||
draft.keywordMatchMode === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...draft,
|
||||
keywordMatchMode:
|
||||
option.value as KeywordMatchMode,
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
"rounded-full border px-3.5 py-1.5 text-xs transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-hairline-strong text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1.5",
|
||||
active
|
||||
? "text-primary-foreground/80"
|
||||
: "text-muted-soft",
|
||||
)}
|
||||
>
|
||||
· {option.hint}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 border-t border-hairline pt-4">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
判断标准
|
||||
</div>
|
||||
<Textarea
|
||||
value={draft.llmCriteria}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...draft,
|
||||
llmCriteria: event.target.value,
|
||||
})
|
||||
}
|
||||
rows={5}
|
||||
placeholder={
|
||||
"Agent 应当回应用户的主动唤醒,同时继续引导用户描述事故情况,不应该无响应。"
|
||||
}
|
||||
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background"
|
||||
/>
|
||||
<p className="text-xs text-muted-soft">
|
||||
用自然语言写清「什么算通过」,后续由 LLM 评判下一句回复。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
409
frontend/src/components/test-cases/suite-case-list.tsx
Normal file
409
frontend/src/components/test-cases/suite-case-list.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 测试集左侧用例列表:
|
||||
* - 普通模式:点击打开编辑;hover 显示拖拽手柄排序
|
||||
* - 选择模式:checkbox + 顶部批量操作栏;禁用拖拽
|
||||
*/
|
||||
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
CheckSquare,
|
||||
Copy,
|
||||
GripVertical,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { SearchInput } from "@/components/ui/search-input";
|
||||
import {
|
||||
TEST_CASE_KIND_LABEL,
|
||||
type TestCase,
|
||||
} from "@/data/test-suites";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function SuiteCaseList({
|
||||
cases,
|
||||
filtered,
|
||||
selectedId,
|
||||
search,
|
||||
selectionMode,
|
||||
checkedIds,
|
||||
onSearchChange,
|
||||
onSelectCase,
|
||||
onCreateCase,
|
||||
onDuplicateCase,
|
||||
onDeleteCase,
|
||||
onEnterSelectionMode,
|
||||
onExitSelectionMode,
|
||||
onToggleChecked,
|
||||
onToggleSelectAll,
|
||||
onBulkDelete,
|
||||
onReorder,
|
||||
}: {
|
||||
cases: TestCase[];
|
||||
filtered: TestCase[];
|
||||
selectedId: string | null;
|
||||
search: string;
|
||||
selectionMode: boolean;
|
||||
checkedIds: Set<string>;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSelectCase: (item: TestCase) => void;
|
||||
onCreateCase: () => void;
|
||||
onDuplicateCase: (item: TestCase) => void;
|
||||
onDeleteCase: (item: TestCase) => void;
|
||||
onEnterSelectionMode: () => void;
|
||||
onExitSelectionMode: () => void;
|
||||
onToggleChecked: (id: string) => void;
|
||||
onToggleSelectAll: () => void;
|
||||
onBulkDelete: () => void;
|
||||
onReorder: (orderedIds: string[]) => void;
|
||||
}) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const allFilteredChecked =
|
||||
filtered.length > 0 && filtered.every((item) => checkedIds.has(item.id));
|
||||
const checkedCount = filtered.filter((item) => checkedIds.has(item.id)).length;
|
||||
const dragEnabled = !selectionMode;
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
if (!dragEnabled) return;
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const filteredIds = filtered.map((item) => item.id);
|
||||
const oldIndex = filteredIds.indexOf(String(active.id));
|
||||
const newIndex = filteredIds.indexOf(String(over.id));
|
||||
if (oldIndex < 0 || newIndex < 0) return;
|
||||
|
||||
const nextFilteredIds = arrayMove(filteredIds, oldIndex, newIndex);
|
||||
const filteredSet = new Set(filteredIds);
|
||||
let cursor = 0;
|
||||
// 筛选视图内重排:未出现在筛选结果中的用例保持相对位置
|
||||
const nextFullOrder = cases.map((item) => {
|
||||
if (!filteredSet.has(item.id)) return item.id;
|
||||
return nextFilteredIds[cursor++];
|
||||
});
|
||||
onReorder(nextFullOrder);
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="flex h-14 shrink-0 items-center gap-2 px-4 sm:px-5">
|
||||
{selectionMode ? (
|
||||
<>
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-3.5 accent-primary"
|
||||
checked={allFilteredChecked}
|
||||
ref={(element) => {
|
||||
if (!element) return;
|
||||
element.indeterminate =
|
||||
checkedCount > 0 && checkedCount < filtered.length;
|
||||
}}
|
||||
onChange={onToggleSelectAll}
|
||||
aria-label="全选当前列表"
|
||||
/>
|
||||
全选
|
||||
</label>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
|
||||
已选择 {checkedCount} 项
|
||||
</span>
|
||||
<Button
|
||||
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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={onSearchChange}
|
||||
placeholder="搜索测试用例"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto px-3 pb-4 sm:px-4">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-hairline-strong bg-canvas-soft px-4 py-10 text-center">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{cases.length === 0 ? "暂无测试用例" : "未找到匹配用例"}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{cases.length === 0
|
||||
? "点击右上角「新建」开始。"
|
||||
: "请调整关键词后再试。"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={filtered.map((item) => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
disabled={!dragEnabled}
|
||||
>
|
||||
<ul className="space-y-2">
|
||||
{filtered.map((item) => (
|
||||
<SortableCaseCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
active={item.id === selectedId}
|
||||
selectionMode={selectionMode}
|
||||
checked={checkedIds.has(item.id)}
|
||||
dragEnabled={dragEnabled}
|
||||
onSelect={() => onSelectCase(item)}
|
||||
onToggleChecked={() => onToggleChecked(item.id)}
|
||||
onDuplicate={() => onDuplicateCase(item)}
|
||||
onDelete={() => onDeleteCase(item)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableCaseCard({
|
||||
item,
|
||||
active,
|
||||
selectionMode,
|
||||
checked,
|
||||
dragEnabled,
|
||||
onSelect,
|
||||
onToggleChecked,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}: {
|
||||
item: TestCase;
|
||||
active: boolean;
|
||||
selectionMode: boolean;
|
||||
checked: boolean;
|
||||
dragEnabled: boolean;
|
||||
onSelect: () => void;
|
||||
onToggleChecked: () => void;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: item.id, disabled: !dragEnabled });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<li ref={setNodeRef} style={style} className={cn(isDragging && "z-10")}>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (selectionMode) {
|
||||
onToggleChecked();
|
||||
return;
|
||||
}
|
||||
onSelect();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
if (selectionMode) onToggleChecked();
|
||||
else onSelect();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"group w-full cursor-pointer rounded-2xl border px-3 py-3 text-left transition-colors",
|
||||
active && !selectionMode
|
||||
? "border-hairline-strong bg-surface-strong"
|
||||
: "border-hairline bg-card hover:bg-canvas-soft/80",
|
||||
checked && selectionMode && "border-hairline-strong bg-surface-strong/70",
|
||||
isDragging && "shadow-md ring-1 ring-hairline-strong",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-1.5">
|
||||
<div className="mt-0.5 flex h-6 w-5 shrink-0 items-center justify-center">
|
||||
{selectionMode ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-3.5 accent-primary"
|
||||
checked={checked}
|
||||
onChange={onToggleChecked}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
aria-label={`选择 ${item.name}`}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"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()}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="inline-flex h-5 shrink-0 items-center rounded-full border border-hairline bg-background px-2 text-[11px] text-muted-foreground">
|
||||
{TEST_CASE_KIND_LABEL[item.kind]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-7 w-7 shrink-0 items-center",
|
||||
selectionMode && "invisible",
|
||||
)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{!selectionMode && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="h-7 w-7 text-muted-soft hover:text-foreground"
|
||||
aria-label={`${item.name} 更多操作`}
|
||||
>
|
||||
<MoreHorizontal size={15} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
onSelect={onDuplicate}
|
||||
>
|
||||
<Copy size={14} />
|
||||
复制
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="rounded-lg"
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
window.setTimeout(onDelete, 0);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1.5 line-clamp-2 text-xs leading-5 text-muted-soft">
|
||||
{item.description || "暂无说明"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
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())}`;
|
||||
}
|
||||
519
frontend/src/data/test-suites.ts
Normal file
519
frontend/src/data/test-suites.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* 测试集 / 测试用例 — 管理页用的本地 mock。
|
||||
* 两层:Test Suite → Test Case。
|
||||
* 第一版只支持「单步回复 / Next Reply Test」:只编辑,不运行。
|
||||
*/
|
||||
|
||||
export type TestCaseKind =
|
||||
| "next_reply"
|
||||
| "tool_call"
|
||||
| "fixed_dialogue"
|
||||
| "user_simulation"
|
||||
| "voice_run";
|
||||
|
||||
export type TestCaseResult = "pass" | "fail" | "not_run";
|
||||
|
||||
export type AssertionType = "keyword" | "llm";
|
||||
|
||||
export type KeywordMatchMode = "any" | "all";
|
||||
|
||||
export type ContextTurn = {
|
||||
role: "agent" | "user";
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type TestSuite = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/** 关联助手展示名;MVP 直接存文案,接 API 后可改成 assistantId */
|
||||
assistantName: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type TestCase = {
|
||||
id: string;
|
||||
suiteId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
kind: TestCaseKind;
|
||||
lastResult: TestCaseResult;
|
||||
/** 对话上下文(通常是 Agent 上一轮) */
|
||||
contextTurns: ContextTurn[];
|
||||
/** 当前用户输入 */
|
||||
userInput: string;
|
||||
assertionType: AssertionType;
|
||||
keywords: string[];
|
||||
keywordMatchMode: KeywordMatchMode;
|
||||
/** LLM 判断时的判断标准 */
|
||||
llmCriteria: string;
|
||||
/** 套件内排序,越小越靠前 */
|
||||
sortOrder: number;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
/** 第一版仅「单步回复」可编辑,其余类型显示开发中 */
|
||||
export const SUPPORTED_TEST_CASE_KIND: TestCaseKind = "next_reply";
|
||||
|
||||
export const TEST_CASE_KIND_LABEL: Record<TestCaseKind, string> = {
|
||||
next_reply: "单步回复",
|
||||
tool_call: "工具调用",
|
||||
fixed_dialogue: "固定对话",
|
||||
user_simulation: "用户模拟",
|
||||
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 ASSERTION_TYPE_LABEL: Record<AssertionType, string> = {
|
||||
keyword: "关键词",
|
||||
llm: "LLM 判断",
|
||||
};
|
||||
|
||||
const INITIAL_SUITES: TestSuite[] = [
|
||||
{
|
||||
id: "suite_001",
|
||||
name: "事故基础流程",
|
||||
description: "核心业务流程和正常事故处理",
|
||||
assistantName: "视频快处助手",
|
||||
updatedAt: "2026-08-05T10:20:00+08:00",
|
||||
},
|
||||
{
|
||||
id: "suite_002",
|
||||
name: "异常输入与澄清",
|
||||
description: "模糊表达、误打断、主动唤醒等",
|
||||
assistantName: "视频快处助手",
|
||||
updatedAt: "2026-08-04T16:40:00+08:00",
|
||||
},
|
||||
{
|
||||
id: "suite_003",
|
||||
name: "实时语音交互",
|
||||
description: "打断、延迟、VAD、语音链路等",
|
||||
assistantName: "视频快处助手",
|
||||
updatedAt: "2026-08-03T09:15:00+08:00",
|
||||
},
|
||||
];
|
||||
|
||||
const RAW_CASES: Omit<TestCase, "sortOrder">[] = [
|
||||
{
|
||||
id: "tc_001",
|
||||
suiteId: "suite_001",
|
||||
name: "正常双车事故开场",
|
||||
description: "开场后用户补充事故经过",
|
||||
kind: "next_reply",
|
||||
lastResult: "pass",
|
||||
contextTurns: [],
|
||||
userInput: "我这里刚刚撞了一下。",
|
||||
assertionType: "keyword",
|
||||
keywords: ["经过", "描述", "受伤"],
|
||||
keywordMatchMode: "any",
|
||||
llmCriteria: "",
|
||||
updatedAt: "2026-08-05T10:18:00+08:00",
|
||||
},
|
||||
{
|
||||
id: "tc_002",
|
||||
suiteId: "suite_001",
|
||||
name: "有人伤转人工",
|
||||
description: "用户提到人伤时应引导转人工",
|
||||
kind: "next_reply",
|
||||
lastResult: "pass",
|
||||
contextTurns: [],
|
||||
userInput: "有人受伤了,流血不止。",
|
||||
assertionType: "llm",
|
||||
keywords: [],
|
||||
keywordMatchMode: "any",
|
||||
llmCriteria:
|
||||
"Agent 应识别人员受伤风险,明确告知将转接人工,并避免继续推进普通快处流程。",
|
||||
updatedAt: "2026-08-05T10:12:00+08:00",
|
||||
},
|
||||
{
|
||||
id: "tc_101",
|
||||
suiteId: "suite_002",
|
||||
name: "用户说“喂”",
|
||||
description: "验证主动唤醒回复且业务状态不推进",
|
||||
kind: "next_reply",
|
||||
lastResult: "pass",
|
||||
contextTurns: [],
|
||||
userInput: "喂",
|
||||
assertionType: "keyword",
|
||||
keywords: ["我在", "请说", "继续"],
|
||||
keywordMatchMode: "any",
|
||||
llmCriteria: "",
|
||||
updatedAt: "2026-08-04T16:35:00+08:00",
|
||||
},
|
||||
{
|
||||
id: "tc_102",
|
||||
suiteId: "suite_002",
|
||||
name: "用户只说“嗯”",
|
||||
description: "短促确认不应误推进流程",
|
||||
kind: "next_reply",
|
||||
lastResult: "fail",
|
||||
contextTurns: [],
|
||||
userInput: "嗯",
|
||||
assertionType: "llm",
|
||||
keywords: [],
|
||||
keywordMatchMode: "any",
|
||||
llmCriteria:
|
||||
"Agent 应当回应用户的主动唤醒,同时继续引导用户描述事故情况,不应该无响应。",
|
||||
updatedAt: "2026-08-04T16:20:00+08:00",
|
||||
},
|
||||
{
|
||||
id: "tc_103",
|
||||
suiteId: "suite_002",
|
||||
name: "模糊事故描述",
|
||||
description: "地点含糊时应主动澄清",
|
||||
kind: "next_reply",
|
||||
lastResult: "not_run",
|
||||
contextTurns: [],
|
||||
userInput: "就在那边……撞了一下。",
|
||||
assertionType: "keyword",
|
||||
keywords: ["哪里", "路口", "路名", "再说"],
|
||||
keywordMatchMode: "any",
|
||||
llmCriteria: "",
|
||||
updatedAt: "2026-08-04T15:30:00+08:00",
|
||||
},
|
||||
{
|
||||
id: "tc_201",
|
||||
suiteId: "suite_003",
|
||||
name: "用户打断播报",
|
||||
description: "播报中打断后正确切换聆听并承接",
|
||||
kind: "next_reply",
|
||||
lastResult: "pass",
|
||||
contextTurns: [],
|
||||
userInput: "等一下,对方走了。",
|
||||
assertionType: "llm",
|
||||
keywords: [],
|
||||
keywordMatchMode: "any",
|
||||
llmCriteria:
|
||||
"Agent 应立即停止原播报思路,确认已听到用户新信息,并围绕「对方离开」继续询问。",
|
||||
updatedAt: "2026-08-03T09:10:00+08:00",
|
||||
},
|
||||
];
|
||||
|
||||
/** 按套件出现顺序写入 sortOrder */
|
||||
const INITIAL_CASES: TestCase[] = (() => {
|
||||
const counters = new Map<string, number>();
|
||||
return RAW_CASES.map((item) => {
|
||||
const order = counters.get(item.suiteId) ?? 0;
|
||||
counters.set(item.suiteId, order + 1);
|
||||
return { ...item, sortOrder: order };
|
||||
});
|
||||
})();
|
||||
|
||||
/** 会话内可变的 mock 仓库(刷新页面会重置) */
|
||||
let suites = [...INITIAL_SUITES];
|
||||
let cases = structuredClone(INITIAL_CASES);
|
||||
let suiteSeq = 4;
|
||||
let caseSeq = 300;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function nextSuiteId() {
|
||||
const id = `suite_${String(suiteSeq).padStart(3, "0")}`;
|
||||
suiteSeq += 1;
|
||||
return id;
|
||||
}
|
||||
|
||||
function nextCaseId() {
|
||||
const id = `tc_${String(caseSeq).padStart(3, "0")}`;
|
||||
caseSeq += 1;
|
||||
return id;
|
||||
}
|
||||
|
||||
export function listTestSuites(): TestSuite[] {
|
||||
return [...suites].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
export function getTestSuite(id: string): TestSuite | null {
|
||||
return suites.find((item) => item.id === id) ?? null;
|
||||
}
|
||||
|
||||
export function getTestCase(id: string): TestCase | null {
|
||||
return cases.find((item) => item.id === id) ?? null;
|
||||
}
|
||||
|
||||
export function listTestCases(suiteId: string): TestCase[] {
|
||||
return cases
|
||||
.filter((item) => item.suiteId === suiteId)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.sortOrder - b.sortOrder || a.name.localeCompare(b.name, "zh-CN"),
|
||||
);
|
||||
}
|
||||
|
||||
export function suiteCaseStats(suiteId: string): {
|
||||
total: number;
|
||||
passed: number;
|
||||
run: number;
|
||||
} {
|
||||
const items = cases.filter((item) => item.suiteId === suiteId);
|
||||
const run = items.filter((item) => item.lastResult !== "not_run");
|
||||
const passed = run.filter((item) => item.lastResult === "pass");
|
||||
return { total: items.length, passed: passed.length, run: run.length };
|
||||
}
|
||||
|
||||
export function createTestSuite(input: {
|
||||
name: string;
|
||||
description: string;
|
||||
assistantName: string;
|
||||
}): TestSuite {
|
||||
const suite: TestSuite = {
|
||||
id: nextSuiteId(),
|
||||
name: input.name.trim(),
|
||||
description: input.description.trim(),
|
||||
assistantName: input.assistantName.trim() || "未关联助手",
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
suites = [suite, ...suites];
|
||||
return suite;
|
||||
}
|
||||
|
||||
export function updateTestSuite(
|
||||
id: string,
|
||||
patch: Partial<Pick<TestSuite, "name" | "description" | "assistantName">>,
|
||||
): TestSuite | null {
|
||||
const index = suites.findIndex((item) => item.id === id);
|
||||
if (index < 0) return null;
|
||||
const next = {
|
||||
...suites[index],
|
||||
...patch,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
suites = [...suites.slice(0, index), next, ...suites.slice(index + 1)];
|
||||
return next;
|
||||
}
|
||||
|
||||
export function removeTestSuite(id: string): boolean {
|
||||
const before = suites.length;
|
||||
suites = suites.filter((item) => item.id !== id);
|
||||
cases = cases.filter((item) => item.suiteId !== id);
|
||||
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: {
|
||||
suiteId: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
}): TestCase | null {
|
||||
if (!getTestSuite(input.suiteId)) return null;
|
||||
const maxOrder = cases
|
||||
.filter((item) => item.suiteId === input.suiteId)
|
||||
.reduce((max, item) => Math.max(max, item.sortOrder), -1);
|
||||
const item: TestCase = {
|
||||
id: nextCaseId(),
|
||||
suiteId: input.suiteId,
|
||||
name: (input.name ?? "未命名用例").trim() || "未命名用例",
|
||||
description: (input.description ?? "").trim(),
|
||||
kind: "next_reply",
|
||||
lastResult: "not_run",
|
||||
contextTurns: [],
|
||||
userInput: "",
|
||||
assertionType: "keyword",
|
||||
keywords: [],
|
||||
keywordMatchMode: "any",
|
||||
llmCriteria: "",
|
||||
sortOrder: maxOrder + 1,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
cases = [...cases, item];
|
||||
updateTestSuite(input.suiteId, {});
|
||||
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<
|
||||
Pick<
|
||||
TestCase,
|
||||
| "name"
|
||||
| "description"
|
||||
| "kind"
|
||||
| "contextTurns"
|
||||
| "userInput"
|
||||
| "assertionType"
|
||||
| "keywords"
|
||||
| "keywordMatchMode"
|
||||
| "llmCriteria"
|
||||
| "lastResult"
|
||||
>
|
||||
>;
|
||||
|
||||
export function updateTestCase(
|
||||
id: string,
|
||||
patch: TestCasePatch,
|
||||
): TestCase | null {
|
||||
const index = cases.findIndex((item) => item.id === id);
|
||||
if (index < 0) return null;
|
||||
const next = {
|
||||
...cases[index],
|
||||
...patch,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
cases = [...cases.slice(0, index), next, ...cases.slice(index + 1)];
|
||||
updateTestSuite(next.suiteId, {});
|
||||
return next;
|
||||
}
|
||||
|
||||
export function removeTestCase(id: string): boolean {
|
||||
const existing = cases.find((item) => item.id === id);
|
||||
if (!existing) return false;
|
||||
cases = cases.filter((item) => item.id !== id);
|
||||
renumberSortOrder(existing.suiteId);
|
||||
updateTestSuite(existing.suiteId, {});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 批量删除;返回受影响的 suiteId(若有) */
|
||||
export function removeTestCases(ids: string[]): string | null {
|
||||
const idSet = new Set(ids);
|
||||
const affected = cases.find((item) => idSet.has(item.id));
|
||||
if (!affected) return null;
|
||||
const suiteId = affected.suiteId;
|
||||
cases = cases.filter((item) => !idSet.has(item.id));
|
||||
renumberSortOrder(suiteId);
|
||||
updateTestSuite(suiteId, {});
|
||||
return suiteId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按给定 id 顺序写回 sortOrder(应包含该套件全部用例 id)。
|
||||
* 用于拖拽结束后持久化顺序。
|
||||
*/
|
||||
export function reorderTestCases(suiteId: string, orderedIds: string[]): void {
|
||||
const orderMap = new Map(orderedIds.map((id, index) => [id, index]));
|
||||
cases = cases.map((item) => {
|
||||
if (item.suiteId !== suiteId) return item;
|
||||
const nextOrder = orderMap.get(item.id);
|
||||
if (nextOrder === undefined) return item;
|
||||
return { ...item, sortOrder: nextOrder };
|
||||
});
|
||||
updateTestSuite(suiteId, {});
|
||||
}
|
||||
|
||||
function renumberSortOrder(suiteId: string) {
|
||||
const ordered = cases
|
||||
.filter((item) => item.suiteId === suiteId)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
const orderMap = new Map(ordered.map((item, index) => [item.id, index]));
|
||||
cases = cases.map((item) => {
|
||||
if (item.suiteId !== suiteId) return item;
|
||||
const nextOrder = orderMap.get(item.id);
|
||||
return nextOrder === undefined ? item : { ...item, sortOrder: nextOrder };
|
||||
});
|
||||
}
|
||||
|
||||
export function formatSuiteResult(suiteId: string): string {
|
||||
const { passed, run, total } = suiteCaseStats(suiteId);
|
||||
if (total === 0) return "—";
|
||||
if (run === 0) return "未运行";
|
||||
return `${passed}/${total}`;
|
||||
}
|
||||
|
||||
export function formatCaseResult(result: TestCaseResult): {
|
||||
label: string;
|
||||
className: string;
|
||||
dotClassName: string | null;
|
||||
} {
|
||||
if (result === "pass") {
|
||||
return {
|
||||
label: "通过",
|
||||
className: "text-emerald-600",
|
||||
dotClassName: "bg-emerald-500",
|
||||
};
|
||||
}
|
||||
if (result === "fail") {
|
||||
return {
|
||||
label: "失败",
|
||||
className: "text-destructive",
|
||||
dotClassName: "bg-destructive",
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "未运行",
|
||||
className: "text-muted-soft",
|
||||
dotClassName: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatUpdatedAt(value?: string | null) {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return date.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user