Files
NextEdu/src/modules/grades/components/batch-grade-entry.tsx
SpecialX e85a5f05dd feat(grades): add appeals, drafts, import, report card, and growth archive
- Add actions-appeal, actions-draft, actions-import, actions-lock for grade workflow

- Add data-access-appeals, data-access-drafts, data-access-exam-entry for data layer

- Add batch-grade-entry-dialog, batch-grade-entry-stats, batch-grade-entry-table

- Add draft-lock-banner, excel-import-dialog for import and draft management

- Add growth-archive-chart, knowledge-point-mastery-chart for analytics

- Add report-card-view, report-card-print-action, report-card-print-button

- Add import-export, lib/notify, lib/report-card, scope-check test, stats-service test

- Add hooks directory
2026-07-03 10:25:01 +08:00

458 lines
15 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 { Info } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
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 { safeActionCall } from "@/shared/lib/action-utils"
import { batchCreateGradeRecordsByExamAction } from "../actions"
import { useBatchGradeEntryUndo } from "../hooks/use-batch-grade-entry-undo"
import { BatchGradeEntryStats } from "./batch-grade-entry-stats"
import { BatchGradeEntryDialog, type PendingSwitch } from "./batch-grade-entry-dialog"
import { BatchGradeEntryTable } from "./batch-grade-entry-table"
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
}
/**
* 按试卷批量录入成绩主组件。
*
* P1-6 重构:将表格、统计栏、确认对话框拆分为独立子组件。
* - useBatchGradeEntryUndo Hook 封装撤销逻辑 + 类型守卫P1-7 修复 `as` 断言)
* - BatchGradeEntryTable / BatchGradeEntryStats / BatchGradeEntryDialog 为组合子组件
*/
export function BatchGradeEntryByExam({
exams,
classes,
classGradeMap,
exam,
students,
defaultExamId,
defaultClassId,
}: Props): JSX.Element {
const router = useRouter()
const t = useTranslations("grades")
const { saveUndoToken, handleUndo } = useBatchGradeEntryUndo()
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>>({})
const [pendingSwitch, setPendingSwitch] = useState<PendingSwitch>(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) {
setPendingSwitch({
kind: "exam",
examId,
message: t("batchByExam.confirmSwitchExam"),
})
return
}
setScores({})
router.push(`/teacher/grades/entry?examId=${examId}`)
}
const handleClassChange = (classId: string): void => {
if (Object.keys(scores).length > 0) {
setPendingSwitch({
kind: "class",
classId,
message: t("batchByExam.confirmSwitchClass"),
})
return
}
setScores({})
router.push(`/teacher/grades/entry?examId=${defaultExamId}&classId=${classId}`)
}
const confirmPendingSwitch = (): void => {
if (!pendingSwitch) return
setScores({})
if (pendingSwitch.kind === "exam") {
router.push(`/teacher/grades/entry?examId=${pendingSwitch.examId}`)
} else {
router.push(`/teacher/grades/entry?examId=${defaultExamId}&classId=${pendingSwitch.classId}`)
}
setPendingSwitch(null)
}
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) {
saveUndoToken(createdIds)
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"))
}
}
// 未选试卷
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>
) : (
<>
{/* 统计栏 */}
<BatchGradeEntryStats
stats={stats}
hasInvalidScores={hasInvalidScores}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
{/* Excel 式表格 */}
<BatchGradeEntryTable
exam={exam}
students={filteredStudents}
scores={scores}
inputRefs={inputRefs}
onScoreChange={handleScoreChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
computeTotal={computeTotal}
isScoreInvalid={isScoreInvalid}
/>
{/* 提交按钮 */}
<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>
</>
)}
{/* 确认对话框 */}
<BatchGradeEntryDialog
pendingSwitch={pendingSwitch}
onConfirm={confirmPendingSwitch}
onCancel={() => setPendingSwitch(null)}
/>
</div>
)
}