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,78 @@
"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 transition-colors hover:bg-primary/40"
/>
<div style={{ width: `${100 - leftPct}%` }} className="h-full min-w-0 overflow-hidden">
{right}
</div>
</div>
)
}