Files
NextEdu/src/modules/grades/components/batch-grade-entry.tsx
SpecialX 45ee1ae43c refactor(grades,diagnostic): 成绩和学情诊断模块审计修复
P0-1: 10 个页面补充 requirePermission 权限校验
P0-2: diagnostic/data-access-reports.ts 移除直查 users 表,改用 getUserNamesByIds
P0-3: 新增 grade/grades/diagnostic 三组 i18n 翻译文件(zh-CN/en)
P0-4: 新增 /management/grade 重定向页面

P1-2: 抽取 toNumber/normalize/buildScopeClassFilter 到 lib/grade-utils.ts
P1-3: 为 12 个 Action 新增 Zod safeParse 校验(schema.ts +12 查询 schema)
P1-4: 修复 as 断言违规,改用类型守卫函数

P2-2: 移除 diagnostic 组件中 Tailwind 任意值

同步更新架构图文档 004 和 005
2026-06-22 16:23:34 +08:00

451 lines
17 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 { Search, TrendingUp, Trophy, AlertCircle } 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 { batchCreateGradeRecordsAction } from "../actions"
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"
}
function isSemester(v: string): v is Semester {
return v === "1" || v === "2"
}
const MAX_SCORE = 100
const DRAFT_KEY_PREFIX = "grade-draft"
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save All Grades"}
</Button>
)
}
export function BatchGradeEntry({
classes,
subjects,
students,
defaultClassId,
defaultSubjectId,
}: {
classes: Option[]
subjects: Option[]
students: Student[]
defaultClassId?: string
defaultSubjectId?: string
}) {
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 [scores, setScores] = useState<Record<string, string>>(() => {
// 惰性初始化:从 localStorage 恢复草稿(避免 useEffect 中 setState 导致级联渲染)
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
}
}
} catch {
// 解析失败,忽略
}
return {}
})
const [draftRestored] = useState(() => {
// 检查是否恢复了草稿(用于显示 toast
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
}
} 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}`
// 草稿恢复提示(仅在首次挂载时显示一次)
useEffect(() => {
if (draftRestored) {
toast.info("已恢复未保存的成绩草稿")
}
}, [draftRestored])
const handleScoreChange = useCallback((studentId: string, value: string) => {
if (value === "" || /^\d*\.?\d{0,2}$/.test(value)) {
setScores((prev) => ({ ...prev, [studentId]: value }))
}
}, [])
const validateScore = (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 => {
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)
return true
}
}
} catch {
// 解析失败,忽略
}
return false
}, [])
const handleClassChange = (newClassId: string) => {
const hasUnsaved = Object.keys(scores).length > 0
if (hasUnsaved && newClassId !== classId) {
if (!window.confirm("当前班级有未保存的成绩记录,确认切换班级?")) {
return
}
}
setClassId(newClassId)
setScores({})
// 切换班级后尝试恢复该班级的草稿
const newDraftKey = `${DRAFT_KEY_PREFIX}-${newClassId}-${subjectId}-${type}`
if (restoreDraft(newDraftKey)) {
toast.info("已恢复未保存的成绩草稿")
}
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]
)
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])
const hasInvalidScores = Object.values(scores).some((v) => v !== "" && v !== undefined && !validateScore(v))
// 草稿保存到 localStorage30秒间隔
useEffect(() => {
const interval = setInterval(() => {
if (Object.keys(scores).length > 0) {
try {
localStorage.setItem(draftKey, JSON.stringify({ scores, timestamp: Date.now() }))
} catch {
// localStorage 可能已满或不可用,静默失败
}
}
}, 30000)
return () => clearInterval(interval)
}, [scores, draftKey])
// 清除草稿
const clearDraft = useCallback(() => {
try {
localStorage.removeItem(draftKey)
} catch {
// 忽略
}
}, [draftKey])
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("Please select class and subject")
return
}
if (hasInvalidScores) {
toast.error("存在无效分数(超过满分或格式错误),请检查后重试")
return
}
const records = students
.map((s) => ({
studentId: s.id,
score: Number(scores[s.id] ?? 0),
}))
.filter((r) => r.score > 0 || scores[r.studentId] !== undefined)
if (records.length === 0) {
toast.error("Please enter at least one score")
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))
const result = await batchCreateGradeRecordsAction(null, formData)
setIsSubmitting(false)
if (result.success) {
clearDraft()
toast.success(result.message)
router.push("/teacher/grades")
router.refresh()
} else {
toast.error(result.message || "Failed to save")
}
}
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...
</div>
</div>
)}
<CardHeader>
<CardTitle>Batch Grade Entry</CardTitle>
<p className="text-sm text-muted-foreground">
{MAX_SCORE} Enter 稿 30 2
</p>
</CardHeader>
<CardContent>
<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>
<Select value={classId} onValueChange={handleClassChange}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Subject</Label>
<Select value={subjectId} onValueChange={setSubjectId}>
<SelectTrigger>
<SelectValue placeholder="Select a subject" />
</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">Exam / Quiz Title</Label>
<Input id="title" name="title" placeholder="e.g. Mid-term Exam" required />
</div>
<div className="grid gap-2">
<Label htmlFor="fullScore">Full Score</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>
<Select value={type} onValueChange={(v) => { if (isGradeType(v)) setType(v) }}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exam">Exam</SelectItem>
<SelectItem value="quiz">Quiz</SelectItem>
<SelectItem value="homework">Homework</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Semester</Label>
<Select value={semester} onValueChange={(v) => { if (isSemester(v)) setSemester(v) }}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Semester 1</SelectItem>
<SelectItem value="2">Semester 2</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{students.length === 0 ? (
<p className="text-sm text-muted-foreground">No students in this class.</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="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"></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="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="font-semibold tabular-nums">{stats.min}</span>
</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" />
</span>
)}
<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..."
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>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>Student</TableHead>
<TableHead className="hidden md:table-cell">Email</TableHead>
<TableHead className="w-32">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)}
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()}>
Cancel
</Button>
<SubmitButton />
</CardFooter>
</form>
</CardContent>
</Card>
)
}