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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user