2026-08-08 17:03:12 +08:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 测试用例管理:
|
|
|
|
|
* - 列表页:Test Suite 一级容器
|
|
|
|
|
* - 详情页:左列表 + 右「单步回复」编辑器(只编辑,不运行)
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
ChevronLeft,
|
2026-08-08 17:37:48 +08:00
|
|
|
Copy,
|
2026-08-08 17:03:12 +08:00
|
|
|
Loader2,
|
|
|
|
|
MoreHorizontal,
|
|
|
|
|
Pencil,
|
|
|
|
|
Plus,
|
|
|
|
|
Rocket,
|
|
|
|
|
Save,
|
|
|
|
|
Trash2,
|
2026-08-08 19:08:48 +08:00
|
|
|
X,
|
2026-08-08 17:03:12 +08:00
|
|
|
} 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 {
|
|
|
|
|
createTestCase,
|
|
|
|
|
createTestSuite,
|
2026-08-08 17:37:48 +08:00
|
|
|
duplicateTestCase,
|
|
|
|
|
duplicateTestSuite,
|
2026-08-08 17:03:12 +08:00
|
|
|
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 />;
|
2026-08-08 19:08:48 +08:00
|
|
|
return <SuiteDetailView key={props.suiteId} suiteId={props.suiteId} />;
|
2026-08-08 17:03:12 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Suite 列表 ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function SuiteListView() {
|
|
|
|
|
const router = useRouter();
|
2026-08-08 19:08:48 +08:00
|
|
|
const [suites, setSuites] = useState<TestSuite[]>(() => listTestSuites());
|
2026-08-08 17:03:12 +08:00
|
|
|
const [search, setSearch] = useState("");
|
|
|
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
|
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const filtered = useMemo(() => {
|
|
|
|
|
const keyword = search.trim().toLowerCase();
|
|
|
|
|
return suites.filter((suite) => {
|
|
|
|
|
if (!keyword) return true;
|
2026-08-08 19:08:48 +08:00
|
|
|
return [suite.name, suite.assistantName, suite.id]
|
2026-08-08 17:03:12 +08:00
|
|
|
.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}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 17:37:48 +08:00
|
|
|
function duplicateSuite(suite: TestSuite) {
|
|
|
|
|
const copied = duplicateTestSuite(suite.id);
|
|
|
|
|
if (!copied) return;
|
|
|
|
|
setSuites(listTestSuites());
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 17:03:12 +08:00
|
|
|
function removeSuite(suite: TestSuite) {
|
|
|
|
|
if (
|
|
|
|
|
!window.confirm(
|
|
|
|
|
`确定删除测试集“${suite.name}”及其全部测试用例吗?`,
|
|
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setDeletingId(suite.id);
|
|
|
|
|
removeTestSuite(suite.id);
|
|
|
|
|
setSuites(listTestSuites());
|
|
|
|
|
setDeletingId(null);
|
|
|
|
|
}
|
2026-08-07 16:00:50 +08:00
|
|
|
|
|
|
|
|
return (
|
2026-08-08 17:03:12 +08:00
|
|
|
<ListPageLayout
|
2026-08-07 16:00:50 +08:00
|
|
|
title="测试用例"
|
2026-08-08 17:03:12 +08:00
|
|
|
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>
|
2026-08-08 19:08:48 +08:00
|
|
|
<div className="mt-1 truncate font-mono text-xs text-muted-soft">
|
|
|
|
|
{suite.id}
|
2026-08-08 17:03:12 +08:00
|
|
|
</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: "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"
|
|
|
|
|
>
|
2026-08-08 17:37:48 +08:00
|
|
|
<DropdownMenuItem
|
|
|
|
|
className="rounded-lg"
|
|
|
|
|
onSelect={() => duplicateSuite(suite)}
|
|
|
|
|
>
|
|
|
|
|
<Copy size={14} />
|
|
|
|
|
复制
|
|
|
|
|
</DropdownMenuItem>
|
2026-08-08 17:03:12 +08:00
|
|
|
<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 [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,
|
2026-08-08 19:08:48 +08:00
|
|
|
description: "",
|
2026-08-08 17:03:12 +08:00
|
|
|
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>
|
2026-08-08 19:08:48 +08:00
|
|
|
<div>
|
|
|
|
|
<div className="mb-2 text-sm font-medium text-foreground">
|
|
|
|
|
关联助手
|
2026-08-08 17:03:12 +08:00
|
|
|
</div>
|
2026-08-08 19:08:48 +08:00
|
|
|
{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"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-08-08 17:03:12 +08:00
|
|
|
</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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 19:08:48 +08:00
|
|
|
function createEmptyCaseDraft(): CaseEditorDraft {
|
|
|
|
|
return {
|
|
|
|
|
name: "未命名用例",
|
|
|
|
|
kind: SUPPORTED_TEST_CASE_KIND,
|
|
|
|
|
contextTurns: [],
|
|
|
|
|
userInput: "",
|
|
|
|
|
assertionType: "keyword",
|
|
|
|
|
keywords: [],
|
|
|
|
|
keywordMatchMode: "any",
|
|
|
|
|
llmCriteria: "",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 17:03:12 +08:00
|
|
|
function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
|
|
|
|
const router = useRouter();
|
2026-08-08 19:08:48 +08:00
|
|
|
const [initial] = useState(() => {
|
|
|
|
|
const initialCases = listTestCases(suiteId);
|
|
|
|
|
const initialCase = initialCases[0] ?? null;
|
|
|
|
|
const initialDraft = initialCase ? caseToDraft(initialCase) : null;
|
|
|
|
|
return {
|
|
|
|
|
suite: getTestSuite(suiteId),
|
|
|
|
|
cases: initialCases,
|
|
|
|
|
selectedId: initialCase?.id ?? null,
|
|
|
|
|
draft: initialDraft,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
const [suite, setSuite] = useState<TestSuite | null>(initial.suite);
|
|
|
|
|
const [cases, setCases] = useState<TestCase[]>(initial.cases);
|
|
|
|
|
const [selectedId, setSelectedId] = useState<string | null>(
|
|
|
|
|
initial.selectedId,
|
|
|
|
|
);
|
2026-08-08 17:03:12 +08:00
|
|
|
const [search, setSearch] = useState("");
|
2026-08-08 19:08:48 +08:00
|
|
|
const [draft, setDraft] = useState<CaseEditorDraft | null>(initial.draft);
|
2026-08-08 17:03:12 +08:00
|
|
|
const [savedSnapshot, setSavedSnapshot] = useState<CaseEditorDraft | null>(
|
2026-08-08 19:08:48 +08:00
|
|
|
initial.draft,
|
2026-08-08 17:03:12 +08:00
|
|
|
);
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const filtered = useMemo(() => {
|
|
|
|
|
const keyword = search.trim().toLowerCase();
|
|
|
|
|
return cases.filter((item) => {
|
|
|
|
|
if (!keyword) return true;
|
2026-08-08 19:08:48 +08:00
|
|
|
return [item.name, TEST_CASE_KIND_LABEL[item.kind]]
|
2026-08-08 17:03:12 +08:00
|
|
|
.join(" ")
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.includes(keyword);
|
|
|
|
|
});
|
|
|
|
|
}, [cases, search]);
|
|
|
|
|
|
|
|
|
|
const dirty =
|
|
|
|
|
draft !== null &&
|
2026-08-08 19:08:48 +08:00
|
|
|
(selectedId === null ||
|
|
|
|
|
savedSnapshot === null ||
|
|
|
|
|
!draftsEqual(draft, savedSnapshot));
|
2026-08-08 17:03:12 +08:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-08-08 19:08:48 +08:00
|
|
|
setSelectedId(null);
|
|
|
|
|
setDraft(createEmptyCaseDraft());
|
|
|
|
|
setSavedSnapshot(null);
|
2026-08-08 17:03:12 +08:00
|
|
|
setStatusMessage("");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleSave() {
|
2026-08-08 19:08:48 +08:00
|
|
|
if (!draft) return;
|
|
|
|
|
const patch = {
|
2026-08-08 17:03:12 +08:00
|
|
|
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,
|
2026-08-08 19:08:48 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let saved: TestCase | null;
|
|
|
|
|
if (selectedId) {
|
|
|
|
|
saved = updateTestCase(selectedId, patch);
|
|
|
|
|
} else {
|
|
|
|
|
const created = createTestCase({
|
|
|
|
|
suiteId,
|
|
|
|
|
name: patch.name,
|
|
|
|
|
});
|
|
|
|
|
saved = created ? updateTestCase(created.id, patch) : null;
|
|
|
|
|
}
|
2026-08-08 17:03:12 +08:00
|
|
|
if (!saved) return;
|
|
|
|
|
const nextDraft = caseToDraft(saved);
|
2026-08-08 19:08:48 +08:00
|
|
|
setSelectedId(saved.id);
|
2026-08-08 17:03:12 +08:00
|
|
|
setCases(listTestCases(suiteId));
|
|
|
|
|
setSuite(getTestSuite(suiteId));
|
|
|
|
|
setDraft(nextDraft);
|
|
|
|
|
setSavedSnapshot(nextDraft);
|
|
|
|
|
setStatusMessage("已保存");
|
|
|
|
|
window.setTimeout(() => setStatusMessage(""), 2000);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 19:08:48 +08:00
|
|
|
function handleCancelNewCase() {
|
|
|
|
|
reload();
|
|
|
|
|
setStatusMessage("");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleBackToList() {
|
|
|
|
|
if (dirty && !window.confirm("当前用例有未保存修改,离开将丢弃。继续?")) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
router.push("/test/cases");
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 17:37:48 +08:00
|
|
|
function handleDuplicateCase(item: TestCase) {
|
|
|
|
|
if (dirty && !window.confirm("当前用例有未保存修改,复制将丢弃。继续?")) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const copied = duplicateTestCase(item.id);
|
|
|
|
|
if (!copied) return;
|
|
|
|
|
reload(copied.id);
|
|
|
|
|
setStatusMessage("");
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 17:03:12 +08:00
|
|
|
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="返回测试集列表"
|
2026-08-08 19:08:48 +08:00
|
|
|
onClick={handleBackToList}
|
2026-08-08 17:03:12 +08:00
|
|
|
/>
|
|
|
|
|
<EditableTitle
|
|
|
|
|
value={suite.name}
|
|
|
|
|
onChange={renameSuite}
|
|
|
|
|
placeholder="未命名测试集"
|
|
|
|
|
editLabel="测试集名称"
|
|
|
|
|
/>
|
2026-08-08 19:08:48 +08:00
|
|
|
<AssistantIdentity assistantId={suite.id} entityLabel="测试集" />
|
2026-08-08 17:03:12 +08:00
|
|
|
</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}
|
2026-08-08 17:37:48 +08:00
|
|
|
onDuplicateCase={handleDuplicateCase}
|
2026-08-08 17:03:12 +08:00
|
|
|
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) => (
|
2026-08-08 19:08:48 +08:00
|
|
|
<SelectItem
|
|
|
|
|
key={option.value}
|
|
|
|
|
value={option.value}
|
|
|
|
|
disabled={!option.available}
|
|
|
|
|
>
|
|
|
|
|
<span>{option.label}</span>
|
|
|
|
|
{!option.available && (
|
|
|
|
|
<span className="ml-auto text-[11px] font-normal text-muted-soft">
|
|
|
|
|
即将支持
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-08-08 17:03:12 +08:00
|
|
|
</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"
|
2026-08-08 19:08:48 +08:00
|
|
|
className={
|
|
|
|
|
selectedId
|
|
|
|
|
? "border-hairline-strong text-muted-foreground hover:text-destructive"
|
|
|
|
|
: "border-hairline-strong text-muted-foreground hover:text-foreground"
|
|
|
|
|
}
|
|
|
|
|
onClick={
|
|
|
|
|
selectedId ? handleDeleteSelected : handleCancelNewCase
|
|
|
|
|
}
|
2026-08-08 17:03:12 +08:00
|
|
|
>
|
2026-08-08 19:08:48 +08:00
|
|
|
{selectedId ? <Trash2 size={14} /> : <X size={14} />}
|
|
|
|
|
{selectedId ? "删除" : "取消"}
|
2026-08-08 17:03:12 +08:00
|
|
|
</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>
|
|
|
|
|
</>
|
2026-08-07 16:00:50 +08:00
|
|
|
);
|
|
|
|
|
}
|