Compare commits

..
3 Commits
Author SHA1 Message Date
jacobxu666andClaude Haiku 4.5 c706f485cc 试卷题目配图(几何体):编辑器上传区+孩子端渲染+文件名校验+public挂卷持久化
test / test (push) Successful in 37s
uploadPaperFigure 存 public/exam/<卷>/<uuid>.ext;编辑器预览/移除;
quiz-block 渲染 q.figure;compose 挂 exam-figures 卷防重建丢失。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-20 16:08:27 +08:00
jacobxu666andClaude Haiku 4.5 890e7ef3ac 编辑卡片类型徽标不随题干换行(shrink-0+min-w-0);新增题目按钮去掉双加号(图标+文字)
test / test (push) Failing after 43s
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-20 15:59:20 +08:00
jacobxu666andClaude Haiku 4.5 95994b14d0 试卷编辑页三处布局优化:头部标题独立成行可换行、网格显式单列+元素自适应、题目编辑表单渲染在被编辑题下方
test / test (push) Failing after 6m52s
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-20 15:24:10 +08:00
10 changed files with 172 additions and 49 deletions
+6
View File
@@ -5,6 +5,8 @@ services:
container_name: superjump container_name: superjump
restart: unless-stopped restart: unless-stopped
env_file: .env env_file: .env
volumes:
- exam-figures:/app/public/exam
networks: [proxy, data] networks: [proxy, data]
labels: labels:
- traefik.enable=true - traefik.enable=true
@@ -21,3 +23,7 @@ networks:
data: data:
name: data name: data
external: true external: true
# 试卷配图持久化:容器内 public/exam 写卷挂载,重建不丢
volumes:
exam-figures:
+7
View File
@@ -106,6 +106,13 @@ export function QuizBlock({
{q.type === "solve" && <span className="ml-2 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-semibold text-amber-700">{q.points} 分 · 拍照作答</span>} {q.type === "solve" && <span className="ml-2 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-semibold text-amber-700">{q.points} 分 · 拍照作答</span>}
</p> </p>
{q.figure && (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={`/exam/${pointId}/${q.figure}`} alt="题目配图" className="mt-2 max-h-56 rounded-lg border border-slate-200 object-contain" />
</>
)}
{q.type === "fill" && ( {q.type === "fill" && (
<input <input
type="text" type="text"
+24
View File
@@ -1,6 +1,8 @@
"use server"; "use server";
import crypto from "node:crypto"; import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { and, eq, sql } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
@@ -392,3 +394,25 @@ export async function deleteExamPaper(paperId: string): Promise<void> {
revalidatePath("/parent/exams"); revalidatePath("/parent/exams");
revalidatePath("/exam"); revalidatePath("/exam");
} }
// xuchao: 上传试卷题目配图(几何体等)——存 public/exam/<paperId>/<uuid>.<ext>,返回文件名写回 q.figure
// 生产容器 public/exam 挂卷持久化(compose volumes: exam-figures),重建不丢
export async function uploadPaperFigure(
paperId: string,
file: File,
): Promise<{ filename?: string; error?: string }> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return { error: "请先以家长身份登录" };
if (!/^[a-z0-9-]+$/.test(paperId)) return { error: "试卷编号不合法" };
if (!file.type.startsWith("image/")) return { error: "只能上传图片文件" };
const ext = file.name.toLowerCase().split(".").pop();
if (!["png", "jpg", "jpeg", "webp"].includes(ext ?? "")) return { error: "只支持 png/jpg/webp 图片" };
if (file.size > 8 * 1024 * 1024) return { error: "图片不能超过 8MB" };
const buf = Buffer.from(await file.arrayBuffer());
const filename = `${crypto.randomUUID()}.${ext}`;
const dir = path.join(process.cwd(), "public", "exam", paperId);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, filename), buf);
return { filename };
}
+2 -2
View File
@@ -17,11 +17,11 @@ export default async function EditExamPage({ params }: { params: Promise<{ paper
return ( return (
<main className="relative mx-auto max-w-3xl space-y-5 p-4 pb-10"> <main className="relative mx-auto max-w-3xl space-y-5 p-4 pb-10">
<BackgroundBlobs /> <BackgroundBlobs />
<header className="flex items-center justify-between"> <header className="space-y-2">
<Link href="/parent/exams" className="text-sm font-medium text-indigo-600"> <Link href="/parent/exams" className="text-sm font-medium text-indigo-600">
← 试卷列表 ← 试卷列表
</Link> </Link>
<h1 className="text-xl font-extrabold text-slate-900">{paper.title}</h1> <h1 className="text-xl font-extrabold break-words text-slate-900">{paper.title}</h1>
</header> </header>
<div className="rounded-2xl border border-slate-100 bg-white p-5 shadow-soft"> <div className="rounded-2xl border border-slate-100 bg-white p-5 shadow-soft">
<PaperForm <PaperForm
+2 -2
View File
@@ -13,11 +13,11 @@ export default async function NewExamPage() {
return ( return (
<main className="relative mx-auto max-w-3xl space-y-5 p-4 pb-10"> <main className="relative mx-auto max-w-3xl space-y-5 p-4 pb-10">
<BackgroundBlobs /> <BackgroundBlobs />
<header className="flex items-center justify-between"> <header className="space-y-2">
<Link href="/parent/exams" className="text-sm font-medium text-indigo-600"> <Link href="/parent/exams" className="text-sm font-medium text-indigo-600">
← 试卷列表 ← 试卷列表
</Link> </Link>
<h1 className="text-xl font-extrabold text-slate-900">新建试卷</h1> <h1 className="text-xl font-extrabold break-words text-slate-900">新建试卷</h1>
</header> </header>
<div className="rounded-2xl border border-slate-100 bg-white p-5 shadow-soft"> <div className="rounded-2xl border border-slate-100 bg-white p-5 shadow-soft">
<PaperForm paper={null} isNew /> <PaperForm paper={null} isNew />
+3 -2
View File
@@ -89,7 +89,7 @@ export function PaperForm({ paper, isNew }: { paper: PaperMeta | null; isNew: bo
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1"> <div className="space-y-1">
<Label>试卷编号(唯一,如 g7s1-m09-2)</Label> <Label>试卷编号(唯一,如 g7s1-m09-2)</Label>
<Input disabled={!isNew} placeholder="g7s1-m09-2" {...n("paperId")} /> <Input disabled={!isNew} placeholder="g7s1-m09-2" {...n("paperId")} />
@@ -161,11 +161,12 @@ export function PaperForm({ paper, isNew }: { paper: PaperMeta | null; isNew: bo
<QuestionList <QuestionList
value={questions} value={questions}
onChange={(qs) => setQuestions(qs)} onChange={(qs) => setQuestions(qs)}
paperId={paper?.paperId ?? f.paperId}
/> />
</div> </div>
{msg && <p className={msg.ok ? "text-sm font-medium text-emerald-600" : "text-sm font-medium text-destructive"}>{msg.text}</p>} {msg && <p className={msg.ok ? "text-sm font-medium text-emerald-600" : "text-sm font-medium text-destructive"}>{msg.text}</p>}
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button disabled={pending} onClick={save}> <Button disabled={pending} onClick={save}>
<Save className="size-4" /> {pending ? "保存中…" : "保存"} <Save className="size-4" /> {pending ? "保存中…" : "保存"}
</Button> </Button>
+26 -8
View File
@@ -14,7 +14,7 @@ beforeEach(() => {
describe("QuestionList 结构化编辑", () => { describe("QuestionList 结构化编辑", () => {
it("渲染题目卡片(题型徽标+题干)并可删除", () => { it("渲染题目卡片(题型徽标+题干)并可删除", () => {
let saved: QuizQ[] = [{ ...choiceQ }]; let saved: QuizQ[] = [{ ...choiceQ }];
render(<QuestionList value={saved} onChange={(v) => (saved = v)} />); render(<QuestionList value={saved} onChange={(v) => (saved = v)} paperId="g7s1-m09-1" />);
expect(screen.getByText("选择题")).toBeInTheDocument(); expect(screen.getByText("选择题")).toBeInTheDocument();
expect(screen.getByText("1+1=?")).toBeInTheDocument(); expect(screen.getByText("1+1=?")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("删除第 1 题")); fireEvent.click(screen.getByLabelText("删除第 1 题"));
@@ -24,8 +24,8 @@ describe("QuestionList 结构化编辑", () => {
it("新增选择题:填题干/选项/点选答案/解析后保存进列表", async () => { it("新增选择题:填题干/选项/点选答案/解析后保存进列表", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
let saved: QuizQ[] = []; let saved: QuizQ[] = [];
render(<QuestionList value={[]} onChange={(v) => (saved = v)} />); render(<QuestionList value={[]} onChange={(v) => (saved = v)} paperId="g7s1-m09-1" />);
await user.click(screen.getByText("+ 新增题目")); await user.click(screen.getByText("新增题目"));
await user.selectOptions(screen.getByLabelText("题型"), "choice"); await user.selectOptions(screen.getByLabelText("题型"), "choice");
await user.type(screen.getByLabelText("题干"), "2+2=?"); await user.type(screen.getByLabelText("题干"), "2+2=?");
await user.click(screen.getByText("+ 添加选项")); await user.click(screen.getByText("+ 添加选项"));
@@ -42,8 +42,8 @@ describe("QuestionList 结构化编辑", () => {
it("新建应用题仅 answer 模式(无判分模式 select)", async () => { it("新建应用题仅 answer 模式(无判分模式 select)", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<QuestionList value={[]} onChange={() => {}} />); render(<QuestionList value={[]} onChange={() => {}} paperId="g7s1-m09-1" />);
await user.click(screen.getByText("+ 新增题目")); await user.click(screen.getByText("新增题目"));
await user.selectOptions(screen.getByLabelText("题型"), "solve"); await user.selectOptions(screen.getByLabelText("题型"), "solve");
expect(screen.queryByLabelText("判分模式")).not.toBeInTheDocument(); expect(screen.queryByLabelText("判分模式")).not.toBeInTheDocument();
expect(screen.getByText("仅最终答案(秒判)")).toBeInTheDocument(); expect(screen.getByText("仅最终答案(秒判)")).toBeInTheDocument();
@@ -54,6 +54,7 @@ describe("QuestionList 结构化编辑", () => {
<QuestionList <QuestionList
value={[{ type: "solve", stem: "解方程", points: 10, judgeMode: "rubric" as const, reference: "x=2", rubric: [{ point: "过程", score: 6 }], explain: "e" }]} value={[{ type: "solve", stem: "解方程", points: 10, judgeMode: "rubric" as const, reference: "x=2", rubric: [{ point: "过程", score: 6 }], explain: "e" }]}
onChange={() => {}} onChange={() => {}}
paperId="g7s1-m09-1"
/>, />,
); );
fireEvent.click(screen.getByLabelText("编辑第 1 题")); fireEvent.click(screen.getByLabelText("编辑第 1 题"));
@@ -66,6 +67,7 @@ describe("QuestionList 结构化编辑", () => {
<QuestionList <QuestionList
value={[{ type: "solve", stem: "解方程", points: 10, reference: "x=2", rubric: [{ point: "过程", score: 6 }], explain: "e" }]} value={[{ type: "solve", stem: "解方程", points: 10, reference: "x=2", rubric: [{ point: "过程", score: 6 }], explain: "e" }]}
onChange={() => {}} onChange={() => {}}
paperId="g7s1-m09-1"
/>, />,
); );
fireEvent.click(screen.getByLabelText("编辑第 1 题")); fireEvent.click(screen.getByLabelText("编辑第 1 题"));
@@ -76,8 +78,8 @@ describe("QuestionList 结构化编辑", () => {
it("题干为空保存时报错,不写入列表", async () => { it("题干为空保存时报错,不写入列表", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
let saved: QuizQ[] = []; let saved: QuizQ[] = [];
render(<QuestionList value={[]} onChange={(v) => (saved = v)} />); render(<QuestionList value={[]} onChange={(v) => (saved = v)} paperId="g7s1-m09-1" />);
await user.click(screen.getByText("+ 新增题目")); await user.click(screen.getByText("新增题目"));
await user.click(screen.getByText("完成")); await user.click(screen.getByText("完成"));
expect(saved.length).toBe(0); expect(saved.length).toBe(0);
expect(screen.getByText(/缺少题干/)).toBeInTheDocument(); expect(screen.getByText(/缺少题干/)).toBeInTheDocument();
@@ -86,7 +88,7 @@ describe("QuestionList 结构化编辑", () => {
it("删除选项后 answer 下标正确回落", async () => { it("删除选项后 answer 下标正确回落", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
let saved: QuizQ[] = [{ type: "choice" as const, stem: "q", options: ["a", "b", "c"], answer: 2, explain: "e" }]; let saved: QuizQ[] = [{ type: "choice" as const, stem: "q", options: ["a", "b", "c"], answer: 2, explain: "e" }];
render(<QuestionList value={saved} onChange={(v) => (saved = v)} />); render(<QuestionList value={saved} onChange={(v) => (saved = v)} paperId="g7s1-m09-1" />);
fireEvent.click(screen.getByLabelText("编辑第 1 题")); fireEvent.click(screen.getByLabelText("编辑第 1 题"));
// xuchao: buttons = [上移,下移,编辑,删除, 取消, 添加选项, 删a, 删b, 删c, 完成] → 删b = [7] // xuchao: buttons = [上移,下移,编辑,删除, 取消, 添加选项, 删a, 删b, 删c, 完成] → 删b = [7]
const buttons = screen.getAllByRole("button"); const buttons = screen.getAllByRole("button");
@@ -96,4 +98,20 @@ describe("QuestionList 结构化编辑", () => {
const d = saved[0] as Record<string, unknown>; const d = saved[0] as Record<string, unknown>;
expect(d.answer).toBe(1); expect(d.answer).toBe(1);
}); });
it("已有配图显示预览并可移除", () => {
let saved: QuizQ[] = [{ ...choiceQ, figure: "tri.png" as const }];
render(<QuestionList value={saved} onChange={(v) => (saved = v)} paperId="g7s1-m09-1" />);
fireEvent.click(screen.getByLabelText("编辑第 1 题"));
expect(screen.getByAltText("题目配图")).toHaveAttribute("src", "/exam/g7s1-m09-1/tri.png");
fireEvent.click(screen.getByText("移除配图"));
fireEvent.click(screen.getByText("完成"));
expect((saved[0] as Record<string, unknown>).figure).toBeUndefined();
});
it("无配图时显示上传配图按钮", () => {
render(<QuestionList value={[{ ...choiceQ }]} onChange={() => {}} paperId="g7s1-m09-1" />);
fireEvent.click(screen.getByLabelText("编辑第 1 题"));
expect(screen.getByText("上传配图")).toBeInTheDocument();
});
}); });
+94 -35
View File
@@ -1,11 +1,12 @@
"use client"; "use client";
import { useState } from "react"; import { Fragment, useRef, useState, useTransition } from "react";
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2, X } from "lucide-react"; import { ArrowDown, ArrowUp, ImagePlus, Pencil, Plus, Trash2, X } 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 type { QuizQ } from "@/lib/quiz-parse";
import { uploadPaperFigure } from "../actions";
const TYPE_LABEL: Record<string, string> = { const TYPE_LABEL: Record<string, string> = {
judge: "判断题", judge: "判断题",
@@ -19,13 +20,29 @@ const TYPE_ORDER = ["judge", "choice", "fill", "solve"];
export function QuestionList({ export function QuestionList({
value, value,
onChange, onChange,
paperId,
}: { }: {
value: QuizQ[]; value: QuizQ[];
onChange: (qs: QuizQ[]) => void; onChange: (qs: QuizQ[]) => void;
paperId: string;
}) { }) {
const [editIndex, setEditIndex] = useState<number | null>(null); const [editIndex, setEditIndex] = useState<number | null>(null);
const [draft, setDraft] = useState<QuizQ | null>(null); const [draft, setDraft] = useState<QuizQ | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [figError, setFigError] = useState("");
const [pendingFig, startFig] = useTransition();
const figureRef = useRef<HTMLInputElement>(null);
// xuchao: 配图上传(几何体等)——成功后把文件名写入题目 draft.figure
const uploadFigure = (file: File | undefined) => {
if (!file || pendingFig) return;
setFigError("");
startFig(async () => {
const r = await uploadPaperFigure(paperId, file);
if (r.error) setFigError(r.error);
else update({ figure: r.filename });
});
};
const startNew = () => { const startNew = () => {
setError(""); setError("");
@@ -72,37 +89,10 @@ export function QuestionList({
onChange(next); onChange(next);
}; };
return ( // xuchao: 编辑表单——编辑存量题渲染在该题卡片下方,新增时渲染在列表末尾
<div className="space-y-3"> const draftForm = draft ? (
{value.map((q, i) => { <div className="rounded-xl border border-indigo-200 bg-white p-3">
const def = q as QuizQ; <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
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"> <label className="text-sm font-medium text-slate-700">
题型 题型
<select <select
@@ -132,6 +122,41 @@ export function QuestionList({
<Input aria-label="题干" value={draft.stem} onChange={(e) => update({ stem: e.target.value })} /> <Input aria-label="题干" value={draft.stem} onChange={(e) => update({ stem: e.target.value })} />
</div> </div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label>题目配图(几何体等,可选)</Label>
{draft.figure && (
<Button size="sm" variant="ghost" className="h-7" onClick={() => update({ figure: undefined })}>
<X className="size-4" /> 移除配图
</Button>
)}
</div>
{draft.figure ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={`/exam/${paperId}/${draft.figure}`} alt="题目配图" className="max-h-48 rounded-lg border border-slate-200 object-contain" />
{figError && <p className="text-xs font-medium text-destructive">{figError}</p>}
</>
) : (
<div>
<input
ref={figureRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(e) => {
uploadFigure(e.target.files?.[0]);
e.target.value = "";
}}
/>
<Button type="button" size="sm" variant="outline" className="h-8" disabled={pendingFig} onClick={() => figureRef.current?.click()}>
<ImagePlus className="size-4" /> {pendingFig ? "上传中…" : "上传配图"}
</Button>
{figError && <p className="mt-1 text-xs font-medium text-destructive">{figError}</p>}
</div>
)}
</div>
{draft.type === "choice" && ( {draft.type === "choice" && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -231,9 +256,43 @@ export function QuestionList({
</Button> </Button>
</div> </div>
</div> </div>
) : ( ) : null;
return (
<div className="space-y-3">
{value.map((q, i) => {
const def = q as QuizQ;
return (
<Fragment key={i}>
<div className="rounded-xl border border-slate-100 bg-slate-50/60 p-3">
<div className="flex items-center gap-2">
<span className="shrink-0 rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-medium text-indigo-700">{TYPE_LABEL[def.type]}</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-slate-800">{def.stem}</span>
<span className="ml-auto flex shrink-0 items-center gap-1">
<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 && editIndex === i && draftForm}
</Fragment>
);
})}
{draft && editIndex == null && draftForm}
{!draft && (
<Button variant="outline" onClick={startNew}> <Button variant="outline" onClick={startNew}>
<Plus className="size-4" /> + 新增题目 <Plus className="size-4" /> 新增题目
</Button> </Button>
)} )}
</div> </div>
+5
View File
@@ -33,6 +33,11 @@ describe("validateQuestions 基础", () => {
validateQuestions(JSON.stringify([{ type: "fill", stem: "f?", explain: "e" }])).error, validateQuestions(JSON.stringify([{ type: "fill", stem: "f?", explain: "e" }])).error,
).toContain("缺少参考答案"); ).toContain("缺少参考答案");
}); });
it("figure 合法/非法文件名", () => {
expect(validateQuestions(JSON.stringify([{ ...base, figure: "tri.png" }])).error).toBeUndefined();
expect(validateQuestions(JSON.stringify([{ ...base, figure: "a b.png" }])).error).toContain("配图文件名不合法");
expect(validateQuestions(JSON.stringify([{ ...base, figure: " " }])).error).toContain("配图文件名不合法");
});
}); });
describe("validateQuestions solve / judgeMode", () => { describe("validateQuestions solve / judgeMode", () => {
+3
View File
@@ -21,6 +21,9 @@ export function validateQuestions(raw: string): ValidationResult {
if (typeof q.explain !== "string" || !q.explain.trim()) return { error: `${no}缺少解析(explain)` }; if (typeof q.explain !== "string" || !q.explain.trim()) return { error: `${no}缺少解析(explain)` };
const t = q.type as (typeof EXAM_TYPES)[number]; const t = q.type as (typeof EXAM_TYPES)[number];
if (!EXAM_TYPES.includes(t)) return { error: `${no}的 type 非法:${String(q.type)}` }; if (!EXAM_TYPES.includes(t)) return { error: `${no}的 type 非法:${String(q.type)}` };
if (typeof q.figure === "string" && (q.figure.trim() === "" || !/^[\w.-]+$/.test(q.figure))) {
return { error: `${no}配图文件名不合法(figure)` };
}
if ((t === "choice" || t === "judge") && typeof q.answer !== "number") return { error: `${no}(${t})缺少答案(answer 数字)` }; if ((t === "choice" || t === "judge") && typeof q.answer !== "number") return { error: `${no}(${t})缺少答案(answer 数字)` };
if (t === "choice" && (!Array.isArray(q.options) || q.options.length === 0)) return { error: `${no}(choice)缺少选项(options)` }; if (t === "choice" && (!Array.isArray(q.options) || q.options.length === 0)) return { error: `${no}(choice)缺少选项(options)` };
if (t === "choice" && (typeof q.answer !== "number" || q.answer < 0 || q.answer >= (q.options as unknown[]).length)) return { error: `${no}(choice)答案下标越界` }; if (t === "choice" && (typeof q.answer !== "number" || q.answer < 0 || q.answer >= (q.options as unknown[]).length)) return { error: `${no}(choice)答案下标越界` };