"use client" import { useState, useRef, useMemo, type JSX, type KeyboardEvent, type ClipboardEvent } from "react" import { toast } from "sonner" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" import { Search, Info, AlertCircle } from "lucide-react" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Button } from "@/shared/components/ui/button" import { Input } from "@/shared/components/ui/input" import { Label } from "@/shared/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select" import { Badge } from "@/shared/components/ui/badge" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/shared/components/ui/table" import { cn } from "@/shared/lib/utils" import { safeActionCall } from "@/shared/lib/action-utils" import { batchCreateGradeRecordsByExamAction, undoBatchCreateGradeRecordsAction } from "../actions" import type { ExamOptionForEntry, ExamForGradeEntry } from "@/modules/exams/types" type Student = { id: string; name: string; email: string } type ClassOption = { id: string; name: string } interface Props { exams: ExamOptionForEntry[] classes: ClassOption[] classGradeMap: Record exam: ExamForGradeEntry | null students: Student[] defaultExamId?: string defaultClassId?: string } /** 序列化撤销数据 */ function serializeUndoData(ids: string[]): string { return JSON.stringify({ ids, timestamp: Date.now() }) } export function BatchGradeEntryByExam({ exams, classes, classGradeMap, exam, students, defaultExamId, defaultClassId, }: Props): JSX.Element { const router = useRouter() const t = useTranslations("grades") const [scores, setScores] = useState>>({}) const [searchQuery, setSearchQuery] = useState("") const [isSubmitting, setIsSubmitting] = useState(false) const inputRefs = useRef>({}) // 按试卷 gradeId 过滤班级 const filteredClasses = useMemo(() => { if (!exam?.gradeId) return classes return classes.filter((c) => classGradeMap[c.id] === exam.gradeId) }, [classes, classGradeMap, exam]) // 过滤学生 const filteredStudents = useMemo(() => { if (!searchQuery) return students const q = searchQuery.toLowerCase() return students.filter( (s) => s.name.toLowerCase().includes(q) || s.email.toLowerCase().includes(q) ) }, [students, searchQuery]) const handleExamChange = (examId: string): void => { if (Object.keys(scores).length > 0) { if (!window.confirm(t("batchByExam.confirmSwitchExam"))) return } setScores({}) router.push(`/teacher/grades/entry?examId=${examId}`) } const handleClassChange = (classId: string): void => { if (Object.keys(scores).length > 0) { if (!window.confirm(t("batchByExam.confirmSwitchClass"))) return } setScores({}) router.push(`/teacher/grades/entry?examId=${defaultExamId}&classId=${classId}`) } const handleScoreChange = (studentId: string, questionId: string, value: string): void => { setScores((prev) => ({ ...prev, [studentId]: { ...(prev[studentId] ?? {}), [questionId]: value, }, })) } // 计算学生总分 const computeTotal = (studentId: string): number => { const studentScores = scores[studentId] if (!studentScores || !exam) return 0 return exam.questions.reduce((sum, q) => { const raw = studentScores[q.id] if (raw === undefined || raw === "") return sum const n = parseFloat(raw) return sum + (isNaN(n) ? 0 : n) }, 0) } // 检查分数是否超出满分 const isScoreInvalid = (studentId: string, questionId: string): boolean => { if (!exam) return false const question = exam.questions.find((q) => q.id === questionId) if (!question) return false const raw = scores[studentId]?.[questionId] if (raw === undefined || raw === "") return false const n = parseFloat(raw) if (isNaN(n)) return true return n < 0 || n > question.score } // 统计 const stats = useMemo(() => { const entered = students.filter((s) => { if (!exam) return false const ss = scores[s.id] if (!ss) return false return exam.questions.every((q) => ss[q.id] !== undefined && ss[q.id] !== "") }).length const totals = students .map((s) => computeTotal(s.id)) .filter((n) => n > 0) const avg = totals.length > 0 ? totals.reduce((a, b) => a + b, 0) / totals.length : 0 const max = totals.length > 0 ? Math.max(...totals) : 0 const min = totals.length > 0 ? Math.min(...totals) : 0 return { entered, total: students.length, avg, max, min } // eslint-disable-next-line react-hooks/exhaustive-deps }, [scores, students, exam]) // Excel 粘贴:支持多行多列 const handlePaste = (e: ClipboardEvent, startStudentId: string, startQuestionId: string): void => { if (!exam) return const text = e.clipboardData.getData("text") const lines = text.split(/\r?\n/).filter((l) => l.trim()) if (lines.length === 0) return const startStudentIdx = students.findIndex((s) => s.id === startStudentId) const startQuestionIdx = exam.questions.findIndex((q) => q.id === startQuestionId) if (startStudentIdx === -1 || startQuestionIdx === -1) return const newScores: Record> = {} let pastedCount = 0 lines.forEach((line, lineIdx) => { const student = students[startStudentIdx + lineIdx] if (!student) return const cells = line.split(/\t/).filter((c) => c.trim() || c === "0") if (cells.length === 1) { // 单列:按行填充当前题目 newScores[student.id] = { ...(newScores[student.id] ?? {}), [exam.questions[startQuestionIdx].id]: cells[0].trim(), } pastedCount++ } else { // 多列:按行×列填充 cells.forEach((cell, colIdx) => { const question = exam.questions[startQuestionIdx + colIdx] if (question) { newScores[student.id] = { ...(newScores[student.id] ?? {}), [question.id]: cell.trim(), } } }) pastedCount++ } }) if (pastedCount > 0) { setScores((prev) => { const merged = { ...prev } for (const [sid, qs] of Object.entries(newScores)) { merged[sid] = { ...(merged[sid] ?? {}), ...qs } } return merged }) e.preventDefault() toast.success(t("batchByExam.pasteApplied", { count: pastedCount })) } } // Enter 跳下一行同一列 const handleKeyDown = (e: KeyboardEvent, studentId: string, questionId: string): void => { if (e.key === "Enter") { e.preventDefault() const studentIdx = students.findIndex((s) => s.id === studentId) const nextStudent = students[studentIdx + 1] if (nextStudent) { const ref = inputRefs.current[`${nextStudent.id}-${questionId}`] ref?.focus() ref?.select() } } } const hasInvalidScores = useMemo(() => { if (!exam) return false return students.some((s) => exam.questions.some((q) => isScoreInvalid(s.id, q.id)) ) // eslint-disable-next-line react-hooks/exhaustive-deps }, [scores, students, exam]) const handleSubmit = async (): Promise => { if (!exam || !defaultClassId || !defaultExamId) return if (hasInvalidScores) { toast.error(t("batchByExam.invalidScoresError")) return } const records = students .map((s) => { const studentScores = scores[s.id] if (!studentScores) return null const hasAny = exam.questions.some( (q) => studentScores[q.id] !== undefined && studentScores[q.id] !== "" ) if (!hasAny) return null return { studentId: s.id, answers: exam.questions.map((q) => ({ questionId: q.id, score: parseFloat(studentScores[q.id] ?? "0") || 0, })), } }) .filter((r): r is NonNullable => r !== null) if (records.length === 0) { toast.error(t("batchByExam.enterAtLeastOne")) return } const formData = new FormData() formData.set("examId", defaultExamId) formData.set("classId", defaultClassId) formData.set("recordsJson", JSON.stringify(records)) setIsSubmitting(true) const result = await safeActionCall( () => batchCreateGradeRecordsByExamAction(null, formData), { onError: () => toast.error(t("error.saveFailed")), onFinally: () => setIsSubmitting(false), } ) if (result?.success) { setScores({}) const createdIds = result.data ?? [] if (createdIds.length > 0) { try { sessionStorage.setItem( "lastBatchGradeRecordIds", serializeUndoData(createdIds) ) } catch { // sessionStorage 不可用时静默失败 } toast.success(result.message, { duration: 10000, action: { label: t("batchByExam.undo"), onClick: () => handleUndo(), }, }) } else { toast.success(result.message) } router.push("/teacher/grades") router.refresh() } else if (result) { toast.error(result.message || t("error.saveFailed")) } } const handleUndo = async (): Promise => { try { const raw = sessionStorage.getItem("lastBatchGradeRecordIds") if (!raw) { toast.error(t("batchByExam.undoNoRecord")) return } const data = JSON.parse(raw) as { ids: string[]; timestamp: number } if (Date.now() - data.timestamp > 5 * 60 * 1000) { toast.error(t("batchByExam.undoExpired")) return } const result = await undoBatchCreateGradeRecordsAction(data.ids) if (result.success) { sessionStorage.removeItem("lastBatchGradeRecordIds") toast.success(result.message) router.refresh() } else { toast.error(result.message || t("batchByExam.undoFailed")) } } catch { toast.error(t("batchByExam.undoFailed")) } } // 未选试卷 if (!exam) { return ( {t("batchByExam.selectExam")}
{t("batchByExam.guideSelectExam")}
) } return (
{/* 试卷 + 班级选择器 */} {t("batchByExam.title")}
{/* 试卷信息 */}
{t("batchByExam.questionCount", { count: exam.questions.length })} {t("batchByExam.fullScore", { score: exam.totalScore })}
{/* 未选班级 */} {!defaultClassId ? ( {t("batchByExam.selectClassFirst")} ) : students.length === 0 ? ( {t("batchByExam.noStudents")} ) : ( <> {/* 统计栏 */}
{t("batchByExam.entered")}: {stats.entered}/{stats.total} {stats.entered > 0 && ( <> {t("batchByExam.average")}: {Math.round(stats.avg * 10) / 10} {t("batchByExam.max")}: {stats.max} {t("batchByExam.min")}: {stats.min} )} {hasInvalidScores && ( {t("batchByExam.invalidScoresBadge")} )}
setSearchQuery(e.target.value)} className="h-8 w-48 pl-8" />
{/* Excel 式表格 */}
# {t("batchByExam.studentName")} {exam.questions.map((q, idx) => (
{t("batchByExam.question", { n: idx + 1 })}
({q.score}{t("batchByExam.points")})
))} {t("batchByExam.totalScore")}
{filteredStudents.map((s, sIdx) => { const total = computeTotal(s.id) return ( {sIdx + 1} {s.name} {exam.questions.map((q) => { const invalid = isScoreInvalid(s.id, q.id) const key = `${s.id}-${q.id}` return ( { inputRefs.current[key] = el }} type="number" step="0.5" min="0" max={q.score} value={scores[s.id]?.[q.id] ?? ""} onChange={(e) => handleScoreChange(s.id, q.id, e.target.value)} onKeyDown={(e) => handleKeyDown(e, s.id, q.id)} onPaste={(e) => handlePaste(e, s.id, q.id)} onFocus={(e) => e.target.select()} className={cn( "h-8 text-center tabular-nums", invalid && "border-destructive focus-visible:ring-destructive" )} aria-label={`${s.name} - ${t("batchByExam.question", { n: exam.questions.findIndex(qq => qq.id === q.id) + 1 })}`} /> ) })} {Math.round(total * 10) / 10} ) })}
{/* 提交按钮 */}
{/* 提示 */}
{t("batchByExam.pasteHint")}
)}
) }