家长端结构化题目编辑器:题型联动表单替代 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:
jacobxu666
2026-08-20 14:16:13 +08:00
co-authored by Claude Haiku 4.5
parent 255c9a244c
commit 0749ba2c38
7 changed files with 1304 additions and 965 deletions
+16 -22
View File
@@ -6,7 +6,9 @@ import { CheckCircle2, Save, Trash2 } 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";
import { deleteExamPaper, saveExamPaper } from "../actions";
import { QuestionList } from "./question-list";
// xuchao: 与 lib/exam.ts 的 EXAM_TYPE_LABEL 保持同步(客户端不能直接引用服务端模块)
const EXAM_TYPE_LABEL: Record<string, string> = {
@@ -32,7 +34,7 @@ export type PaperMeta = {
questions: string;
};
// xuchao: 试卷编辑器——元信息表单 + 题目 JSON 文本域;保存走服务端完整校验
// xuchao: 试卷编辑器——元信息表单 + 结构化题目编辑器;保存走服务端完整校验
export function PaperForm({ paper, isNew }: { paper: PaperMeta | null; isNew: boolean }) {
const router = useRouter();
const [f, setF] = useState({
@@ -47,26 +49,23 @@ export function PaperForm({ paper, isNew }: { paper: PaperMeta | null; isNew: bo
seq: String(paper?.seq ?? 1),
sourceUrl: paper?.sourceUrl ?? "",
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 [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 = () => {
setMsg(null);
const fd = new FormData();
(Object.keys(f) as (keyof typeof f)[]).forEach((k) => fd.set(k, f[k]));
fd.set("questions", JSON.stringify(questions));
startTransition(() => {
saveExamPaper(fd).then((r) => {
if (r.error) setMsg({ text: r.error });
@@ -158,15 +157,10 @@ export function PaperForm({ paper, isNew }: { paper: PaperMeta | null; isNew: bo
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label>题目(quiz JSON 数组)</Label>
<span className={preview.ok ? "text-xs text-emerald-600" : "text-xs text-destructive"}>{preview.text}</span>
</div>
<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"
<Label>题目(结构化编辑)</Label>
<QuestionList
value={questions}
onChange={(qs) => setQuestions(qs)}
/>
</div>