"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 { useTranslations } from "next-intl" import { Search, TrendingUp, Trophy, AlertCircle, Download, Info, X } 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 { safeActionCall } from "@/shared/lib/action-utils" import { batchCreateGradeRecordsAction, undoBatchCreateGradeRecordsAction, saveGradeDraftAction, getGradeDraftAction, deleteGradeDraftAction } from "../actions" import type { SelectOption, GradeRecordType, GradeRecordSemester } from "../types" import { isGradeType, isSemester } from "../lib/type-guards" type Student = { id: string; name: string; email: string } interface DraftData { scores: Record timestamp: number } interface UndoData { ids: string[] timestamp: number } /** 类型守卫:验证 localStorage 草稿数据结构 */ function isDraftData(v: unknown): v is DraftData { if (typeof v !== "object" || v === null) return false // 从 unknown 转换为 Record 以访问属性(已通过对象检查) const obj = v as Record if (typeof obj.timestamp !== "number") return false if (typeof obj.scores !== "object" || obj.scores === null) return false // 从 unknown 转换:已通过对象检查 const scores = obj.scores as Record return Object.values(scores).every((val) => typeof val === "string") } /** 类型守卫:验证 sessionStorage 撤销数据结构 */ function isUndoData(v: unknown): v is UndoData { if (typeof v !== "object" || v === null) return false // 从 unknown 转换为 Record 以访问属性(已通过对象检查) const obj = v as Record if (typeof obj.timestamp !== "number") return false if (!Array.isArray(obj.ids)) return false return obj.ids.every((id) => typeof id === "string") } const MAX_SCORE = 100 const DRAFT_KEY_PREFIX = "grade-draft" /** 将创建的记录 ID 序列化为可撤销的 sessionStorage 数据(提取到组件外部避免 purity lint) */ function serializeUndoData(ids: string[]): string { return JSON.stringify({ ids, timestamp: Date.now() }) } function SubmitButton() { const t = useTranslations("grades") const { pending } = useFormStatus() return ( ) } export function BatchGradeEntry({ classes, subjects, students, defaultClassId, defaultSubjectId, }: { classes: SelectOption[] subjects: SelectOption[] students: Student[] defaultClassId?: string defaultSubjectId?: string }) { const t = useTranslations("grades") 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 导致级联渲染) // P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错 if (typeof window === "undefined") return {} try { const raw = localStorage.getItem(initialDraftKey) if (raw) { const parsed: unknown = JSON.parse(raw) if ( isDraftData(parsed) && Date.now() - parsed.timestamp < 2 * 60 * 60 * 1000 && Object.keys(parsed.scores).length > 0 ) { return parsed.scores } } } catch { // 解析失败,忽略 } return {} }) const [draftRestored] = useState(() => { // 检查是否恢复了草稿(用于显示 toast) // P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错 if (typeof window === "undefined") return false try { const raw = localStorage.getItem(initialDraftKey) if (raw) { const parsed: unknown = JSON.parse(raw) if (isDraftData(parsed)) { return ( Date.now() - parsed.timestamp < 2 * 60 * 60 * 1000 && Object.keys(parsed.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}` // P3-12 修复:使用 ref 保存最新 scores,避免草稿保存 useEffect 依赖 scores 导致每次按键触发重建定时器 const scoresRef = useRef(scores) useEffect(() => { scoresRef.current = scores }, [scores]) // 草稿恢复提示(仅在首次挂载时显示一次) useEffect(() => { if (draftRestored) { toast.info(t("batch.restored")) } }, [draftRestored, t]) // v3-P2-10: 服务端草稿恢复(localStorage 无草稿时,尝试从服务端恢复) useEffect(() => { if (draftRestored) return // 本地已恢复,跳过 if (typeof window === "undefined") return let cancelled = false void (async () => { const result = await safeActionCall(() => getGradeDraftAction({ classId, subjectId, type }) ) if (cancelled || !result || !result.success || !result.data) return const draft = result.data // 24 小时过期检查(服务端已检查,这里双重校验) if (Date.now() - draft.timestamp > 24 * 60 * 60 * 1000) return if (Object.keys(draft.scores).length === 0) return setScores(draft.scores) toast.info(t("batch.restoredFromServer")) })() return () => { cancelled = true } }, [classId, subjectId, type, draftRestored, t]) const handleScoreChange = useCallback((studentId: string, value: string) => { if (value === "" || /^\d*\.?\d{0,2}$/.test(value)) { setScores((prev) => ({ ...prev, [studentId]: value })) } }, []) const validateScore = useCallback((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 => { // P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错 if (typeof window === "undefined") return false try { const raw = localStorage.getItem(key) if (raw) { const parsed: unknown = JSON.parse(raw) if ( isDraftData(parsed) && Date.now() - parsed.timestamp < 2 * 60 * 60 * 1000 && Object.keys(parsed.scores).length > 0 ) { setScores(parsed.scores) return true } } } catch { // 解析失败,忽略 } return false }, []) const handleClassChange = (newClassId: string) => { const hasUnsaved = Object.keys(scores).length > 0 if (hasUnsaved && newClassId !== classId) { if (!window.confirm(t("batch.confirmSwitchClass"))) { return } } setClassId(newClassId) setScores({}) // 切换班级后尝试恢复该班级的草稿 const newDraftKey = `${DRAFT_KEY_PREFIX}-${newClassId}-${subjectId}-${type}` if (restoreDraft(newDraftKey)) { toast.info(t("batch.restored")) } 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] ) /** * Excel 粘贴处理器:从剪贴板解析一列分数,按当前学生列表顺序填充。 * 支持格式: * - 单列分数(每行一个数字) * - 多列(取第一列或第一个可解析为数字的列) * - Tab/逗号/空格分隔 */ const handlePaste = useCallback((e: React.ClipboardEvent, startIndex: number) => { const text = e.clipboardData.getData("text") if (!text) return // 按行分割(兼容 \r\n 和 \n) const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "") if (lines.length === 0) return // 尝试从每行提取数字(支持单列和多列格式) const values: string[] = [] for (const line of lines) { const trimmed = line.trim() // 尝试 Tab/逗号分隔,取第一个可解析为数字的部分 const parts = trimmed.split(/[\t,]/).map((p) => p.trim()) let found: string | null = null for (const part of parts) { if (part !== "" && /^\d*\.?\d+$/.test(part)) { found = part break } } // 如果多列没匹配到,尝试整行作为数字 if (found === null && /^\d*\.?\d+$/.test(trimmed)) { found = trimmed } if (found !== null) values.push(found) } if (values.length === 0) { toast.error(t("batch.pasteNoMatch")) e.preventDefault() return } // 按当前过滤后的学生列表顺序填充 const targetStudents = filteredStudents.slice(startIndex) const newScores: Record = {} let appliedCount = 0 for (let i = 0; i < Math.min(values.length, targetStudents.length); i += 1) { const student = targetStudents[i] if (!student) break const val = values[i] // 校验分数格式(与 handleScoreChange 一致) if (/^\d*\.?\d{0,2}$/.test(val)) { newScores[student.id] = val appliedCount += 1 } } if (appliedCount > 0) { setScores((prev) => ({ ...prev, ...newScores })) toast.success(t("batch.pasteApplied", { count: appliedCount })) e.preventDefault() } }, [filteredStudents, t]) 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, validateScore]) // v3-P3-1: 下载成绩录入模板(CSV,含学生姓名列表,支持 Excel 粘贴) const handleDownloadTemplate = useCallback(() => { const headers = [t("batch.templateStudentName"), t("batch.templateScore"), t("batch.templateRemark")] const rows = students.map((s) => [s.name, "", ""]) const csv = [headers, ...rows] .map((r) => r.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(",")) .join("\n") // 添加 BOM 以支持 Excel 正确识别 UTF-8 const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" }) const url = URL.createObjectURL(blob) const a = document.createElement("a") a.href = url a.download = t("batch.templateFilename") document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) }, [students, t]) // v4-P3-2: 新手引导提示框,使用 localStorage 记住用户是否已关闭 const [guideVisible, setGuideVisible] = useState(() => { if (typeof window === "undefined") return true try { return localStorage.getItem("grade-entry-guide-dismissed") !== "true" } catch { return true } }) const handleDismissGuide = useCallback(() => { setGuideVisible(false) try { localStorage.setItem("grade-entry-guide-dismissed", "true") } catch { // localStorage 不可用时静默失败 } }, []) const hasInvalidScores = Object.values(scores).some((v) => v !== "" && v !== undefined && !validateScore(v)) // v3-P2-10: 草稿保存到 localStorage(30秒间隔)+ 服务端同步(60秒间隔) // P3-12 修复:使用 scoresRef 读取最新 scores,定时器不再依赖 scores,避免每次按键重建定时器 useEffect(() => { // P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错 if (typeof window === "undefined") return // 本地草稿:30 秒间隔,快速恢复 const localInterval = setInterval(() => { const currentScores = scoresRef.current if (Object.keys(currentScores).length > 0) { try { localStorage.setItem(draftKey, JSON.stringify({ scores: currentScores, timestamp: Date.now() })) } catch { // localStorage 可能已满或不可用,静默失败 } } }, 30000) // 服务端草稿:60 秒间隔,跨设备同步 const serverInterval = setInterval(() => { const currentScores = scoresRef.current if (Object.keys(currentScores).length > 0) { void safeActionCall(() => saveGradeDraftAction({ classId, subjectId, type, scores: currentScores, }) ) } }, 60000) return () => { clearInterval(localInterval) clearInterval(serverInterval) } }, [draftKey, classId, subjectId, type]) // 清除草稿(本地 + 服务端) const clearDraft = useCallback(() => { // P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错 if (typeof window === "undefined") return try { localStorage.removeItem(draftKey) } catch { // 忽略 } // v3-P2-10: 同步清除服务端草稿 void safeActionCall(() => deleteGradeDraftAction({ classId, subjectId, type }) ) }, [draftKey, classId, subjectId, type]) 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(t("batch.selectClassAndSubject")) return } if (hasInvalidScores) { toast.error(t("batch.invalidScoresError")) return } // P3 修复:区分"未输入"和"输入 0" // 只有当 scores[studentId] 有值且非空字符串时才视为已输入 // 这样输入 0 会被正确提交,而未输入会被跳过 const records = students .map((s) => { const raw = scores[s.id] if (raw === undefined || raw === "") return null return { studentId: s.id, score: Number(raw), } }) .filter((r): r is { studentId: string; score: number } => r !== null) if (records.length === 0) { toast.error(t("batch.enterAtLeastOne")) 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)) // P3 修复:使用 safeActionCall 包装,确保异常时也能重置 loading 状态 const result = await safeActionCall( () => batchCreateGradeRecordsAction(null, formData), { onError: () => toast.error(t("error.saveFailed")), onFinally: () => setIsSubmitting(false), } ) if (result?.success) { clearDraft() // v3-P2-3:保存创建的记录 ID 到 sessionStorage,供撤销使用 const createdIds = result.data ?? [] if (createdIds.length > 0) { try { sessionStorage.setItem( "lastBatchGradeRecordIds", serializeUndoData(createdIds) ) } catch { // sessionStorage 不可用时静默失败(不影响主流程) } // 显示带撤销按钮的 toast,10 秒内可撤销 toast.success(result.message, { duration: 10000, action: { label: t("batch.undo"), onClick: () => handleUndo(), }, }) } else { toast.success(result.message) } router.push("/teacher/grades") router.refresh() } else if (result) { toast.error(result.message || t("error.saveFailed")) } } /** * v3-P2-3: 撤销最近一次批量录入。 * 从 sessionStorage 读取上次创建的记录 ID 列表,调用 undo action 删除。 */ const handleUndo = useCallback(async () => { if (typeof window === "undefined") return let ids: string[] = [] try { const raw = sessionStorage.getItem("lastBatchGradeRecordIds") if (!raw) { toast.error(t("batch.undoNoRecord")) return } const parsed: unknown = JSON.parse(raw) if (!isUndoData(parsed)) { toast.error(t("batch.undoNoRecord")) return } // 仅允许 5 分钟内的撤销 if (Date.now() - parsed.timestamp > 5 * 60 * 1000) { sessionStorage.removeItem("lastBatchGradeRecordIds") toast.error(t("batch.undoExpired")) return } ids = parsed.ids } catch { toast.error(t("batch.undoNoRecord")) return } if (ids.length === 0) { toast.error(t("batch.undoNoRecord")) return } const result = await safeActionCall( () => undoBatchCreateGradeRecordsAction(ids), { onError: () => toast.error(t("batch.undoFailed")), } ) if (result?.success) { try { sessionStorage.removeItem("lastBatchGradeRecordIds") } catch { // 忽略 } toast.success(result.message) router.refresh() } else if (result) { toast.error(result.message || t("batch.undoFailed")) } }, [router, t]) return ( {isSubmitting && (
{t("batch.savingGrades")}
)} {t("batch.title")}

{t("batch.fullScoreHint", { max: MAX_SCORE })}

{/* v4-P3-2: 新手引导提示框 */} {guideVisible && (
)}
{students.length === 0 ? (

{t("batch.noStudentsInClass")}

) : ( <> {/* 实时统计栏 */}
{t("batch.entered")} {stats.entered} / {stats.total} {stats.entered > 0 && ( <> {t("batch.min")} {stats.min} )} {t("batch.pasteHint")}
{hasInvalidScores && ( )} {/* v3-P3-1: 下载成绩录入模板 */}
setSearchQuery(e.target.value)} className="h-8 w-40 pl-8 text-sm" />
# {t("list.columns.student")} {t("batch.emailColumn")} {t("batch.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)} onPaste={(e) => handlePaste(e, idx)} className={cn("h-8", isInvalid && "border-destructive focus-visible:ring-destructive")} aria-invalid={isInvalid} /> ) })}
{t("batch.caption")}
)}
) }