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

1001 lines
41 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 结构化题目编辑器 + 应用题答案判分 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<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` 中调用改为:
```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<QuizQ, "answers">, 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<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:
```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 (
<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") {` 块的第一行):
```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(<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`**
```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 初始化;题目区替换为:
```tsx
<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: 提交**
```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)。