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

913 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
</>
);
}