- 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
458 lines
15 KiB
TypeScript
458 lines
15 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 { 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>
|
||
)
|
||
}
|