diff --git a/compose.yaml b/compose.yaml index a178a6a..4b58098 100644 --- a/compose.yaml +++ b/compose.yaml @@ -5,6 +5,8 @@ services: container_name: superjump restart: unless-stopped env_file: .env + volumes: + - exam-figures:/app/public/exam networks: [proxy, data] labels: - traefik.enable=true @@ -21,3 +23,7 @@ networks: data: name: data external: true + +# 试卷配图持久化:容器内 public/exam 写卷挂载,重建不丢 +volumes: + exam-figures: diff --git a/src/app/learn/quiz-block.tsx b/src/app/learn/quiz-block.tsx index 297424e..2b9dbbb 100644 --- a/src/app/learn/quiz-block.tsx +++ b/src/app/learn/quiz-block.tsx @@ -106,6 +106,13 @@ export function QuizBlock({ {q.type === "solve" && {q.points} 分 · 拍照作答}

+ {q.figure && ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + 题目配图 + + )} + {q.type === "fill" && ( { revalidatePath("/parent/exams"); revalidatePath("/exam"); } + +// xuchao: 上传试卷题目配图(几何体等)——存 public/exam//.,返回文件名写回 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 }; +} diff --git a/src/app/parent/exams/paper-form.tsx b/src/app/parent/exams/paper-form.tsx index 658219f..9e61a8e 100644 --- a/src/app/parent/exams/paper-form.tsx +++ b/src/app/parent/exams/paper-form.tsx @@ -161,6 +161,7 @@ export function PaperForm({ paper, isNew }: { paper: PaperMeta | null; isNew: bo setQuestions(qs)} + paperId={paper?.paperId ?? f.paperId} /> diff --git a/src/app/parent/exams/question-list.test.tsx b/src/app/parent/exams/question-list.test.tsx index 1b7608a..bf63626 100644 --- a/src/app/parent/exams/question-list.test.tsx +++ b/src/app/parent/exams/question-list.test.tsx @@ -14,7 +14,7 @@ beforeEach(() => { describe("QuestionList 结构化编辑", () => { it("渲染题目卡片(题型徽标+题干)并可删除", () => { let saved: QuizQ[] = [{ ...choiceQ }]; - render( (saved = v)} />); + render( (saved = v)} paperId="g7s1-m09-1" />); expect(screen.getByText("选择题")).toBeInTheDocument(); expect(screen.getByText("1+1=?")).toBeInTheDocument(); fireEvent.click(screen.getByLabelText("删除第 1 题")); @@ -24,7 +24,7 @@ describe("QuestionList 结构化编辑", () => { it("新增选择题:填题干/选项/点选答案/解析后保存进列表", async () => { const user = userEvent.setup(); let saved: QuizQ[] = []; - render( (saved = v)} />); + render( (saved = v)} paperId="g7s1-m09-1" />); await user.click(screen.getByText("新增题目")); await user.selectOptions(screen.getByLabelText("题型"), "choice"); await user.type(screen.getByLabelText("题干"), "2+2=?"); @@ -42,7 +42,7 @@ describe("QuestionList 结构化编辑", () => { it("新建应用题仅 answer 模式(无判分模式 select)", async () => { const user = userEvent.setup(); - render( {}} />); + render( {}} paperId="g7s1-m09-1" />); await user.click(screen.getByText("新增题目")); await user.selectOptions(screen.getByLabelText("题型"), "solve"); expect(screen.queryByLabelText("判分模式")).not.toBeInTheDocument(); @@ -54,6 +54,7 @@ describe("QuestionList 结构化编辑", () => { {}} + paperId="g7s1-m09-1" />, ); fireEvent.click(screen.getByLabelText("编辑第 1 题")); @@ -66,6 +67,7 @@ describe("QuestionList 结构化编辑", () => { {}} + paperId="g7s1-m09-1" />, ); fireEvent.click(screen.getByLabelText("编辑第 1 题")); @@ -76,7 +78,7 @@ describe("QuestionList 结构化编辑", () => { it("题干为空保存时报错,不写入列表", async () => { const user = userEvent.setup(); let saved: QuizQ[] = []; - render( (saved = v)} />); + render( (saved = v)} paperId="g7s1-m09-1" />); await user.click(screen.getByText("新增题目")); await user.click(screen.getByText("完成")); expect(saved.length).toBe(0); @@ -86,7 +88,7 @@ describe("QuestionList 结构化编辑", () => { it("删除选项后 answer 下标正确回落", async () => { const user = userEvent.setup(); let saved: QuizQ[] = [{ type: "choice" as const, stem: "q", options: ["a", "b", "c"], answer: 2, explain: "e" }]; - render( (saved = v)} />); + render( (saved = v)} paperId="g7s1-m09-1" />); fireEvent.click(screen.getByLabelText("编辑第 1 题")); // xuchao: buttons = [上移,下移,编辑,删除, 取消, 添加选项, 删a, 删b, 删c, 完成] → 删b = [7] const buttons = screen.getAllByRole("button"); @@ -96,4 +98,20 @@ describe("QuestionList 结构化编辑", () => { const d = saved[0] as Record; expect(d.answer).toBe(1); }); + + it("已有配图显示预览并可移除", () => { + let saved: QuizQ[] = [{ ...choiceQ, figure: "tri.png" as const }]; + render( (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).figure).toBeUndefined(); + }); + + it("无配图时显示上传配图按钮", () => { + render( {}} paperId="g7s1-m09-1" />); + fireEvent.click(screen.getByLabelText("编辑第 1 题")); + expect(screen.getByText("上传配图")).toBeInTheDocument(); + }); }); diff --git a/src/app/parent/exams/question-list.tsx b/src/app/parent/exams/question-list.tsx index a74d55e..47cfc05 100644 --- a/src/app/parent/exams/question-list.tsx +++ b/src/app/parent/exams/question-list.tsx @@ -1,11 +1,12 @@ "use client"; -import { Fragment, useState } from "react"; -import { ArrowDown, ArrowUp, Pencil, Plus, Trash2, X } from "lucide-react"; +import { Fragment, useRef, useState, useTransition } from "react"; +import { ArrowDown, ArrowUp, ImagePlus, 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"; +import { uploadPaperFigure } from "../actions"; const TYPE_LABEL: Record = { judge: "判断题", @@ -19,13 +20,29 @@ const TYPE_ORDER = ["judge", "choice", "fill", "solve"]; export function QuestionList({ value, onChange, + paperId, }: { value: QuizQ[]; onChange: (qs: QuizQ[]) => void; + paperId: string; }) { const [editIndex, setEditIndex] = useState(null); const [draft, setDraft] = useState(null); const [error, setError] = useState(""); + const [figError, setFigError] = useState(""); + const [pendingFig, startFig] = useTransition(); + const figureRef = useRef(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 = () => { setError(""); @@ -105,6 +122,41 @@ export function QuestionList({ update({ stem: e.target.value })} /> +
+
+ + {draft.figure && ( + + )} +
+ {draft.figure ? ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + 题目配图 + {figError &&

{figError}

} + + ) : ( +
+ { + uploadFigure(e.target.files?.[0]); + e.target.value = ""; + }} + /> + + {figError &&

{figError}

} +
+ )} +
+ {draft.type === "choice" && (
diff --git a/src/lib/question-validation.test.ts b/src/lib/question-validation.test.ts index 4bb0370..b88cf59 100644 --- a/src/lib/question-validation.test.ts +++ b/src/lib/question-validation.test.ts @@ -33,6 +33,11 @@ describe("validateQuestions 基础", () => { validateQuestions(JSON.stringify([{ type: "fill", stem: "f?", explain: "e" }])).error, ).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", () => { diff --git a/src/lib/question-validation.ts b/src/lib/question-validation.ts index 0b657d2..f8bbfef 100644 --- a/src/lib/question-validation.ts +++ b/src/lib/question-validation.ts @@ -21,6 +21,9 @@ export function validateQuestions(raw: string): ValidationResult { if (typeof q.explain !== "string" || !q.explain.trim()) return { error: `${no}缺少解析(explain)` }; const t = q.type as (typeof EXAM_TYPES)[number]; 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" && (!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)答案下标越界` };