Files
NextEdu/docs/superpowers/plans/2026-06-24-exam-rich-editor-and-photo-grading.md
SpecialX 0c64219cb8
Some checks failed
CI / scheduled-backup (push) Has been skipped
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 1s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
docs: add exam rich editor and photo grading design plan
- Add design plan for exam rich text editor and photo-based grading feature
2026-06-24 12:04:26 +08:00

67 KiB

试卷富文本编辑器与拍照阅卷重构 实施计划

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.imagesquestions.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 组件

"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<HTMLDivElement>(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 (
    <div ref={containerRef} className={cn("flex h-full w-full", className)}>
      <div style={{ width: `${leftPct}%` }} className="h-full min-w-0 overflow-hidden">
        {left}
      </div>
      <div
        role="separator"
        aria-orientation="vertical"
        onPointerDown={onPointerDown}
        className="w-1.5 shrink-0 cursor-col-resize bg-border hover:bg-primary/40 transition-colors"
      />
      <div style={{ width: `${100 - leftPct}%` }} className="h-full min-w-0 overflow-hidden">
        {right}
      </div>
    </div>
  )
}
  • Step 2: 验证类型

Run: npx tsc --noEmit Expected: 无新错误

  • Step 3: Commit
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

import { Mark, mergeAttributes } from "@tiptap/core"

declare module "@tiptap/core" {
  interface Commands<ReturnType> {
    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
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(原子节点,渲染为下划线空)

import { Node, mergeAttributes } from "@tiptap/core"
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"
import { css } from "@emotion/react"

declare module "@tiptap/core" {
  interface Commands<ReturnType> {
    blank: {
      insertBlank: () => ReturnType
    }
  }
}

const BlankView = () => (
  <NodeViewWrapper as="span" className="inline-block align-baseline">
    <span
      data-blank="true"
      className="mx-1 inline-block border-b border-current align-baseline"
      style={{ minWidth: "80px", height: "1.2em", display: "inline-block" }}
      contentEditable={false}
      aria-label="填空"
    />
  </NodeViewWrapper>
)

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
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)

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
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(大题分组,如"一、选择题")

import { Node, mergeAttributes } from "@tiptap/core"
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent } from "@tiptap/react"

declare module "@tiptap/core" {
  interface Commands<ReturnType> {
    groupBlock: {
      insertGroup: (title?: string) => ReturnType
    }
  }
}

const GroupView = ({ node, updateAttributes }: any) => (
  <NodeViewWrapper className="my-4 rounded-md border-l-4 border-l-primary bg-primary/5 p-3">
    <input
      type="text"
      value={node.attrs.title || ""}
      onChange={(e) => updateAttributes({ title: e.target.value })}
      placeholder="大题标题(如:一、选择题)"
      className="w-full bg-transparent text-base font-semibold focus:outline-none"
    />
    <NodeViewContent className="mt-2 block" />
  </NodeViewWrapper>
)

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(单道题,含题型/分值/题干)
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<ReturnType> {
    questionBlock: {
      insertQuestion: (attrs?: Partial<QuestionBlockAttrs>) => ReturnType
    }
  }
}

const QuestionView = ({ node, updateAttributes }: any) => {
  const { type, score } = node.attrs as QuestionBlockAttrs
  return (
    <NodeViewWrapper className="my-3 rounded-md border bg-card p-3">
      <div className="mb-2 flex items-center gap-2 border-b pb-2">
        <select
          value={type}
          onChange={(e) => updateAttributes({ type: e.target.value })}
          className="rounded border bg-background px-2 py-1 text-xs"
        >
          <option value="single_choice">单选</option>
          <option value="multiple_choice">多选</option>
          <option value="judgment">判断</option>
          <option value="text">填空/简答</option>
          <option value="composite">复合</option>
        </select>
        <input
          type="number"
          min={0}
          value={score}
          onChange={(e) => updateAttributes({ score: Number(e.target.value) || 0 })}
          className="w-16 rounded border bg-background px-2 py-1 text-xs"
        />
        <span className="text-xs text-muted-foreground"></span>
      </div>
      <NodeViewContent className="block text-sm" />
    </NodeViewWrapper>
  )
}

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
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: 聚合导出

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
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: 定义类型

// 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)
// 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,用于回填)
// 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
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: 实现选区浮层(选中文字时显示,提供标记按钮)

"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 (
    <div
      className="fixed z-50 flex items-center gap-1 rounded-md border bg-background p-1 shadow-md"
      style={{ top: pos.top, left: pos.left, transform: "translateX(-50%)" }}
      role="toolbar"
      aria-label="选区标记"
    >
      <Button size="sm" variant="ghost" className="h-7 px-2 text-xs" onClick={() => wrap("questionBlock")}>
        标为题目
      </Button>
      <Button size="sm" variant="ghost" className="h-7 px-2 text-xs" onClick={() => wrap("groupBlock")}>
        标为大题
      </Button>
      <Button
        size="sm"
        variant="ghost"
        className="h-7 px-2 text-xs"
        onClick={() => {
          editor.chain().focus().toggleDotted().run()
          setVisible(false)
        }}
      >
        加点字
      </Button>
      <Button
        size="sm"
        variant="ghost"
        className="h-7 px-2 text-xs"
        onClick={() => {
          editor.chain().focus().insertBlank().run()
          setVisible(false)
        }}
      >
        填空
      </Button>
    </div>
  )
}
  • Step 2: Commit
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: 实现主编辑器(工具栏 + 编辑区 + 选区浮层 + 图片上传)

"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<HTMLInputElement>(null)
  const lastEmitRef = useRef<string>("")

  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<HTMLInputElement>) => {
      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 (
    <div className={cn("flex flex-col rounded-md border bg-background overflow-hidden", className)}>
      <div className="flex flex-wrap items-center gap-1 border-b bg-background p-1">
        <Button size="sm" variant="ghost" className="h-8" onClick={() => editor.chain().focus().toggleBold().run()} title="加粗">
          <Bold className="h-4 w-4" />
        </Button>
        <Button size="sm" variant="ghost" className="h-8" onClick={() => editor.chain().focus().toggleItalic().run()} title="斜体">
          <Italic className="h-4 w-4" />
        </Button>
        <Separator orientation="vertical" className="mx-1 h-6" />
        <Button size="sm" variant="ghost" className="h-8" onClick={() => editor.chain().focus().insertQuestion().run()} title="插入题目">
          <FileText className="h-4 w-4" /> <span className="ml-1 text-xs">题目</span>
        </Button>
        <Button size="sm" variant="ghost" className="h-8" onClick={() => editor.chain().focus().insertGroup().run()} title="插入大题">
          <Heading className="h-4 w-4" /> <span className="ml-1 text-xs">大题</span>
        </Button>
        <Button size="sm" variant="ghost" className="h-8" onClick={() => editor.chain().focus().insertBlank().run()} title="插入填空">
          <Plus className="h-4 w-4" /> <span className="ml-1 text-xs">填空</span>
        </Button>
        <Button size="sm" variant="ghost" className="h-8" onClick={() => editor.chain().focus().toggleDotted().run()} title="加点字">
          <span className="text-xs underline decoration-dotted underline-offset-4">加点字</span>
        </Button>
        <Separator orientation="vertical" className="mx-1 h-6" />
        <Button size="sm" variant="ghost" className="h-8" onClick={() => editor.chain().focus().toggleBulletList().run()} title="无序列表">
          <List className="h-4 w-4" />
        </Button>
        <Button size="sm" variant="ghost" className="h-8" onClick={() => editor.chain().focus().toggleOrderedList().run()} title="选项列表">
          <ListOrdered className="h-4 w-4" />
        </Button>
        <Button size="sm" variant="ghost" className="h-8" onClick={handleImagePick} title="插入图片">
          <ImageIcon className="h-4 w-4" />
        </Button>
        <input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileChange} />
      </div>
      <EditorContent editor={editor} className="flex-1 overflow-y-auto" />
      <SelectionToolbar editor={editor} />
    </div>
  )
}
  • Step 2: 验证类型

Run: npx tsc --noEmit Expected: 无新错误

  • Step 3: Commit
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: 实现新创建页(左编辑器 + 右预览,可拖拽)

"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<EditorDoc>({ 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 (
    <div className="flex h-[calc(100vh-8rem)] flex-col gap-4">
      <div className="flex items-center gap-3">
        <Label className="text-sm">试卷标题</Label>
        <Input value={title} onChange={(e) => setTitle(e.target.value)} className="max-w-xs" placeholder="未命名试卷" />
        <Button onClick={handleCreate} disabled={isPending} className="ml-auto">
          {isPending ? "创建中..." : "创建试卷"}
        </Button>
      </div>
      <ResizablePanel
        initialLeft={55}
        left={<ExamRichEditor title={title} onChange={setDoc} className="h-full" />}
        right={
          <div className="h-full overflow-y-auto p-4">
            <h3 className="mb-3 text-sm font-semibold text-muted-foreground">实时预览</h3>
            <ExamViewer structure={doc.structure} questions={doc.questions.map((q) => ({
              questionId: q.id,
              questionType: q.type,
              questionContent: q.content,
              maxScore: q.score,
            }))} />
          </div>
        }
      />
    </div>
  )
}
  • Step 2: 验证类型

Run: npx tsc --noEmit Expected: 无新错误

  • Step 3: Commit
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

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 (
    <div className="space-y-4">
      <div>
        <h1 className="text-2xl font-bold tracking-tight">{t("exam.form.createTitle")}</h1>
        <p className="text-sm text-muted-foreground">粘贴试卷文本,选区标记题目,实时预览。</p>
      </div>
      <ExamRichForm />
    </div>
  )
}
  • Step 2: 确认权限点存在

Run: grep -n "exam.create" src/shared/types/permissions.ts src/shared/lib/permissions.ts 若不存在,使用现有 exam 创建权限点(查 Permissions 常量)。

  • Step 3: Commit
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 末尾添加:

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

// 在 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)
  }
}

并在 <ExamRichEditor key={editorKey} ... /> 加 key。

注意:需 import createId from @paralleldrive/cuid2autoMarkExamAction

  • Step 2: 验证类型

Run: npx tsc --noEmit Expected: 无新错误

  • Step 3: Commit
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)

"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 (
    <div className="space-y-3">
      <label className="flex cursor-pointer flex-col items-center justify-center rounded-md border border-dashed p-6 text-center hover:bg-muted/30">
        {uploading ? (
          <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
        ) : (
          <Upload className="h-6 w-6 text-muted-foreground" />
        )}
        <span className="mt-2 text-sm text-muted-foreground">点击或拖拽上传答题图片(可多张分页)</span>
        <Input
          type="file"
          accept="image/*"
          multiple
          className="hidden"
          disabled={disabled || uploading}
          onChange={(e) => e.target.files && handleUpload(e.target.files)}
        />
      </label>
      {value.length > 0 && (
        <div className="grid grid-cols-3 gap-2">
          {value.map((scan, idx) => (
            <div key={scan.fileId} className="group relative aspect-[3/4] overflow-hidden rounded-md border">
              {/* eslint-disable-next-line @next/next/no-img-element */}
              <img src={scan.url} alt={scan.filename} className="h-full w-full object-cover" />
              <div className="absolute left-1 top-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] text-white">{idx + 1}</div>
              {!disabled && (
                <button
                  type="button"
                  onClick={() => remove(idx)}
                  className="absolute right-1 top-1 rounded bg-black/60 p-1 text-white opacity-0 transition-opacity group-hover:opacity-100"
                  aria-label="删除"
                >
                  <X className="h-3 w-3" />
                </button>
              )}
            </div>
          ))}
        </div>
      )}
    </div>
  )
}
  • Step 2: Commit
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

toAnswerShapeparseSavedAnswer 中增加对 { answer, imageFileIds } 的兼容:

// 修改 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 工具函数
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
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: 在答题页底部增加整卷拍照上传区

HomeworkTakeViewshowQuestions 区块末尾、提交按钮前,增加整卷扫描上传区。主观题(text/composite)在题目卡片内提示"请在纸上作答"。

initialData.questions.map 之后、</ScrollArea> 之前增加:

{showQuestions && (
  <Card className="border-dashed">
    <CardHeader>
      <h3 className="text-base font-semibold">主观题答题纸上传(可选)</h3>
      <p className="text-sm text-muted-foreground">若主观题在纸上作答,请拍照上传整卷,教师将阅卷批改。</p>
    </CardHeader>
    <CardContent>
      <ScanUploader
        value={submissionScans}
        onChange={setSubmissionScans}
        disabled={!canEdit}
      />
    </CardContent>
  </Card>
)}

并在组件顶部增加状态:

const [submissionScans, setSubmissionScans] = useState<UploadedScan[]>([])

handleSubmit 中,将 scans 关联到 submission(通过 fileAttachments 的 targetType/targetId):

// 在 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
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)

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
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: 实现图片查看器(滚动/缩放/翻页)

"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 <div className="flex h-full items-center justify-center text-sm text-muted-foreground">无答题图片</div>
  }

  const current = images[idx]

  return (
    <div className={cn("flex h-full flex-col", className)}>
      <div className="flex items-center justify-between border-b p-2">
        <span className="text-xs text-muted-foreground">{current.filename} ({idx + 1}/{images.length})</span>
        <div className="flex items-center gap-1">
          <Button size="sm" variant="ghost" className="h-7 w-7 p-0" onClick={() => setZoom((z) => Math.max(0.5, z - 0.2))}>
            <ZoomOut className="h-4 w-4" />
          </Button>
          <span className="text-xs tabular-nums">{Math.round(zoom * 100)}%</span>
          <Button size="sm" variant="ghost" className="h-7 w-7 p-0" onClick={() => setZoom((z) => Math.min(3, z + 0.2))}>
            <ZoomIn className="h-4 w-4" />
          </Button>
        </div>
      </div>
      <div className="relative flex-1 overflow-auto bg-muted/30">
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img
          src={current.url}
          alt={current.filename}
          className="mx-auto block"
          style={{ transform: `scale(${zoom})`, transformOrigin: "top center" }}
        />
      </div>
      {images.length > 1 && (
        <div className="flex items-center justify-between border-t p-2">
          <Button size="sm" variant="outline" disabled={idx === 0} onClick={() => setIdx((i) => Math.max(0, i - 1))}>
            <ChevronLeft className="h-4 w-4" /> 上一页
          </Button>
          <Button size="sm" variant="outline" disabled={idx === images.length - 1} onClick={() => setIdx((i) => Math.min(images.length - 1, i + 1))}>
            下一页 <ChevronRight className="h-4 w-4" />
          </Button>
        </div>
      )}
    </div>
  )
}
  • Step 2: Commit
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: 实现阅卷式批改(左题目+评分,右答案图片)

"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 (
    <div className="flex h-[calc(100vh-8rem)] flex-col gap-4">
      <div className="flex items-center justify-between">
        <div>
          <h2 className="text-lg font-semibold">{assignmentTitle}</h2>
          <p className="text-sm text-muted-foreground">{studentName} · 总分 {totalScore}/{maxTotal}</p>
        </div>
        <Button onClick={handleSubmit} disabled={isSubmitting}>
          {isSubmitting ? "保存中..." : "保存批改"}
        </Button>
      </div>
      <ResizablePanel
        initialLeft={50}
        left={
          <ScrollArea className="h-full">
            <div className="space-y-4 p-4">
              {answerStates.map((ans, idx) => (
                <Card key={ans.id} id={`grade-${ans.id}`}>
                  <CardHeader className="pb-2">
                    <QuestionRenderer
                      questionId={ans.id}
                      questionType={ans.questionType}
                      questionContent={ans.questionContent}
                      maxScore={ans.maxScore}
                      index={idx}
                      mode="grade"
                      value={ans.studentAnswer}
                      showCorrectAnswer
                    />
                  </CardHeader>
                  <CardContent className="space-y-3">
                    <div className="flex items-center gap-2">
                      <Label className="text-sm">评分</Label>
                      <Input
                        type="number"
                        min={0}
                        max={ans.maxScore}
                        value={ans.score ?? ""}
                        onChange={(e) => handleScoreChange(ans.id, e.target.value)}
                        className="w-20"
                      />
                      <span className="text-sm text-muted-foreground">/ {ans.maxScore}</span>
                    </div>
                    <Textarea
                      placeholder="批改反馈(可选)"
                      value={ans.feedback ?? ""}
                      onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
                      className="min-h-[60px]"
                    />
                  </CardContent>
                </Card>
              ))}
            </div>
          </ScrollArea>
        }
        right={
          <div className="h-full rounded-md border">
            <ScanImageViewer images={scans} />
          </div>
        }
      />
    </div>
  )
}
  • Step 2: 验证类型

Run: npx tsc --noEmit Expected: 无新错误

  • Step 3: Commit
git add src/modules/homework/components/homework-scan-grading-view.tsx
git commit -m "feat(homework): add HomeworkScanGradingView with split question+scan layout"

Task 20: 阅卷式批改页路由

Files:

  • Create: src/app/(dashboard)/teacher/homework/submissions/[id]/scan/page.tsx

  • Step 1: 实现路由页(读取 submission + scans)

import { notFound } from "next/navigation"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { getHomeworkSubmissionForGrading } from "@/modules/homework/data-access"
import { getFileAttachmentsByTarget } from "@/modules/files/data-access"
import { HomeworkScanGradingView } from "@/modules/homework/components/homework-scan-grading-view"

export const dynamic = "force-dynamic"

export default async function ScanGradingPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  await requirePermission("homework.grade")
  const { id } = await params
  const t = await getTranslations("examHomework")

  const submission = await getHomeworkSubmissionForGrading(id)
  if (!submission) return notFound()

  const scans = await getFileAttachmentsByTarget("homework_submission", id)

  return (
    <HomeworkScanGradingView
      submissionId={id}
      studentName={submission.studentName}
      assignmentTitle={submission.assignmentTitle}
      answers={submission.answers}
      scans={scans.map((s) => ({ url: s.url, filename: s.originalName }))}
    />
  )
}
  • Step 2: 确认 getHomeworkSubmissionForGrading 存在

Run: grep -n "getHomeworkSubmissionForGrading" src/modules/homework/data-access.ts 若不存在,在 data-access.ts 中添加该函数(读取 submission + answers + studentName + assignmentTitle)。

  • Step 3: Commit
git add src/app/(dashboard)/teacher/homework/submissions/[id]/scan/page.tsx
git commit -m "feat(homework): add scan grading route page"

阶段六:i18n 与架构图同步

Task 21: 补齐题型标签 i18n

Files:

  • Modify: src/shared/i18n/messages/zh-CN/exam-homework.json

  • Modify: src/shared/i18n/messages/en/exam-homework.json

  • Modify: src/modules/homework/components/question-renderer.tsx

  • Step 1: 在 zh-CN/exam-homework.json 的 homework.take 下增加 questionType 节点

"questionType": {
  "single_choice": "单选题",
  "multiple_choice": "多选题",
  "judgment": "判断题",
  "text": "填空/简答",
  "composite": "复合题"
}

在 en/exam-homework.json 同步:

"questionType": {
  "single_choice": "Single Choice",
  "multiple_choice": "Multiple Choice",
  "judgment": "True/False",
  "text": "Short Answer",
  "composite": "Composite"
}
  • Step 2: 修改 question-renderer.tsx 使用 i18n 题型标签

将第 97 行:

{questionType.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase())}  {maxScore} {t("homework.take.points")}

改为:

{t(`homework.take.questionType.${questionType}`)}  {maxScore} {t("homework.take.points")}
  • Step 3: 验证类型

Run: npx tsc --noEmit Expected: 无新错误

  • Step 4: Commit
git add src/shared/i18n/messages/zh-CN/exam-homework.json src/shared/i18n/messages/en/exam-homework.json src/modules/homework/components/question-renderer.tsx
git commit -m "feat(i18n): add question type labels and replace hardcoded English in QuestionRenderer"

Task 22: 补齐编辑器与阅卷文案 i18n

Files:

  • Modify: src/shared/i18n/messages/zh-CN/exam-homework.json

  • Modify: src/shared/i18n/messages/en/exam-homework.json

  • Step 1: 增加 exam.editor 与 homework.scanGrading 文案节点

zh-CN:

"editor": {
  "toolbar": {
    "bold": "加粗",
    "italic": "斜体",
    "question": "题目",
    "group": "大题",
    "blank": "填空",
    "dotted": "加点字",
    "image": "图片",
    "bulletList": "无序列表",
    "orderedList": "选项列表"
  },
  "placeholder": "粘贴试卷文本,选中内容标记为题目/大题,或点击工具栏插入...",
  "selection": {
    "markQuestion": "标为题目",
    "markGroup": "标为大题",
    "dotted": "加点字",
    "blank": "填空"
  },
  "autoMark": "AI 自动标记",
  "autoMarking": "AI 标记中...",
  "preview": "实时预览",
  "create": "创建试卷"
},
"scanGrading": {
  "title": "阅卷批改",
  "saveGrades": "保存批改",
  "saving": "保存中...",
  "noScans": "无答题图片",
  "prevPage": "上一页",
  "nextPage": "下一页"
}

en 同步英文。

  • Step 2: 将编辑器/阅卷组件中的硬编码中文替换为 t() 调用

修改 exam-rich-editor.tsxselection-toolbar.tsxexam-rich-form.tsxscan-uploader.tsxscan-image-viewer.tsxhomework-scan-grading-view.tsx 中的硬编码文案为 useTranslations("examHomework") 调用。

  • Step 3: 验证类型

Run: npx tsc --noEmit Expected: 无新错误

  • Step 4: Commit
git add src/shared/i18n/messages/zh-CN/exam-homework.json src/shared/i18n/messages/en/exam-homework.json src/modules/exams/editor/ src/modules/exams/components/exam-rich-form.tsx src/modules/homework/components/
git commit -m "feat(i18n): add editor and scan grading i18n messages, replace hardcoded strings"

Task 23: 同步架构图

Files:

  • Modify: docs/architecture/004_architecture_impact_map.md

  • Modify: docs/architecture/005_architecture_data.json

  • Step 1: 在 004 文档的 exams 模块章节增加 editor 子目录说明

在 exams 模块章节增加:

#### exams/editor (新增)
- ExamRichEditor: 富文本试卷编辑器(Tiptap 扩展)
- extensions/: 自定义节点(questionBlock/groupBlock/blankNode/dottedMark/imageNode)
- editor-to-structure / structure-to-editor: 文档模型与试卷 structure 双向转换
- selection-toolbar: 选区标记浮层
  • Step 2: 在 homework 模块章节增加 scan 组件说明
#### homework/components (新增)
- ScanUploader: 整卷分页拍照上传
- ScanImageViewer: 答案图片查看器(缩放/翻页)
- HomeworkScanGradingView: 阅卷式批改视图(左题目右图片)
  • Step 3: 在 005 JSON 的 modules.exams.exports 与 modules.homework.exports 增加新导出
"exams": {
  "exports": [
    "ExamRichEditor",
    "autoMarkExamAction",
    "editorDocToStructure",
    "structureToEditorDoc"
  ]
},
"homework": {
  "exports": [
    "ScanUploader",
    "ScanImageViewer",
    "HomeworkScanGradingView"
  ]
}
  • Step 4: Commit
git add docs/architecture/004_architecture_impact_map.md docs/architecture/005_architecture_data.json
git commit -m "docs(architecture): sync exams editor and homework scan grading modules"

阶段七:验证与收尾

Task 24: Lint 与类型检查

  • Step 1: 运行 lint

Run: npm run lint Expected: 零错误(警告可接受)

  • Step 2: 运行类型检查

Run: npx tsc --noEmit Expected: 零错误

  • Step 3: 修复发现的问题

逐一修复 lint/tsc 报错。

  • Step 4: Commit
git add -A
git commit -m "chore: fix lint and type errors from exam editor refactor"

Task 25: 手动验证关键流程

  • Step 1: 验证创建流程
  1. 访问 /teacher/exams/new
  2. 粘贴试卷文本
  3. 选中文字 → 点击"标为题目"/"标为大题"/"加点字"/"填空"
  4. 点击"AI 自动标记"
  5. 拖拽分栏分隔条,确认可调整
  6. 点击"创建试卷",确认跳转到 build 页
  • Step 2: 验证答题流程
  1. 学生访问作业
  2. 客观题在线作答
  3. 底部上传答题图片(多张)
  4. 提交
  • Step 3: 验证阅卷流程
  1. 教师访问 /teacher/homework/submissions/[id]/scan
  2. 左侧题目评分,右侧图片滚动/缩放/翻页
  3. 保存批改
  • Step 4: 修复发现的问题并 Commit

Self-Review

1. Spec coverage:

  • 富文本编辑器选区标记题目/分组/填空/加点字 → Task 2-9 ✓
  • AI 自动标记 → Task 12-13 ✓
  • 可拖拽分栏预览 → Task 1, 10 ✓
  • 整卷分页拍照上传 → Task 14-17 ✓
  • 阅卷式批改(学生答案图片侧边展示) → Task 18-20 ✓
  • i18n 题型标签 → Task 21-22 ✓
  • 架构图同步 → Task 23 ✓
  • 图片附件(书签、宣传照) → Task 4, 9 图片上传 ✓
  • 加点字(拼音注音题) → Task 2 ✓
  • 多空填空 → Task 3, 7 blanks 结构 ✓

2. Placeholder scan: 无 TBD/TODO,每个步骤有具体代码。

3. Type consistency:

  • EditorDoc / EditorQuestion / EditorStructureNode 在 Task 7 定义,Task 10/13 使用一致
  • UploadedScan 在 Task 14 定义,Task 16 使用一致
  • AnswerShape 在 Task 15 扩展,与现有 parseSavedAnswer 兼容

注意点:

  • Task 5 的 NodeView 用了 any,后续可替换为 NodeProps(不阻塞功能)
  • Task 12 的 AI 返回结构需与 Task 13 的消费端一致(groups[].questions[])
  • Task 17 的 API 需确认 /api/upload 返回字段(id vs fileId),需在 Task 9/14 验证