Files
NextEdu/src/modules/grades/components/batch-grade-entry.tsx
SpecialX 95145cd03b feat(grades): add ranking trend, school-wide summary, score cell, and scope filter
- Add ranking-trend-card and school-wide-summary-card for broader analytics

- Add score-cell and grade-filters components for table rendering

- Add scope-filter and type-guards lib utilities for grade data filtering

- Update actions, data-access (analytics, ranking, main), stats-service, export

- Update schema, types, and grade-utils lib

- Update all grade chart and report components (distribution, trend, comparison, query)
2026-06-23 17:37:32 +08:00

802 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string, string>
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<string, unknown>
if (typeof obj.timestamp !== "number") return false
if (typeof obj.scores !== "object" || obj.scores === null) return false
// 从 unknown 转换:已通过对象检查
const scores = obj.scores as Record<string, unknown>
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<string, unknown>
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 (
<Button type="submit" disabled={pending}>
{pending ? t("batch.saving") : t("batch.saveAllGrades")}
</Button>
)
}
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<GradeRecordType>("exam")
const [semester, setSemester] = useState<GradeRecordSemester>("1")
const [scores, setScores] = useState<Record<string, string>>(() => {
// 惰性初始化:从 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<Record<string, HTMLInputElement | null>>({})
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<HTMLInputElement>, 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<string, string> = {}
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<boolean>(() => {
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: 草稿保存到 localStorage30秒间隔+ 服务端同步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<HTMLInputElement>, 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 不可用时静默失败(不影响主流程)
}
// 显示带撤销按钮的 toast10 秒内可撤销
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 (
<Card className="relative">
{isSubmitting && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-background/60 backdrop-blur-sm">
<div className="flex items-center gap-2 text-sm font-medium">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
{t("batch.savingGrades")}
</div>
</div>
)}
<CardHeader>
<CardTitle>{t("batch.title")}</CardTitle>
<p className="text-sm text-muted-foreground">
{t("batch.fullScoreHint", { max: MAX_SCORE })}
</p>
</CardHeader>
<CardContent>
{/* v4-P3-2: 新手引导提示框 */}
{guideVisible && (
<div
role="region"
aria-label={t("batch.guide.title")}
className="mb-6 rounded-md border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-blue-950/40"
>
<div className="flex items-start gap-3">
<Info className="mt-0.5 h-5 w-5 shrink-0 text-blue-600 dark:text-blue-400" aria-hidden="true" />
<div className="flex-1 space-y-2">
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100">
{t("batch.guide.title")}
</h3>
<ol className="list-decimal space-y-1 pl-4 text-sm text-blue-800 dark:text-blue-200">
<li>{t("batch.guide.step1")}</li>
<li>{t("batch.guide.step2")}</li>
<li>{t("batch.guide.step3")}</li>
<li>{t("batch.guide.step4")}</li>
</ol>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-blue-600 hover:bg-blue-100 hover:text-blue-700 dark:text-blue-400 dark:hover:bg-blue-900"
onClick={handleDismissGuide}
aria-label={t("batch.guide.dismissAriaLabel")}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
)}
<form action={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="batch-class">{t("filters.class")}</Label>
<Select value={classId} onValueChange={handleClassChange}>
<SelectTrigger id="batch-class">
<SelectValue placeholder={t("form.selectClass")} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="batch-subject">{t("filters.subject")}</Label>
<Select value={subjectId} onValueChange={setSubjectId}>
<SelectTrigger id="batch-subject">
<SelectValue placeholder={t("form.selectSubject")} />
</SelectTrigger>
<SelectContent>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="title">{t("batch.examQuizTitle")}</Label>
<Input id="title" name="title" placeholder={t("form.titlePlaceholder")} required />
</div>
<div className="grid gap-2">
<Label htmlFor="fullScore">{t("form.fullScore")}</Label>
<Input id="fullScore" name="fullScore" type="number" step="0.01" min="1" defaultValue={String(MAX_SCORE)} />
</div>
<div className="grid gap-2">
<Label htmlFor="batch-type">{t("filters.type")}</Label>
<Select value={type} onValueChange={(v) => { if (isGradeType(v)) setType(v) }}>
<SelectTrigger id="batch-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exam">{t("type.exam")}</SelectItem>
<SelectItem value="quiz">{t("type.quiz")}</SelectItem>
<SelectItem value="homework">{t("type.homework")}</SelectItem>
<SelectItem value="other">{t("type.other")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="batch-semester">{t("filters.semester")}</Label>
<Select value={semester} onValueChange={(v) => { if (isSemester(v)) setSemester(v) }}>
<SelectTrigger id="batch-semester">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">{t("semester.s1")}</SelectItem>
<SelectItem value="2">{t("semester.s2")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{students.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("batch.noStudentsInClass")}</p>
) : (
<>
{/* 实时统计栏 */}
<div className="flex flex-col gap-3 rounded-md border bg-muted/30 p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-4 text-sm">
<span className="inline-flex items-center gap-1.5">
<span className="text-muted-foreground">{t("batch.entered")}</span>
<span className="font-semibold tabular-nums">{stats.entered}</span>
<span className="text-muted-foreground">/ {stats.total}</span>
</span>
{stats.entered > 0 && (
<>
<span className="inline-flex items-center gap-1.5">
<TrendingUp className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
<span className="text-muted-foreground">{t("batch.average")}</span>
<span className="font-semibold tabular-nums">{stats.average}</span>
</span>
<span className="inline-flex items-center gap-1.5">
<Trophy className="h-3.5 w-3.5 text-amber-500" aria-hidden="true" />
<span className="text-muted-foreground">{t("batch.max")}</span>
<span className="font-semibold tabular-nums">{stats.max}</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="text-muted-foreground">{t("batch.min")}</span>
<span className="font-semibold tabular-nums">{stats.min}</span>
</span>
</>
)}
<span className="text-xs text-muted-foreground">{t("batch.pasteHint")}</span>
</div>
<div className="flex items-center gap-2">
{hasInvalidScores && (
<span className="inline-flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="h-3.5 w-3.5" aria-hidden="true" />
{t("batch.invalidScoresBadge")}
</span>
)}
{/* v3-P3-1: 下载成绩录入模板 */}
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={handleDownloadTemplate}
aria-label={t("batch.templateAriaLabel")}
disabled={students.length === 0}
>
<Download className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
{t("batch.downloadTemplate")}
</Button>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder={t("batch.searchStudent")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 w-40 pl-8 text-sm"
/>
</div>
</div>
</div>
<div className="rounded-md border">
<Table>
<caption className="sr-only">{t("batch.caption")}</caption>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>{t("list.columns.student")}</TableHead>
<TableHead className="hidden md:table-cell">{t("batch.emailColumn")}</TableHead>
<TableHead className="w-32">{t("batch.score")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredStudents.map((s, idx) => {
const scoreValue = scores[s.id] ?? ""
const isInvalid = scoreValue !== "" && !validateScore(scoreValue)
return (
<TableRow key={s.id}>
<TableCell className="text-muted-foreground tabular-nums">{idx + 1}</TableCell>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">{s.email}</TableCell>
<TableCell>
<Input
ref={(el) => { 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}
/>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</>
)}
<CardFooter className="justify-end gap-2 px-0">
<Button type="button" variant="outline" onClick={() => router.back()}>
{t("batch.cancel")}
</Button>
<SubmitButton />
</CardFooter>
</form>
</CardContent>
</Card>
)
}