Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
131ee41ac8 | ||
|
|
5ce33f3edf | ||
|
|
5e4904742a | ||
|
|
fe68ed7512 | ||
|
|
063420595d | ||
|
|
0749ba2c38 | ||
|
|
255c9a244c | ||
|
|
e896b2be51 | ||
|
|
bb8bb25096 | ||
|
|
637c6032d4 | ||
|
|
611b493c84 | ||
|
|
8de35e85c2 |
@@ -0,0 +1,12 @@
|
|||||||
|
# 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
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
|||||||
|
# 结构化题目编辑器 + 应用题答案判分模式 - 设计文档
|
||||||
|
|
||||||
|
日期:2026-08-20
|
||||||
|
状态:已批准
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
`/parent/exams/[paperId]` 编辑页目前用**裸 JSON 文本域**录入题目,家长需手写 quiz JSON 数组(曾明确拒绝结构化表单,现转向)。同时用户对应用题判分提出新模式:孩子拍照上传解题过程 + 填写最终答案来判定对错(替代/补充现有 AI 逐评分点判分)。
|
||||||
|
|
||||||
|
现状事实(grilling 确认):
|
||||||
|
|
||||||
|
- 现有 86 道种子题 = choice 48 + fill 30 + solve 8,**judge/order/match 零数据**。
|
||||||
|
- solve 题现状:孩子拍照 → 百炼 Qwen-VL 按 rubric 评分点逐点判分(ai-grade.ts,无 key 演示回退),照片与逐点判定进错题本。
|
||||||
|
- 孩子端 QuizBlock 已支持全部 6 题型答题 + 对错反馈;fill 题的参考答案本身是**数组**(answers)。
|
||||||
|
- 现有 svaeEqxamPaper 的 validateQuestionsJson 已做形状校验。
|
||||||
|
|
||||||
|
## 需求(已与用户确认)
|
||||||
|
|
||||||
|
1. **结构化编辑器**:试卷题目改为逐题表单(题型联动字段),不再手写 JSON。题目卡片流:新增/编辑/删除/上移/下移。
|
||||||
|
2. **首发四种题型**:判断题/选择题/填空题/应用题(judge/order/match 暂不做,有数据时后置)。
|
||||||
|
3. **应用题新增判定模式** `judgeMode: "answer"`:家长只填**最终答案数组**(复用 fill 的 answers 语义)+ 分值;孩子拍照解题过程 + 填最终答案,**归一化匹配秒级判对错**,不调 AI。
|
||||||
|
4. **共存**:现有 rubric 模式(AI 逐点评分)保留,存量 8 道题零改动(`judgeMode` 缺省 = rubric)。
|
||||||
|
5. 孩子端此模式下:拍照必填(进错题本),最终答案判对错;对 = 满分,错 = 0 分(不做部分给分,可解释)。
|
||||||
|
|
||||||
|
## 方案选择
|
||||||
|
|
||||||
|
- **方案 A(采用):结构化表单产出仍是 QuizQ JSON,存储/孩子端渲染零schema改动**
|
||||||
|
- 编辑 UI 从 JSON textarea 换成题型联动表单;服务端校验扩展 judgeMode 分支;判分在现有 child 提交链路内按 judgeMode 分流。
|
||||||
|
- 方案 B(弃):引入独立"题目表"(questions 拆表)→ 迁移 86 题、错题本/组卷/判分全部重写,收益为零。
|
||||||
|
- 方案 C(弃):AI 判最终答案(取代归一化)→ 有延迟、依赖 key、判定无法对孩子解释;归一化数组对家庭场景足够。
|
||||||
|
|
||||||
|
## 设计
|
||||||
|
|
||||||
|
### 1. QuizQ 扩展(src/lib/quiz-parse.ts)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// solve 新增可选字段(缺省即 rubric 老模式,向后兼容):
|
||||||
|
judgeMode?: "rubric" | "answer";
|
||||||
|
// judgeMode==="answer" 时复用:
|
||||||
|
answers?: string[]; // 最终答案数组(与 fill 同字段语义,等价答法都填)
|
||||||
|
// points 两种模式下都必须(分值);reference 为展示用参考解答
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 归一化判定 lib(新建 src/lib/answer-match.ts)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// xuchao: 答案等价判定——数值类归一化(分数/小数/百分数互转),文本类去空格/全角/大小写
|
||||||
|
export function normalizeAnswer(s: string): string;
|
||||||
|
export function answersMatch(expected: string[], given: string): boolean; // 任一期望答案等价即中
|
||||||
|
```
|
||||||
|
|
||||||
|
- 纯数字语义(`1/2`→`0.5`、`50%`→`0.5`、`0.50`→`0.5`,去尾零)互转比较。
|
||||||
|
- 含字母/单位词(如 `10个`、`across`)按文本归一(去空白、全角→半角、小写)严格比较;等价形式靠家长在答案数组里补填。
|
||||||
|
- **fill 现有判分不动**(grammar.ts `gradeOne`:trim+小写精确匹配)——归一化仅用于 judgeMode==="answer" 的应用题,避免改变既有周测/记录判分行为。
|
||||||
|
|
||||||
|
### 3. 孩子端判分流(exam/actions.ts takeExam + QuizBlock)
|
||||||
|
|
||||||
|
- `takeExam` 中 solve 题分流:
|
||||||
|
- `judgeMode==="answer"`:`answersMatch(q.answers, 孩子填的答案)` → score=points(对)/0(错);判定与照片写 `solveSnap`(`hits:[{ point:"最终答案", score, hit, reason }]`),照片 OSS 照常上传。
|
||||||
|
- 缺省/`rubric`:走现有 ai-grade 逐点评分,零改动。
|
||||||
|
- QuizBlock solve 分支:answer 模式下拍照必填 + 显示最终答案 Input;rubric 模式现状不变。
|
||||||
|
- 错题本:**零改动**(solveSnap 结构兼容;answerText 已对 solve 显示拍照与得分)。
|
||||||
|
|
||||||
|
### 4. 家长端结构化编辑器(/parent/exams/[paperId] 重构)
|
||||||
|
|
||||||
|
- PaperForm 拆为:试卷 MetaForm(现有字段,不动)+ `QuestionList`(题目卡片区)。
|
||||||
|
- QuestionList:
|
||||||
|
- 每题卡片:题型徽标 + 题干摘要 + 操作(编辑/上移/下移/删除)。
|
||||||
|
- 「+ 新增题目」按钮;题型下拉(judge/choice/fill/solve 四项)。
|
||||||
|
- QuestionForm 按 type 联动:
|
||||||
|
- **judge**:题干 + 解析 + 对/错单选
|
||||||
|
- **choice**:题干 + 选项列表(增/删/编辑,单选正确答案)+ 解析
|
||||||
|
- **fill**:题干 + 参考答案数组(增/删/编辑)+ 解析
|
||||||
|
- **solve**:题干 + 分值 + **新建题固定「仅最终答案(秒判)」模式**(最终答案数组 + 参考解答可选);存量 rubric 题只读徽标「AI 逐评分点判分(存量题,保留原评分点)」+ 参考解答(必填语义),不提供评分点编辑(2026-08-20 用户裁定:新题仅 answer,无判分模式开关)
|
||||||
|
- 非法时客户端给出对应提示(与服务端同文案);保存仍走现有 `saveExamPaper`(JSON 序列化 QuizQ 数组)。
|
||||||
|
- 服务端 validateQuestionsJson 扩展:
|
||||||
|
- solve:rubric 模式沿用现行(reference + points 必填);answer 模式要求 `answers` 非空数组 + points 必填,reference 可选;answer 模式不得带 rubric。
|
||||||
|
- 其余题型校验不变。
|
||||||
|
|
||||||
|
### 5. 不改动
|
||||||
|
|
||||||
|
- DB schema(quiz JSON 存储不变)、ai-grade.ts、错题本 WrongRow、积分/达标规则、周测组卷。
|
||||||
|
- 孩子端之外:seed、overview、记录管理全不动。
|
||||||
|
|
||||||
|
### 6. 自动化测试框架(用户追加要求:没有就搭建)
|
||||||
|
|
||||||
|
- 框架:**Vitest**(TS 原生、零配置成本),+ jsdom + @testing-library/react 用于组件测试(QuestionForm/QuestionList 题型联动)。
|
||||||
|
- 关键可测逻辑**沉淀为纯函数**进 lib,不做 DB/网络依赖:
|
||||||
|
- 新增 `src/lib/answer-match.ts`(归一化/等价,已设计于 §2)
|
||||||
|
- 现 `validateQuestionsJson`(parent/actions.ts 私有)→ 迁到新 `src/lib/question-validation.ts` 导出,`saveExamPaper` 引用之(行为不变)
|
||||||
|
- 测试覆盖:answer-match 全分支(数值/文本/单位/边界);question-validation 全题型 + judgeMode 新分支(accept/reject 用例);QuestionForm 题型联动渲染与非法输入提示。
|
||||||
|
- 脚本:`"test": "vitest run"`;vitest.config.ts 配 `@` alias。
|
||||||
|
- CI:Gitea Actions 新增 `.gitea/workflows/test.yaml`,push 即跑 `npm ci && npm run test`(runner 为 node:24-slim,已可用)。
|
||||||
|
|
||||||
|
## 测试清单(手工 + 自动化)
|
||||||
|
|
||||||
|
- 自动化:上述 Vitest 用例全绿;`npm run test` 通过。
|
||||||
|
- 手工:编辑页 JSON 文本域消失,题目卡片流可新增/编辑/删除/上下移/保存。
|
||||||
|
- 手工:四种题型表单字段联动正确;应用题的 rubric/answer 开关切换字段正确。
|
||||||
|
- 手工:新建 answer 应用题(答案 0.5 与 1/2 都填):孩子端拍照 + 填 `1/2` → 判对满分;填 `2/3` → 判错 0 分,错题本含照片与判定。
|
||||||
|
- 手工:存量 rubric 题:孩子照旧拍照 AI 逐点评分,进错题本逐点判定。
|
||||||
|
- 手工:服务端校验:answer 模式缺 answers / rubric 模式缺 reference → 对应报错不保存。
|
||||||
|
- 部署 jump.hn12301.net 后线上复验。
|
||||||
Generated
+2515
-599
File diff suppressed because it is too large
Load Diff
+8
-2
@@ -11,7 +11,8 @@
|
|||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:seed": "tsx scripts/seed.ts",
|
"db:seed": "tsx scripts/seed.ts",
|
||||||
"db:seed-math": "tsx scripts/seed-math.ts",
|
"db:seed-math": "tsx scripts/seed-math.ts",
|
||||||
"db:seed-exam": "tsx scripts/seed-exam.ts"
|
"db:seed-exam": "tsx scripts/seed-exam.ts",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/plus-jakarta-sans": "^5.3.0",
|
"@fontsource-variable/plus-jakarta-sans": "^5.3.0",
|
||||||
@@ -34,13 +35,18 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.11",
|
"@tailwindcss/postcss": "^4.1.11",
|
||||||
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.5",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"dotenv": "^17.2.0",
|
"dotenv": "^17.2.0",
|
||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.4",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3",
|
||||||
|
"vitest": "^4.1.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { getSessionUser } from "@/lib/auth";
|
import { getSessionUser } from "@/lib/auth";
|
||||||
import { gradeOne, gradeAnswers, type AnswerValue } from "@/lib/grammar";
|
import { gradeOne, gradeAnswers, type AnswerValue } from "@/lib/grammar";
|
||||||
|
import { solveAnswerHit } from "@/lib/answer-match";
|
||||||
|
import type { SolveAnswerPayload } from "@/lib/quiz-parse";
|
||||||
import { getPaper, gradeCountToday, latestSolveLogs, paperQuestions, recordExam } from "@/lib/exam";
|
import { getPaper, gradeCountToday, latestSolveLogs, paperQuestions, recordExam } from "@/lib/exam";
|
||||||
import { recordWrong } from "@/lib/wrongbook";
|
import { recordWrong } from "@/lib/wrongbook";
|
||||||
import { gradeSolvePhoto, type GradeVerdict } from "@/lib/ai-grade";
|
import { gradeSolvePhoto, type GradeVerdict } from "@/lib/ai-grade";
|
||||||
@@ -135,6 +137,29 @@ export async function takeExam(paperId: string, answers: (AnswerValue | null)[])
|
|||||||
const pts = q.points ?? 1;
|
const pts = q.points ?? 1;
|
||||||
pointSum += pts;
|
pointSum += pts;
|
||||||
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 || "未作答"}` },
|
||||||
|
];
|
||||||
|
// xuchao: ossKey 前缀校验——防止客户端伪造路径
|
||||||
|
const prefix = `exam-solve/${user.id}/${paperId}/`;
|
||||||
|
const rawKey = payload?.ossKey ?? "";
|
||||||
|
const safeKey = rawKey.startsWith(prefix) && rawKey.endsWith(".jpg") ? rawKey : "";
|
||||||
|
solves.push({ qIndex: i, ossKey: safeKey, status: hit ? "ok" : "wrong", earned, points: pts, hits, comment: "" });
|
||||||
|
storedAnswers.push({ type: "solve", qIndex: i, ossKey: safeKey, earned, points: pts });
|
||||||
|
if (!hit) {
|
||||||
|
void recordWrong(user.id, "exam", paperId, paper.title, q, null, { ossKey: safeKey, earned, hits });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const log = logs.get(i);
|
const log = logs.get(i);
|
||||||
const okLog = log && log.status === "ok" ? log : null;
|
const okLog = log && log.status === "ok" ? log : null;
|
||||||
const earned = okLog ? Math.min(okLog.earned, pts) : 0;
|
const earned = okLog ? Math.min(okLog.earned, pts) : 0;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Link from "next/link";
|
|||||||
import { Camera, Check, Loader2, RotateCcw, X } from "lucide-react";
|
import { Camera, Check, Loader2, RotateCcw, X } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { QuizQ } from "@/lib/quiz-parse";
|
import type { QuizQ, SolveAnswerPayload } from "@/lib/quiz-parse";
|
||||||
import type { AnswerValue } from "@/lib/grammar";
|
import type { AnswerValue } from "@/lib/grammar";
|
||||||
import type { GradeVerdict } from "@/lib/ai-grade";
|
import type { GradeVerdict } from "@/lib/ai-grade";
|
||||||
import { getSolveUploadUrl, gradeSolve } from "@/app/exam/actions";
|
import { getSolveUploadUrl, gradeSolve } from "@/app/exam/actions";
|
||||||
@@ -18,7 +18,9 @@ type SolveState =
|
|||||||
| { phase: "idle" }
|
| { phase: "idle" }
|
||||||
| { phase: "preview"; blob: Blob; dataUrl: string }
|
| { phase: "preview"; blob: Blob; dataUrl: string }
|
||||||
| { phase: "grading"; dataUrl: string }
|
| { phase: "grading"; dataUrl: string }
|
||||||
| { phase: "graded"; dataUrl: string; photoUrl: string; verdict: GradeVerdict };
|
| { phase: "graded"; dataUrl: string; photoUrl: string; verdict: GradeVerdict }
|
||||||
|
// xuchao: answer 模式照片传完即停,填最终答案待交卷秒判
|
||||||
|
| { phase: "uploaded"; dataUrl: string; ossKey: string; answer: string };
|
||||||
|
|
||||||
type CellValue = AV | SolveState;
|
type CellValue = AV | SolveState;
|
||||||
|
|
||||||
@@ -56,6 +58,10 @@ export function QuizBlock({
|
|||||||
|
|
||||||
const isAnswered = (q: QuizQ, a: CellValue): boolean => {
|
const isAnswered = (q: QuizQ, a: CellValue): boolean => {
|
||||||
if (q.type === "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";
|
return !!a && typeof a === "object" && "phase" in a && a.phase === "graded" && a.verdict.status === "ok";
|
||||||
}
|
}
|
||||||
if (q.type === "fill") return typeof a === "string" && a.trim() !== "";
|
if (q.type === "fill") return typeof a === "string" && a.trim() !== "";
|
||||||
@@ -68,8 +74,15 @@ export function QuizBlock({
|
|||||||
const submit = () => {
|
const submit = () => {
|
||||||
setError("");
|
setError("");
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
// xuchao: 解答题得分以服务端判分日志为准,客户端只传 null 占位
|
// xuchao: rubric 解答题以服务端判分日志为准传 null;answer 模式传最终答案+照片 key
|
||||||
const payload = answers.map((a, i) => (questions[i].type === "solve" ? null : (a as AnswerValue)));
|
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;
|
||||||
|
});
|
||||||
const r = submitter
|
const r = submitter
|
||||||
? await submitter(payload as AnswerValue[])
|
? await submitter(payload as AnswerValue[])
|
||||||
: await submitQuiz(pointId, payload as AnswerValue[]);
|
: await submitQuiz(pointId, payload as AnswerValue[]);
|
||||||
@@ -142,6 +155,7 @@ export function QuizBlock({
|
|||||||
<SolveInput
|
<SolveInput
|
||||||
paperId={pointId}
|
paperId={pointId}
|
||||||
qIndex={qi}
|
qIndex={qi}
|
||||||
|
judgeMode={q.judgeMode ?? "rubric"}
|
||||||
value={a as SolveState | null}
|
value={a as SolveState | null}
|
||||||
disabled={!!result}
|
disabled={!!result}
|
||||||
onChange={(v) => set(qi, v)}
|
onChange={(v) => set(qi, v)}
|
||||||
@@ -221,12 +235,14 @@ export function QuizBlock({
|
|||||||
function SolveInput({
|
function SolveInput({
|
||||||
paperId,
|
paperId,
|
||||||
qIndex,
|
qIndex,
|
||||||
|
judgeMode,
|
||||||
value,
|
value,
|
||||||
disabled,
|
disabled,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
paperId: string;
|
paperId: string;
|
||||||
qIndex: number;
|
qIndex: number;
|
||||||
|
judgeMode: "rubric" | "answer";
|
||||||
value: SolveState | null;
|
value: SolveState | null;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onChange: (v: SolveState) => void;
|
onChange: (v: SolveState) => void;
|
||||||
@@ -235,6 +251,7 @@ function SolveInput({
|
|||||||
const [busy, setBusy] = useState("");
|
const [busy, setBusy] = useState("");
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
const st = value ?? { phase: "idle" as const };
|
const st = value ?? { phase: "idle" as const };
|
||||||
|
const isAnswerMode = judgeMode === "answer";
|
||||||
|
|
||||||
// xuchao: 压缩到 ≤2MB(先缩到 2000px 内,再降质量)
|
// xuchao: 压缩到 ≤2MB(先缩到 2000px 内,再降质量)
|
||||||
const compress = (file: File) =>
|
const compress = (file: File) =>
|
||||||
@@ -306,6 +323,11 @@ function SolveInput({
|
|||||||
const resp = await fetch("/api/dev-photo", { method: "POST", body: fd });
|
const resp = await fetch("/api/dev-photo", { method: "POST", body: fd });
|
||||||
if (!resp.ok) throw new Error("照片上传失败,请重试");
|
if (!resp.ok) throw new Error("照片上传失败,请重试");
|
||||||
}
|
}
|
||||||
|
if (isAnswerMode) {
|
||||||
|
// xuchao: answer 模式不调 AI,照片存档后等待最终答案提交秒判
|
||||||
|
onChange({ phase: "uploaded", dataUrl, ossKey: up.key, answer: "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
setBusy("AI 判分中…");
|
setBusy("AI 判分中…");
|
||||||
let r = await gradeSolve(paperId, qIndex, up.key);
|
let r = await gradeSolve(paperId, qIndex, up.key);
|
||||||
// xuchao: 调用失败自动重试一次
|
// xuchao: 调用失败自动重试一次
|
||||||
@@ -347,6 +369,38 @@ function SolveInput({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (st.phase === "uploaded") {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
void onPick(e.target.files?.[0]);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const dataUrl = st.phase === "preview" || st.phase === "grading" || st.phase === "graded" ? st.dataUrl : "";
|
const dataUrl = st.phase === "preview" || st.phase === "grading" || st.phase === "graded" ? st.dataUrl : "";
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-2">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { todayStr } from "@/lib/dates";
|
|||||||
import { ensureDay } from "@/lib/tasks";
|
import { ensureDay } from "@/lib/tasks";
|
||||||
import { getSessionUser } from "@/lib/auth";
|
import { getSessionUser } from "@/lib/auth";
|
||||||
import { DEFAULT_RULE_POINTS, upsertPointRule } from "@/lib/points";
|
import { DEFAULT_RULE_POINTS, upsertPointRule } from "@/lib/points";
|
||||||
|
import { validateQuestions } from "@/lib/question-validation";
|
||||||
|
|
||||||
function hashCode(code: string) {
|
function hashCode(code: string) {
|
||||||
return crypto.createHash("sha256").update(code).digest("hex");
|
return crypto.createHash("sha256").update(code).digest("hex");
|
||||||
@@ -325,40 +326,6 @@ export async function deleteExamRecord(id: number): Promise<void> {
|
|||||||
revalidatePath("/parent");
|
revalidatePath("/parent");
|
||||||
}
|
}
|
||||||
|
|
||||||
// xuchao: 试卷题目 JSON 形状校验:数组 + 每题必需字段 + 题型条件字段
|
|
||||||
const EXAM_TYPES = ["choice", "fill", "judge", "order", "match", "solve"];
|
|
||||||
|
|
||||||
function validateQuestionsJson(raw: string): { error?: string; count?: number } {
|
|
||||||
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;
|
|
||||||
if (!EXAM_TYPES.includes(t as string)) return { error: `${no}的 type 非法:${String(t)}` };
|
|
||||||
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 === "order" && (!Array.isArray(q.words) || q.words.length === 0)) return { error: `${no}(order)缺少词块(words)` };
|
|
||||||
if (t === "fill" && !Array.isArray(q.answers)) return { error: `${no}(fill)缺少参考答案(answers 数组)` };
|
|
||||||
if (t === "order" && !Array.isArray(q.answer)) return { error: `${no}(order)缺少答案(answer 数组)` };
|
|
||||||
if (t === "match" && !Array.isArray(q.pairs)) return { error: `${no}(match)缺少配对(pairs 数组)` };
|
|
||||||
if (t === "solve") {
|
|
||||||
if (typeof q.reference !== "string" || !q.reference.trim()) return { error: `${no}(solve)缺少参考解答(reference)` };
|
|
||||||
if (typeof q.points !== "number" || q.points <= 0) return { error: `${no}(solve)缺少满分(points>0)` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { count: arr.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
// xuchao: 保存试卷(paperId 重复则全量更新,孩子端 /exam 立即生效)
|
// xuchao: 保存试卷(paperId 重复则全量更新,孩子端 /exam 立即生效)
|
||||||
export async function saveExamPaper(formData: FormData): Promise<{ ok?: boolean; error?: string }> {
|
export async function saveExamPaper(formData: FormData): Promise<{ ok?: boolean; error?: string }> {
|
||||||
const parent = await getSessionUser();
|
const parent = await getSessionUser();
|
||||||
@@ -373,7 +340,7 @@ export async function saveExamPaper(formData: FormData): Promise<{ ok?: boolean;
|
|||||||
if (!Number.isInteger(month) || month < 0 || month > 12) return { error: "月份需为 0-12 的整数" };
|
if (!Number.isInteger(month) || month < 0 || month > 12) return { error: "月份需为 0-12 的整数" };
|
||||||
if (!Number.isInteger(seq) || seq < 0) return { error: "展示序号需为不小于 0 的整数" };
|
if (!Number.isInteger(seq) || seq < 0) return { error: "展示序号需为不小于 0 的整数" };
|
||||||
const questions = f("questions");
|
const questions = f("questions");
|
||||||
const chk = validateQuestionsJson(questions);
|
const chk = validateQuestions(questions);
|
||||||
if (chk.error) return { error: chk.error };
|
if (chk.error) return { error: chk.error };
|
||||||
|
|
||||||
await db
|
await db
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { CheckCircle2, Save, Trash2 } from "lucide-react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import type { QuizQ } from "@/lib/quiz-parse";
|
||||||
import { deleteExamPaper, saveExamPaper } from "../actions";
|
import { deleteExamPaper, saveExamPaper } from "../actions";
|
||||||
|
import { QuestionList } from "./question-list";
|
||||||
|
|
||||||
// xuchao: 与 lib/exam.ts 的 EXAM_TYPE_LABEL 保持同步(客户端不能直接引用服务端模块)
|
// xuchao: 与 lib/exam.ts 的 EXAM_TYPE_LABEL 保持同步(客户端不能直接引用服务端模块)
|
||||||
const EXAM_TYPE_LABEL: Record<string, string> = {
|
const EXAM_TYPE_LABEL: Record<string, string> = {
|
||||||
@@ -32,7 +34,7 @@ export type PaperMeta = {
|
|||||||
questions: string;
|
questions: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// xuchao: 试卷编辑器——元信息表单 + 题目 JSON 文本域;保存走服务端完整校验
|
// xuchao: 试卷编辑器——元信息表单 + 结构化题目编辑器;保存走服务端完整校验
|
||||||
export function PaperForm({ paper, isNew }: { paper: PaperMeta | null; isNew: boolean }) {
|
export function PaperForm({ paper, isNew }: { paper: PaperMeta | null; isNew: boolean }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [f, setF] = useState({
|
const [f, setF] = useState({
|
||||||
@@ -47,26 +49,23 @@ export function PaperForm({ paper, isNew }: { paper: PaperMeta | null; isNew: bo
|
|||||||
seq: String(paper?.seq ?? 1),
|
seq: String(paper?.seq ?? 1),
|
||||||
sourceUrl: paper?.sourceUrl ?? "",
|
sourceUrl: paper?.sourceUrl ?? "",
|
||||||
summary: paper?.summary ?? "",
|
summary: paper?.summary ?? "",
|
||||||
questions: paper ? JSON.stringify(JSON.parse(paper.questions), null, 2) : "[]",
|
});
|
||||||
|
// xuchao: 题目用结构化数组管理,保存时序列化为 JSON(与旧 JSON 形状一致)
|
||||||
|
const [questions, setQuestions] = useState<QuizQ[]>(() => {
|
||||||
|
try {
|
||||||
|
return paper ? (JSON.parse(paper.questions) as QuizQ[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
});
|
});
|
||||||
const [msg, setMsg] = useState<{ ok?: boolean; text: string } | null>(null);
|
const [msg, setMsg] = useState<{ ok?: boolean; text: string } | null>(null);
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
// xuchao: 客户端轻量预览:JSON 可解析则显示题数,失败显示错误(完整校验在服务端)
|
|
||||||
const preview = (() => {
|
|
||||||
try {
|
|
||||||
const arr = JSON.parse(f.questions) as unknown[];
|
|
||||||
if (!Array.isArray(arr)) return { ok: false, text: "题目必须是 JSON 数组" };
|
|
||||||
return { ok: true, text: `共 ${arr.length} 道题` };
|
|
||||||
} catch (e) {
|
|
||||||
return { ok: false, text: `JSON 语法错误:${(e as Error).message}` };
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
(Object.keys(f) as (keyof typeof f)[]).forEach((k) => fd.set(k, f[k]));
|
(Object.keys(f) as (keyof typeof f)[]).forEach((k) => fd.set(k, f[k]));
|
||||||
|
fd.set("questions", JSON.stringify(questions));
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
saveExamPaper(fd).then((r) => {
|
saveExamPaper(fd).then((r) => {
|
||||||
if (r.error) setMsg({ text: r.error });
|
if (r.error) setMsg({ text: r.error });
|
||||||
@@ -158,15 +157,10 @@ export function PaperForm({ paper, isNew }: { paper: PaperMeta | null; isNew: bo
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center justify-between">
|
<Label>题目(结构化编辑)</Label>
|
||||||
<Label>题目(quiz JSON 数组)</Label>
|
<QuestionList
|
||||||
<span className={preview.ok ? "text-xs text-emerald-600" : "text-xs text-destructive"}>{preview.text}</span>
|
value={questions}
|
||||||
</div>
|
onChange={(qs) => setQuestions(qs)}
|
||||||
<textarea
|
|
||||||
value={f.questions}
|
|
||||||
onChange={n("questions").onChange}
|
|
||||||
rows={16}
|
|
||||||
className="w-full rounded-lg border border-slate-200 bg-white p-3 font-mono text-xs leading-relaxed focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { QuestionList } from "./question-list";
|
||||||
|
import type { QuizQ } from "@/lib/quiz-parse";
|
||||||
|
|
||||||
|
const choiceQ = { type: "choice" as const, stem: "1+1=?", options: ["1", "2"], answer: 1, explain: "e" };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("QuestionList 结构化编辑", () => {
|
||||||
|
it("渲染题目卡片(题型徽标+题干)并可删除", () => {
|
||||||
|
let saved: QuizQ[] = [{ ...choiceQ }];
|
||||||
|
render(<QuestionList value={saved} onChange={(v) => (saved = v)} />);
|
||||||
|
expect(screen.getByText("选择题")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1+1=?")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByLabelText("删除第 1 题"));
|
||||||
|
expect(saved.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("新增选择题:填题干/选项/点选答案/解析后保存进列表", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let saved: QuizQ[] = [];
|
||||||
|
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("新建应用题仅 answer 模式(无判分模式 select)", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<QuestionList value={[]} onChange={() => {}} />);
|
||||||
|
await user.click(screen.getByText("+ 新增题目"));
|
||||||
|
await user.selectOptions(screen.getByLabelText("题型"), "solve");
|
||||||
|
expect(screen.queryByLabelText("判分模式")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("仅最终答案(秒判)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("编辑存量 rubric 题显示只读徽标", () => {
|
||||||
|
render(
|
||||||
|
<QuestionList
|
||||||
|
value={[{ type: "solve", stem: "解方程", points: 10, judgeMode: "rubric" as const, reference: "x=2", rubric: [{ point: "过程", score: 6 }], explain: "e" }]}
|
||||||
|
onChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByLabelText("编辑第 1 题"));
|
||||||
|
expect(screen.queryByLabelText("判分模式")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("AI 逐评分点判分(存量题,保留原评分点)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("编辑存量无 judgeMode 字段的题显示 AI 判分徽标与必填参考解答语义", () => {
|
||||||
|
render(
|
||||||
|
<QuestionList
|
||||||
|
value={[{ type: "solve", stem: "解方程", points: 10, reference: "x=2", rubric: [{ point: "过程", score: 6 }], explain: "e" }]}
|
||||||
|
onChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByLabelText("编辑第 1 题"));
|
||||||
|
expect(screen.getByText("AI 逐评分点判分(存量题,保留原评分点)")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("参考解答(完整解题过程,展示给孩子)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("题干为空保存时报错,不写入列表", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let saved: QuizQ[] = [];
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("删除选项后 answer 下标正确回落", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let saved: QuizQ[] = [{ type: "choice" as const, stem: "q", options: ["a", "b", "c"], answer: 2, explain: "e" }];
|
||||||
|
render(<QuestionList value={saved} onChange={(v) => (saved = v)} />);
|
||||||
|
fireEvent.click(screen.getByLabelText("编辑第 1 题"));
|
||||||
|
// xuchao: buttons = [上移,下移,编辑,删除, 取消, 添加选项, 删a, 删b, 删c, 完成] → 删b = [7]
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
fireEvent.click(buttons[7]);
|
||||||
|
// xuchao: 点"完成"保存,onChange 被调用
|
||||||
|
await user.click(screen.getByText("完成"));
|
||||||
|
const d = saved[0] as Record<string, unknown>;
|
||||||
|
expect(d.answer).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
"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;
|
||||||
|
const no = `第 ${editIndex != null ? editIndex + 1 : value.length + 1} 题`;
|
||||||
|
if (!d.stem.trim()) return setError(`${no}缺少题干(stem)`);
|
||||||
|
if (!d.explain.trim()) return setError(`${no}缺少解析(explain)`);
|
||||||
|
if (d.type === "choice" && !d.options?.some((o) => o.trim())) return setError(`${no}缺少选项(options)`);
|
||||||
|
if (d.type === "fill" && (d.answers ?? []).every((a) => !a.trim())) return setError(`${no}缺少参考答案(answers 数组)`);
|
||||||
|
if (d.type === "solve") {
|
||||||
|
if (d.judgeMode === "answer") {
|
||||||
|
if ((d.answers ?? []).every((a) => !a.trim())) return setError(`${no}缺少最终答案(answers 数组)`);
|
||||||
|
} else if (!d.reference?.trim()) {
|
||||||
|
return setError(`${no}缺少参考解答(reference)`);
|
||||||
|
}
|
||||||
|
if (!(d.points && d.points > 0)) return setError(`${no}缺少满分(points>0)`);
|
||||||
|
}
|
||||||
|
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, options: undefined, answers: undefined, reference: undefined, rubric: undefined, judgeMode: undefined, points: undefined, ...(t === "solve" ? { judgeMode: "answer", points: 10 } : {}) });
|
||||||
|
}}
|
||||||
|
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={() => {
|
||||||
|
const newOpts = (draft.options ?? []).filter((_, xi) => xi !== oi);
|
||||||
|
const patch: Partial<QuizQ> = { options: newOpts };
|
||||||
|
if (draft.answer === oi) patch.answer = 0;
|
||||||
|
else if (typeof draft.answer === "number" && draft.answer > oi) patch.answer = draft.answer - 1;
|
||||||
|
update(patch);
|
||||||
|
}}>
|
||||||
|
<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}
|
||||||
|
placeholder="10"
|
||||||
|
value={draft.points ?? ""}
|
||||||
|
onChange={(e) => update({ points: Number(e.target.value) || undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-slate-50 px-3 py-2 text-xs text-slate-600">
|
||||||
|
{draft.judgeMode === "answer" ? "仅最终答案(秒判)" : "AI 逐评分点判分(存量题,保留原评分点)"}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{draft.judgeMode === "answer" ? "参考解答(可选,给孩子看)" : "参考解答(完整解题过程,展示给孩子)"}</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { answersMatch, normalizeAnswer, solveAnswerHit } 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("solveAnswerHit", () => {
|
||||||
|
const q = { answers: ["2", "x=2"] };
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// xuchao: 答案等价判定——数值归一(分数/小数/百分数互转),文本归一(去空白/全角/小写)
|
||||||
|
import type { QuizQ } from "@/lib/quiz-parse";
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// xuchao: 应用题 answer 模式判定——最终答案等值即判对
|
||||||
|
export function solveAnswerHit(q: Pick<QuizQ, "answers">, given: string): boolean {
|
||||||
|
return answersMatch(q.answers ?? [], given);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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("rubric 模式缺 rubric 评分点报错", () => {
|
||||||
|
const { rubric: _rub, ...rest } = solveRubric;
|
||||||
|
expect(validateQuestions(JSON.stringify([rest])).error).toContain("缺少评分点");
|
||||||
|
});
|
||||||
|
it("answer 模式缺 answers 报错,不要求 reference", () => {
|
||||||
|
const q = { ...solveRubric, judgeMode: "answer", answers: [], rubric: undefined };
|
||||||
|
delete q.rubric;
|
||||||
|
expect(validateQuestions(JSON.stringify([q])).error).toContain("缺少最终答案");
|
||||||
|
const ok = { ...solveRubric, judgeMode: "answer", answers: ["2"], rubric: undefined, reference: 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// 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 === "choice" && (typeof q.answer !== "number" || q.answer < 0 || q.answer >= (q.options as unknown[]).length)) return { error: `${no}(choice)答案下标越界` };
|
||||||
|
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)` };
|
||||||
|
if (!Array.isArray(q.rubric) || q.rubric.length === 0) return { error: `${no}(solve·rubric 模式)缺少评分点(rubric)` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { count: arr.length };
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@ export type QuizQ = {
|
|||||||
points?: number; // solve:本题满分
|
points?: number; // solve:本题满分
|
||||||
reference?: string; // solve:完整参考解答
|
reference?: string; // solve:完整参考解答
|
||||||
rubric?: { point: string; score: number }[]; // solve:评分点,合计 = points
|
rubric?: { point: string; score: number }[]; // solve:评分点,合计 = points
|
||||||
|
// xuchao: 应用题判定模式——rubric=AI 逐评分点(缺省,存量兼容);answer=最终答案数组匹配秒判
|
||||||
|
judgeMode?: "rubric" | "answer";
|
||||||
|
// judgeMode==="answer" 时复用上面 answers 数组(等价答法都填)
|
||||||
// xuchao: 错题本里的解答题作答快照(照片 + 逐点判定),交卷入错题本时写入
|
// xuchao: 错题本里的解答题作答快照(照片 + 逐点判定),交卷入错题本时写入
|
||||||
solveSnap?: { ossKey: string; earned: number; hits: { point: string; score: number; hit: boolean; reason: string }[] };
|
solveSnap?: { ossKey: string; earned: number; hits: { point: string; score: number; hit: boolean; reason: string }[] };
|
||||||
explain: string;
|
explain: string;
|
||||||
@@ -33,3 +36,6 @@ export function extractQuiz(md: string): QuizQ[] {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// xuchao: 应用题 answer 模式的提交载荷(最终答案 + 照片 key,供秒判与错题本快照)
|
||||||
|
export type SolveAnswerPayload = { answer: string; ossKey: string };
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { afterEach } from "vitest";
|
||||||
|
import { cleanup } from "@testing-library/react";
|
||||||
|
|
||||||
|
// xuchao: 组件测试后自动清理 DOM,避免跨测试污染
|
||||||
|
afterEach(() => cleanup());
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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") } },
|
||||||
|
oxc: { jsx: { runtime: "automatic" } },
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["src/**/*.test.{ts,tsx}"],
|
||||||
|
setupFiles: ["src/test-setup.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user