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

@@ -21,12 +21,13 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert, CloudUpload, CloudOff, Check, Loader2, Timer } from "lucide-react"
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert, CloudUpload, CloudOff, Check, Loader2, Timer, Camera } from "lucide-react"
import { formatDate, cn } from "@/shared/lib/utils"
import type { StudentHomeworkTakeData } from "../types"
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions"
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction, getScansAction, deleteScanAction } from "../actions"
import { QuestionRenderer } from "./question-renderer"
import { ScanUploader, type ScanImage } from "./scan-uploader"
import { parseSavedAnswer } from "../lib/question-content-utils"
import { useDebouncedAutoSave, loadOfflineCache, clearOfflineCache } from "../hooks/use-debounced-auto-save"
import { useExamCountdown } from "../hooks/use-exam-countdown"
@@ -43,6 +44,26 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
const [submissionStatus, setSubmissionStatus] = useState<string>(initialData.submission?.status ?? "not_started")
const [isBusy, setIsBusy] = useState(false)
const [showSubmitConfirm, setShowSubmitConfirm] = useState(false)
const [scanImages, setScanImages] = useState<ScanImage[]>([])
// 加载已有答题扫描图(拍照上传)
useEffect(() => {
if (!submissionId) return
void (async () => {
const result = await getScansAction(submissionId)
if (result.success && result.data) {
setScanImages(result.data)
}
})()
}, [submissionId])
const handleDeleteScan = async (fileId: string) => {
if (!submissionId) return
const result = await deleteScanAction(submissionId, fileId)
if (!result.success) {
toast.error(result.message || t("homework.take.saveFailed"))
}
}
const initialAnswersByQuestionId = useMemo(() => {
const map = new Map<string, { answer: unknown }>()
@@ -357,6 +378,29 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
</Card>
)
})}
{showQuestions && submissionId && (
<Card className="border-l-4 border-l-blue-500 shadow-sm">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<Camera className="h-4 w-4 text-blue-500" />
<h3 className="font-semibold text-sm">{t("homework.take.scanTitle")}</h3>
</div>
<p className="text-xs text-muted-foreground mt-1">
{t("homework.take.scanDescription")}
</p>
<div className="mt-3">
<ScanUploader
images={scanImages}
onChange={setScanImages}
onDeleteScan={handleDeleteScan}
submissionId={submissionId}
disabled={!canEdit}
/>
</div>
</CardHeader>
</Card>
)}
</div>
</ScrollArea>
</div>