Files
SuperJump/docs/superpowers/plans/2026-08-20-structured-question-editor.md
T

41 KiB
Raw Blame History

结构化题目编辑器 + 应用题答案判分 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:

npm i -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event

创建 vitest.config.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:

import "@testing-library/jest-dom/vitest";

package.json scripts 增加:

"test": "vitest run"
  • Step 2: QuizQ 类型扩展(quiz-parse.ts)

solve 字段区(现有 points/reference/rubric 附近)追加:

  // xuchao: 应用题判定模式——rubric=AI 逐评分点(缺省,存量兼容);answer=最终答案数组匹配秒判
  judgeMode?: "rubric" | "answer";
  // judgeMode==="answer" 时复用的参考答案数组(等价答法都填)
  answers?: string[];

文件末追加:

// xuchao: 应用题 answer 模式的提交载荷(最终答案 + 照片 key,供秒判与错题本快照)
export type SolveAnswerPayload = { answer: string; ossKey: string };
  • Step 3: 写失败测试 src/lib/answer-match.test.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
// 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: 提交
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

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
// 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<string, unknown>;
    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 中调用改为:

  const chk = validateQuestions(questions);
  if (chk.error) return { error: chk.error };
  • Step 6: 全量验证 + 类型检查

Run: npm run test → 全绿 Run: npx tsc --noEmit → 无输出

  • Step 7: 提交
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<QuizQ, "answers">, given: string): boolean;孩子端 answer 模式可交卷判分。

  • Step 1: 追加判分纯函数测试(answer-match.test.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
// xuchao: 应用题 answer 模式判定——最终答案等值即判对
export function solveAnswerHit(q: Pick<QuizQ, "answers">, 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:

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 分支改为:

    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 构造改为:

      // 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:

  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 模式分支——上传态渲染:

  if (st.phase === "uploaded") {
    return (
      <div className="mt-2 space-y-2">
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img src={st.dataUrl} alt="解题照片(已上传)" className="max-h-40 rounded-lg border border-slate-200 object-contain" />
        <p className="text-xs text-emerald-600">✅ 解题过程已上传</p>
        <input
          type="text"
          value={st.answer}
          disabled={disabled}
          onChange={(e) => 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"
        />
        <button type="button" onClick={() => fileRef.current?.click()} disabled={disabled} className="text-xs text-indigo-600">
          重新拍照
        </button>
      </div>
    );
  }

每次 fileRef.current?.click() 前的 st.phase==="idle" 上传按钮保持现状;把 350 行附近的数据提取改掉(避免 phase==="graded" 外的旧分支访问 verdict)。

f) QuizBlock 的 solve 渲染处传参(<SolveInput paperId={pointId} qIndex={qi} judgeMode={questions[qi].judgeMode ?? "rubric"} ... />)。

  • Step 6: takeExam solve 分流(exam/actions.ts)

在 questions.forEach 的 solve 分支前插 answer 判断(替换现有 if (q.type === "solve") { 块的第一行):

    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: 提交
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

// @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(<QuestionList value={[{ ...choiceQ }]} onChange={() => {}} />);
    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(<QuestionList value={[]} onChange={(v) => (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<string, unknown>).stem).toBe("2+2=?");
    expect((saved[0] as Record<string, unknown>).answer).toBe(1);
    expect((saved[0] as Record<string, unknown>).explain).toBe("e1");
  });

  it("应用题 judgeMode 切换:answer 显示最终答案数组,rubric 显示评分点", async () => {
    const user = userEvent.setup();
    render(<QuestionList value={[{ type: "solve", stem: "解方程", points: 10, reference: "x=2", explain: "e" }]} onChange={() => {}} />);
    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(<QuestionList value={[]} onChange={(v) => (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
"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<string, string> = {
  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<number | null>(null);
  const [draft, setDraft] = useState<QuizQ | null>(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<QuizQ>) => 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 (
    <div className="space-y-3">
      {value.map((q, i) => {
        const def = q as QuizQ;
        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">
              题型
              <select
                value={draft.type}
                aria-label="题型"
                onChange={(e) => {
                  const t = e.target.value as QuizQ["type"];
                  update({ type: t, answers: undefined, options: undefined, reference: undefined });
                }}
                className="ml-2 h-8 rounded-lg border border-slate-200 bg-white px-2 text-sm"
              >
                {TYPE_ORDER.map((t) => (
                  <option key={t} value={t}>
                    {TYPE_LABEL[t]}
                  </option>
                ))}
              </select>
            </label>
            <Button size="sm" variant="ghost" onClick={() => setDraft(null)}>
              <X className="size-4" /> 取消
            </Button>
          </div>

          <div className="space-y-2">
            <div className="space-y-1">
              <Label>题干</Label>
              <Input aria-label="题干" value={draft.stem} onChange={(e) => update({ stem: e.target.value })} />
            </div>

            {draft.type === "choice" && (
              <div className="space-y-1.5">
                <div className="flex items-center justify-between">
                  <Label>选项(点选正确答案)</Label>
                  <Button size="sm" variant="outline" className="h-7" onClick={() => update({ options: [...(draft.options ?? []), ""] })}>
                    + 添加选项
                  </Button>
                </div>
                {(draft.options ?? []).map((o, oi) => (
                  <div key={oi} className="flex items-center gap-2">
                    <input
                      type="radio"
                      name="opt"
                      checked={draft.answer === oi}
                      onChange={() => update({ answer: oi })}
                      aria-label={`选 ${o || `选项${oi + 1}`} 为正确答案`}
                    />
                    <Input placeholder={`选项${oi + 1}`} value={o} onChange={(e) => update({ options: (draft.options ?? []).map((x, xi) => (xi === oi ? e.target.value : x)) })} />
                    <Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => update({ options: (draft.options ?? []).filter((_, xi) => xi !== oi) })}>
                      <X className="size-4" />
                    </Button>
                  </div>
                ))}
              </div>
            )}

            {draft.type === "judge" && (
              <div className="space-y-1">
                <Label>答案</Label>
                <div className="flex gap-3 text-sm">
                  {[0, 1].map((v) => (
                    <label key={v} className="flex items-center gap-1">
                      <input type="radio" checked={draft.answer === v} onChange={() => update({ answer: v })} />
                      {v === 0 ? "对" : "错"}
                    </label>
                  ))}
                </div>
              </div>
            )}

            {(draft.type === "fill" || (draft.type === "solve" && draft.judgeMode === "answer")) && (
              <div className="space-y-1.5">
                <div className="flex items-center justify-between">
                  <Label>{draft.type === "fill" ? "参考答案(等价答法可填多个)" : "最终答案(等价答法可填多个)"}</Label>
                  <Button size="sm" variant="outline" className="h-7" onClick={() => update({ answers: [...(draft.answers ?? []), ""] })}>
                    + 添加答案
                  </Button>
                </div>
                {(draft.answers ?? []).map((a, ai) => (
                  <div key={ai} className="flex items-center gap-2">
                    <Input value={a} onChange={(e) => update({ answers: (draft.answers ?? []).map((x, xi) => (xi === ai ? e.target.value : x)) })} />
                    <Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => update({ answers: (draft.answers ?? []).filter((_, xi) => xi !== ai) })}>
                      <X className="size-4" />
                    </Button>
                  </div>
                ))}
              </div>
            )}

            {draft.type === "solve" && (
              <div className="space-y-2">
                <div className="space-y-1">
                  <Label>分值</Label>
                  <Input
                    aria-label="分值"
                    type="number"
                    min={1}
                    value={draft.points ?? 10}
                    onChange={(e) => update({ points: Number(e.target.value) || undefined })}
                  />
                </div>
                <div className="space-y-1">
                  <Label>判分模式</Label>
                  <select
                    aria-label="判分模式"
                    value={draft.judgeMode ?? "rubric"}
                    onChange={(e) => update({ judgeMode: e.target.value as "rubric" | "answer" })}
                    className="h-8 rounded-lg border border-slate-200 bg-white px-2 text-sm"
                  >
                    <option value="answer">仅最终答案(秒判)</option>
                    <option value="rubric">AI 逐评分点判分</option>
                  </select>
                </div>
                {draft.judgeMode === "answer" && draft.answers == null && (
                  <p className="text-xs text-slate-500">选「仅最终答案」后请填写最终答案数组(第 1 题缺少最终答案时无法保存)。</p>
                )}
                {draft.judgeMode === "answer" ? (
                  <div className="space-y-1">
                    <Label>参考解答(可选,给孩子看)</Label>
                    <Input aria-label="参考解答" value={draft.reference ?? ""} onChange={(e) => update({ reference: e.target.value })} />
                  </div>
                ) : (
                  <div className="space-y-1">
                    <Label>参考解答(完整解题过程,展示给孩子)</Label>
                    <Input aria-label="参考解答" value={draft.reference ?? ""} onChange={(e) => update({ reference: e.target.value })} />
                  </div>
                )}
              </div>
            )}

            <div className="space-y-1">
              <Label>解析</Label>
              <Input aria-label="解析" value={draft.explain} onChange={(e) => update({ explain: e.target.value })} />
            </div>

            {error && <p className="text-sm font-medium text-destructive">{error}</p>}
            <Button size="sm" onClick={commit}>
              完成
            </Button>
          </div>
        </div>
      ) : (
        <Button variant="outline" onClick={startNew}>
          <Plus className="size-4" /> 新增题目
        </Button>
      )}
    </div>
  );
}
  • 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 初始化;题目区替换为:

      <div className="space-y-1">
        <Label>题目(结构化编辑)</Label>
        <QuestionList
          value={questions}
          onChange={(qs) => setQuestions(qs)}
        />
      </div>

b) state:const [questions, setQuestions] = useState<QuizQ[]>(() => { 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: 提交
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

# 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: 提交
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)。