"use client" import { useState, useRef, useEffect, useCallback, useMemo } from "react" import { useFormStatus } from "react-dom" import { toast } from "sonner" import { useRouter } from "next/navigation" import { Search, TrendingUp, Trophy, AlertCircle } from "lucide-react" import { Card, CardContent, CardFooter, 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/shared/components/ui/table" import { cn } from "@/shared/lib/utils" import { batchCreateGradeRecordsAction } from "../actions" type Option = { id: string; name: string } type Student = { id: string; name: string; email: string } type GradeType = "exam" | "quiz" | "homework" | "other" type Semester = "1" | "2" function isGradeType(v: string): v is GradeType { return v === "exam" || v === "quiz" || v === "homework" || v === "other" } function isSemester(v: string): v is Semester { return v === "1" || v === "2" } const MAX_SCORE = 100 const DRAFT_KEY_PREFIX = "grade-draft" function SubmitButton() { const { pending } = useFormStatus() return ( ) } export function BatchGradeEntry({ classes, subjects, students, defaultClassId, defaultSubjectId, }: { classes: Option[] subjects: Option[] students: Student[] defaultClassId?: string defaultSubjectId?: string }) { const router = useRouter() const initialDraftKey = `${DRAFT_KEY_PREFIX}-${defaultClassId ?? classes[0]?.id ?? ""}-${defaultSubjectId ?? subjects[0]?.id ?? ""}-exam` const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "") const [subjectId, setSubjectId] = useState(defaultSubjectId ?? subjects[0]?.id ?? "") const [type, setType] = useState("exam") const [semester, setSemester] = useState("1") const [scores, setScores] = useState>(() => { // 惰性初始化:从 localStorage 恢复草稿(避免 useEffect 中 setState 导致级联渲染) try { const raw = localStorage.getItem(initialDraftKey) if (raw) { const data = JSON.parse(raw) as { scores: Record; timestamp: number } if (Date.now() - data.timestamp < 2 * 60 * 60 * 1000 && Object.keys(data.scores).length > 0) { return data.scores } } } catch { // 解析失败,忽略 } return {} }) const [draftRestored] = useState(() => { // 检查是否恢复了草稿(用于显示 toast) try { const raw = localStorage.getItem(initialDraftKey) if (raw) { const data = JSON.parse(raw) as { scores: Record; timestamp: number } return Date.now() - data.timestamp < 2 * 60 * 60 * 1000 && Object.keys(data.scores).length > 0 } } catch { // 解析失败,忽略 } return false }) const [searchQuery, setSearchQuery] = useState("") const [isSubmitting, setIsSubmitting] = useState(false) const inputRefs = useRef>({}) const draftKey = `${DRAFT_KEY_PREFIX}-${classId}-${subjectId}-${type}` // 草稿恢复提示(仅在首次挂载时显示一次) useEffect(() => { if (draftRestored) { toast.info("已恢复未保存的成绩草稿") } }, [draftRestored]) const handleScoreChange = useCallback((studentId: string, value: string) => { if (value === "" || /^\d*\.?\d{0,2}$/.test(value)) { setScores((prev) => ({ ...prev, [studentId]: value })) } }, []) const validateScore = (value: string): boolean => { if (value === "") return true const num = Number(value) return !isNaN(num) && num >= 0 && num <= MAX_SCORE } const restoreDraft = useCallback((key: string): boolean => { try { const raw = localStorage.getItem(key) if (raw) { const data = JSON.parse(raw) as { scores: Record; timestamp: number } if (Date.now() - data.timestamp < 2 * 60 * 60 * 1000 && Object.keys(data.scores).length > 0) { setScores(data.scores) return true } } } catch { // 解析失败,忽略 } return false }, []) const handleClassChange = (newClassId: string) => { const hasUnsaved = Object.keys(scores).length > 0 if (hasUnsaved && newClassId !== classId) { if (!window.confirm("当前班级有未保存的成绩记录,确认切换班级?")) { return } } setClassId(newClassId) setScores({}) // 切换班级后尝试恢复该班级的草稿 const newDraftKey = `${DRAFT_KEY_PREFIX}-${newClassId}-${subjectId}-${type}` if (restoreDraft(newDraftKey)) { toast.info("已恢复未保存的成绩草稿") } const newUrl = newClassId ? `/teacher/grades/entry?classId=${encodeURIComponent(newClassId)}` : "/teacher/grades/entry" router.push(newUrl) } const filteredStudents = useMemo( () => students.filter((s) => !searchQuery || s.name.toLowerCase().includes(searchQuery.toLowerCase())), [students, searchQuery] ) const stats = useMemo(() => { const validScores = students .map((s) => scores[s.id]) .filter((v): v is string => v !== undefined && v !== "" && validateScore(v)) .map(Number) const entered = validScores.length if (entered === 0) return { entered: 0, average: 0, max: 0, min: 0, total: students.length } const sum = validScores.reduce((acc, v) => acc + v, 0) return { entered, average: Math.round((sum / entered) * 100) / 100, max: Math.max(...validScores), min: Math.min(...validScores), total: students.length, } }, [scores, students]) const hasInvalidScores = Object.values(scores).some((v) => v !== "" && v !== undefined && !validateScore(v)) // 草稿保存到 localStorage(30秒间隔) useEffect(() => { const interval = setInterval(() => { if (Object.keys(scores).length > 0) { try { localStorage.setItem(draftKey, JSON.stringify({ scores, timestamp: Date.now() })) } catch { // localStorage 可能已满或不可用,静默失败 } } }, 30000) return () => clearInterval(interval) }, [scores, draftKey]) // 清除草稿 const clearDraft = useCallback(() => { try { localStorage.removeItem(draftKey) } catch { // 忽略 } }, [draftKey]) const handleKeyDown = (e: React.KeyboardEvent, studentId: string) => { if (e.key === "Enter") { e.preventDefault() const currentIndex = filteredStudents.findIndex((s) => s.id === studentId) const nextStudent = filteredStudents[currentIndex + 1] if (nextStudent && inputRefs.current[nextStudent.id]) { inputRefs.current[nextStudent.id]?.focus() inputRefs.current[nextStudent.id]?.select() } } } const handleSubmit = async (formData: FormData) => { if (!classId || !subjectId) { toast.error("Please select class and subject") return } if (hasInvalidScores) { toast.error("存在无效分数(超过满分或格式错误),请检查后重试") return } const records = students .map((s) => ({ studentId: s.id, score: Number(scores[s.id] ?? 0), })) .filter((r) => r.score > 0 || scores[r.studentId] !== undefined) if (records.length === 0) { toast.error("Please enter at least one score") return } setIsSubmitting(true) formData.set("classId", classId) formData.set("subjectId", subjectId) formData.set("type", type) formData.set("semester", semester) formData.set("recordsJson", JSON.stringify(records)) const result = await batchCreateGradeRecordsAction(null, formData) setIsSubmitting(false) if (result.success) { clearDraft() toast.success(result.message) router.push("/teacher/grades") router.refresh() } else { toast.error(result.message || "Failed to save") } } return ( {isSubmitting && (
Saving grades...
)} Batch Grade Entry

满分 {MAX_SCORE} 分。输入分数后按 Enter 跳到下一位学生。草稿每 30 秒自动保存,2 小时内有效。

{students.length === 0 ? (

No students in this class.

) : ( <> {/* 实时统计栏 */}
已录入 {stats.entered} / {stats.total} {stats.entered > 0 && ( <> 最低 {stats.min} )}
{hasInvalidScores && ( )}
setSearchQuery(e.target.value)} className="h-8 w-40 pl-8 text-sm" />
# Student Email Score {filteredStudents.map((s, idx) => { const scoreValue = scores[s.id] ?? "" const isInvalid = scoreValue !== "" && !validateScore(scoreValue) return ( {idx + 1} {s.name} {s.email} { inputRefs.current[s.id] = el }} type="number" step="0.01" min="0" max={MAX_SCORE} placeholder="0" value={scoreValue} onChange={(e) => handleScoreChange(s.id, e.target.value)} onKeyDown={(e) => handleKeyDown(e, s.id)} className={cn("h-8", isInvalid && "border-destructive focus-visible:ring-destructive")} aria-invalid={isInvalid} /> ) })}
)}
) }