From 637c6032d4914ec0e8d50349dff0eee1bf2a9e58 Mon Sep 17 00:00:00 2001 From: jacobxu666 Date: Thu, 20 Aug 2026 13:49:01 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E7=BB=93=E6=9E=84=E5=8C=96=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8+=E7=AD=94=E6=A1=88=E5=88=A4=E5=88=86?= =?UTF-8?q?=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92(5=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=90=ABVitest=E6=A1=86=E6=9E=B6/=E7=BB=84=E4=BB=B6=E6=B5=8B?= =?UTF-8?q?=E8=AF=95/CI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-08-20-structured-question-editor.md | 1001 +++++++++++++++++ 1 file changed, 1001 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-20-structured-question-editor.md diff --git a/docs/superpowers/plans/2026-08-20-structured-question-editor.md b/docs/superpowers/plans/2026-08-20-structured-question-editor.md new file mode 100644 index 0000000..0e42d26 --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-structured-question-editor.md @@ -0,0 +1,1001 @@ +# 结构化题目编辑器 + 应用题答案判分 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 家长端试卷编辑从裸 JSON 改为四种题型(判断/选择/填空/应用题)联动表单;应用题新增 `judgeMode:"answer"`——最终答案数组 + 归一化匹配秒判,孩子拍照填答案、交卷即判;并搭建 Vitest 自动化测试框架 + CI 测试。 + +**Architecture:** 纯逻辑沉淀为可单测的 lib(`answer-match.ts` 归一化/等价、`question-validation.ts` 形状校验 v2);QuizQ 加 `judgeMode`/solve 的 `answers` 字段(向后兼容,缺省 rubric);孩子端 quiz-block/takeExam 按 judgeMode 分流(answer 不调 AI,照片照存照进错题本);家长端 PaperForm 重构为 QuestionList+QuestionForm。TDD 先行,组件测试用 @testing-library/react(jsdom)。 + +**Tech Stack:** Next.js 15.5 / React 19 / TypeScript / Drizzle(mysql2);新增 dev 依赖:vitest、jsdom、@testing-library/react、@testing-library/jest-dom、@testing-library/user-event;Gitea Actions CI(node:24-slim runner)。 + +**Spec:** `docs/superpowers/specs/2026-08-20-structured-question-editor-design.md` + +## Global Constraints + +- **本 plan 起,验证 = `npx tsc --noEmit` + `npm run test`(vitest)全绿**,最终任务加 `npm run build`;每任务的“类型检查”步骤都含这两项。 +- 新增 list 依赖:**仅** vitest、jsdom、@testing-library/react、@testing-library/jest-dom、@testing-library/user-event(devDependencies),不引入其它测试工具。 +- 纯逻辑一律放 `src/lib/*.ts` 纯模块(不 import db/next),便于单测;测试文件与源码同目录 `src/**/*.test.ts(x)` 共置。 +- `judgeMode` 规则:`"rubric"`(缺省,存量 8 题兼容)走 AI 逐点评分不动;`"answer"` 走答案数组匹配,不调 AI、不写 aiGradeLog。answer 模式不得带 rubric;rubric 模式必须 reference。 +- 答案等价:`normalizeAnswer` 数值类(分数/小数/百分数→小数)与文本类(去空白/全角→半角/小写)归一;fill 的既有判分(grammar.ts gradeOne trim+小写)不动,归一化只用于 apply answer 模式。 +- 家长端结构化表单产出仍是 QuizQ JSON 数组(存储零改动);服务端校验(validateQuestions)为唯一写入门禁。 +- 新注释遵循项目风格,新函数加一行 `// xuchao:` 注释。 + +--- + +### Task 1: 测试框架 + answer-match 纯函数(TDD) + +**Files:** +- Create: `vitest.config.ts`、`src/test-setup.ts` +- Modify: `package.json`(devDependencies + `test` script) +- Create: `src/lib/answer-match.ts`、`src/lib/answer-match.test.ts` +- Modify: `src/lib/quiz-parse.ts`(QuizQ 加 `judgeMode`/solve `answers`,另导出 `SolveAnswerPayload`) + +**Interfaces:** +- Produces: + - `normalizeAnswer(s: string): string`、`answersMatch(expected: string[], given: string): boolean`(src/lib/answer-match.ts) + - QuizQ 新增:`judgeMode?: "rubric" | "answer"`(solve 用);solve 复用 `answers?: string[]` + - `export type SolveAnswerPayload = { answer: string; ossKey: string }`(quiz-parse.ts) + +- [ ] **Step 1: 安装测试依赖 + 配置** + +Run: +```bash +npm i -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event +``` + +创建 `vitest.config.ts`: + +```ts +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +// xuchao: Vitest 配置——@ 别名对齐 tsconfig;组件测试文件用 // @vitest-environment jsdom 标注 +export default defineConfig({ + resolve: { alias: { "@": path.resolve(__dirname, "src") } }, + test: { + environment: "node", + include: ["src/**/*.test.{ts,tsx}"], + setupFiles: ["src/test-setup.ts"], + }, +}); +``` + +创建 `src/test-setup.ts`: + +```ts +import "@testing-library/jest-dom/vitest"; +``` + +`package.json` scripts 增加: + +```json +"test": "vitest run" +``` + +- [ ] **Step 2: QuizQ 类型扩展(quiz-parse.ts)** + +`solve` 字段区(现有 `points`/`reference`/`rubric` 附近)追加: + +```ts + // xuchao: 应用题判定模式——rubric=AI 逐评分点(缺省,存量兼容);answer=最终答案数组匹配秒判 + judgeMode?: "rubric" | "answer"; + // judgeMode==="answer" 时复用的参考答案数组(等价答法都填) + answers?: string[]; +``` + +文件末追加: + +```ts +// xuchao: 应用题 answer 模式的提交载荷(最终答案 + 照片 key,供秒判与错题本快照) +export type SolveAnswerPayload = { answer: string; ossKey: string }; +``` + +- [ ] **Step 3: 写失败测试 `src/lib/answer-match.test.ts`** + +```ts +import { describe, expect, it } from "vitest"; +import { answersMatch, normalizeAnswer } from "./answer-match"; + +describe("normalizeAnswer", () => { + it("分数转小数", () => { + expect(normalizeAnswer("1/2")).toBe("0.5"); + expect(normalizeAnswer("2/4")).toBe("0.5"); + }); + it("百分数转小数", () => { + expect(normalizeAnswer("50%")).toBe("0.5"); + }); + it("小数去尾零", () => { + expect(normalizeAnswer("0.50")).toBe("0.5"); + }); + it("整数原样", () => { + expect(normalizeAnswer("10")).toBe("10"); + }); + it("文本去空白/全角/大小写", () => { + expect(normalizeAnswer("ABC")).toBe("abc"); + expect(normalizeAnswer("10 个")).toBe("10个"); + }); +}); + +describe("answersMatch", () => { + it("等值命中:1/2 ≡ 0.5", () => { + expect(answersMatch(["0.5", "1/2"], "1/2")).toBe(true); + expect(answersMatch(["0.5"], "0.50")).toBe(true); + }); + it("百分数命中", () => { + expect(answersMatch(["0.5"], "50%")).toBe(true); + }); + it("文本大小写/全角不敏感", () => { + expect(answersMatch(["across"], "ACROSS")).toBe(true); + }); + it("单位严格:10个 ≠ 10", () => { + expect(answersMatch(["10个"], "10")).toBe(false); + expect(answersMatch(["10", "10个"], "10个")).toBe(true); + }); + it("空白答案不判对", () => { + expect(answersMatch(["0.5"], " ")).toBe(false); + }); + it("空期望数组不判对", () => { + expect(answersMatch([], "0.5")).toBe(false); + }); +}); +``` + +- [ ] **Step 4: 跑测试确认 RED** + +Run: `npm run test` +Expected: FAIL(shell 报模块不存在之类),未通过 + +- [ ] **Step 5: 实现 `src/lib/answer-match.ts`** + +```ts +// xuchao: 答案等价判定——数值归一(分数/小数/百分数互转),文本归一(去空白/全角/小写) +const toHalf = (s: string) => + s.replace(/[!-~]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xfee0)).replace(/ /g, " "); + +function stripWs(s: string): string { + return toHalf(s).replace(/\s+/g, "").toLowerCase(); +} + +export function normalizeAnswer(s: string): string { + const t = stripWs(s); + const frac = t.match(/^(-?\d+)\/(-?\d+)$/); + if (frac && Number(frac[2]) !== 0) { + const v = Number(frac[1]) / Number(frac[2]); + if (Number.isFinite(v)) return String(v); + } + const pct = t.match(/^(-?\d+(?:\.\d+)?)%$/); + if (pct) return String(Number(pct[1]) / 100); + if (/^-?\d+\.\d+$/.test(t)) return String(Number(t)); + return t; +} + +// xuchao: 任一期望答案等值命中即判对;空白答案一律不判对 +export function answersMatch(expected: string[], given: string): boolean { + const g = normalizeAnswer(given); + if (!g) return false; + return expected.some((x) => normalizeAnswer(x) === g); +} +``` + +- [ ] **Step 6: 跑测试确认 GREEN + 类型检查** + +Run: `npm run test` → 全部通过 +Run: `npx tsc --noEmit` → 无输出 + +- [ ] **Step 7: 提交** + +```bash +git add vitest.config.ts src/test-setup.ts package.json package-lock.json src/lib/answer-match.ts src/lib/answer-match.test.ts src/lib/quiz-parse.ts +git commit -m "搭建 Vitest 测试框架;新增答案等价判定 lib(answer-match),QuizQ 支持 judgeMode" +``` + +--- + +### Task 2: question-validation 抽取 + judgeMode 校验(TDD) + +**Files:** +- Create: `src/lib/question-validation.ts`、`src/lib/question-validation.test.ts` +- Modify: `src/app/parent/actions.ts`(删本地 `EXAM_TYPES`/`validateQuestionsJson`,改 import `validateQuestions` 并更新调用) + +**Interfaces:** +- Consumes: Task 1 的 QuizQ 类型(judgeMode/answers)。 +- Produces: `validateQuestions(raw: string): { error?: string; count?: number }` +- 行为兼容:`saveExamPaper` 现在的调用点 `const chk = validateQuestionsJson(questions); if (chk.error) return { error: chk.error };` 改为 `validateQuestions`,其余不变。 + +- [ ] **Step 1: 写失败测试 `src/lib/question-validation.test.ts`** + +```ts +import { describe, expect, it } from "vitest"; +import { validateQuestions } from "./question-validation"; + +const base = { + type: "choice", + stem: "x?", + options: ["a", "b"], + answer: 0, + explain: "e", +}; + +describe("validateQuestions 基础", () => { + it("解析失败报错", () => { + expect(validateQuestions("{bad").error).toContain("解析失败"); + }); + it("非数组报错", () => { + expect(validateQuestions("{}").error).toContain("必须是 JSON 数组"); + }); + it("空数组报错", () => { + expect(validateQuestions("[]").error).toContain("至少需要 1 道题"); + }); + it("合法 choice 通过并返回题数", () => { + const r = validateQuestions(JSON.stringify([base])); + expect(r.error).toBeUndefined(); + expect(r.count).toBe(1); + }); + it("choice 缺 options 报错", () => { + const { stem, answer, explain } = base; + expect(validateQuestions(JSON.stringify([{ type: "choice", stem, answer, explain }])).error).toContain("缺少选项"); + }); + it("fill 缺 answers 报错", () => { + expect( + validateQuestions(JSON.stringify([{ type: "fill", stem: "f?", explain: "e" }])).error, + ).toContain("缺少参考答案"); + }); +}); + +describe("validateQuestions solve / judgeMode", () => { + const solveRubric = { + type: "solve", + stem: "解方程", + points: 10, + reference: "x=2", + rubric: [{ point: "过程", score: 6 }, { point: "答案", score: 4 }], + explain: "e", + }; + it("合法 rubric solve(缺省 judgeMode)通过", () => { + expect(validateQuestions(JSON.stringify([solveRubric])).error).toBeUndefined(); + }); + it("rubric 模式缺 reference 报错", () => { + const { reference: _ref, ...rest } = solveRubric; + expect(validateQuestions(JSON.stringify([rest])).error).toContain("缺少参考解答"); + }); + it("answer 模式缺 answers 报错,不要求 reference", () => { + const q = { ...solveRubric, judgeMode: "answer", answers: [] }; + delete q.rubric; + expect(validateQuestions(JSON.stringify([q])).error).toContain("缺少最终答案"); + const ok = { ...solveRubric, judgeMode: "answer", answers: ["2"], rubric: undefined }; + delete ok.rubric; + delete ok.reference; + expect(validateQuestions(JSON.stringify([ok])).error).toBeUndefined(); + }); + it("answer 模式带 rubric 报错", () => { + const q = { ...solveRubric, judgeMode: "answer", answers: ["2"], rubric: [solveRubric.rubric[0]] }; + expect(validateQuestions(JSON.stringify([q])).error).toContain("不应携带 rubric"); + }); + it("judgeMode 非法值报错", () => { + const q = { ...solveRubric, judgeMode: "auto" }; + expect(validateQuestions(JSON.stringify([q])).error).toContain("judgeMode 非法"); + }); + it("存量 86 题形状全通过(48 choice/30 fill/8 solve 代表用例)", () => { + const fill = { type: "fill", stem: "f?", answers: ["a", "b"], explain: "e" }; + const arr: unknown[] = Array.from({ length: 48 }, () => base); + arr.push(...Array.from({ length: 30 }, () => fill)); + arr.push(...Array.from({ length: 8 }, () => solveRubric)); + const r = validateQuestions(JSON.stringify(arr)); + expect(r.error).toBeUndefined(); + expect(r.count).toBe(86); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认 RED** + +Run: `npx vitest run src/lib/question-validation.test.ts` +Expected: FAIL(模块不存在) + +- [ ] **Step 3: 实现 `src/lib/question-validation.ts`** + +```ts +// xuchao: 试卷题目 JSON 形状校验(服务端保存前唯一写入门禁),纯函数便于单测 +export const EXAM_TYPES = ["choice", "fill", "judge", "order", "match", "solve"] as const; +const SOLVE_JUDGE_MODES = ["rubric", "answer"] as const; + +export type ValidationResult = { error?: string; count?: number }; + +export function validateQuestions(raw: string): ValidationResult { + let arr: unknown; + try { + arr = JSON.parse(raw); + } catch { + return { error: "题目 JSON 解析失败,请检查语法" }; + } + if (!Array.isArray(arr)) return { error: "题目必须是 JSON 数组" }; + if (arr.length === 0) return { error: "试卷至少需要 1 道题" }; + for (let i = 0; i < arr.length; i++) { + const q = arr[i] as Record; + const no = `第 ${i + 1} 题`; + if (!q || typeof q !== "object") return { error: `${no}不是对象` }; + if (typeof q.stem !== "string" || !q.stem.trim()) return { error: `${no}缺少题干(stem)` }; + 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 ((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 === "fill" && !Array.isArray(q.answers)) return { error: `${no}(fill)缺少参考答案(answers 数组)` }; + if (t === "order") { + if (!Array.isArray(q.answer)) return { error: `${no}(order)缺少答案(answer 数组)` }; + if (!Array.isArray(q.words) || q.words.length === 0) return { error: `${no}(order)缺少词块(words)` }; + } + if (t === "match" && !Array.isArray(q.pairs)) return { error: `${no}(match)缺少配对(pairs 数组)` }; + if (t === "solve") { + if (typeof q.points !== "number" || q.points <= 0) return { error: `${no}(solve)缺少满分(points>0)` }; + const mode = q.judgeMode; + if (mode !== undefined && !SOLVE_JUDGE_MODES.includes(mode as (typeof SOLVE_JUDGE_MODES)[number])) { + return { error: `${no}(solve)judgeMode 非法:${String(mode)}` }; + } + if (mode === "answer") { + if (!Array.isArray(q.answers) || q.answers.length === 0) return { error: `${no}(solve·answer 模式)缺少最终答案(answers 数组)` }; + if (Array.isArray(q.rubric) && q.rubric.length > 0) return { error: `${no}(solve·answer 模式)不应携带 rubric 评分点` }; + } else { + if (typeof q.reference !== "string" || !q.reference.trim()) return { error: `${no}(solve)缺少参考解答(reference)` }; + } + } + } + return { count: arr.length }; +} +``` + +- [ ] **Step 4: 跑测试确认 GREEN** + +Run: `npx vitest run src/lib/question-validation.test.ts` +Expected: 全部通过 + +- [ ] **Step 5: actions.ts 切换 import** + +`src/app/parent/actions.ts`: +a) 删除文件内 `const EXAM_TYPES = [...]` 与整个 `function validateQuestionsJson(...) {...}`。 +b) 顶部构造区加 `import { validateQuestions } from "@/lib/question-validation";`(放现有 `import { getSessionUser } from "@/lib/auth";` 附近)。 +c) `saveExamPaper` 中调用改为: + +```ts + const chk = validateQuestions(questions); + if (chk.error) return { error: chk.error }; +``` + +- [ ] **Step 6: 全量验证 + 类型检查** + +Run: `npm run test` → 全绿 +Run: `npx tsc --noEmit` → 无输出 + +- [ ] **Step 7: 提交** + +```bash +git add src/lib/question-validation.ts src/lib/question-validation.test.ts src/app/parent/actions.ts +git commit -m "题目校验抽取为可单测 lib;扩展 solve judgeMode(answer/rubric)分支校验" +``` + +--- + +### Task 3: 孩子端 answer 判分(写真测试于判分纯函数) + +**Files:** +- Modify: `src/lib/answer-match.ts`(追加 `solveAnswerHit`)、`src/lib/answer-match.test.ts`(追用例) +- Modify: `src/app/learn/quiz-block.tsx`(SolveState + SolveInput + payload + isAnswered) +- Modify: `src/app/exam/actions.ts`(takeExam solve 分流) + +**Interfaces:** +- Consumes: Task 1 的 `answersMatch`/`SolveAnswerPayload`;Task 2 的 judgeMode 协议(quiz JSON 已过校验)。 +- Produces: `solveAnswerHit(q: Pick, given: string): boolean`;孩子端 answer 模式可交卷判分。 + +- [ ] **Step 1: 追加判分纯函数测试(answer-match.test.ts)** + +```ts +import { answersMatch, normalizeAnswer, solveAnswerHit } from "./answer-match"; + +describe("solveAnswerHit", () => { + const q = { answers: ["2", "x=2"] } as const; + it("命中最终答案", () => { + expect(solveAnswerHit({ answers: ["2"] }, "2")).toBe(true); + expect(solveAnswerHit(q, " x = 2 ")).toBe(true); + }); + it("未命中", () => { + expect(solveAnswerHit(q, "3")).toBe(false); + expect(solveAnswerHit({ answers: [] }, "2")).toBe(false); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认 RED** + +Run: `npx vitest run src/lib/answer-match.test.ts` +Expected: FAIL(不存在 solveAnswerHit) + +- [ ] **Step 3: answer-match.ts 追加 `solveAnswerHit`** + +```ts +// xuchao: 应用题 answer 模式判定——最终答案等值即判对 +export function solveAnswerHit(q: Pick, given: string): boolean { + return answersMatch(q.answers ?? [], given); +} +``` + +文件头 import 改为 `import type { QuizQ } from "@/lib/quiz-parse";`。 + +- [ ] **Step 4: 跑测试确认 GREEN** + +Run: `npx vitest run src/lib/answer-match.test.ts` +Expected: 通过 + +- [ ] **Step 5: quiz-block.tsx 支持 answer 模式** + +a) `SolveState`(第 17-21 行)追加一个 phase: + +```ts +type SolveState = + | { phase: "idle" } + | { phase: "preview"; blob: Blob; dataUrl: string } + | { phase: "grading"; dataUrl: string } + | { phase: "graded"; dataUrl: string; photoUrl: string; verdict: GradeVerdict } + // xuchao: answer 模式照片传完即停,填最终答案待交卷秒判 + | { phase: "uploaded"; dataUrl: string; ossKey: string; answer: string }; +``` + +b) `isAnswered` 的 solve 分支改为: + +```ts + if (q.type === "solve") { + if (q.judgeMode === "answer") + return ( + !!a && typeof a === "object" && "phase" in a && a.phase === "uploaded" && (a.answer?.trim() ?? "") !== "" + ); + return !!a && typeof a === "object" && "phase" in a && a.phase === "graded" && a.verdict.status === "ok"; + } +``` + +c) `submit` 的 payload 构造改为: + +```ts + // xuchao: rubric 解答题以服务端判分日志为准传 null;answer 模式传最终答案+照片 key + const payload = answers.map((a, i) => { + const q = questions[i]; + if (q.type !== "solve") return a as AnswerValue; + if (q.judgeMode === "answer" && a && typeof a === "object" && "phase" in a && a.phase === "uploaded") { + return { answer: a.answer ?? "", ossKey: a.ossKey } as SolveAnswerPayload; + } + return null; + }); +``` + +顶部 import 加 `import type { SolveAnswerPayload } from "@/lib/quiz-parse";`(若 quiz-parse 的 QuizQ import 已存在则并入)。 + +d) `SolveInput` 增加 `judgeMode` prop;`doGrade` 在 answer 模式上传后不进 AI: + +```ts + const isAnswerMode = judgeMode === "answer"; + const doGrade = async (blob: Blob, dataUrl: string) => { + setErr(""); + setBusy("上传中…"); + onChange({ phase: "grading", dataUrl }); + try { + const up = await getSolveUploadUrl(paperId, qIndex); + if ("error" in up) throw new Error(up.error); + if (up.mode === "oss") { + const resp = await fetch(up.url, { method: "PUT", headers: { "Content-Type": "image/jpeg" }, body: blob }); + if (!resp.ok) throw new Error("照片上传失败,请重试"); + } else { + const fd = new FormData(); + fd.append("file", blob, "solve.jpg"); + fd.append("key", up.key); + const resp = await fetch("/api/dev-photo", { method: "POST", body: fd }); + if (!resp.ok) throw new Error("照片上传失败,请重试"); + } + if (isAnswerMode) { + // xuchao: answer 模式不调 AI,照片存档后等待最终答案提交秒判 + onChange({ phase: "uploaded", dataUrl, ossKey: up.key, answer: "" }); + return; + } + setBusy("AI 判分中…"); + let r = await gradeSolve(paperId, qIndex, up.key); + if ("error" in r && /失败|超时/.test(r.error)) r = await gradeSolve(paperId, qIndex, up.key); + if ("error" in r) throw new Error(r.error); + onChange({ phase: "graded", dataUrl, photoUrl: r.photoUrl, verdict: r.verdict }); + } catch (e) { + setErr(e instanceof Error ? e.message : "判分失败,请重试"); + onChange({ phase: "preview", blob, dataUrl }); + } finally { + setBusy(""); + } + }; +``` + +e) `SolveInput` 在 `phase==="uploaded"` 分支渲染一张已传照片 + 最终答案输入框(值 `st.answer`,`onChange` 合并更新 `{ ...st, answer: e.target.value }`)。具体:在现有 graded 预览渲染区(约 350 行 `dataUrl` 提取与后续 JSX)后追加 answer 模式分支——上传态渲染: + +```tsx + if (st.phase === "uploaded") { + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + 解题照片(已上传) +

✅ 解题过程已上传

+ onChange({ ...st, answer: e.target.value })} + placeholder="填写最终答案,如:1/2 或 0.5" + className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200" + /> + +
+ ); + } +``` + +每次 `fileRef.current?.click()` 前的 `st.phase==="idle"` 上传按钮保持现状;把 350 行附近的数据提取改掉(避免 `phase==="graded"` 外的旧分支访问 verdict)。 + +f) `QuizBlock` 的 solve 渲染处传参(``)。 + +- [ ] **Step 6: takeExam solve 分流(exam/actions.ts)** + +在 `questions.forEach` 的 solve 分支前插 answer 判断(替换现有 `if (q.type === "solve") {` 块的第一行): + +```ts + if (q.type === "solve") { + if (q.judgeMode === "answer") { + // xuchao: answer 模式——最终答案归一化匹配秒判,不调 AI 不写审计日志 + const payload = answers[i] as SolveAnswerPayload | null | undefined; + const given = payload?.answer?.trim() ?? ""; + const hit = solveAnswerHit(q, given); + const earned = hit ? pts : 0; + detail.push(hit); + if (hit) correct++; + earnedSum += earned; + const hits: GradeVerdict["hits"] = [ + { point: "最终答案", score: earned, hit, reason: hit ? "与最终答案一致" : `期望:${(q.answers ?? []).join(" / ")},实际:${given || "未作答"}` }, + ]; + solves.push({ qIndex: i, ossKey: payload?.ossKey ?? "", status: hit ? "ok" : "wrong", earned, points: pts, hits, comment: "" }); + storedAnswers.push({ type: "solve", qIndex: i, ossKey: payload?.ossKey ?? "", earned, points: pts }); + if (!hit) { + void recordWrong(user.id, "exam", paperId, paper.title, q, null, { ossKey: payload?.ossKey ?? "", earned, hits }); + } + return; + } + // …既有 rubric 逻辑原样 +``` + +(注意:forEach 回调里 `return` 结束本轮;`detail.push`/`storedAnswers.push`/`recordWrong` 均在两分支内各自完成,总题数与 score 计算不变。) + +import 行补:`import { solveAnswerHit } from "@/lib/answer-match";` 与 `import type { SolveAnswerPayload } from "@/lib/quiz-parse";`。 + +- [ ] **Step 7: 全量验证 + 类型检查** + +Run: `npm run test` → 全绿 +Run: `npx tsc --noEmit` → 无输出 + +- [ ] **Step 8: 提交** + +```bash +git add src/lib/answer-match.ts src/lib/answer-match.test.ts src/app/learn/quiz-block.tsx src/app/exam/actions.ts +git commit -m "孩子端应用题 answer 模式:拍照+填最终答案,交卷归一化匹配秒判(rubric 流程不变)" +``` + +--- + +### Task 4: 家长端结构化编辑器(组件测试) + +**Files:** +- Create: `src/app/parent/exams/question-list.tsx`(客户端:题目卡片列表 + 新增/编辑态) +- Create: `src/app/parent/exams/question-list.test.tsx`(组件测试) +- Modify: `src/app/parent/exams/paper-form.tsx`(题目区替换 JSON textarea → QuestionList;整体布局保持:试卷 meta 表单 + 题目区 + 保存) + +**Interfaces:** +- Consumes: Task 1 `QuizQ`;Task 2 `validateQuestions`(服务端)+ 客户端同款文案;现有 `saveExamPaper`。 +- Produces: PaperForm 内 `QuestionList`;JSON textarea 从编辑页消失;保存时序列化 QuizQ 数组(与旧 JSON 形状一致)。 + +- [ ] **Step 1: 写组件失败测试 `src/app/parent/exams/question-list.test.tsx`** + +```tsx +// @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", stem: "1+1=?", options: ["1", "2"], answer: 1, explain: "e" } as const; + +describe("QuestionList 结构化编辑", () => { + it("渲染题目卡片(题型徽标+题干)并可删除", () => { + render( {}} />); + 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( (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).stem).toBe("2+2=?"); + expect((saved[0] as Record).answer).toBe(1); + expect((saved[0] as Record).explain).toBe("e1"); + }); + + it("应用题 judgeMode 切换:answer 显示最终答案数组,rubric 显示评分点", async () => { + const user = userEvent.setup(); + render( {}} />); + 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( (saved = v)} />); + await user.click(screen.getByText("+ 新增题目")); + await user.click(screen.getByText("完成")); + expect(saved.length).toBe(0); + expect(screen.getByText(/缺少题干/)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认 RED** + +Run: `npx vitest run src/app/parent/exams/question-list.test.tsx` +Expected: FAIL(模块不存在) + +- [ ] **Step 3: 实现 `src/app/parent/exams/question-list.tsx`** + +```tsx +"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 = { + 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(null); + const [draft, setDraft] = useState(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) => 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 ( +
+ {value.map((q, i) => { + const def = q as QuizQ; + return ( +
+
+ {TYPE_LABEL[def.type]} + {def.stem} + + + + + + +
+
+ ); + })} + + {draft ? ( +
+
+ + +
+ +
+
+ + update({ stem: e.target.value })} /> +
+ + {draft.type === "choice" && ( +
+
+ + +
+ {(draft.options ?? []).map((o, oi) => ( +
+ update({ answer: oi })} + aria-label={`选 ${o || `选项${oi + 1}`} 为正确答案`} + /> + update({ options: (draft.options ?? []).map((x, xi) => (xi === oi ? e.target.value : x)) })} /> + +
+ ))} +
+ )} + + {draft.type === "judge" && ( +
+ +
+ {[0, 1].map((v) => ( + + ))} +
+
+ )} + + {(draft.type === "fill" || (draft.type === "solve" && draft.judgeMode === "answer")) && ( +
+
+ + +
+ {(draft.answers ?? []).map((a, ai) => ( +
+ update({ answers: (draft.answers ?? []).map((x, xi) => (xi === ai ? e.target.value : x)) })} /> + +
+ ))} +
+ )} + + {draft.type === "solve" && ( +
+
+ + update({ points: Number(e.target.value) || undefined })} + /> +
+
+ + +
+ {draft.judgeMode === "answer" && draft.answers == null && ( +

选「仅最终答案」后请填写最终答案数组(第 1 题缺少最终答案时无法保存)。

+ )} + {draft.judgeMode === "answer" ? ( +
+ + update({ reference: e.target.value })} /> +
+ ) : ( +
+ + update({ reference: e.target.value })} /> +
+ )} +
+ )} + +
+ + update({ explain: e.target.value })} /> +
+ + {error &&

{error}

} + +
+
+ ) : ( + + )} +
+ ); +} +``` + +- [ ] **Step 4: 空表单点「完成」需有错误提示——补实现再跑测试** + +Run: `npx vitest run src/app/parent/exams/question-list.test.tsx` → 若有失败按 Step 3 组件逻辑微调(assertion 文案来自 commit 内错误 set)。 + +- [ ] **Step 5: PaperForm 接入 QuestionList** + +`src/app/parent/exams/paper-form.tsx`: +a) 删除 `questions` 文本域 UI 与其 useState 中的 JSON 初始化;题目区替换为: + +```tsx +
+ + setQuestions(qs)} + /> +
+``` + +b) state:`const [questions, setQuestions] = useState(() => { try { return paper ? (JSON.parse(paper.questions) as QuizQ[]) : []; } catch { return []; } });` + +c) `save` 时序列化:`questions: JSON.stringify(questions)` 计入 FormData(替换旧 questions 字段来源),并删除客户端 preview 统计 UI。 + +- [ ] **Step 6: 全量验证 + 类型检查 + 构建** + +Run: `npm run test` → 全绿 +Run: `npx tsc --noEmit` → 无输出 +Run: `npm run build` → 成功,`/parent/exams` 相关路由在列 + +- [ ] **Step 7: 提交** + +```bash +git add src/app/parent/exams/question-list.tsx src/app/parent/exams/question-list.test.tsx src/app/parent/exams/paper-form.tsx +git commit -m "家长端结构化题目编辑器:题型联动表单替代 JSON 文本域,应用题支持 judgeMode 切换" +``` + +--- + +### Task 5: CI 测试 workflow + 全量收尾 + +**Files:** +- Create: `.gitea/workflows/test.yaml` + +**Interfaces:** +- Consumes: 前四任务产物(Task 1 测试框架,Task 2-4 全部测试与代码)。 + +- [ ] **Step 1: 创建 `.gitea/workflows/test.yaml`** + +```yaml +# xuchao: 自动化测试——push 即跑,守护题型校验/答案判定/编辑器组件 +name: test +on: + push: + pull_request: +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: npm ci + - run: npm run test +``` + +- [ ] **Step 2: 全量验证(测试 + 类型 + 构建)** + +Run: `npm run test` → 全绿 +Run: `npx tsc --noEmit` → 无输出 +Run: `npm run build` → 成功 + +- [ ] **Step 3: 提交** + +```bash +git add .gitea/workflows/test.yaml +git commit -m "新增 Gitea Actions 测试 workflow:push 即跑 vitest 全量" +``` + +--- + +## 手工测试清单(部署 jump.hn12301.net 后) + +1. 编辑页:JSON 文本域消失,题目卡片流新增/编辑/删除/上下移/保存正常;四种题型联动正确。 +2. answer 应用题(答案 0.5 与 1/2 都填):孩子端拍照 + 填 `1/2` → 判对满分;填 `2/3` → 判错 0 分,错题本含照片与「最终答案」判定。 +3. rubric 应用题:孩子照旧拍照 AI 逐点评分,进错题本逐点判定(存量 8 题不受影响)。 +4. 服务端校验:answer 缺最终答案 / rubric 缺 reference / judgeMode 非法 → 报错不保存。 +5. CI:push 触发 Gitea Actions 测试通过(仓库首条 workflow)。 \ No newline at end of file