- 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
542 lines
19 KiB
TypeScript
542 lines
19 KiB
TypeScript
"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>
|
||
)
|
||
}
|