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
275 lines
9.9 KiB
TypeScript
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>
|
|
)
|
|
}
|