feat(exams,homework): add rich text exam editor and scan-based grading

- Add Tiptap-based rich text editor with custom extensions (dotted-mark,
  blank-node, image-node, group-block, question-block) for exam creation
- Add AI auto-marking action to convert pasted exam text to structured editor doc
- Add resizable split-panel layout for editor + live preview
- Add student scan upload (photo of paper answers) with drag-drop and reorder
- Add scan image viewer with zoom/rotate/fullscreen for teachers
- Add scan grading view with side-by-side questions and scan images
- Add /teacher/exams/new and /teacher/homework/submissions/[id]/scan-grading routes
- Fix getScansAction to support both teacher (HOMEWORK_GRADE) and student
  (HOMEWORK_SUBMIT) permission scopes
- Add i18n keys for rich editor, scan upload, and scan grading (zh-CN/en)
- Sync architecture diagrams (004/005) with new modules, routes, and deps
This commit is contained in:
SpecialX
2026-06-24 13:16:33 +08:00
parent 0c64219cb8
commit 6114607c1e
30 changed files with 3548 additions and 26 deletions

View File

@@ -0,0 +1,202 @@
"use client"
import { useEffect, useState } from "react"
import type { Editor } from "@tiptap/react"
import {
Box,
Brackets,
CircleSlash,
FileText,
Heading,
Underline,
} from "lucide-react"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import type { QuestionBlockType } from "./extensions/question-block"
interface SelectionToolbarProps {
editor: Editor | null
/** 当前选区所在题目块类型(用于显示状态) */
activeQuestionType?: QuestionBlockType
className?: string
}
interface ToolbarButtonProps {
onClick: () => void
icon: React.ElementType
label: string
active?: boolean
}
function ToolbarButton({ onClick, icon: Icon, label, active }: ToolbarButtonProps) {
return (
<Button
type="button"
variant={active ? "secondary" : "ghost"}
size="sm"
className={cn(
"h-7 gap-1 px-2 text-xs",
active && "bg-primary/15 text-primary hover:bg-primary/20"
)}
onMouseDown={(e) => {
// 防止点击按钮时编辑器失焦
e.preventDefault()
}}
onClick={(e) => {
e.preventDefault()
onClick()
}}
title={label}
>
<Icon className="h-3.5 w-3.5" />
<span>{label}</span>
</Button>
)
}
/**
* 选区标记浮层 —— 选中文本时浮现,提供"标记为题目/分组/加点字/填空"等快捷操作。
* 跟随选区位置定位,使用 getBoundingClientRect 计算坐标。
*/
export function SelectionToolbar({
editor,
activeQuestionType,
className,
}: SelectionToolbarProps) {
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null)
const [hasSelection, setHasSelection] = useState(false)
useEffect(() => {
if (!editor) return
const updatePosition = () => {
const { from, to, empty } = editor.state.selection
if (empty || from === to) {
setCoords(null)
setHasSelection(false)
return
}
// 检查选区是否在 questionBlock/groupBlock 内部
const $from = editor.state.selection.$from
const depth = $from.depth
let inBlock = false
for (let d = depth; d > 0; d--) {
const node = $from.node(d)
if (node.type.name === "questionBlock" || node.type.name === "groupBlock") {
inBlock = true
break
}
}
// 即使不在块内也允许显示(可标记为题目/分组)
void inBlock
const view = editor.view
const start = view.coordsAtPos(from)
const end = view.coordsAtPos(to)
const top = Math.min(start.top, end.top) - 44 // 浮在选区上方
const left = (start.left + end.right) / 2
setCoords({ top, left })
setHasSelection(true)
}
editor.on("selectionUpdate", updatePosition)
editor.on("blur", () => {
// 延迟以允许点击工具栏按钮
setTimeout(() => {
if (!editor.isFocused) {
setCoords(null)
setHasSelection(false)
}
}, 200)
})
return () => {
editor.off("selectionUpdate", updatePosition)
}
}, [editor])
if (!editor || !hasSelection || !coords) return null
const insertQuestion = (type: QuestionBlockType) => {
editor.chain().focus().insertQuestion({ type, score: type === "composite" ? 5 : 2 }).run()
}
const insertGroup = () => {
editor.chain().focus().insertGroup("一、选择题").run()
}
const toggleDotted = () => {
editor.chain().focus().toggleDotted().run()
}
const insertBlank = () => {
editor.chain().focus().insertBlank().run()
}
const insertImage = () => {
// 触发隐藏的文件输入
const input = document.createElement("input")
input.type = "file"
input.accept = "image/*"
input.onchange = async () => {
const file = input.files?.[0]
if (!file) return
const formData = new FormData()
formData.append("file", file)
formData.append("targetType", "exam")
try {
const res = await fetch("/api/upload", { method: "POST", body: formData })
const data = await res.json()
if (data?.success && data?.url) {
editor.chain().focus().setImage({ src: data.url, fileId: data.id }).run()
}
} catch (e) {
console.error("[SelectionToolbar] upload failed", e)
}
}
input.click()
}
return (
<div
role="toolbar"
aria-label="选区标记工具栏"
className={cn(
"fixed z-50 flex items-center gap-0.5 rounded-md border bg-popover/95 p-1 shadow-md backdrop-blur",
className
)}
style={{
top: `${Math.max(8, coords.top)}px`,
left: `${coords.left}px`,
transform: "translateX(-50%)",
}}
>
<ToolbarButton onClick={insertGroup} icon={Heading} label="大题" />
<ToolbarButton
onClick={() => insertQuestion("single_choice")}
icon={CircleSlash}
label="单选"
active={activeQuestionType === "single_choice"}
/>
<ToolbarButton
onClick={() => insertQuestion("text")}
icon={FileText}
label="填空/简答"
active={activeQuestionType === "text"}
/>
<ToolbarButton
onClick={() => insertQuestion("composite")}
icon={Brackets}
label="复合"
active={activeQuestionType === "composite"}
/>
<div className="mx-1 h-5 w-px bg-border" />
<ToolbarButton onClick={toggleDotted} icon={Underline} label="加点字" />
<ToolbarButton onClick={insertBlank} icon={Box} label="填空" />
<ToolbarButton onClick={insertImage} icon={FileText} label="图片" />
</div>
)
}