"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 ; if (props.mode === "create") return ; return ( ); } // ─── Suite 列表 ────────────────────────────────────────────────────────────── function SuiteListView() { const router = useRouter(); const [suites, setSuites] = useState(() => listTestSuites()); const [search, setSearch] = useState(""); const [currentPage, setCurrentPage] = useState(1); const [deletingId, setDeletingId] = useState(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 ( router.push("/test/cases/new")} > 新建测试集 } > { setSearch(value); setCurrentPage(1); }} placeholder="搜索测试集..." className="lg:w-[320px]" /> } /> 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) => ( <> {suite.name} {suite.id} > ), }, { 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) => ( event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} > { event.stopPropagation(); openSuite(suite); }} > 编辑 event.stopPropagation()} > duplicateSuite(suite)} > 复制 { event.preventDefault(); window.setTimeout(() => removeSuite(suite), 0); }} > {deletingId === suite.id ? ( ) : ( )} 删除 ), }, ]} /> ); } // ─── 新建测试集 ────────────────────────────────────────────────────────────── function SuiteCreateView() { const router = useRouter(); const [assistants, setAssistants] = useState([]); 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 ( router.push("/test/cases")} > 返回列表 } > 测试集名称 setName(event.target.value)} placeholder="例如:事故基础流程" className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft" /> 关联助手 {loadingAssistants ? ( 正在加载助手… ) : assistants.length > 0 ? ( {assistants.map((item) => ( {item.name} ))} ) : ( setAssistantName(event.target.value)} placeholder="助手名称" className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft" /> )} router.push("/test/cases")} > 取消 {creating ? ( ) : ( )} 创建测试集 ); } // ─── 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(initial.suite); const [cases, setCases] = useState(initial.cases); const [selectedId, setSelectedId] = useState( initial.selectedId, ); const [search, setSearch] = useState(""); const [draft, setDraft] = useState(initial.draft); const [savedSnapshot, setSavedSnapshot] = useState( initial.draft, ); const [statusMessage, setStatusMessage] = useState(""); const [selectionMode, setSelectionMode] = useState(false); const [checkedIds, setCheckedIds] = useState>(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("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 ( 测试集不存在 router.push("/test/cases")} > 返回列表 ); } return ( <> {!draft ? ( 请选择左侧用例,或新建一个测试用例。 ) : ( <> setDraft({ ...draft, name: value || "未命名用例" }) } placeholder="未命名用例" editLabel="用例名称" variant="panel" allowEmpty /> setDraft(applyInputMode(draft, mode))} /> {dirty && validationMessage ? ( {validationMessage} ) : dirty ? ( 未保存 ) : statusMessage ? ( {statusMessage} ) : null} 保存 {selectedId ? : } {selectedId ? "删除" : "取消"} {draft.inputMode === "fixed_script_text" ? ( ) : ( {TEST_CASE_INPUT_MODE_LABEL[draft.inputMode]} · 开发中 当前仅支持「固定脚本 · 文字」编辑器,其它输入模式稍后接入。 )} > )} > ); }
当前仅支持「固定脚本 · 文字」编辑器,其它输入模式稍后接入。