"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 { Info } from "lucide-react" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Button } from "@/shared/components/ui/button" 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 { safeActionCall } from "@/shared/lib/action-utils" import { batchCreateGradeRecordsByExamAction } from "../actions" import { useBatchGradeEntryUndo } from "../hooks/use-batch-grade-entry-undo" import { BatchGradeEntryStats } from "./batch-grade-entry-stats" import { BatchGradeEntryDialog, type PendingSwitch } from "./batch-grade-entry-dialog" import { BatchGradeEntryTable } from "./batch-grade-entry-table" 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 } /** * 按试卷批量录入成绩主组件。 * * P1-6 重构:将表格、统计栏、确认对话框拆分为独立子组件。 * - useBatchGradeEntryUndo Hook 封装撤销逻辑 + 类型守卫(P1-7 修复 `as` 断言) * - BatchGradeEntryTable / BatchGradeEntryStats / BatchGradeEntryDialog 为组合子组件 */ export function BatchGradeEntryByExam({ exams, classes, classGradeMap, exam, students, defaultExamId, defaultClassId, }: Props): JSX.Element { const router = useRouter() const t = useTranslations("grades") const { saveUndoToken, handleUndo } = useBatchGradeEntryUndo() const [scores, setScores] = useState>>({}) const [searchQuery, setSearchQuery] = useState("") const [isSubmitting, setIsSubmitting] = useState(false) const inputRefs = useRef>({}) const [pendingSwitch, setPendingSwitch] = useState(null) // 按试卷 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) { setPendingSwitch({ kind: "exam", examId, message: t("batchByExam.confirmSwitchExam"), }) return } setScores({}) router.push(`/teacher/grades/entry?examId=${examId}`) } const handleClassChange = (classId: string): void => { if (Object.keys(scores).length > 0) { setPendingSwitch({ kind: "class", classId, message: t("batchByExam.confirmSwitchClass"), }) return } setScores({}) router.push(`/teacher/grades/entry?examId=${defaultExamId}&classId=${classId}`) } const confirmPendingSwitch = (): void => { if (!pendingSwitch) return setScores({}) if (pendingSwitch.kind === "exam") { router.push(`/teacher/grades/entry?examId=${pendingSwitch.examId}`) } else { router.push(`/teacher/grades/entry?examId=${defaultExamId}&classId=${pendingSwitch.classId}`) } setPendingSwitch(null) } 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) { saveUndoToken(createdIds) 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")) } } // 未选试卷 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")} ) : ( <> {/* 统计栏 */} {/* Excel 式表格 */} {/* 提交按钮 */}
{/* 提示 */}
{t("batchByExam.pasteHint")}
)} {/* 确认对话框 */} setPendingSwitch(null)} />
) }