- 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)
802 lines
30 KiB
TypeScript
802 lines
30 KiB
TypeScript
"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: 草稿保存到 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<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 不可用时静默失败(不影响主流程)
|
||
}
|
||
// 显示带撤销按钮的 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 (
|
||
<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>
|
||
)
|
||
}
|