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.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— 加点字(下加点)marksrc/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/cuid2 和 autoMarkExamAction。
- 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
在 toAnswerShape 和 parseSavedAnswer 中增加对 { 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: 在答题页底部增加整卷拍照上传区
在 HomeworkTakeView 的 showQuestions 区块末尾、提交按钮前,增加整卷扫描上传区。主观题(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.tsx、selection-toolbar.tsx、exam-rich-form.tsx、scan-uploader.tsx、scan-image-viewer.tsx、homework-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: 验证创建流程
- 访问
/teacher/exams/new - 粘贴试卷文本
- 选中文字 → 点击"标为题目"/"标为大题"/"加点字"/"填空"
- 点击"AI 自动标记"
- 拖拽分栏分隔条,确认可调整
- 点击"创建试卷",确认跳转到 build 页
- Step 2: 验证答题流程
- 学生访问作业
- 客观题在线作答
- 底部上传答题图片(多张)
- 提交
- Step 3: 验证阅卷流程
- 教师访问
/teacher/homework/submissions/[id]/scan - 左侧题目评分,右侧图片滚动/缩放/翻页
- 保存批改
- 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返回字段(idvsfileId),需在 Task 9/14 验证