Files
NextEdu/src/modules/homework/components/homework-scan-grading-view.tsx
SpecialX dfffb61e94 feat(homework,classes,course-plans): add scans, student data, take confirm, error boundaries, dialogs, hooks, calendar
homework:

- Add data-access-scans, data-access-student, data-access-utils, data-access-exam-cross

- Add excellent-submissions, homework-take-confirm-dialog, homework-take-sidebar components

classes:

- Add class-delete-dialog, class-error-boundary, class-form-dialog, class-form-utils

- Add class-list-table, class-list-toolbar, class-skeleton

- Add schedule-create-dialog, schedule-delete-dialog, schedule-edit-dialog, schedule-utils

- Add data-access-teacher and hooks directory

course-plans:

- Add course-plan-calendar, sortable-week-row, template-picker-dialog components

- Add lib directory
2026-07-03 10:25:35 +08:00

275 lines
9.9 KiB
TypeScript

"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 } from "../actions"
import { QuestionRenderer } from "./question-renderer"
import { ScanImageViewer } from "./scan-image-viewer"
import type { HomeworkSubmissionAnswerDetails, ScanAttachment } from "../types"
type HomeworkScanGradingViewProps = {
submissionId: string
studentName: string
assignmentTitle: string
submittedAt: string | null
status: string
totalScore: number | null
answers: HomeworkSubmissionAnswerDetails[]
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>
)
}