From 0c64219cb87222ff008291543435fc1925e374e3 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:04:26 +0800 Subject: [PATCH] docs: add exam rich editor and photo grading design plan - Add design plan for exam rich text editor and photo-based grading feature --- ...6-24-exam-rich-editor-and-photo-grading.md | 2130 +++++++++++++++++ 1 file changed, 2130 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-24-exam-rich-editor-and-photo-grading.md diff --git a/docs/superpowers/plans/2026-06-24-exam-rich-editor-and-photo-grading.md b/docs/superpowers/plans/2026-06-24-exam-rich-editor-and-photo-grading.md new file mode 100644 index 0000000..e6ac730 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-exam-rich-editor-and-photo-grading.md @@ -0,0 +1,2130 @@ +# 试卷富文本编辑器与拍照阅卷重构 实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 用 Tiptap 富文本编辑器替换现有"粘贴文本+AI解析+弹窗预览"的试卷创建流程,支持选区标记题目/分组/填空/加点字 + AI自动标记 + 可拖拽分栏预览;答题侧支持客观题在线作答 + 主观题整卷分页拍照上传,批改侧改为高考阅卷式(左题目右答案图片)。 + +**Architecture:** +- 创建侧:基于 Tiptap 3.15.3 扩展自定义节点(`questionBlock`/`groupBlock`/`blankNode`/`dottedMark`/`imageNode`),编辑器文档模型与试卷 structure 双向同步;左右分栏可拖拽(自实现 ResizablePanel);AI 自动标记复用现有 `previewAiExamAction` 但输出标记到编辑器而非弹窗。 +- 答题侧:`homeworkAnswers.answerContent` 扩展为 `{ answer, imageFileIds }`;整卷图片复用 `fileAttachments` 表(`targetType="homework_submission"`);客观题保留在线作答与自动判分,主观题拍照。 +- 批改侧:新 `HomeworkScanGradingView` 组件,左题目+评分面板,右答案图片滚动/缩放/翻页。 +- 不新增 DB 题型枚举:填空用 `text` + `content.blanks`,排序/复合用 `composite` + `content.subQuestions`,看图用 `content.images`。`questions.content` 是 json,无需 migration。 + +**Tech Stack:** Tiptap 3.15.3、@dnd-kit/sortable(已安装)、next-intl、Drizzle JSON、现有 files 模块上传接口。 + +--- + +## 文件结构 + +### 创建侧(富文本编辑器) +- `src/modules/exams/editor/extensions/question-block.ts` — 题目块节点(题号/题型/分值/题干) +- `src/modules/exams/editor/extensions/group-block.ts` — 分组块节点(大题标题) +- `src/modules/exams/editor/extensions/blank-node.ts` — 填空占位节点 +- `src/modules/exams/editor/extensions/dotted-mark.ts` — 加点字(下加点)mark +- `src/modules/exams/editor/extensions/image-node.ts` — 图片节点(复用 Tiptap Image,绑定 fileAttachments) +- `src/modules/exams/editor/extensions/index.ts` — 聚合导出 +- `src/modules/exams/editor/exam-rich-editor.tsx` — 富文本编辑器主组件(工具栏+选区标记浮层) +- `src/modules/exams/editor/selection-toolbar.tsx` — 选区标记浮层(选中文字→标记为题目/分组/填空/加点字) +- `src/modules/exams/editor/editor-to-structure.ts` — 编辑器文档 → 试卷 structure + questions 纯函数 +- `src/modules/exams/editor/structure-to-editor.ts` — 试卷 structure → 编辑器文档 纯函数 +- `src/modules/exams/editor/exam-rich-editor-types.ts` — 类型定义 +- `src/shared/components/ui/resizable-panel.tsx` — 可拖拽分栏容器(自实现,无新依赖) + +### 创建侧(新创建页) +- `src/modules/exams/components/exam-rich-form.tsx` — 新创建页主组件(替换 ExamForm 的 AI 模式) +- `src/modules/exams/components/exam-rich-preview-pane.tsx` — 右侧实时预览面板(复用 ExamViewer) + +### 答题侧(拍照上传) +- `src/modules/homework/components/scan-uploader.tsx` — 整卷分页拍照上传组件(多图,复用 /api/upload) +- `src/modules/homework/components/homework-take-view.tsx` — 修改:客观题在线作答 + 主观题显示"请在纸上作答并拍照上传" +- `src/modules/homework/lib/question-content-utils.ts` — 修改:扩展 answerContent 支持 imageFileIds;填空多空判分 + +### 批改侧(阅卷式) +- `src/modules/homework/components/homework-scan-grading-view.tsx` — 新阅卷式批改视图(左题目右图片) +- `src/modules/homework/components/scan-image-viewer.tsx` — 答案图片查看器(滚动/缩放/翻页) +- `src/app/(dashboard)/teacher/homework/submissions/[id]/scan/page.tsx` — 阅卷式批改页路由 + +### i18n +- `src/shared/i18n/messages/zh-CN/exam-homework.json` — 修改:补齐题型标签、编辑器工具栏、阅卷式批改文案 +- `src/shared/i18n/messages/en/exam-homework.json` — 修改:同步英文 + +### 架构图同步 +- `docs/architecture/004_architecture_impact_map.md` — 修改:exams/homework 模块章节 +- `docs/architecture/005_architecture_data.json` — 修改:对应节点 + +--- + +## 阶段一:富文本编辑器扩展(Tiptap 自定义节点) + +### Task 1: 可拖拽分栏容器 + +**Files:** +- Create: `src/shared/components/ui/resizable-panel.tsx` + +- [ ] **Step 1: 实现 ResizablePanel 组件** + +```tsx +"use client" + +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react" +import { cn } from "@/shared/lib/utils" + +interface ResizablePanelProps { + /** 左侧最小宽度百分比 */ + minLeft?: number + /** 右侧最小宽度百分比 */ + minRight?: number + /** 初始左侧宽度百分比 */ + initialLeft?: number + left: ReactNode + right: ReactNode + className?: string +} + +export function ResizablePanel({ + minLeft = 20, + minRight = 20, + initialLeft = 50, + left, + right, + className, +}: ResizablePanelProps) { + const [leftPct, setLeftPct] = useState(initialLeft) + const containerRef = useRef(null) + const draggingRef = useRef(false) + + const onPointerDown = useCallback((e: React.PointerEvent) => { + e.preventDefault() + draggingRef.current = true + document.body.style.cursor = "col-resize" + document.body.style.userSelect = "none" + }, []) + + useEffect(() => { + const onMove = (e: PointerEvent) => { + if (!draggingRef.current || !containerRef.current) return + const rect = containerRef.current.getBoundingClientRect() + const pct = ((e.clientX - rect.left) / rect.width) * 100 + const clamped = Math.min(100 - minRight, Math.max(minLeft, pct)) + setLeftPct(clamped) + } + const onUp = () => { + draggingRef.current = false + document.body.style.cursor = "" + document.body.style.userSelect = "" + } + window.addEventListener("pointermove", onMove) + window.addEventListener("pointerup", onUp) + return () => { + window.removeEventListener("pointermove", onMove) + window.removeEventListener("pointerup", onUp) + } + }, [minLeft, minRight]) + + return ( +
+
+ {left} +
+
+
+ {right} +
+
+ ) +} +``` + +- [ ] **Step 2: 验证类型** + +Run: `npx tsc --noEmit` +Expected: 无新错误 + +- [ ] **Step 3: Commit** + +```bash +git add src/shared/components/ui/resizable-panel.tsx +git commit -m "feat(shared): add ResizablePanel component for draggable split layout" +``` + +--- + +### Task 2: 加点字 mark 扩展 + +**Files:** +- Create: `src/modules/exams/editor/extensions/dotted-mark.ts` + +- [ ] **Step 1: 实现 dottedMark** + +```ts +import { Mark, mergeAttributes } from "@tiptap/core" + +declare module "@tiptap/core" { + interface Commands { + dotted: { + toggleDotted: () => ReturnType + } + } +} + +export const DottedMark = Mark.create({ + name: "dotted", + inclusive: true, + parseHTML: () => [ + { tag: "span[data-dotted]" }, + { style: "text-decoration", getAttrs: (v) => (v === "underline dotted" ? null : false) }, + ], + renderHTML: ({ HTMLAttributes }) => [ + "span", + mergeAttributes(HTMLAttributes, { + "data-dotted": "true", + style: "text-decoration: underline dotted; text-underline-offset: 3px;", + }), + 0, + ], + addCommands() { + return { + toggleDotted: + () => + ({ commands }) => + commands.toggleMark(this.name), + } + }, +}) +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/modules/exams/editor/extensions/dotted-mark.ts +git commit -m "feat(exams): add DottedMark tiptap extension for underdotted chars" +``` + +--- + +### Task 3: 填空占位节点 + +**Files:** +- Create: `src/modules/exams/editor/extensions/blank-node.ts` + +- [ ] **Step 1: 实现 blankNode(原子节点,渲染为下划线空)** + +```ts +import { Node, mergeAttributes } from "@tiptap/core" +import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react" +import { css } from "@emotion/react" + +declare module "@tiptap/core" { + interface Commands { + blank: { + insertBlank: () => ReturnType + } + } +} + +const BlankView = () => ( + + + +) + +export const BlankNode = Node.create({ + name: "blank", + group: "inline", + inline: true, + atom: true, + selectable: true, + parseHTML: () => [{ tag: "span[data-blank]" }], + renderHTML: ({ HTMLAttributes }) => [ + "span", + mergeAttributes(HTMLAttributes, { "data-blank": "true" }), + ], + addNodeView() { + return ReactNodeViewRenderer(BlankView) + }, + addCommands() { + return { + insertBlank: + () => + ({ commands }) => + commands.insertContent({ type: "blank" }), + } + }, +}) +``` + +注意:若项目未装 `@emotion/react`,移除该 import(此处未使用)。检查后删除未用 import。 + +- [ ] **Step 2: 移除未用 import 并验证** + +检查 `@emotion/react` 是否在 package.json。若没有,删除该行。 + +Run: `npx tsc --noEmit` +Expected: 无新错误 + +- [ ] **Step 3: Commit** + +```bash +git add src/modules/exams/editor/extensions/blank-node.ts +git commit -m "feat(exams): add BlankNode tiptap extension for fill-in-the-blank placeholder" +``` + +--- + +### Task 4: 图片节点(绑定 fileAttachments) + +**Files:** +- Create: `src/modules/exams/editor/extensions/image-node.ts` + +- [ ] **Step 1: 实现 imageNode(存储 fileId + url)** + +```ts +import Image from "@tiptap/extension-image" +import { mergeAttributes } from "@tiptap/core" + +export interface ImageNodeOptions { + /** 上传回调,返回 { url, fileId } */ + uploadFn?: (file: File) => Promise<{ url: string; fileId: string }> +} + +export const ImageNode = Image.extend({ + addAttributes() { + return { + ...this.parent?.(), + fileId: { + default: null, + parseHTML: (el) => el.getAttribute("data-file-id"), + renderHTML: (attrs) => (attrs.fileId ? { "data-file-id": attrs.fileId } : {}), + }, + } + }, + renderHTML({ HTMLAttributes }) { + return ["img", mergeAttributes(HTMLAttributes)] + }, +}) +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/modules/exams/editor/extensions/image-node.ts +git commit -m "feat(exams): add ImageNode tiptap extension with fileId binding" +``` + +--- + +### Task 5: 题目块与分组块节点 + +**Files:** +- Create: `src/modules/exams/editor/extensions/question-block.ts` +- Create: `src/modules/exams/editor/extensions/group-block.ts` + +- [ ] **Step 1: 实现 groupBlock(大题分组,如"一、选择题")** + +```ts +import { Node, mergeAttributes } from "@tiptap/core" +import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent } from "@tiptap/react" + +declare module "@tiptap/core" { + interface Commands { + groupBlock: { + insertGroup: (title?: string) => ReturnType + } + } +} + +const GroupView = ({ node, updateAttributes }: any) => ( + + updateAttributes({ title: e.target.value })} + placeholder="大题标题(如:一、选择题)" + className="w-full bg-transparent text-base font-semibold focus:outline-none" + /> + + +) + +export const GroupBlock = Node.create({ + name: "groupBlock", + group: "block", + content: "block+", + defining: true, + isolating: true, + addAttributes() { + return { title: { default: "" } } + }, + parseHTML: () => [{ tag: "div[data-group-block]" }], + renderHTML: ({ HTMLAttributes }) => ["div", mergeAttributes(HTMLAttributes, { "data-group-block": "true" }), 0], + addNodeView() { + return ReactNodeViewRenderer(GroupView) + }, + addCommands() { + return { + insertGroup: + (title) => + ({ commands }) => + commands.insertContent({ type: "groupBlock", attrs: { title: title || "" } }), + } + }, +}) +``` + +- [ ] **Step 2: 实现 questionBlock(单道题,含题型/分值/题干)** + +```ts +import { Node, mergeAttributes } from "@tiptap/core" +import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent } from "@tiptap/react" + +export type QuestionBlockAttrs = { + questionId: string + type: "single_choice" | "multiple_choice" | "judgment" | "text" | "composite" + score: number +} + +declare module "@tiptap/core" { + interface Commands { + questionBlock: { + insertQuestion: (attrs?: Partial) => ReturnType + } + } +} + +const QuestionView = ({ node, updateAttributes }: any) => { + const { type, score } = node.attrs as QuestionBlockAttrs + return ( + +
+ + updateAttributes({ score: Number(e.target.value) || 0 })} + className="w-16 rounded border bg-background px-2 py-1 text-xs" + /> + +
+ +
+ ) +} + +export const QuestionBlock = Node.create({ + name: "questionBlock", + group: "block", + content: "block+", + defining: true, + isolating: true, + addAttributes() { + return { + questionId: { default: "" }, + type: { default: "single_choice" }, + score: { default: 0 }, + } + }, + parseHTML: () => [{ tag: "div[data-question-block]" }], + renderHTML: ({ HTMLAttributes }) => ["div", mergeAttributes(HTMLAttributes, { "data-question-block": "true" }), 0], + addNodeView() { + return ReactNodeViewRenderer(QuestionView) + }, + addCommands() { + return { + insertQuestion: + (attrs) => + ({ commands }) => + commands.insertContent({ + type: "questionBlock", + attrs: { type: attrs?.type ?? "single_choice", score: attrs?.score ?? 0, questionId: attrs?.questionId ?? "" }, + }), + } + }, +}) +``` + +- [ ] **Step 3: 验证类型** + +Run: `npx tsc --noEmit` +Expected: 无新错误(可能有 `any` 警告,后续 Task 替换为 NodeProps 类型) + +- [ ] **Step 4: Commit** + +```bash +git add src/modules/exams/editor/extensions/question-block.ts src/modules/exams/editor/extensions/group-block.ts +git commit -m "feat(exams): add QuestionBlock and GroupBlock tiptap extensions" +``` + +--- + +### Task 6: 扩展聚合导出 + +**Files:** +- Create: `src/modules/exams/editor/extensions/index.ts` + +- [ ] **Step 1: 聚合导出** + +```ts +export { DottedMark } from "./dotted-mark" +export { BlankNode } from "./blank-node" +export { ImageNode } from "./image-node" +export { QuestionBlock } from "./question-block" +export { GroupBlock } from "./group-block" +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/modules/exams/editor/extensions/index.ts +git commit -m "feat(exams): aggregate tiptap extensions export" +``` + +--- + +## 阶段二:编辑器主组件与选区标记 + +### Task 7: 编辑器文档与试卷 structure 双向转换 + +**Files:** +- Create: `src/modules/exams/editor/exam-rich-editor-types.ts` +- Create: `src/modules/exams/editor/editor-to-structure.ts` +- Create: `src/modules/exams/editor/structure-to-editor.ts` + +- [ ] **Step 1: 定义类型** + +```ts +// exam-rich-editor-types.ts +import type { JSONContent } from "@tiptap/react" + +export type RichQuestionType = "single_choice" | "multiple_choice" | "judgment" | "text" | "composite" + +export interface RichQuestionContent { + text: string + options?: Array<{ id: string; text: string; isCorrect?: boolean }> + blanks?: Array<{ id: string; answer?: string; score?: number }> + images?: Array<{ fileId: string; url: string; alt?: string }> + subQuestions?: Array<{ id: string; text: string; answer?: string; score?: number }> + correctAnswer?: unknown +} + +export interface EditorQuestion { + id: string + type: RichQuestionType + score: number + content: RichQuestionContent +} + +export interface EditorStructureNode { + id: string + type: "group" | "question" + title?: string + questionId?: string + score?: number + children?: EditorStructureNode[] +} + +export interface EditorDoc { + title: string + questions: EditorQuestion[] + structure: EditorStructureNode[] +} +``` + +- [ ] **Step 2: 实现 editor-to-structure(遍历 Tiptap JSONContent)** + +```ts +// editor-to-structure.ts +import { createId } from "@paralleldrive/cuid2" +import type { JSONContent } from "@tiptap/react" +import type { EditorDoc, EditorQuestion, EditorStructureNode, RichQuestionContent, RichQuestionType } from "./exam-rich-editor-types" + +const extractText = (node: JSONContent | undefined): string => { + if (!node) return "" + if (node.type === "text") return node.text ?? "" + if (Array.isArray(node.content)) return node.content.map(extractText).join("") + return "" +} + +const collectBlanks = (nodes: JSONContent[]): Array<{ id: string }> => { + const blanks: Array<{ id: string }> = [] + let i = 0 + const walk = (n: JSONContent) => { + if (n.type === "blank") { + i += 1 + blanks.push({ id: String(i) }) + } + if (Array.isArray(n.content)) n.content.forEach(walk) + } + nodes.forEach(walk) + return blanks +} + +const collectImages = (nodes: JSONContent[]): Array<{ fileId: string; url: string; alt?: string }> => { + const imgs: Array<{ fileId: string; url: string; alt?: string }> = [] + const walk = (n: JSONContent) => { + if (n.type === "image" && n.attrs) { + const fileId = typeof n.attrs.fileId === "string" ? n.attrs.fileId : "" + const url = typeof n.attrs.src === "string" ? n.attrs.src : "" + if (fileId && url) imgs.push({ fileId, url, alt: typeof n.attrs.alt === "string" ? n.attrs.alt : undefined }) + } + if (Array.isArray(n.content)) n.content.forEach(walk) + } + nodes.forEach(walk) + return imgs +} + +const parseOptions = (nodes: JSONContent[]): Array<{ id: string; text: string; isCorrect?: boolean }> => { + // 约定:题干后紧跟的 orderedList/bulletList 项为选项,以 "A." "B." 开头 + const options: Array<{ id: string; text: string; isCorrect?: boolean }> = [] + for (const n of nodes) { + if (n.type === "orderedList" || n.type === "bulletList") { + if (Array.isArray(n.content)) { + n.content.forEach((item, idx) => { + const text = extractText(item).trim() + const match = text.match(/^([A-Z])[.、)]\s*(.+)$/) + if (match) { + options.push({ id: match[1], text: match[2] }) + } else { + options.push({ id: String.fromCharCode(65 + idx), text }) + } + }) + } + } + } + return options +} + +export const editorDocToStructure = (doc: JSONContent, title: string): EditorDoc => { + const questions: EditorQuestion[] = [] + const structure: EditorStructureNode[] = [] + const topBlocks = doc.content ?? [] + + for (const block of topBlocks) { + if (block.type === "groupBlock") { + const groupTitle = typeof block.attrs?.title === "string" ? block.attrs.title : "" + const children: EditorStructureNode[] = [] + const innerBlocks = block.content ?? [] + for (const qb of innerBlocks) { + if (qb.type === "questionBlock") { + const q = buildQuestion(qb) + questions.push(q) + children.push({ id: createId(), type: "question", questionId: q.id, score: q.score }) + } + } + structure.push({ id: createId(), type: "group", title: groupTitle, children }) + } else if (block.type === "questionBlock") { + const q = buildQuestion(block) + questions.push(q) + structure.push({ id: createId(), type: "question", questionId: q.id, score: q.score }) + } + } + + return { title, questions, structure } +} + +const buildQuestion = (qb: JSONContent): EditorQuestion => { + const attrs = qb.attrs ?? {} + const id = typeof attrs.questionId === "string" && attrs.questionId ? attrs.questionId : createId() + const type = (typeof attrs.type === "string" ? attrs.type : "single_choice") as RichQuestionType + const score = typeof attrs.score === "number" ? attrs.score : 0 + const inner = qb.content ?? [] + const text = extractText({ type: "doc", content: inner.filter((n) => n.type !== "orderedList" && n.type !== "bulletList" && n.type !== "image") }) + const options = parseOptions(inner) + const blanks = collectBlanks(inner) + const images = collectImages(inner) + const content: RichQuestionContent = { text: text.trim() } + if (options.length > 0) content.options = options + if (blanks.length > 0) content.blanks = blanks + if (images.length > 0) content.images = images + return { id, type, score, content } +} +``` + +- [ ] **Step 3: 实现 structure-to-editor(试卷 structure → Tiptap JSONContent,用于回填)** + +```ts +// structure-to-editor.ts +import type { JSONContent } from "@tiptap/react" +import type { EditorDoc, EditorQuestion, EditorStructureNode } from "./exam-rich-editor-types" + +const textToParagraphs = (text: string): JSONContent[] => { + const lines = text.split("\n").filter((l) => l.trim().length > 0) + return lines.map((line) => ({ type: "paragraph", content: [{ type: "text", text: line }] })) +} + +const questionToBlock = (q: EditorQuestion): JSONContent => { + const content: JSONContent[] = textToParagraphs(q.content.text) + if (q.content.options) { + content.push({ + type: "orderedList", + content: q.content.options.map((o) => ({ + type: "listItem", + content: [{ type: "paragraph", content: [{ type: "text", text: `${o.id}. ${o.text}` }] }], + })), + }) + } + if (q.content.images) { + for (const img of q.content.images) { + content.push({ type: "image", attrs: { src: img.url, "data-file-id": img.fileId, alt: img.alt ?? "" } }) + } + } + return { + type: "questionBlock", + attrs: { questionId: q.id, type: q.type, score: q.score }, + content, + } +} + +export const structureToEditorDoc = (doc: EditorDoc): JSONContent => { + const content: JSONContent[] = [] + for (const node of doc.structure) { + if (node.type === "group") { + const children: JSONContent[] = [] + for (const child of node.children ?? []) { + if (child.type === "question") { + const q = doc.questions.find((x) => x.id === child.questionId) + if (q) children.push(questionToBlock(q)) + } + } + content.push({ type: "groupBlock", attrs: { title: node.title ?? "" }, content: children }) + } else if (node.type === "question") { + const q = doc.questions.find((x) => x.id === node.questionId) + if (q) content.push(questionToBlock(q)) + } + } + return { type: "doc", content } +} +``` + +- [ ] **Step 4: 验证类型** + +Run: `npx tsc --noEmit` +Expected: 无新错误 + +- [ ] **Step 5: Commit** + +```bash +git add src/modules/exams/editor/exam-rich-editor-types.ts src/modules/exams/editor/editor-to-structure.ts src/modules/exams/editor/structure-to-editor.ts +git commit -m "feat(exams): add editor doc <-> exam structure bidirectional conversion" +``` + +--- + +### Task 8: 选区标记浮层 + +**Files:** +- Create: `src/modules/exams/editor/selection-toolbar.tsx` + +- [ ] **Step 1: 实现选区浮层(选中文字时显示,提供标记按钮)** + +```tsx +"use client" + +import { useEffect, useState } from "react" +import type { Editor } from "@tiptap/react" +import { Button } from "@/shared/components/ui/button" +import { cn } from "@/shared/lib/utils" + +interface SelectionToolbarProps { + editor: Editor +} + +export function SelectionToolbar({ editor }: SelectionToolbarProps) { + const [visible, setVisible] = useState(false) + const [pos, setPos] = useState({ top: 0, left: 0 }) + + useEffect(() => { + const handler = () => { + const sel = window.getSelection() + if (!sel || sel.isCollapsed || !editor.isFocused) { + setVisible(false) + return + } + const range = sel.getRangeAt(0) + const rect = range.getBoundingClientRect() + if (rect.width === 0 && rect.height === 0) { + setVisible(false) + return + } + setPos({ top: rect.top - 48, left: rect.left + rect.width / 2 }) + setVisible(true) + } + document.addEventListener("selectionchange", handler) + return () => document.removeEventListener("selectionchange", handler) + }, [editor]) + + if (!visible) return null + + const wrap = (type: "questionBlock" | "groupBlock") => { + // 将选中的块转换为题目块/分组块 + if (type === "questionBlock") editor.chain().focus().insertQuestion().run() + if (type === "groupBlock") editor.chain().focus().insertGroup().run() + setVisible(false) + } + + return ( +
+ + + + +
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/modules/exams/editor/selection-toolbar.tsx +git commit -m "feat(exams): add SelectionToolbar for marking questions/groups/blanks/dotted" +``` + +--- + +### Task 9: 富文本编辑器主组件 + +**Files:** +- Create: `src/modules/exams/editor/exam-rich-editor.tsx` + +- [ ] **Step 1: 实现主编辑器(工具栏 + 编辑区 + 选区浮层 + 图片上传)** + +```tsx +"use client" + +import { useCallback, useRef } from "react" +import { useEditor, EditorContent } from "@tiptap/react" +import StarterKit from "@tiptap/starter-kit" +import Placeholder from "@tiptap/extension-placeholder" +import { Button } from "@/shared/components/ui/button" +import { Separator } from "@/shared/components/ui/separator" +import { cn } from "@/shared/lib/utils" +import { Bold, Italic, List, ListOrdered, Image as ImageIcon, Plus, FileText, Heading } from "lucide-react" + +import { DottedMark, BlankNode, ImageNode, QuestionBlock, GroupBlock } from "./extensions" +import { SelectionToolbar } from "./selection-toolbar" +import type { EditorDoc } from "./exam-rich-editor-types" +import { editorDocToStructure } from "./editor-to-structure" +import { structureToEditorDoc } from "./structure-to-editor" + +interface ExamRichEditorProps { + value?: EditorDoc | null + title: string + onChange: (doc: EditorDoc) => void + className?: string +} + +const uploadImage = async (file: File): Promise<{ url: string; fileId: string }> => { + const fd = new FormData() + fd.append("file", file) + const res = await fetch("/api/upload", { method: "POST", body: fd }) + if (!res.ok) throw new Error("upload failed") + const data = (await res.json()) as { url?: string; id?: string; fileId?: string } + return { url: data.url ?? "", fileId: data.id ?? data.fileId ?? "" } +} + +export function ExamRichEditor({ value, title, onChange, className }: ExamRichEditorProps) { + const fileInputRef = useRef(null) + const lastEmitRef = useRef("") + + const editor = useEditor({ + immediatelyRender: false, + extensions: [ + StarterKit.configure({ heading: { levels: [1, 2, 3] } }), + Placeholder.configure({ placeholder: "粘贴试卷文本,选中内容标记为题目/大题,或点击工具栏插入..." }), + DottedMark, + BlankNode, + ImageNode, + QuestionBlock, + GroupBlock, + ], + editorProps: { + attributes: { class: "prose prose-sm dark:prose-invert max-w-none min-h-[400px] p-4 focus:outline-none" }, + handleDrop: (view, event) => { + const files = event.dataTransfer?.files + if (!files || files.length === 0) return false + event.preventDefault() + for (const file of Array.from(files)) { + if (!file.type.startsWith("image/")) continue + void uploadImage(file).then(({ url, fileId }) => { + view.dispatch(view.state.tr.replaceSelectionWith( + view.state.schema.nodes.image.create({ src: url, "data-file-id": fileId }) + )) + }) + } + return true + }, + }, + content: value ? structureToEditorDoc(value) : { type: "doc", content: [] }, + onUpdate: ({ editor }) => { + const doc = editorDocToStructure(editor.getJSON(), title) + const sig = JSON.stringify(doc) + if (sig !== lastEmitRef.current) { + lastEmitRef.current = sig + onChange(doc) + } + }, + }) + + const handleImagePick = useCallback(() => { + fileInputRef.current?.click() + }, []) + + const handleFileChange = useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file || !editor) return + try { + const { url, fileId } = await uploadImage(file) + editor.chain().focus().setImage({ src: url, "data-file-id": fileId }).run() + } catch { + // toast error + } + e.target.value = "" + }, + [editor] + ) + + if (!editor) return null + + return ( +
+
+ + + + + + + + + + + + +
+ + +
+ ) +} +``` + +- [ ] **Step 2: 验证类型** + +Run: `npx tsc --noEmit` +Expected: 无新错误 + +- [ ] **Step 3: Commit** + +```bash +git add src/modules/exams/editor/exam-rich-editor.tsx +git commit -m "feat(exams): add ExamRichEditor main component with toolbar and image upload" +``` + +--- + +## 阶段三:新创建页(可拖拽分栏 + AI自动标记) + +### Task 10: 新创建页主组件 + +**Files:** +- Create: `src/modules/exams/components/exam-rich-form.tsx` + +- [ ] **Step 1: 实现新创建页(左编辑器 + 右预览,可拖拽)** + +```tsx +"use client" + +import { useState, useTransition } from "react" +import { useRouter } from "next/navigation" +import { toast } from "sonner" +import { Button } from "@/shared/components/ui/button" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { ResizablePanel } from "@/shared/components/ui/resizable-panel" +import { ExamRichEditor } from "../editor/exam-rich-editor" +import { ExamViewer } from "./exam-viewer" +import { createExamAction } from "../actions" +import type { EditorDoc } from "../editor/exam-rich-editor-types" + +export function ExamRichForm() { + const router = useRouter() + const [isPending, startTransition] = useTransition() + const [title, setTitle] = useState("") + const [doc, setDoc] = useState({ title: "", questions: [], structure: [] }) + + const handleCreate = () => { + if (doc.questions.length === 0) { + toast.error("请先在编辑器中标记题目") + return + } + const fd = new FormData() + fd.append("title", title.trim() || "未命名试卷") + fd.append("subject", "") + fd.append("grade", "") + fd.append("difficulty", "3") + fd.append("totalScore", String(doc.questions.reduce((s, q) => s + q.score, 0))) + fd.append("durationMin", "90") + fd.append("examMode", "homework") + fd.append("aiQuestionsJson", JSON.stringify(doc.questions)) + fd.append("structureJson", JSON.stringify(doc.structure)) + + startTransition(async () => { + const result = await createExamAction(null, fd) + if (result.success && result.data) { + toast.success("试卷草稿已创建") + router.push(`/teacher/exams/${result.data}/build`) + } else { + toast.error(result.message || "创建失败") + } + }) + } + + return ( +
+
+ + setTitle(e.target.value)} className="max-w-xs" placeholder="未命名试卷" /> + +
+ } + right={ +
+

实时预览

+ ({ + questionId: q.id, + questionType: q.type, + questionContent: q.content, + maxScore: q.score, + }))} /> +
+ } + /> +
+ ) +} +``` + +- [ ] **Step 2: 验证类型** + +Run: `npx tsc --noEmit` +Expected: 无新错误 + +- [ ] **Step 3: Commit** + +```bash +git add src/modules/exams/components/exam-rich-form.tsx +git commit -m "feat(exams): add ExamRichForm with resizable editor+preview split layout" +``` + +--- + +### Task 11: 新创建页路由 + +**Files:** +- Create: `src/app/(dashboard)/teacher/exams/new/page.tsx` + +- [ ] **Step 1: 实现路由页(替换或并存现有创建页)** + +先查看现有创建页路由位置。假设现有为 `/teacher/exams` 下的弹窗创建,新增独立路由 `/teacher/exams/new`。 + +```tsx +import { getTranslations } from "next-intl/server" +import { requirePermission } from "@/shared/lib/auth-guard" +import { ExamRichForm } from "@/modules/exams/components/exam-rich-form" + +export const dynamic = "force-dynamic" + +export default async function NewExamPage() { + await requirePermission("exam.create") + const t = await getTranslations("examHomework") + return ( +
+
+

{t("exam.form.createTitle")}

+

粘贴试卷文本,选区标记题目,实时预览。

+
+ +
+ ) +} +``` + +- [ ] **Step 2: 确认权限点存在** + +Run: `grep -n "exam.create" src/shared/types/permissions.ts src/shared/lib/permissions.ts` +若不存在,使用现有 exam 创建权限点(查 `Permissions` 常量)。 + +- [ ] **Step 3: Commit** + +```bash +git add src/app/(dashboard)/teacher/exams/new/page.tsx +git commit -m "feat(exams): add /teacher/exams/new route for rich editor exam creation" +``` + +--- + +### Task 12: AI 自动标记 Action + +**Files:** +- Modify: `src/modules/exams/actions.ts` — 新增 `autoMarkExamAction` + +- [ ] **Step 1: 新增 AI 自动标记 action(调用 AI 返回题目边界,前端应用到编辑器)** + +在 actions.ts 末尾添加: + +```ts +"use server" + +import { z } from "zod" +import { requirePermission } from "@/shared/lib/auth-guard" +import { createAiChatCompletion } from "@/shared/lib/ai" +import { env } from "@/env.mjs" +import { ActionState } from "@/shared/types/action-state" + +const AutoMarkSchema = z.object({ + sourceText: z.string().min(1), +}) + +const AUTO_MARK_PROMPT = [ + "你是试卷结构识别引擎。将给定试卷文本切分为大题分组和题目。", + '输出 JSON:{"groups":[{"title":"一、选择题","questions":[{"text":"题目文本","type":"single_choice","score":2}]}]}', + "type 取值:single_choice/multiple_choice/judgment/text/composite。", + "仅输出 JSON,不要 markdown。", +].join("\n") + +export async function autoMarkExamAction( + _prev: ActionState<{ groups: Array<{ title: string; questions: Array<{ text: string; type: string; score: number }> }> }> | null, + formData: FormData +) { + await requirePermission("exam.create") + const parsed = AutoMarkSchema.safeParse({ sourceText: formData.get("sourceText") }) + if (!parsed.success) return { success: false, message: "文本不能为空", data: null } + + try { + const result = await createAiChatCompletion({ + model: String(env.AI_MODEL ?? "gpt-4o-mini"), + messages: [ + { role: "system", content: AUTO_MARK_PROMPT }, + { role: "user", content: parsed.data.sourceText }, + ], + temperature: 0, + maxTokens: 8000, + }) + const json = JSON.parse(result.content) + return { success: true, message: "", data: json } + } catch (error) { + return { success: false, message: "AI 标记失败", data: null } + } +} +``` + +- [ ] **Step 2: 验证类型** + +Run: `npx tsc --noEmit` +Expected: 无新错误 + +- [ ] **Step 3: Commit** + +```bash +git add src/modules/exams/actions.ts +git commit -m "feat(exams): add autoMarkExamAction for AI auto-marking question boundaries" +``` + +--- + +### Task 13: 编辑器接入 AI 自动标记按钮 + +**Files:** +- Modify: `src/modules/exams/components/exam-rich-form.tsx` + +- [ ] **Step 1: 在 ExamRichForm 增加"AI自动标记"按钮** + +在标题栏增加按钮,调用 `autoMarkExamAction`,将返回的 groups 通过 `structureToEditorDoc` 转换后填入编辑器。 + +需在 ExamRichEditor 暴露 `setDoc` 方法(通过 ref 或 key 重渲染)。简单方案:用 `key` 强制重建编辑器并传入新 value。 + +修改 ExamRichForm: + +```tsx +// 在 handleCreate 之前增加 +const [aiLoading, setAiLoading] = useState(false) +const [editorKey, setEditorKey] = useState(0) + +const handleAutoMark = async () => { + const sourceText = doc.questions.length > 0 + ? doc.questions.map((q) => q.content.text).join("\n\n") + : "" + if (!sourceText) { + toast.error("请先粘贴试卷文本") + return + } + setAiLoading(true) + const fd = new FormData() + fd.append("sourceText", sourceText) + try { + const res = await autoMarkExamAction(null, fd) + if (res.success && res.data) { + const newDoc: EditorDoc = { + title, + questions: [], + structure: res.data.groups.map((g) => ({ + id: createId(), + type: "group" as const, + title: g.title, + children: g.questions.map((q) => ({ id: createId(), type: "question" as const, questionId: "", score: q.score })), + })), + } + // 重建编辑器以应用新文档 + setDoc(newDoc) + setEditorKey((k) => k + 1) + toast.success("AI 标记完成,请检查并调整") + } else { + toast.error(res.message || "AI 标记失败") + } + } finally { + setAiLoading(false) + } +} +``` + +并在 `` 加 key。 + +注意:需 import `createId` from `@paralleldrive/cuid2` 和 `autoMarkExamAction`。 + +- [ ] **Step 2: 验证类型** + +Run: `npx tsc --noEmit` +Expected: 无新错误 + +- [ ] **Step 3: Commit** + +```bash +git add src/modules/exams/components/exam-rich-form.tsx +git commit -m "feat(exams): integrate AI auto-marking button into rich form" +``` + +--- + +## 阶段四:答题侧拍照上传 + +### Task 14: 扫描上传组件 + +**Files:** +- Create: `src/modules/homework/components/scan-uploader.tsx` + +- [ ] **Step 1: 实现整卷分页拍照上传(多图,复用 /api/upload)** + +```tsx +"use client" + +import { useState, useTransition } from "react" +import Image from "next/image" +import { Button } from "@/shared/components/ui/button" +import { Input } from "@/shared/components/ui/input" +import { Upload, X, Loader2 } from "lucide-react" +import { cn } from "@/shared/lib/utils" + +export interface UploadedScan { + fileId: string + url: string + filename: string +} + +interface ScanUploaderProps { + value: UploadedScan[] + onChange: (scans: UploadedScan[]) => void + disabled?: boolean +} + +export function ScanUploader({ value, onChange, disabled }: ScanUploaderProps) { + const [uploading, setUploading] = useState(false) + + const handleUpload = async (files: FileList) => { + setUploading(true) + try { + const uploaded: UploadedScan[] = [] + for (const file of Array.from(files)) { + if (!file.type.startsWith("image/")) continue + const fd = new FormData() + fd.append("file", file) + const res = await fetch("/api/upload", { method: "POST", body: fd }) + if (!res.ok) continue + const data = (await res.json()) as { url?: string; id?: string } + uploaded.push({ fileId: data.id ?? "", url: data.url ?? "", filename: file.name }) + } + onChange([...value, ...uploaded]) + } finally { + setUploading(false) + } + } + + const remove = (idx: number) => { + onChange(value.filter((_, i) => i !== idx)) + } + + return ( +
+ + {value.length > 0 && ( +
+ {value.map((scan, idx) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {scan.filename} +
第{idx + 1}页
+ {!disabled && ( + + )} +
+ ))} +
+ )} +
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/modules/homework/components/scan-uploader.tsx +git commit -m "feat(homework): add ScanUploader for paginated answer sheet photo upload" +``` + +--- + +### Task 15: 扩展 answerContent 支持图片 + +**Files:** +- Modify: `src/modules/homework/lib/question-content-utils.ts` + +- [ ] **Step 1: 扩展 AnswerShape 与解析函数支持 imageFileIds** + +在 `toAnswerShape` 和 `parseSavedAnswer` 中增加对 `{ answer, imageFileIds }` 的兼容: + +```ts +// 修改 AnswerShape 类型 +export type AnswerShape = + | { answer: string } + | { answer: boolean } + | { answer: string[] } + | { answer: unknown; imageFileIds?: string[] } + +// 修改 toAnswerShape:若传入对象含 imageFileIds,保留 +export const toAnswerShape = (questionType: QuestionType, v: unknown): AnswerShape => { + if (isRecord(v) && "imageFileIds" in v && Array.isArray(v.imageFileIds)) { + const inner = "answer" in v ? v.answer : "" + const base = toAnswerShape(questionType, inner) + return { ...base, imageFileIds: v.imageFileIds.filter((x): x is string => typeof x === "string") } + } + if (questionType === "text") return { answer: typeof v === "string" ? v : "" } + if (questionType === "judgment") return { answer: typeof v === "boolean" ? v : false } + if (questionType === "single_choice") return { answer: typeof v === "string" ? v : "" } + if (questionType === "multiple_choice") { + return { answer: Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [] } + } + return { answer: v } +} +``` + +- [ ] **Step 2: 新增 getImageFileIds 工具函数** + +```ts +export const getImageFileIds = (studentAnswer: unknown): string[] => { + if (!isRecord(studentAnswer)) return [] + const raw = studentAnswer.imageFileIds + if (!Array.isArray(raw)) return [] + return raw.filter((x): x is string => typeof x === "string") +} +``` + +- [ ] **Step 3: 验证类型** + +Run: `npx tsc --noEmit` +Expected: 无新错误 + +- [ ] **Step 4: Commit** + +```bash +git add src/modules/homework/lib/question-content-utils.ts +git commit -m "feat(homework): extend answerContent to support imageFileIds for scanned answers" +``` + +--- + +### Task 16: 答题页接入拍照上传 + +**Files:** +- Modify: `src/modules/homework/components/homework-take-view.tsx` + +- [ ] **Step 1: 在答题页底部增加整卷拍照上传区** + +在 `HomeworkTakeView` 的 `showQuestions` 区块末尾、提交按钮前,增加整卷扫描上传区。主观题(text/composite)在题目卡片内提示"请在纸上作答"。 + +在 `initialData.questions.map` 之后、`` 之前增加: + +```tsx +{showQuestions && ( + + +

主观题答题纸上传(可选)

+

若主观题在纸上作答,请拍照上传整卷,教师将阅卷批改。

+
+ + + +
+)} +``` + +并在组件顶部增加状态: + +```tsx +const [submissionScans, setSubmissionScans] = useState([]) +``` + +在 `handleSubmit` 中,将 scans 关联到 submission(通过 fileAttachments 的 targetType/targetId): + +```tsx +// 在 submitHomeworkAction 调用前,先关联图片 +if (submissionScans.length > 0 && submissionId) { + const fd = new FormData() + fd.append("submissionId", submissionId) + fd.append("scansJson", JSON.stringify(submissionScans)) + await fetch("/api/homework/attach-scans", { method: "POST", body: fd }) +} +``` + +注意:需新增 `/api/homework/attach-scans` 路由(Task 17)。 + +- [ ] **Step 2: Commit** + +```bash +git add src/modules/homework/components/homework-take-view.tsx +git commit -m "feat(homework): integrate ScanUploader into take view for subjective answers" +``` + +--- + +### Task 17: 关联扫描图到 submission 的 API + +**Files:** +- Create: `src/app/api/homework/attach-scans/route.ts` + +- [ ] **Step 1: 实现 API(更新 fileAttachments 的 targetType/targetId)** + +```ts +import { NextRequest, NextResponse } from "next/server" +import { z } from "zod" +import { getServerSession } from "next-auth" +import { authOptions } from "@/auth" +import { db } from "@/shared/db" +import { fileAttachments } from "@/shared/db/schema" +import { eq, inArray } from "drizzle-orm" + +const Schema = z.object({ + submissionId: z.string().min(1), + scansJson: z.string(), +}) + +export async function POST(req: NextRequest) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) return NextResponse.json({ error: "unauthorized" }, { status: 401 }) + + const body = await req.json() + const parsed = Schema.safeParse(body) + if (!parsed.success) return NextResponse.json({ error: "invalid" }, { status: 400 }) + + const scans = JSON.parse(parsed.data.scansJson) as Array<{ fileId: string }> + const fileIds = scans.map((s) => s.fileId).filter(Boolean) + if (fileIds.length === 0) return NextResponse.json({ ok: true }) + + await db.update(fileAttachments) + .set({ targetType: "homework_submission", targetId: parsed.data.submissionId }) + .where(inArray(fileAttachments.id, fileIds)) + + return NextResponse.json({ ok: true }) +} +``` + +- [ ] **Step 2: 验证类型** + +Run: `npx tsc --noEmit` +Expected: 无新错误 + +- [ ] **Step 3: Commit** + +```bash +git add src/app/api/homework/attach-scans/route.ts +git commit -m "feat(homework): add attach-scans API to link answer images to submission" +``` + +--- + +## 阶段五:阅卷式批改视图 + +### Task 18: 答案图片查看器 + +**Files:** +- Create: `src/modules/homework/components/scan-image-viewer.tsx` + +- [ ] **Step 1: 实现图片查看器(滚动/缩放/翻页)** + +```tsx +"use client" + +import { useState } from "react" +import { Button } from "@/shared/components/ui/button" +import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut } from "lucide-react" +import { cn } from "@/shared/lib/utils" + +interface ScanImageViewerProps { + images: Array<{ url: string; filename: string }> + className?: string +} + +export function ScanImageViewer({ images, className }: ScanImageViewerProps) { + const [idx, setIdx] = useState(0) + const [zoom, setZoom] = useState(1) + + if (images.length === 0) { + return
无答题图片
+ } + + const current = images[idx] + + return ( +
+
+ {current.filename} ({idx + 1}/{images.length}) +
+ + {Math.round(zoom * 100)}% + +
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {current.filename} +
+ {images.length > 1 && ( +
+ + +
+ )} +
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/modules/homework/components/scan-image-viewer.tsx +git commit -m "feat(homework): add ScanImageViewer with zoom and pagination" +``` + +--- + +### Task 19: 阅卷式批改视图 + +**Files:** +- Create: `src/modules/homework/components/homework-scan-grading-view.tsx` + +- [ ] **Step 1: 实现阅卷式批改(左题目+评分,右答案图片)** + +```tsx +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { useTranslations } from "next-intl" +import { toast } from "sonner" +import { Button } from "@/shared/components/ui/button" +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { Textarea } from "@/shared/components/ui/textarea" +import { ScrollArea } from "@/shared/components/ui/scroll-area" +import { ResizablePanel } from "@/shared/components/ui/resizable-panel" +import { ScanImageViewer } from "./scan-image-viewer" +import { QuestionRenderer } from "./question-renderer" +import { gradeHomeworkSubmissionAction } from "../actions" +import { getFileAttachmentsByTarget } from "@/modules/files/data-access" +import type { UploadedScan } from "./scan-uploader" + +type Answer = { + id: string + questionId: string + questionContent: unknown + questionType: string + maxScore: number + studentAnswer: unknown + score: number | null + feedback: string | null + order: number +} + +interface HomeworkScanGradingViewProps { + submissionId: string + studentName: string + assignmentTitle: string + answers: Answer[] + scans: Array<{ url: string; filename: string }> +} + +export function HomeworkScanGradingView({ + submissionId, + studentName, + assignmentTitle, + answers, + scans, +}: HomeworkScanGradingViewProps) { + const router = useRouter() + const t = useTranslations("examHomework") + const [answerStates, setAnswerStates] = useState(() => answers) + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleScoreChange = (id: string, val: string) => { + const num = val === "" ? 0 : Number(val) + setAnswerStates((prev) => prev.map((a) => (a.id === id ? { ...a, score: Number.isFinite(num) ? num : 0 } : a))) + } + + const handleFeedbackChange = (id: string, val: string) => { + setAnswerStates((prev) => prev.map((a) => (a.id === id ? { ...a, feedback: val } : a))) + } + + const handleSubmit = async () => { + setIsSubmitting(true) + const payload = answerStates.map((a) => ({ + id: a.id, + score: a.score ?? 0, + feedback: a.feedback?.trim() || undefined, + })) + const fd = new FormData() + fd.set("submissionId", submissionId) + fd.set("answersJson", JSON.stringify(payload)) + try { + const res = await gradeHomeworkSubmissionAction(null, fd) + if (res.success) { + toast.success(t("homework.grade.gradesSaved")) + router.refresh() + } else { + toast.error(res.message || t("homework.grade.gradesSaveFailed")) + } + } finally { + setIsSubmitting(false) + } + } + + const totalScore = answerStates.reduce((s, a) => s + (a.score ?? 0), 0) + const maxTotal = answerStates.reduce((s, a) => s + a.maxScore, 0) + + return ( +
+
+
+

{assignmentTitle}

+

{studentName} · 总分 {totalScore}/{maxTotal}

+
+ +
+ +
+ {answerStates.map((ans, idx) => ( + + + + + +
+ + handleScoreChange(ans.id, e.target.value)} + className="w-20" + /> + / {ans.maxScore} +
+