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
This commit is contained in:
SpecialX
2026-07-03 10:25:01 +08:00
parent 2b95fd668b
commit e85a5f05dd
51 changed files with 6871 additions and 916 deletions

View File

@@ -4,26 +4,20 @@ import { useState, useRef, useMemo, type JSX, type KeyboardEvent, type Clipboard
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { Search, Info, AlertCircle } from "lucide-react"
import { Info } 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 { 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 }
@@ -39,11 +33,13 @@ interface Props {
defaultClassId?: string
}
/** 序列化撤销数据 */
function serializeUndoData(ids: string[]): string {
return JSON.stringify({ ids, timestamp: Date.now() })
}
/**
* 按试卷批量录入成绩主组件。
*
* P1-6 重构:将表格、统计栏、确认对话框拆分为独立子组件。
* - useBatchGradeEntryUndo Hook 封装撤销逻辑 + 类型守卫P1-7 修复 `as` 断言)
* - BatchGradeEntryTable / BatchGradeEntryStats / BatchGradeEntryDialog 为组合子组件
*/
export function BatchGradeEntryByExam({
exams,
classes,
@@ -55,10 +51,12 @@ export function BatchGradeEntryByExam({
}: 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(() => {
@@ -77,7 +75,12 @@ export function BatchGradeEntryByExam({
const handleExamChange = (examId: string): void => {
if (Object.keys(scores).length > 0) {
if (!window.confirm(t("batchByExam.confirmSwitchExam"))) return
setPendingSwitch({
kind: "exam",
examId,
message: t("batchByExam.confirmSwitchExam"),
})
return
}
setScores({})
router.push(`/teacher/grades/entry?examId=${examId}`)
@@ -85,12 +88,28 @@ export function BatchGradeEntryByExam({
const handleClassChange = (classId: string): void => {
if (Object.keys(scores).length > 0) {
if (!window.confirm(t("batchByExam.confirmSwitchClass"))) return
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,
@@ -162,14 +181,12 @@ export function BatchGradeEntryByExam({
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) {
@@ -266,14 +283,7 @@ export function BatchGradeEntryByExam({
setScores({})
const createdIds = result.data ?? []
if (createdIds.length > 0) {
try {
sessionStorage.setItem(
"lastBatchGradeRecordIds",
serializeUndoData(createdIds)
)
} catch {
// sessionStorage 不可用时静默失败
}
saveUndoToken(createdIds)
toast.success(result.message, {
duration: 10000,
action: {
@@ -291,31 +301,6 @@ export function BatchGradeEntryByExam({
}
}
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 (
@@ -414,101 +399,25 @@ export function BatchGradeEntryByExam({
) : (
<>
{/* 统计栏 */}
<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>
<BatchGradeEntryStats
stats={stats}
hasInvalidScores={hasInvalidScores}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
{/* 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>
<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">
@@ -536,6 +445,13 @@ export function BatchGradeEntryByExam({
</div>
</>
)}
{/* 确认对话框 */}
<BatchGradeEntryDialog
pendingSwitch={pendingSwitch}
onConfirm={confirmPendingSwitch}
onCancel={() => setPendingSwitch(null)}
/>
</div>
)
}