Files
NextEdu/src/modules/grades/components/batch-grade-entry.tsx
SpecialX 0cee93676b feat(grades): add scope-check and update analytics
- Add scope-check lib for grade data access scope validation

- Update actions, actions-analytics, data-access, data-access-analytics

- Update batch-grade-entry, schema, and types
2026-06-24 12:02:50 +08:00

542 lines
19 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, useMemo, type JSX, type KeyboardEvent, type ClipboardEvent } from "react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { Search, Info, AlertCircle } from "lucide-react"
import { Card, CardContent, 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 { Badge } from "@/shared/components/ui/badge"
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 { batchCreateGradeRecordsByExamAction, undoBatchCreateGradeRecordsAction } from "../actions"
import type { ExamOptionForEntry, ExamForGradeEntry } from "@/modules/exams/types"
type Student = { id: string; name: string; email: string }
type ClassOption = { id: string; name: string }
interface Props {
exams: ExamOptionForEntry[]
classes: ClassOption[]
classGradeMap: Record<string, string>
exam: ExamForGradeEntry | null
students: Student[]
defaultExamId?: string
defaultClassId?: string
}
/** 序列化撤销数据 */
function serializeUndoData(ids: string[]): string {
return JSON.stringify({ ids, timestamp: Date.now() })
}
export function BatchGradeEntryByExam({
exams,
classes,
classGradeMap,
exam,
students,
defaultExamId,
defaultClassId,
}: Props): JSX.Element {
const router = useRouter()
const t = useTranslations("grades")
const [scores, setScores] = useState<Record<string, Record<string, string>>>({})
const [searchQuery, setSearchQuery] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
const inputRefs = useRef<Record<string, HTMLInputElement | null>>({})
// 按试卷 gradeId 过滤班级
const filteredClasses = useMemo(() => {
if (!exam?.gradeId) return classes
return classes.filter((c) => classGradeMap[c.id] === exam.gradeId)
}, [classes, classGradeMap, exam])
// 过滤学生
const filteredStudents = useMemo(() => {
if (!searchQuery) return students
const q = searchQuery.toLowerCase()
return students.filter(
(s) => s.name.toLowerCase().includes(q) || s.email.toLowerCase().includes(q)
)
}, [students, searchQuery])
const handleExamChange = (examId: string): void => {
if (Object.keys(scores).length > 0) {
if (!window.confirm(t("batchByExam.confirmSwitchExam"))) return
}
setScores({})
router.push(`/teacher/grades/entry?examId=${examId}`)
}
const handleClassChange = (classId: string): void => {
if (Object.keys(scores).length > 0) {
if (!window.confirm(t("batchByExam.confirmSwitchClass"))) return
}
setScores({})
router.push(`/teacher/grades/entry?examId=${defaultExamId}&classId=${classId}`)
}
const handleScoreChange = (studentId: string, questionId: string, value: string): void => {
setScores((prev) => ({
...prev,
[studentId]: {
...(prev[studentId] ?? {}),
[questionId]: value,
},
}))
}
// 计算学生总分
const computeTotal = (studentId: string): number => {
const studentScores = scores[studentId]
if (!studentScores || !exam) return 0
return exam.questions.reduce((sum, q) => {
const raw = studentScores[q.id]
if (raw === undefined || raw === "") return sum
const n = parseFloat(raw)
return sum + (isNaN(n) ? 0 : n)
}, 0)
}
// 检查分数是否超出满分
const isScoreInvalid = (studentId: string, questionId: string): boolean => {
if (!exam) return false
const question = exam.questions.find((q) => q.id === questionId)
if (!question) return false
const raw = scores[studentId]?.[questionId]
if (raw === undefined || raw === "") return false
const n = parseFloat(raw)
if (isNaN(n)) return true
return n < 0 || n > question.score
}
// 统计
const stats = useMemo(() => {
const entered = students.filter((s) => {
if (!exam) return false
const ss = scores[s.id]
if (!ss) return false
return exam.questions.every((q) => ss[q.id] !== undefined && ss[q.id] !== "")
}).length
const totals = students
.map((s) => computeTotal(s.id))
.filter((n) => n > 0)
const avg = totals.length > 0 ? totals.reduce((a, b) => a + b, 0) / totals.length : 0
const max = totals.length > 0 ? Math.max(...totals) : 0
const min = totals.length > 0 ? Math.min(...totals) : 0
return { entered, total: students.length, avg, max, min }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scores, students, exam])
// Excel 粘贴:支持多行多列
const handlePaste = (e: ClipboardEvent, startStudentId: string, startQuestionId: string): void => {
if (!exam) return
const text = e.clipboardData.getData("text")
const lines = text.split(/\r?\n/).filter((l) => l.trim())
if (lines.length === 0) return
const startStudentIdx = students.findIndex((s) => s.id === startStudentId)
const startQuestionIdx = exam.questions.findIndex((q) => q.id === startQuestionId)
if (startStudentIdx === -1 || startQuestionIdx === -1) return
const newScores: Record<string, Record<string, string>> = {}
let pastedCount = 0
lines.forEach((line, lineIdx) => {
const student = students[startStudentIdx + lineIdx]
if (!student) return
const cells = line.split(/\t/).filter((c) => c.trim() || c === "0")
if (cells.length === 1) {
// 单列:按行填充当前题目
newScores[student.id] = {
...(newScores[student.id] ?? {}),
[exam.questions[startQuestionIdx].id]: cells[0].trim(),
}
pastedCount++
} else {
// 多列:按行×列填充
cells.forEach((cell, colIdx) => {
const question = exam.questions[startQuestionIdx + colIdx]
if (question) {
newScores[student.id] = {
...(newScores[student.id] ?? {}),
[question.id]: cell.trim(),
}
}
})
pastedCount++
}
})
if (pastedCount > 0) {
setScores((prev) => {
const merged = { ...prev }
for (const [sid, qs] of Object.entries(newScores)) {
merged[sid] = { ...(merged[sid] ?? {}), ...qs }
}
return merged
})
e.preventDefault()
toast.success(t("batchByExam.pasteApplied", { count: pastedCount }))
}
}
// Enter 跳下一行同一列
const handleKeyDown = (e: KeyboardEvent, studentId: string, questionId: string): void => {
if (e.key === "Enter") {
e.preventDefault()
const studentIdx = students.findIndex((s) => s.id === studentId)
const nextStudent = students[studentIdx + 1]
if (nextStudent) {
const ref = inputRefs.current[`${nextStudent.id}-${questionId}`]
ref?.focus()
ref?.select()
}
}
}
const hasInvalidScores = useMemo(() => {
if (!exam) return false
return students.some((s) =>
exam.questions.some((q) => isScoreInvalid(s.id, q.id))
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scores, students, exam])
const handleSubmit = async (): Promise<void> => {
if (!exam || !defaultClassId || !defaultExamId) return
if (hasInvalidScores) {
toast.error(t("batchByExam.invalidScoresError"))
return
}
const records = students
.map((s) => {
const studentScores = scores[s.id]
if (!studentScores) return null
const hasAny = exam.questions.some(
(q) => studentScores[q.id] !== undefined && studentScores[q.id] !== ""
)
if (!hasAny) return null
return {
studentId: s.id,
answers: exam.questions.map((q) => ({
questionId: q.id,
score: parseFloat(studentScores[q.id] ?? "0") || 0,
})),
}
})
.filter((r): r is NonNullable<typeof r> => r !== null)
if (records.length === 0) {
toast.error(t("batchByExam.enterAtLeastOne"))
return
}
const formData = new FormData()
formData.set("examId", defaultExamId)
formData.set("classId", defaultClassId)
formData.set("recordsJson", JSON.stringify(records))
setIsSubmitting(true)
const result = await safeActionCall(
() => batchCreateGradeRecordsByExamAction(null, formData),
{
onError: () => toast.error(t("error.saveFailed")),
onFinally: () => setIsSubmitting(false),
}
)
if (result?.success) {
setScores({})
const createdIds = result.data ?? []
if (createdIds.length > 0) {
try {
sessionStorage.setItem(
"lastBatchGradeRecordIds",
serializeUndoData(createdIds)
)
} catch {
// sessionStorage 不可用时静默失败
}
toast.success(result.message, {
duration: 10000,
action: {
label: t("batchByExam.undo"),
onClick: () => handleUndo(),
},
})
} else {
toast.success(result.message)
}
router.push("/teacher/grades")
router.refresh()
} else if (result) {
toast.error(result.message || t("error.saveFailed"))
}
}
const handleUndo = async (): Promise<void> => {
try {
const raw = sessionStorage.getItem("lastBatchGradeRecordIds")
if (!raw) {
toast.error(t("batchByExam.undoNoRecord"))
return
}
const data = JSON.parse(raw) as { ids: string[]; timestamp: number }
if (Date.now() - data.timestamp > 5 * 60 * 1000) {
toast.error(t("batchByExam.undoExpired"))
return
}
const result = await undoBatchCreateGradeRecordsAction(data.ids)
if (result.success) {
sessionStorage.removeItem("lastBatchGradeRecordIds")
toast.success(result.message)
router.refresh()
} else {
toast.error(result.message || t("batchByExam.undoFailed"))
}
} catch {
toast.error(t("batchByExam.undoFailed"))
}
}
// 未选试卷
if (!exam) {
return (
<Card className="shadow-none">
<CardHeader>
<CardTitle className="text-base">{t("batchByExam.selectExam")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>{t("batchByExam.selectExam")}</Label>
<Select onValueChange={handleExamChange}>
<SelectTrigger>
<SelectValue placeholder={t("batchByExam.selectExamPlaceholder")} />
</SelectTrigger>
<SelectContent>
{exams.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.title}{e.subjectName} · {e.questionCount} · {e.totalScore}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-start gap-2 rounded-md bg-muted/50 p-3 text-sm text-muted-foreground">
<Info className="mt-0.5 h-4 w-4 shrink-0" />
<span>{t("batchByExam.guideSelectExam")}</span>
</div>
</CardContent>
</Card>
)
}
return (
<div className="space-y-6">
{/* 试卷 + 班级选择器 */}
<Card className="shadow-none">
<CardHeader>
<CardTitle className="text-base">{t("batchByExam.title")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>{t("batchByExam.selectExam")}</Label>
<Select value={defaultExamId} onValueChange={handleExamChange}>
<SelectTrigger>
<SelectValue placeholder={t("batchByExam.selectExamPlaceholder")} />
</SelectTrigger>
<SelectContent>
{exams.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.title}{e.subjectName} · {e.questionCount}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t("batchByExam.selectClass")}</Label>
<Select value={defaultClassId} onValueChange={handleClassChange}>
<SelectTrigger>
<SelectValue placeholder={t("batchByExam.selectClassPlaceholder")} />
</SelectTrigger>
<SelectContent>
{filteredClasses.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 试卷信息 */}
<div className="md:col-span-2 flex flex-wrap gap-2 text-sm">
<Badge variant="secondary">
{t("batchByExam.questionCount", { count: exam.questions.length })}
</Badge>
<Badge variant="secondary">
{t("batchByExam.fullScore", { score: exam.totalScore })}
</Badge>
</div>
</CardContent>
</Card>
{/* 未选班级 */}
{!defaultClassId ? (
<Card className="shadow-none">
<CardContent className="py-8 text-center text-muted-foreground">
{t("batchByExam.selectClassFirst")}
</CardContent>
</Card>
) : students.length === 0 ? (
<Card className="shadow-none">
<CardContent className="py-8 text-center text-muted-foreground">
{t("batchByExam.noStudents")}
</CardContent>
</Card>
) : (
<>
{/* 统计栏 */}
<div className="flex flex-wrap items-center gap-4">
<Badge variant="outline" className="tabular-nums">
{t("batchByExam.entered")}: {stats.entered}/{stats.total}
</Badge>
{stats.entered > 0 && (
<>
<Badge variant="outline" className="tabular-nums">
{t("batchByExam.average")}: {Math.round(stats.avg * 10) / 10}
</Badge>
<Badge variant="outline" className="tabular-nums">
{t("batchByExam.max")}: {stats.max}
</Badge>
<Badge variant="outline" className="tabular-nums">
{t("batchByExam.min")}: {stats.min}
</Badge>
</>
)}
{hasInvalidScores && (
<Badge variant="destructive">
<AlertCircle className="mr-1 h-3 w-3" />
{t("batchByExam.invalidScoresBadge")}
</Badge>
)}
<div className="relative ml-auto">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={t("batchByExam.searchStudent")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 w-48 pl-8"
/>
</div>
</div>
{/* Excel 式表格 */}
<Card className="shadow-none">
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-12 text-center">#</TableHead>
<TableHead className="min-w-[120px]">{t("batchByExam.studentName")}</TableHead>
{exam.questions.map((q, idx) => (
<TableHead key={q.id} className="text-center min-w-[80px]">
<div className="font-medium">{t("batchByExam.question", { n: idx + 1 })}</div>
<div className="text-xs text-muted-foreground tabular-nums">({q.score}{t("batchByExam.points")})</div>
</TableHead>
))}
<TableHead className="text-center min-w-[80px]">{t("batchByExam.totalScore")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredStudents.map((s, sIdx) => {
const total = computeTotal(s.id)
return (
<TableRow key={s.id}>
<TableCell className="text-center text-muted-foreground tabular-nums">{sIdx + 1}</TableCell>
<TableCell className="font-medium">{s.name}</TableCell>
{exam.questions.map((q) => {
const invalid = isScoreInvalid(s.id, q.id)
const key = `${s.id}-${q.id}`
return (
<TableCell key={q.id} className="p-1">
<Input
ref={(el) => { inputRefs.current[key] = el }}
type="number"
step="0.5"
min="0"
max={q.score}
value={scores[s.id]?.[q.id] ?? ""}
onChange={(e) => handleScoreChange(s.id, q.id, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, s.id, q.id)}
onPaste={(e) => handlePaste(e, s.id, q.id)}
onFocus={(e) => e.target.select()}
className={cn(
"h-8 text-center tabular-nums",
invalid && "border-destructive focus-visible:ring-destructive"
)}
aria-label={`${s.name} - ${t("batchByExam.question", { n: exam.questions.findIndex(qq => qq.id === q.id) + 1 })}`}
/>
</TableCell>
)
})}
<TableCell className="text-center font-medium tabular-nums">
{Math.round(total * 10) / 10}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* 提交按钮 */}
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => setScores({})}
disabled={isSubmitting}
>
{t("batchByExam.clear")}
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={isSubmitting || hasInvalidScores}
>
{isSubmitting ? t("batchByExam.saving") : t("batchByExam.saveAll")}
</Button>
</div>
{/* 提示 */}
<div className="flex items-start gap-2 rounded-md bg-muted/50 p-3 text-sm text-muted-foreground">
<Info className="mt-0.5 h-4 w-4 shrink-0" />
<span>{t("batchByExam.pasteHint")}</span>
</div>
</>
)}
</div>
)
}