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:
287
src/modules/homework/components/homework-scan-grading-view.tsx
Normal file
287
src/modules/homework/components/homework-scan-grading-view.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { Save, ChevronLeft, ChevronRight, User, Clock } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, 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 { Badge } from "@/shared/components/ui/badge"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { ResizablePanel } from "@/shared/components/ui/resizable-panel"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
|
||||
import { gradeHomeworkSubmissionAction, getScansAction, type ScanAttachment } from "../actions"
|
||||
import { QuestionRenderer } from "./question-renderer"
|
||||
import { ScanImageViewer } from "./scan-image-viewer"
|
||||
|
||||
type QuestionContent = { text?: string } & Record<string, unknown>
|
||||
|
||||
type Answer = {
|
||||
id: string
|
||||
questionId: string
|
||||
questionContent: QuestionContent | null
|
||||
questionType: string
|
||||
maxScore: number
|
||||
studentAnswer: unknown
|
||||
score: number | null
|
||||
feedback: string | null
|
||||
order: number
|
||||
}
|
||||
|
||||
type HomeworkScanGradingViewProps = {
|
||||
submissionId: string
|
||||
studentName: string
|
||||
assignmentTitle: string
|
||||
submittedAt: string | null
|
||||
status: string
|
||||
totalScore: number | null
|
||||
answers: Answer[]
|
||||
prevSubmissionId?: string | null
|
||||
nextSubmissionId?: string | null
|
||||
}
|
||||
|
||||
interface GradingState {
|
||||
score: string
|
||||
feedback: string
|
||||
}
|
||||
|
||||
export function HomeworkScanGradingView({
|
||||
submissionId,
|
||||
studentName,
|
||||
assignmentTitle,
|
||||
submittedAt,
|
||||
status,
|
||||
totalScore,
|
||||
answers,
|
||||
prevSubmissionId,
|
||||
nextSubmissionId,
|
||||
}: HomeworkScanGradingViewProps) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("examHomework")
|
||||
const [scans, setScans] = useState<ScanAttachment[]>([])
|
||||
const [loadingScans, setLoadingScans] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const [grading, setGrading] = useState<Record<string, GradingState>>(() => {
|
||||
const obj: Record<string, GradingState> = {}
|
||||
for (const ans of answers) {
|
||||
obj[ans.id] = {
|
||||
score: ans.score?.toString() ?? "",
|
||||
feedback: ans.feedback ?? "",
|
||||
}
|
||||
}
|
||||
return obj
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const result = await getScansAction(submissionId)
|
||||
if (result.success && result.data) {
|
||||
setScans(result.data)
|
||||
}
|
||||
setLoadingScans(false)
|
||||
})()
|
||||
}, [submissionId])
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const answersPayload = answers.map((ans) => {
|
||||
const g = grading[ans.id]
|
||||
return {
|
||||
id: ans.id,
|
||||
score: g ? Number(g.score) || 0 : 0,
|
||||
feedback: g?.feedback ?? null,
|
||||
}
|
||||
})
|
||||
const fd = new FormData()
|
||||
fd.set("submissionId", submissionId)
|
||||
fd.set("answersJson", JSON.stringify(answersPayload))
|
||||
const result = await gradeHomeworkSubmissionAction(null, fd)
|
||||
if (result.success) {
|
||||
toast.success(t("homework.grade.gradesSaved"))
|
||||
} else {
|
||||
toast.error(result.message || t("homework.grade.gradesSaveFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("homework.grade.saveFailed"))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNavigate = (targetId: string | null | undefined) => {
|
||||
if (!targetId) return
|
||||
router.push(`/teacher/homework/submissions/${targetId}/scan-grading`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-10rem)] flex-col gap-4">
|
||||
{/* 顶部信息栏 */}
|
||||
<div className="flex items-center justify-between rounded-md border bg-card p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.back()}
|
||||
className="gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t("homework.grade.back")}
|
||||
</Button>
|
||||
<div className="h-6 w-px bg-border" />
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{studentName}</span>
|
||||
</div>
|
||||
<div className="h-6 w-px bg-border" />
|
||||
<span className="text-sm text-muted-foreground">{assignmentTitle}</span>
|
||||
{submittedAt && (
|
||||
<>
|
||||
<div className="h-6 w-px bg-border" />
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDate(submittedAt)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{status}
|
||||
</Badge>
|
||||
{totalScore !== null && (
|
||||
<Badge variant="secondary">{t("homework.grade.totalScore")}: {totalScore}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleNavigate(prevSubmissionId)}
|
||||
disabled={!prevSubmissionId}
|
||||
className="gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
{t("homework.grade.prevSubmission")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleNavigate(nextSubmissionId)}
|
||||
disabled={!nextSubmissionId}
|
||||
className="gap-1"
|
||||
>
|
||||
{t("homework.grade.nextSubmission")}
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving} className="gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
{isSaving ? t("homework.grade.saving") : t("homework.grade.saveGrading")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主体:左题目右扫描图 */}
|
||||
<div className="flex-1 min-h-0 rounded-md border">
|
||||
<ResizablePanel
|
||||
initialLeft={55}
|
||||
minLeft={30}
|
||||
minRight={25}
|
||||
left={
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
{t("homework.grade.questionsCount", { count: answers.length })}
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-4 p-4">
|
||||
{answers.map((ans, idx) => (
|
||||
<Card key={ans.id} className="border-l-4 border-l-primary">
|
||||
<CardHeader className="pb-2">
|
||||
<QuestionRenderer
|
||||
questionId={ans.questionId}
|
||||
questionType={ans.questionType}
|
||||
questionContent={ans.questionContent}
|
||||
maxScore={ans.maxScore}
|
||||
index={idx}
|
||||
mode="review"
|
||||
value={ans.studentAnswer}
|
||||
disabled
|
||||
showCorrectAnswer
|
||||
feedback={ans.feedback}
|
||||
/>
|
||||
<div className="mt-3 grid gap-2 border-t pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`score-${ans.id}`} className="text-xs whitespace-nowrap">
|
||||
{t("homework.grade.scoreLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`score-${ans.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={ans.maxScore}
|
||||
value={grading[ans.id]?.score ?? ""}
|
||||
onChange={(e) =>
|
||||
setGrading((prev) => ({
|
||||
...prev,
|
||||
[ans.id]: {
|
||||
score: e.target.value,
|
||||
feedback: prev[ans.id]?.feedback ?? "",
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="h-8 w-20"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("homework.grade.scoreOutOf", { max: ans.maxScore })}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder={t("homework.grade.scanFeedbackPlaceholder")}
|
||||
value={grading[ans.id]?.feedback ?? ""}
|
||||
onChange={(e) =>
|
||||
setGrading((prev) => ({
|
||||
...prev,
|
||||
[ans.id]: {
|
||||
score: prev[ans.id]?.score ?? "",
|
||||
feedback: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="min-h-[60px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
{loadingScans
|
||||
? t("homework.grade.loadingScans")
|
||||
: t("homework.grade.scanPagesCount", { count: scans.length })}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
{loadingScans ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{t("homework.grade.loadingImages")}
|
||||
</div>
|
||||
) : (
|
||||
<ScanImageViewer images={scans} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user