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:
@@ -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))
|
||||
|
||||
// 草稿保存到 localStorage(30秒间隔)
|
||||
// v3-P2-10: 草稿保存到 localStorage(30秒间隔)+ 服务端同步(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 不可用时静默失败(不影响主流程)
|
||||
}
|
||||
// 显示带撤销按钮的 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 {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user