Files
ai-video-fullstack/frontend/src/components/pages/TestCasesPage.tsx
T

913 lines
28 KiB
TypeScript

"use client";
/**
* 测试用例管理:
* - 列表页:Test Suite 一级容器
* - 详情页:左列表 + 右编辑器(固定脚本 / 用户模拟,只编辑不运行)
*/
import {
ChevronLeft,
Copy,
Loader2,
MoreHorizontal,
Pencil,
Plus,
Rocket,
Save,
Trash2,
X,
} 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 { InputModePicker } from "@/components/test-cases/input-mode-picker";
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 {
cloneOverallCriteria,
cloneTurns,
createEmptyFixedInputTurn,
createTestCase,
createTestSuite,
DEFAULT_INPUT_MODE,
duplicateTestCase,
duplicateTestSuite,
getTestCaseValidationMessage,
getTestSuite,
listTestCases,
listTestSuites,
normalizeOverallCriteria,
removeTestCase,
removeTestCases,
removeTestSuite,
reorderTestCases,
suiteCaseStats,
TEST_CASE_INPUT_MODE_LABEL,
TEST_CASE_INPUT_MODE_SHORT_LABEL,
updateTestCase,
updateTestSuite,
type TestCase,
type TestCaseInputMode,
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; initialCaseId?: string };
export function TestCasesPage(props: TestCasesPageProps) {
if (props.mode === "list") return <SuiteListView />;
if (props.mode === "create") return <SuiteCreateView />;
return (
<SuiteDetailView
key={`${props.suiteId}:${props.initialCaseId ?? ""}`}
suiteId={props.suiteId}
initialCaseId={props.initialCaseId}
/>
);
}
// ─── Suite 列表 ──────────────────────────────────────────────────────────────
function SuiteListView() {
const router = useRouter();
const [suites, setSuites] = useState<TestSuite[]>(() => listTestSuites());
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;
return [suite.name, 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 font-mono text-xs text-muted-soft">
{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: "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 [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>
<div className="mb-2 text-sm font-medium text-foreground">
关联助手
</div>
{loadingAssistants ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
正在加载助手…
</div>
) : assistants.length > 0 ? (
<Select value={assistantName} onValueChange={setAssistantName}>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<SelectValue placeholder="选择关联助手" />
</SelectTrigger>
<SelectContent>
{assistants.map((item) => (
<SelectItem key={item.id} value={item.name}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={assistantName}
onChange={(event) => setAssistantName(event.target.value)}
placeholder="助手名称"
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
)}
</div>
</ListPageSection>
<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,
inputMode: item.inputMode,
contextTurns: item.contextTurns.map((turn) => ({ ...turn })),
turns: cloneTurns(item.turns),
overallCriteria: cloneOverallCriteria(item.overallCriteria),
};
}
function draftsEqual(a: CaseEditorDraft, b: CaseEditorDraft) {
return JSON.stringify(a) === JSON.stringify(b);
}
function createEmptyCaseDraft(): CaseEditorDraft {
return {
name: "未命名用例",
inputMode: DEFAULT_INPUT_MODE,
contextTurns: [],
turns: [createEmptyFixedInputTurn()],
overallCriteria: [],
};
}
function applyInputMode(
draft: CaseEditorDraft,
mode: TestCaseInputMode,
): CaseEditorDraft {
return { ...draft, inputMode: mode };
}
function SuiteDetailView({
suiteId,
initialCaseId,
}: {
suiteId: string;
initialCaseId?: string;
}) {
const router = useRouter();
const [initial] = useState(() => {
const initialCases = listTestCases(suiteId);
const initialCase =
initialCases.find((item) => item.id === initialCaseId) ??
initialCases[0] ??
null;
const initialDraft = initialCase ? caseToDraft(initialCase) : null;
return {
suite: getTestSuite(suiteId),
cases: initialCases,
selectedId: initialCase?.id ?? null,
draft: initialDraft,
};
});
const [suite, setSuite] = useState<TestSuite | null>(initial.suite);
const [cases, setCases] = useState<TestCase[]>(initial.cases);
const [selectedId, setSelectedId] = useState<string | null>(
initial.selectedId,
);
const [search, setSearch] = useState("");
const [draft, setDraft] = useState<CaseEditorDraft | null>(initial.draft);
const [savedSnapshot, setSavedSnapshot] = useState<CaseEditorDraft | null>(
initial.draft,
);
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;
return [
item.name,
TEST_CASE_INPUT_MODE_SHORT_LABEL[item.inputMode],
]
.join(" ")
.toLowerCase()
.includes(keyword);
});
}, [cases, search]);
const dirty =
draft !== null &&
(selectedId === null ||
savedSnapshot === null ||
!draftsEqual(draft, savedSnapshot));
const validationMessage = draft
? getTestCaseValidationMessage(draft)
: "请先选择测试用例";
const canSave =
Boolean(draft?.name.trim()) && dirty && validationMessage === null;
useEffect(() => {
if (!dirty) return;
function handleBeforeUnload(event: BeforeUnloadEvent) {
event.preventDefault();
event.returnValue = "";
}
function handleInternalLink(event: MouseEvent) {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
const target = event.target;
if (!(target instanceof Element)) return;
const link = target.closest<HTMLAnchorElement>("a[href]");
if (!link || link.target === "_blank" || link.hasAttribute("download")) {
return;
}
const nextUrl = new URL(link.href, window.location.href);
if (nextUrl.origin !== window.location.origin) return;
if (
nextUrl.pathname === window.location.pathname &&
nextUrl.search === window.location.search
) {
return;
}
if (window.confirm("当前用例有未保存修改,离开将丢弃。继续?")) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
}
window.addEventListener("beforeunload", handleBeforeUnload);
document.addEventListener("click", handleInternalLink, true);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
document.removeEventListener("click", handleInternalLink, true);
};
}, [dirty]);
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;
}
setSelectedId(null);
setDraft(createEmptyCaseDraft());
setSavedSnapshot(null);
setStatusMessage("");
}
function handleSave() {
if (!draft || validationMessage) return;
const patch = {
name: draft.name.trim() || "未命名用例",
inputMode: draft.inputMode,
contextTurns: draft.contextTurns,
turns: draft.turns,
overallCriteria: normalizeOverallCriteria(draft.overallCriteria),
};
let saved: TestCase | null;
if (selectedId) {
saved = updateTestCase(selectedId, patch);
} else {
const created = createTestCase({
suiteId,
name: patch.name,
});
saved = created ? updateTestCase(created.id, patch) : null;
}
if (!saved) return;
const nextDraft = caseToDraft(saved);
setSelectedId(saved.id);
setCases(listTestCases(suiteId));
setSuite(getTestSuite(suiteId));
setDraft(nextDraft);
setSavedSnapshot(nextDraft);
setStatusMessage("已保存");
window.setTimeout(() => setStatusMessage(""), 2000);
}
function handleCancelNewCase() {
reload();
setStatusMessage("");
}
function handleBackToList() {
if (dirty && !window.confirm("当前用例有未保存修改,离开将丢弃。继续?")) {
return;
}
router.push("/test/cases");
}
function handleDuplicateCase(item: TestCase) {
if (dirty && !window.confirm("当前用例有未保存修改,复制将丢弃。继续?")) {
return;
}
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={handleBackToList}
/>
<EditableTitle
value={suite.name}
onChange={renameSuite}
placeholder="未命名测试集"
editLabel="测试集名称"
/>
<AssistantIdentity assistantId={suite.id} entityLabel="测试集" />
</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
/>
<InputModePicker
value={draft.inputMode}
onChange={(mode) => setDraft(applyInputMode(draft, mode))}
/>
<div className="ml-auto flex shrink-0 items-center gap-2">
{dirty && validationMessage ? (
<span
className="max-w-56 truncate text-xs text-destructive"
title={validationMessage}
role="status"
>
{validationMessage}
</span>
) : 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={!canSave}
title={validationMessage ?? undefined}
onClick={handleSave}
>
<Save size={14} />
保存
</Button>
<Button
variant="outline"
size="sm"
className={
selectedId
? "border-hairline-strong text-muted-foreground hover:text-destructive"
: "border-hairline-strong text-muted-foreground hover:text-foreground"
}
onClick={
selectedId ? handleDeleteSelected : handleCancelNewCase
}
>
{selectedId ? <Trash2 size={14} /> : <X size={14} />}
{selectedId ? "删除" : "取消"}
</Button>
</div>
</div>
{draft.inputMode === "fixed_script_text" ? (
<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_INPUT_MODE_LABEL[draft.inputMode]} · 开发中
</div>
<p className="max-w-sm text-xs leading-5 text-muted-foreground">
当前仅支持「固定脚本 · 文字」编辑器,其它输入模式稍后接入。
</p>
</div>
)}
</>
)}
</main>
</div>
</>
);
}