Update batch test page
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
Rocket,
|
||||
Save,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -53,13 +54,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
createTestCase,
|
||||
createTestSuite,
|
||||
duplicateTestCase,
|
||||
duplicateTestSuite,
|
||||
formatSuiteResult,
|
||||
getTestSuite,
|
||||
listTestCases,
|
||||
listTestSuites,
|
||||
@@ -91,27 +90,23 @@ export type TestCasesPageProps =
|
||||
export function TestCasesPage(props: TestCasesPageProps) {
|
||||
if (props.mode === "list") return <SuiteListView />;
|
||||
if (props.mode === "create") return <SuiteCreateView />;
|
||||
return <SuiteDetailView suiteId={props.suiteId} />;
|
||||
return <SuiteDetailView key={props.suiteId} suiteId={props.suiteId} />;
|
||||
}
|
||||
|
||||
// ─── Suite 列表 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function SuiteListView() {
|
||||
const router = useRouter();
|
||||
const [suites, setSuites] = useState<TestSuite[]>([]);
|
||||
const [suites, setSuites] = useState<TestSuite[]>(() => listTestSuites());
|
||||
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]
|
||||
return [suite.name, suite.assistantName, suite.id]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(keyword);
|
||||
@@ -209,8 +204,8 @@ function SuiteListView() {
|
||||
<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 className="mt-1 truncate font-mono text-xs text-muted-soft">
|
||||
{suite.id}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
@@ -229,13 +224,6 @@ function SuiteListView() {
|
||||
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: "操作",
|
||||
@@ -316,7 +304,6 @@ function SuiteCreateView() {
|
||||
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);
|
||||
|
||||
@@ -339,7 +326,7 @@ function SuiteCreateView() {
|
||||
setCreating(true);
|
||||
const saved = createTestSuite({
|
||||
name,
|
||||
description,
|
||||
description: "",
|
||||
assistantName,
|
||||
});
|
||||
router.push(`/test/cases/${saved.id}`);
|
||||
@@ -377,49 +364,36 @@ function SuiteCreateView() {
|
||||
</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 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>
|
||||
|
||||
@@ -468,15 +442,41 @@ function draftsEqual(a: CaseEditorDraft, b: CaseEditorDraft) {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
function createEmptyCaseDraft(): CaseEditorDraft {
|
||||
return {
|
||||
name: "未命名用例",
|
||||
kind: SUPPORTED_TEST_CASE_KIND,
|
||||
contextTurns: [],
|
||||
userInput: "",
|
||||
assertionType: "keyword",
|
||||
keywords: [],
|
||||
keywordMatchMode: "any",
|
||||
llmCriteria: "",
|
||||
};
|
||||
}
|
||||
|
||||
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 [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,
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
const [draft, setDraft] = useState<CaseEditorDraft | null>(null);
|
||||
const [draft, setDraft] = useState<CaseEditorDraft | null>(initial.draft);
|
||||
const [savedSnapshot, setSavedSnapshot] = useState<CaseEditorDraft | null>(
|
||||
null,
|
||||
initial.draft,
|
||||
);
|
||||
const [statusMessage, setStatusMessage] = useState("");
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
@@ -512,18 +512,11 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
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]]
|
||||
return [item.name, TEST_CASE_KIND_LABEL[item.kind]]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(keyword);
|
||||
@@ -532,8 +525,9 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
||||
|
||||
const dirty =
|
||||
draft !== null &&
|
||||
savedSnapshot !== null &&
|
||||
!draftsEqual(draft, savedSnapshot);
|
||||
(selectedId === null ||
|
||||
savedSnapshot === null ||
|
||||
!draftsEqual(draft, savedSnapshot));
|
||||
|
||||
function selectCase(item: TestCase) {
|
||||
if (dirty && !window.confirm("当前用例有未保存修改,切换将丢弃。继续?")) {
|
||||
@@ -550,18 +544,15 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
||||
if (dirty && !window.confirm("当前用例有未保存修改,新建将丢弃。继续?")) {
|
||||
return;
|
||||
}
|
||||
const created = createTestCase({
|
||||
suiteId,
|
||||
name: "未命名用例",
|
||||
});
|
||||
if (!created) return;
|
||||
reload(created.id);
|
||||
setSelectedId(null);
|
||||
setDraft(createEmptyCaseDraft());
|
||||
setSavedSnapshot(null);
|
||||
setStatusMessage("");
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!selectedId || !draft) return;
|
||||
const saved = updateTestCase(selectedId, {
|
||||
if (!draft) return;
|
||||
const patch = {
|
||||
name: draft.name.trim() || "未命名用例",
|
||||
kind: draft.kind,
|
||||
contextTurns: draft.contextTurns,
|
||||
@@ -570,9 +561,21 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
||||
keywords: draft.keywords,
|
||||
keywordMatchMode: draft.keywordMatchMode,
|
||||
llmCriteria: draft.llmCriteria,
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -581,6 +584,18 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
||||
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;
|
||||
@@ -696,7 +711,7 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
||||
<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")}
|
||||
onClick={handleBackToList}
|
||||
/>
|
||||
<EditableTitle
|
||||
value={suite.name}
|
||||
@@ -704,7 +719,7 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
||||
placeholder="未命名测试集"
|
||||
editLabel="测试集名称"
|
||||
/>
|
||||
<AssistantIdentity assistantId={suite.id} />
|
||||
<AssistantIdentity assistantId={suite.id} entityLabel="测试集" />
|
||||
</div>
|
||||
</TopbarPortal>
|
||||
|
||||
@@ -765,8 +780,17 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TEST_CASE_KIND_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
<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>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -792,11 +816,17 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-hairline-strong text-muted-foreground hover:text-destructive"
|
||||
onClick={handleDeleteSelected}
|
||||
className={
|
||||
selectedId
|
||||
? "border-hairline-strong text-muted-foreground hover:text-destructive"
|
||||
: "border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
}
|
||||
onClick={
|
||||
selectedId ? handleDeleteSelected : handleCancelNewCase
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
{selectedId ? <Trash2 size={14} /> : <X size={14} />}
|
||||
{selectedId ? "删除" : "取消"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user