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)
This commit is contained in:
SpecialX
2026-06-23 17:37:32 +08:00
parent 2197e68069
commit 95145cd03b
32 changed files with 3202 additions and 682 deletions

View File

@@ -4,7 +4,8 @@ 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 { 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"
@@ -20,30 +21,60 @@ import {
TableRow,
} from "@/shared/components/ui/table"
import { cn } from "@/shared/lib/utils"
import { safeActionCall } from "@/shared/lib/action-utils"
import { batchCreateGradeRecordsAction } from "../actions"
import { batchCreateGradeRecordsAction, undoBatchCreateGradeRecordsAction, saveGradeDraftAction, getGradeDraftAction, deleteGradeDraftAction } from "../actions"
import type { SelectOption, GradeRecordType, GradeRecordSemester } from "../types"
import { isGradeType, isSemester } from "../lib/type-guards"
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"
interface DraftData {
scores: Record<string, string>
timestamp: number
}
function isSemester(v: string): v is Semester {
return v === "1" || v === "2"
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 ? "Saving..." : "Save All Grades"}
{pending ? t("batch.saving") : t("batch.saveAllGrades")}
</Button>
)
}
@@ -55,26 +86,33 @@ export function BatchGradeEntry({
defaultClassId,
defaultSubjectId,
}: {
classes: Option[]
subjects: Option[]
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<GradeType>("exam")
const [semester, setSemester] = useState<Semester>("1")
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 data = JSON.parse(raw) as { scores: Record<string, string>; timestamp: number }
if (Date.now() - data.timestamp < 2 * 60 * 60 * 1000 && Object.keys(data.scores).length > 0) {
return data.scores
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 {
@@ -84,11 +122,18 @@ export function BatchGradeEntry({
})
const [draftRestored] = useState(() => {
// 检查是否恢复了草稿(用于显示 toast
// P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错
if (typeof window === "undefined") return false
try {
const raw = localStorage.getItem(initialDraftKey)
if (raw) {
const data = JSON.parse(raw) as { scores: Record<string, string>; timestamp: number }
return Date.now() - data.timestamp < 2 * 60 * 60 * 1000 && Object.keys(data.scores).length > 0
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 {
// 解析失败,忽略
@@ -101,12 +146,42 @@ export function BatchGradeEntry({
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("已恢复未保存的成绩草稿")
toast.info(t("batch.restored"))
}
}, [draftRestored])
}, [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)) {
@@ -114,19 +189,25 @@ export function BatchGradeEntry({
}
}, [])
const validateScore = (value: string): boolean => {
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 data = JSON.parse(raw) as { scores: Record<string, string>; timestamp: number }
if (Date.now() - data.timestamp < 2 * 60 * 60 * 1000 && Object.keys(data.scores).length > 0) {
setScores(data.scores)
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
}
}
@@ -139,7 +220,7 @@ export function BatchGradeEntry({
const handleClassChange = (newClassId: string) => {
const hasUnsaved = Object.keys(scores).length > 0
if (hasUnsaved && newClassId !== classId) {
if (!window.confirm("当前班级有未保存的成绩记录,确认切换班级?")) {
if (!window.confirm(t("batch.confirmSwitchClass"))) {
return
}
}
@@ -148,7 +229,7 @@ export function BatchGradeEntry({
// 切换班级后尝试恢复该班级的草稿
const newDraftKey = `${DRAFT_KEY_PREFIX}-${newClassId}-${subjectId}-${type}`
if (restoreDraft(newDraftKey)) {
toast.info("已恢复未保存的成绩草稿")
toast.info(t("batch.restored"))
}
const newUrl = newClassId ? `/teacher/grades/entry?classId=${encodeURIComponent(newClassId)}` : "/teacher/grades/entry"
router.push(newUrl)
@@ -159,6 +240,69 @@ export function BatchGradeEntry({
[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])
@@ -174,32 +318,101 @@ export function BatchGradeEntry({
min: Math.min(...validScores),
total: students.length,
}
}, [scores, students])
}, [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))
// 草稿保存到 localStorage30秒间隔
// v3-P2-10: 草稿保存到 localStorage30秒间隔+ 服务端同步60秒间隔
// P3-12 修复:使用 scoresRef 读取最新 scores定时器不再依赖 scores避免每次按键重建定时器
useEffect(() => {
const interval = setInterval(() => {
if (Object.keys(scores).length > 0) {
// 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, timestamp: Date.now() }))
localStorage.setItem(draftKey, JSON.stringify({ scores: currentScores, timestamp: Date.now() }))
} catch {
// localStorage 可能已满或不可用,静默失败
}
}
}, 30000)
return () => clearInterval(interval)
}, [scores, draftKey])
// 清除草稿
// 服务端草稿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 {
// 忽略
}
}, [draftKey])
// 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") {
@@ -215,23 +428,31 @@ export function BatchGradeEntry({
const handleSubmit = async (formData: FormData) => {
if (!classId || !subjectId) {
toast.error("Please select class and subject")
toast.error(t("batch.selectClassAndSubject"))
return
}
if (hasInvalidScores) {
toast.error("存在无效分数(超过满分或格式错误),请检查后重试")
toast.error(t("batch.invalidScoresError"))
return
}
// P3 修复:区分"未输入"和"输入 0"
// 只有当 scores[studentId] 有值且非空字符串时才视为已输入
// 这样输入 0 会被正确提交,而未输入会被跳过
const records = students
.map((s) => ({
studentId: s.id,
score: Number(scores[s.id] ?? 0),
}))
.filter((r) => r.score > 0 || scores[r.studentId] !== undefined)
.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("Please enter at least one score")
toast.error(t("batch.enterAtLeastOne"))
return
}
@@ -242,42 +463,156 @@ export function BatchGradeEntry({
formData.set("semester", semester)
formData.set("recordsJson", JSON.stringify(records))
const result = await batchCreateGradeRecordsAction(null, formData)
setIsSubmitting(false)
if (result.success) {
// P3 修复:使用 safeActionCall 包装,确保异常时也能重置 loading 状态
const result = await safeActionCall(
() => batchCreateGradeRecordsAction(null, formData),
{
onError: () => toast.error(t("error.saveFailed")),
onFinally: () => setIsSubmitting(false),
}
)
if (result?.success) {
clearDraft()
toast.success(result.message)
// 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 {
toast.error(result.message || "Failed to save")
} 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" />
Saving grades...
{t("batch.savingGrades")}
</div>
</div>
)}
<CardHeader>
<CardTitle>Batch Grade Entry</CardTitle>
<CardTitle>{t("batch.title")}</CardTitle>
<p className="text-sm text-muted-foreground">
{MAX_SCORE} Enter 稿 30 2
{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>Class</Label>
<Label htmlFor="batch-class">{t("filters.class")}</Label>
<Select value={classId} onValueChange={handleClassChange}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
<SelectTrigger id="batch-class">
<SelectValue placeholder={t("form.selectClass")} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
@@ -290,10 +625,10 @@ export function BatchGradeEntry({
</div>
<div className="grid gap-2">
<Label>Subject</Label>
<Label htmlFor="batch-subject">{t("filters.subject")}</Label>
<Select value={subjectId} onValueChange={setSubjectId}>
<SelectTrigger>
<SelectValue placeholder="Select a subject" />
<SelectTrigger id="batch-subject">
<SelectValue placeholder={t("form.selectSubject")} />
</SelectTrigger>
<SelectContent>
{subjects.map((s) => (
@@ -306,53 +641,53 @@ export function BatchGradeEntry({
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="title">Exam / Quiz Title</Label>
<Input id="title" name="title" placeholder="e.g. Mid-term Exam" required />
<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">Full Score</Label>
<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>Type</Label>
<Label htmlFor="batch-type">{t("filters.type")}</Label>
<Select value={type} onValueChange={(v) => { if (isGradeType(v)) setType(v) }}>
<SelectTrigger>
<SelectTrigger id="batch-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exam">Exam</SelectItem>
<SelectItem value="quiz">Quiz</SelectItem>
<SelectItem value="homework">Homework</SelectItem>
<SelectItem value="other">Other</SelectItem>
<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>Semester</Label>
<Label htmlFor="batch-semester">{t("filters.semester")}</Label>
<Select value={semester} onValueChange={(v) => { if (isSemester(v)) setSemester(v) }}>
<SelectTrigger>
<SelectTrigger id="batch-semester">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Semester 1</SelectItem>
<SelectItem value="2">Semester 2</SelectItem>
<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">No students in this class.</p>
<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"></span>
<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>
@@ -360,33 +695,47 @@ export function BatchGradeEntry({
<>
<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"></span>
<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"></span>
<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"></span>
<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="Search student..."
placeholder={t("batch.searchStudent")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 w-40 pl-8 text-sm"
@@ -396,12 +745,13 @@ export function BatchGradeEntry({
</div>
<div className="rounded-md border">
<Table>
<caption className="sr-only">{t("batch.caption")}</caption>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>Student</TableHead>
<TableHead className="hidden md:table-cell">Email</TableHead>
<TableHead className="w-32">Score</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>
@@ -424,6 +774,7 @@ export function BatchGradeEntry({
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}
/>
@@ -439,7 +790,7 @@ export function BatchGradeEntry({
<CardFooter className="justify-end gap-2 px-0">
<Button type="button" variant="outline" onClick={() => router.back()}>
Cancel
{t("batch.cancel")}
</Button>
<SubmitButton />
</CardFooter>