家长端结构化题目编辑器:题型联动表单替代 JSON 文本域,应用题支持 judgeMode 切换
- 新增 question-list.tsx:结构化题目卡片列表(选择题/判断题/填空题/应用题) - 新增 question-list.test.tsx:4 个 RTL 组件测试覆盖渲染/新增/编辑/校验 - paper-form.tsx:JSON textarea 替换为 QuestionList,保存时序列化 QuizQ[] - vitest.config.ts:加 @vitejs/plugin-react 支持 JSX 组件测试 - test-setup.ts:注册 afterEach cleanup 避免跨测试 DOM 污染 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
255c9a244c
commit
0749ba2c38
Generated
+973
-942
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,7 @@
|
|||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@vitejs/plugin-react": "^6.1.0",
|
||||||
"dotenv": "^17.2.0",
|
"dotenv": "^17.2.0",
|
||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.4",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { CheckCircle2, Save, Trash2 } from "lucide-react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import type { QuizQ } from "@/lib/quiz-parse";
|
||||||
import { deleteExamPaper, saveExamPaper } from "../actions";
|
import { deleteExamPaper, saveExamPaper } from "../actions";
|
||||||
|
import { QuestionList } from "./question-list";
|
||||||
|
|
||||||
// xuchao: 与 lib/exam.ts 的 EXAM_TYPE_LABEL 保持同步(客户端不能直接引用服务端模块)
|
// xuchao: 与 lib/exam.ts 的 EXAM_TYPE_LABEL 保持同步(客户端不能直接引用服务端模块)
|
||||||
const EXAM_TYPE_LABEL: Record<string, string> = {
|
const EXAM_TYPE_LABEL: Record<string, string> = {
|
||||||
@@ -32,7 +34,7 @@ export type PaperMeta = {
|
|||||||
questions: string;
|
questions: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// xuchao: 试卷编辑器——元信息表单 + 题目 JSON 文本域;保存走服务端完整校验
|
// xuchao: 试卷编辑器——元信息表单 + 结构化题目编辑器;保存走服务端完整校验
|
||||||
export function PaperForm({ paper, isNew }: { paper: PaperMeta | null; isNew: boolean }) {
|
export function PaperForm({ paper, isNew }: { paper: PaperMeta | null; isNew: boolean }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [f, setF] = useState({
|
const [f, setF] = useState({
|
||||||
@@ -47,26 +49,23 @@ export function PaperForm({ paper, isNew }: { paper: PaperMeta | null; isNew: bo
|
|||||||
seq: String(paper?.seq ?? 1),
|
seq: String(paper?.seq ?? 1),
|
||||||
sourceUrl: paper?.sourceUrl ?? "",
|
sourceUrl: paper?.sourceUrl ?? "",
|
||||||
summary: paper?.summary ?? "",
|
summary: paper?.summary ?? "",
|
||||||
questions: paper ? JSON.stringify(JSON.parse(paper.questions), null, 2) : "[]",
|
});
|
||||||
|
// xuchao: 题目用结构化数组管理,保存时序列化为 JSON(与旧 JSON 形状一致)
|
||||||
|
const [questions, setQuestions] = useState<QuizQ[]>(() => {
|
||||||
|
try {
|
||||||
|
return paper ? (JSON.parse(paper.questions) as QuizQ[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
});
|
});
|
||||||
const [msg, setMsg] = useState<{ ok?: boolean; text: string } | null>(null);
|
const [msg, setMsg] = useState<{ ok?: boolean; text: string } | null>(null);
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
// xuchao: 客户端轻量预览:JSON 可解析则显示题数,失败显示错误(完整校验在服务端)
|
|
||||||
const preview = (() => {
|
|
||||||
try {
|
|
||||||
const arr = JSON.parse(f.questions) as unknown[];
|
|
||||||
if (!Array.isArray(arr)) return { ok: false, text: "题目必须是 JSON 数组" };
|
|
||||||
return { ok: true, text: `共 ${arr.length} 道题` };
|
|
||||||
} catch (e) {
|
|
||||||
return { ok: false, text: `JSON 语法错误:${(e as Error).message}` };
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
(Object.keys(f) as (keyof typeof f)[]).forEach((k) => fd.set(k, f[k]));
|
(Object.keys(f) as (keyof typeof f)[]).forEach((k) => fd.set(k, f[k]));
|
||||||
|
fd.set("questions", JSON.stringify(questions));
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
saveExamPaper(fd).then((r) => {
|
saveExamPaper(fd).then((r) => {
|
||||||
if (r.error) setMsg({ text: r.error });
|
if (r.error) setMsg({ text: r.error });
|
||||||
@@ -158,15 +157,10 @@ export function PaperForm({ paper, isNew }: { paper: PaperMeta | null; isNew: bo
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center justify-between">
|
<Label>题目(结构化编辑)</Label>
|
||||||
<Label>题目(quiz JSON 数组)</Label>
|
<QuestionList
|
||||||
<span className={preview.ok ? "text-xs text-emerald-600" : "text-xs text-destructive"}>{preview.text}</span>
|
value={questions}
|
||||||
</div>
|
onChange={(qs) => setQuestions(qs)}
|
||||||
<textarea
|
|
||||||
value={f.questions}
|
|
||||||
onChange={n("questions").onChange}
|
|
||||||
rows={16}
|
|
||||||
className="w-full rounded-lg border border-slate-200 bg-white p-3 font-mono text-xs leading-relaxed focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { QuestionList } from "./question-list";
|
||||||
|
|
||||||
|
const choiceQ = { type: "choice" as const, stem: "1+1=?", options: ["1", "2"], answer: 1, explain: "e" };
|
||||||
|
|
||||||
|
describe("QuestionList 结构化编辑", () => {
|
||||||
|
it("渲染题目卡片(题型徽标+题干)并可删除", () => {
|
||||||
|
render(<QuestionList value={[{ ...choiceQ }]} onChange={() => {}} />);
|
||||||
|
expect(screen.getByText("选择题")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1+1=?")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByLabelText("删除第 1 题"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("新增选择题:填题干/选项/点选答案/解析后保存进列表", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let saved: unknown[] = [];
|
||||||
|
render(<QuestionList value={[]} onChange={(v) => (saved = v)} />);
|
||||||
|
await user.click(screen.getByText("+ 新增题目"));
|
||||||
|
await user.selectOptions(screen.getByLabelText("题型"), "choice");
|
||||||
|
await user.type(screen.getByLabelText("题干"), "2+2=?");
|
||||||
|
await user.click(screen.getByText("+ 添加选项"));
|
||||||
|
await user.type(screen.getByPlaceholderText("选项1"), "3");
|
||||||
|
await user.click(screen.getByText("+ 添加选项"));
|
||||||
|
await user.type(screen.getByPlaceholderText("选项2"), "4");
|
||||||
|
fireEvent.click(screen.getByLabelText("选 4 为正确答案"));
|
||||||
|
await user.type(screen.getByLabelText("解析"), "e1");
|
||||||
|
await user.click(screen.getByText("完成"));
|
||||||
|
expect((saved[0] as Record<string, unknown>).stem).toBe("2+2=?");
|
||||||
|
expect((saved[0] as Record<string, unknown>).answer).toBe(1);
|
||||||
|
expect((saved[0] as Record<string, unknown>).explain).toBe("e1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("应用题 judgeMode 切换:answer 显示最终答案数组,rubric 显示评分点", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<QuestionList value={[{ type: "solve", stem: "解方程", points: 10, reference: "x=2", explain: "e" }]} onChange={() => {}} />);
|
||||||
|
fireEvent.click(screen.getByLabelText("编辑第 1 题"));
|
||||||
|
await user.selectOptions(screen.getByLabelText("判分模式"), "answer");
|
||||||
|
expect(screen.getByText("最终答案(等价答法可填多个)")).toBeInTheDocument();
|
||||||
|
await user.selectOptions(screen.getByLabelText("判分模式"), "rubric");
|
||||||
|
expect(screen.getByLabelText("参考解答")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("题干为空保存时报错,不写入列表", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let saved: unknown[] = [];
|
||||||
|
render(<QuestionList value={[]} onChange={(v) => (saved = v)} />);
|
||||||
|
await user.click(screen.getByText("+ 新增题目"));
|
||||||
|
await user.click(screen.getByText("完成"));
|
||||||
|
expect(saved.length).toBe(0);
|
||||||
|
expect(screen.getByText(/缺少题干/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2, X } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import type { QuizQ } from "@/lib/quiz-parse";
|
||||||
|
|
||||||
|
const TYPE_LABEL: Record<string, string> = {
|
||||||
|
judge: "判断题",
|
||||||
|
choice: "选择题",
|
||||||
|
fill: "填空题",
|
||||||
|
solve: "应用题",
|
||||||
|
};
|
||||||
|
const TYPE_ORDER = ["judge", "choice", "fill", "solve"];
|
||||||
|
|
||||||
|
// xuchao: 结构化题目编辑器——题型联动表单;产出 QuizQ JSON(与服务端校验同文案)
|
||||||
|
export function QuestionList({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: QuizQ[];
|
||||||
|
onChange: (qs: QuizQ[]) => void;
|
||||||
|
}) {
|
||||||
|
const [editIndex, setEditIndex] = useState<number | null>(null);
|
||||||
|
const [draft, setDraft] = useState<QuizQ | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const startNew = () => {
|
||||||
|
setError("");
|
||||||
|
setDraft({ type: "choice", stem: "", options: [""], answer: 0, explain: "" });
|
||||||
|
setEditIndex(null);
|
||||||
|
};
|
||||||
|
const startEdit = (i: number) => {
|
||||||
|
setError("");
|
||||||
|
setDraft(JSON.parse(JSON.stringify(value[i])) as QuizQ);
|
||||||
|
setEditIndex(i);
|
||||||
|
};
|
||||||
|
const update = (patch: Partial<QuizQ>) => setDraft((d) => ({ ...(d as QuizQ), ...patch }));
|
||||||
|
|
||||||
|
const commit = () => {
|
||||||
|
const d = draft as QuizQ;
|
||||||
|
if (!d.stem.trim()) return setError("第 1 题缺少题干(stem)");
|
||||||
|
if (!d.explain.trim()) return setError("第 1 题缺少解析(explain)");
|
||||||
|
if (d.type === "choice" && !d.options?.some((o) => o.trim())) return setError("第 1 题缺少选项(options)");
|
||||||
|
if (d.type === "fill" && (d.answers ?? []).every((a) => !a.trim())) return setError("第 1 题缺少参考答案(answers 数组)");
|
||||||
|
if (d.type === "solve") {
|
||||||
|
if (d.judgeMode === "answer") {
|
||||||
|
if ((d.answers ?? []).every((a) => !a.trim())) return setError("第 1 题缺少最终答案(answers 数组)");
|
||||||
|
} else if (!d.reference?.trim()) {
|
||||||
|
return setError("第 1 题缺少参考解答(reference)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
|
const q = { ...d, stem: d.stem.trim(), explain: d.explain.trim() };
|
||||||
|
const next = editIndex == null ? [...value, q] : value.map((x, i) => (i === editIndex ? q : x));
|
||||||
|
onChange(next);
|
||||||
|
setDraft(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (i: number) => {
|
||||||
|
if (confirm("确定删除这道题?")) onChange(value.filter((_, idx) => idx !== i));
|
||||||
|
};
|
||||||
|
const move = (i: number, dir: -1 | 1) => {
|
||||||
|
const j = i + dir;
|
||||||
|
if (j < 0 || j >= value.length) return;
|
||||||
|
const next = [...value];
|
||||||
|
[next[i], next[j]] = [next[j], next[i]];
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{value.map((q, i) => {
|
||||||
|
const def = q as QuizQ;
|
||||||
|
return (
|
||||||
|
<div key={i} className="rounded-xl border border-slate-100 bg-slate-50/60 p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-medium text-indigo-700">{TYPE_LABEL[def.type]}</span>
|
||||||
|
<span className="truncate text-sm font-medium text-slate-800">{def.stem}</span>
|
||||||
|
<span className="ml-auto flex items-center gap-1.5">
|
||||||
|
<button type="button" aria-label={`上移第 ${i + 1} 题`} className="text-muted-foreground hover:text-indigo-600" onClick={() => move(i, -1)}>
|
||||||
|
<ArrowUp className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button type="button" aria-label={`下移第 ${i + 1} 题`} className="text-muted-foreground hover:text-indigo-600" onClick={() => move(i, 1)}>
|
||||||
|
<ArrowDown className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button type="button" aria-label={`编辑第 ${i + 1} 题`} className="text-muted-foreground hover:text-indigo-600" onClick={() => startEdit(i)}>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button type="button" aria-label={`删除第 ${i + 1} 题`} className="text-muted-foreground hover:text-destructive" onClick={() => remove(i)}>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{draft ? (
|
||||||
|
<div className="rounded-xl border border-indigo-200 bg-white p-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium text-slate-700">
|
||||||
|
题型
|
||||||
|
<select
|
||||||
|
value={draft.type}
|
||||||
|
aria-label="题型"
|
||||||
|
onChange={(e) => {
|
||||||
|
const t = e.target.value as QuizQ["type"];
|
||||||
|
update({ type: t, answers: undefined, options: undefined, reference: undefined });
|
||||||
|
}}
|
||||||
|
className="ml-2 h-8 rounded-lg border border-slate-200 bg-white px-2 text-sm"
|
||||||
|
>
|
||||||
|
{TYPE_ORDER.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{TYPE_LABEL[t]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setDraft(null)}>
|
||||||
|
<X className="size-4" /> 取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>题干</Label>
|
||||||
|
<Input aria-label="题干" value={draft.stem} onChange={(e) => update({ stem: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{draft.type === "choice" && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>选项(点选正确答案)</Label>
|
||||||
|
<Button size="sm" variant="outline" className="h-7" onClick={() => update({ options: [...(draft.options ?? []), ""] })}>
|
||||||
|
+ 添加选项
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{(draft.options ?? []).map((o, oi) => (
|
||||||
|
<div key={oi} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="opt"
|
||||||
|
checked={draft.answer === oi}
|
||||||
|
onChange={() => update({ answer: oi })}
|
||||||
|
aria-label={`选 ${o || `选项${oi + 1}`} 为正确答案`}
|
||||||
|
/>
|
||||||
|
<Input placeholder={`选项${oi + 1}`} value={o} onChange={(e) => update({ options: (draft.options ?? []).map((x, xi) => (xi === oi ? e.target.value : x)) })} />
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => update({ options: (draft.options ?? []).filter((_, xi) => xi !== oi) })}>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{draft.type === "judge" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>答案</Label>
|
||||||
|
<div className="flex gap-3 text-sm">
|
||||||
|
{[0, 1].map((v) => (
|
||||||
|
<label key={v} className="flex items-center gap-1">
|
||||||
|
<input type="radio" checked={draft.answer === v} onChange={() => update({ answer: v })} />
|
||||||
|
{v === 0 ? "对" : "错"}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(draft.type === "fill" || (draft.type === "solve" && draft.judgeMode === "answer")) && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>{draft.type === "fill" ? "参考答案(等价答法可填多个)" : "最终答案(等价答法可填多个)"}</Label>
|
||||||
|
<Button size="sm" variant="outline" className="h-7" onClick={() => update({ answers: [...(draft.answers ?? []), ""] })}>
|
||||||
|
+ 添加答案
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{(draft.answers ?? []).map((a, ai) => (
|
||||||
|
<div key={ai} className="flex items-center gap-2">
|
||||||
|
<Input value={a} onChange={(e) => update({ answers: (draft.answers ?? []).map((x, xi) => (xi === ai ? e.target.value : x)) })} />
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => update({ answers: (draft.answers ?? []).filter((_, xi) => xi !== ai) })}>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{draft.type === "solve" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>分值</Label>
|
||||||
|
<Input
|
||||||
|
aria-label="分值"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={draft.points ?? 10}
|
||||||
|
onChange={(e) => update({ points: Number(e.target.value) || undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>判分模式</Label>
|
||||||
|
<select
|
||||||
|
aria-label="判分模式"
|
||||||
|
value={draft.judgeMode ?? "rubric"}
|
||||||
|
onChange={(e) => update({ judgeMode: e.target.value as "rubric" | "answer" })}
|
||||||
|
className="h-8 rounded-lg border border-slate-200 bg-white px-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="answer">仅最终答案(秒判)</option>
|
||||||
|
<option value="rubric">AI 逐评分点判分</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{draft.judgeMode === "answer" && draft.answers == null && (
|
||||||
|
<p className="text-xs text-slate-500">选「仅最终答案」后请填写最终答案数组(第 1 题缺少最终答案时无法保存)。</p>
|
||||||
|
)}
|
||||||
|
{draft.judgeMode === "answer" ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>参考解答(可选,给孩子看)</Label>
|
||||||
|
<Input aria-label="参考解答" value={draft.reference ?? ""} onChange={(e) => update({ reference: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>参考解答(完整解题过程,展示给孩子)</Label>
|
||||||
|
<Input aria-label="参考解答" value={draft.reference ?? ""} onChange={(e) => update({ reference: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>解析</Label>
|
||||||
|
<Input aria-label="解析" value={draft.explain} onChange={(e) => update({ explain: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||||
|
<Button size="sm" onClick={commit}>
|
||||||
|
完成
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" onClick={startNew}>
|
||||||
|
<Plus className="size-4" /> + 新增题目
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+6
-1
@@ -1 +1,6 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { afterEach } from "vitest";
|
||||||
|
import { cleanup } from "@testing-library/react";
|
||||||
|
|
||||||
|
// xuchao: 组件测试后自动清理 DOM,避免跨测试污染
|
||||||
|
afterEach(() => cleanup());
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
// xuchao: Vitest 配置——@ 别名对齐 tsconfig;组件测试文件用 // @vitest-environment jsdom 标注
|
// xuchao: Vitest 配置——@ 别名对齐 tsconfig;组件测试文件用 // @vitest-environment jsdom 标注
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
resolve: { alias: { "@": path.resolve(__dirname, "src") } },
|
resolve: { alias: { "@": path.resolve(__dirname, "src") } },
|
||||||
test: {
|
test: {
|
||||||
environment: "node",
|
environment: "node",
|
||||||
|
|||||||
Reference in New Issue
Block a user