"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 { CalendarDays, Search, CheckCircle2, XCircle, Clock, LogOut, FileText, School } 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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/shared/components/ui/alert-dialog" import { cn } from "@/shared/lib/utils" import { resolveActionError } from "@/shared/lib/resolve-action-error" import { batchRecordAttendanceAction } from "../actions" import { ATTENDANCE_STATUS_OPTIONS, ATTENDANCE_STATUS_SHORTCUTS, ATTENDANCE_STATUS_LABEL_KEYS, createInitialStatusCounts, type AttendanceStatus, } from "../constants" import type { AttendancePeriod } from "../types" type Option = { id: string; name: string } type Student = { id: string; name: string; email: string } /** L-6:节次选项(顺序即选择器中的展示顺序)。 */ const ATTENDANCE_PERIOD_OPTIONS: AttendancePeriod[] = [ "full_day", "morning_reading", "morning", "afternoon", "evening", ] /** L-6:节次对应的 i18n key 后缀。 */ const ATTENDANCE_PERIOD_LABEL_KEYS: Record = { full_day: "period.full_day", morning_reading: "period.morning_reading", morning: "period.morning", afternoon: "period.afternoon", evening: "period.evening", } const STATUS_STYLES: Record = { present: { active: "bg-emerald-500 text-white border-emerald-500 hover:bg-emerald-600", icon: CheckCircle2 }, absent: { active: "bg-red-500 text-white border-red-500 hover:bg-red-600", icon: XCircle }, late: { active: "bg-amber-500 text-white border-amber-500 hover:bg-amber-600", icon: Clock }, early_leave: { active: "bg-blue-500 text-white border-blue-500 hover:bg-blue-600", icon: LogOut }, excused: { active: "bg-purple-500 text-white border-purple-500 hover:bg-purple-600", icon: FileText }, school_activity: { active: "bg-cyan-500 text-white border-cyan-500 hover:bg-cyan-600", icon: School }, } /** 需要填写原因的状态(缺勤/迟到/早退/请假/校内活动)。 */ const STATUSES_REQUIRING_REASON: ReadonlySet = new Set([ "absent", "late", "early_leave", "excused", "school_activity", ]) function SubmitButton() { const { pending } = useFormStatus() const t = useTranslations("attendance") return ( ) } export function AttendanceSheet({ classes, students, defaultClassId, defaultDate, }: { classes: Option[] students: Student[] defaultClassId?: string defaultDate?: string }) { const router = useRouter() const t = useTranslations("attendance") const today = new Date().toISOString().slice(0, 10) const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "") const [date, setDate] = useState(defaultDate ?? today) // L-6:节次默认 full_day(向后兼容已有数据) const [period, setPeriod] = useState("full_day") const [statuses, setStatuses] = useState>({}) const [reasons, setReasons] = useState>({}) const [searchQuery, setSearchQuery] = useState("") const [focusedStudentIndex, setFocusedStudentIndex] = useState(0) const [isSubmitting, setIsSubmitting] = useState(false) const [showSwitchConfirm, setShowSwitchConfirm] = useState(false) const [pendingClassId, setPendingClassId] = useState(null) const studentRefs = useRef<(HTMLTableRowElement | null)[]>([]) const containerRef = useRef(null) const handleStatusChange = useCallback((studentId: string, status: AttendanceStatus) => { setStatuses((prev) => ({ ...prev, [studentId]: status })) // 切回 present 时清除原因(present 不需要原因) if (status === "present") { setReasons((prev) => { if (!prev[studentId]) return prev const next = { ...prev } delete next[studentId] return next }) } }, []) const handleReasonChange = useCallback((studentId: string, reason: string) => { setReasons((prev) => ({ ...prev, [studentId]: reason })) }, []) const markAllPresent = useCallback(() => { const all: Record = {} for (const s of students) all[s.id] = "present" setStatuses(all) setReasons({}) toast.success(t("actions.markAllPresent")) }, [students, t]) const handleClassChange = (newClassId: string) => { const hasUnsaved = Object.keys(statuses).length > 0 if (hasUnsaved && newClassId !== classId) { setPendingClassId(newClassId) setShowSwitchConfirm(true) return } confirmClassSwitch(newClassId) } const confirmClassSwitch = (newClassId: string) => { setClassId(newClassId) setStatuses({}) setReasons({}) const newUrl = newClassId ? `/teacher/attendance/sheet?classId=${encodeURIComponent(newClassId)}` : "/teacher/attendance/sheet" router.push(newUrl) } const filteredStudents = students.filter( (s) => !searchQuery || s.name.toLowerCase().includes(searchQuery.toLowerCase()) ) const statusCounts = useMemo( () => ATTENDANCE_STATUS_OPTIONS.reduce( (acc, st) => { acc[st] = students.filter((s) => (statuses[s.id] ?? "present") === st).length return acc }, createInitialStatusCounts() ), [students, statuses] ) // 派生值:当筛选结果变少时,焦点索引自动夹紧到有效范围,避免 useEffect 重置导致的级联渲染 const effectiveFocusedIndex = filteredStudents.length === 0 ? 0 : Math.min(focusedStudentIndex, filteredStudents.length - 1) useEffect(() => { const container = containerRef.current if (!container) return const handleKeyDown = (e: KeyboardEvent) => { // P2-7 修复:限制监听范围到容器内,并排除所有可交互元素 const target = e.target if ( target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || (target instanceof HTMLElement && target.isContentEditable) ) { return } const key = e.key.toLowerCase() if (ATTENDANCE_STATUS_SHORTCUTS[key] && filteredStudents[effectiveFocusedIndex]) { e.preventDefault() handleStatusChange(filteredStudents[effectiveFocusedIndex].id, ATTENDANCE_STATUS_SHORTCUTS[key]) if (effectiveFocusedIndex < filteredStudents.length - 1) { setFocusedStudentIndex((prev) => prev + 1) } } if (e.key === "ArrowDown" && effectiveFocusedIndex < filteredStudents.length - 1) { e.preventDefault() setFocusedStudentIndex((prev) => prev + 1) } if (e.key === "ArrowUp" && effectiveFocusedIndex > 0) { e.preventDefault() setFocusedStudentIndex((prev) => prev - 1) } } container.addEventListener("keydown", handleKeyDown) return () => container.removeEventListener("keydown", handleKeyDown) }, [filteredStudents, effectiveFocusedIndex, handleStatusChange]) useEffect(() => { studentRefs.current[effectiveFocusedIndex]?.scrollIntoView({ block: "nearest" }) }, [effectiveFocusedIndex]) const handleSubmit = async (formData: FormData) => { if (!classId || !date) { toast.error(t("errors.invalidForm")) return } const records = students.map((s) => { const status = statuses[s.id] ?? "present" return { studentId: s.id, classId, date, status, // L-6:节次考勤,提交时携带当前选中的节次 period, // 仅当状态需要原因且原因非空时携带(present 不需要原因) reason: STATUSES_REQUIRING_REASON.has(status) && reasons[s.id] ? reasons[s.id].slice(0, 255) : undefined, } }) if (records.length === 0) { toast.error(t("sheet.noStudents")) return } setIsSubmitting(true) formData.set("recordsJson", JSON.stringify(records)) try { const result = await batchRecordAttendanceAction(null, formData) if (result.success) { toast.success(result.message || t("sheet.saved")) router.push("/teacher/attendance") router.refresh() } else { toast.error(resolveActionError(result, t, t("errors.unexpected"))) } } catch { toast.error(t("errors.unexpected")) } finally { setIsSubmitting(false) } } return ( {isSubmitting && (
{t("sheet.saving")}
)} {t("title.sheet")}

{t("description.teacherRecords")}

setDate(e.target.value)} className="pl-9" required aria-label={t("filters.date")} />
{/* L-6:节次选择器,允许按节次记录考勤 */}
{students.length === 0 ? (

{t("sheet.noStudents")}

) : ( <>
{ATTENDANCE_STATUS_OPTIONS.map((st) => { const Icon = STATUS_STYLES[st].icon return ( ) })}
setSearchQuery(e.target.value)} className="h-8 w-40 pl-8 text-sm" aria-label={t("list.columns.student")} />
# {t("list.columns.student")} {t("list.columns.reason")} {t("list.columns.status")} {filteredStudents.map((s, idx) => { const currentStatus = statuses[s.id] ?? "present" const isFocused = idx === effectiveFocusedIndex const needsReason = STATUSES_REQUIRING_REASON.has(currentStatus) return ( { studentRefs.current[idx] = el }} className={cn("cursor-pointer", isFocused && "bg-primary/5")} onClick={() => setFocusedStudentIndex(idx)} role="button" tabIndex={isFocused ? 0 : -1} aria-label={s.name} > {idx + 1}
{s.name} {s.email}
handleReasonChange(s.id, e.target.value)} onClick={(e) => e.stopPropagation()} disabled={!needsReason} placeholder={needsReason ? t("sheet.reasonPlaceholder") : ""} maxLength={255} className="h-8 text-sm" aria-label={t("list.columns.reason")} />
{ATTENDANCE_STATUS_OPTIONS.map((st) => { const Icon = STATUS_STYLES[st].icon const isActive = currentStatus === st return ( ) })}
) })}
)}
{t("sheet.confirmClassSwitch")} {t("sheet.confirmClassSwitch")} {t("actions.cancel")} { if (pendingClassId) { confirmClassSwitch(pendingClassId) setPendingClassId(null) } setShowSwitchConfirm(false) }} > {t("sheet.confirmClassSwitchAction")} ) }