# 试卷富文本编辑器与拍照阅卷重构 实施计划 > **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}