uploadPaperFigure 存 public/exam/<卷>/<uuid>.ext;编辑器预览/移除; quiz-block 渲染 q.figure;compose 挂 exam-figures 卷防重建丢失。 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
890e7ef3ac
commit
c706f485cc
@@ -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:
|
||||
|
||||
@@ -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>}
|
||||
</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" && (
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
@@ -392,3 +394,25 @@ export async function deleteExamPaper(paperId: string): Promise<void> {
|
||||
revalidatePath("/parent/exams");
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -161,6 +161,7 @@ export function PaperForm({ paper, isNew }: { paper: PaperMeta | null; isNew: bo
|
||||
<QuestionList
|
||||
value={questions}
|
||||
onChange={(qs) => setQuestions(qs)}
|
||||
paperId={paper?.paperId ?? f.paperId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ beforeEach(() => {
|
||||
describe("QuestionList 结构化编辑", () => {
|
||||
it("渲染题目卡片(题型徽标+题干)并可删除", () => {
|
||||
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("1+1=?")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByLabelText("删除第 1 题"));
|
||||
@@ -24,7 +24,7 @@ describe("QuestionList 结构化编辑", () => {
|
||||
it("新增选择题:填题干/选项/点选答案/解析后保存进列表", async () => {
|
||||
const user = userEvent.setup();
|
||||
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.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(<QuestionList value={[]} onChange={() => {}} />);
|
||||
render(<QuestionList value={[]} onChange={() => {}} 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 结构化编辑", () => {
|
||||
<QuestionList
|
||||
value={[{ type: "solve", stem: "解方程", points: 10, judgeMode: "rubric" as const, reference: "x=2", rubric: [{ point: "过程", score: 6 }], explain: "e" }]}
|
||||
onChange={() => {}}
|
||||
paperId="g7s1-m09-1"
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("编辑第 1 题"));
|
||||
@@ -66,6 +67,7 @@ describe("QuestionList 结构化编辑", () => {
|
||||
<QuestionList
|
||||
value={[{ type: "solve", stem: "解方程", points: 10, reference: "x=2", rubric: [{ point: "过程", score: 6 }], explain: "e" }]}
|
||||
onChange={() => {}}
|
||||
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(<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("完成"));
|
||||
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(<QuestionList value={saved} onChange={(v) => (saved = v)} />);
|
||||
render(<QuestionList value={saved} onChange={(v) => (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<string, unknown>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<number | null>(null);
|
||||
const [draft, setDraft] = useState<QuizQ | null>(null);
|
||||
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 = () => {
|
||||
setError("");
|
||||
@@ -105,6 +122,41 @@ export function QuestionList({
|
||||
<Input aria-label="题干" value={draft.stem} onChange={(e) => update({ stem: e.target.value })} />
|
||||
</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" && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)答案下标越界` };
|
||||
|
||||
Reference in New Issue
Block a user