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:
202
src/modules/exams/editor/selection-toolbar.tsx
Normal file
202
src/modules/exams/editor/selection-toolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user