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
451 lines
17 KiB
TypeScript
451 lines
17 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 { 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))
|
||
|
||
// 草稿保存到 localStorage(30秒间隔)
|
||
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>
|
||
)
|
||
}
|