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

@@ -0,0 +1,518 @@
"use client"
import type { JSX } from "react"
import { useRef, useState } from "react"
import { Upload, Download, FileSpreadsheet, Loader2, AlertCircle, CheckCircle2 } from "lucide-react"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Label } from "@/shared/components/ui/label"
import { Input } from "@/shared/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { downloadBase64File } from "@/shared/lib/download"
import { safeActionCall } from "@/shared/lib/action-utils"
import {
downloadGradeImportTemplateAction,
importGradesFromExcelAction,
} from "../actions-import"
import type { GradeImportResult } from "../import-export"
interface ExcelImportDialogProps {
/** 班级 ID必填 */
classId: string
/** 班级列表(供切换) */
classes: Array<{ id: string; name: string }>
/** 科目列表 */
subjects: Array<{ id: string; name: string }>
/** 触发按钮渲染函数 */
trigger?: (open: () => void) => JSX.Element
/** 导入成功后的回调(通常用于刷新列表) */
onSuccess?: () => void
}
interface ImportFormState {
classId: string
subjectId: string
title: string
examId: string
fullScore: string
type: string
semester: string
}
const INITIAL_FORM: ImportFormState = {
classId: "",
subjectId: "",
title: "",
examId: "",
fullScore: "100",
type: "exam",
semester: "1",
}
/**
* P3-10: 成绩 Excel 批量导入对话框。
*
* 工作流程:
* 1. 选择班级 → 自动下载模板(含学生姓名示例)
* 2. 填写参数(科目、评估标题、满分等)
* 3. 上传填好的 Excel 文件
* 4. 点击"开始导入"调用 Server Action
* 5. 显示导入结果(成功数 / 失败数 / 失败明细)
*/
export function ExcelImportDialog({
classId: initialClassId,
classes,
subjects,
trigger,
onSuccess,
}: ExcelImportDialogProps): JSX.Element {
const t = useTranslations("grades")
const [isOpen, setIsOpen] = useState(false)
const [isDownloadingTemplate, setIsDownloadingTemplate] = useState(false)
const [isImporting, setIsImporting] = useState(false)
const [file, setFile] = useState<File | null>(null)
const [form, setForm] = useState<ImportFormState>({
...INITIAL_FORM,
classId: initialClassId,
subjectId: subjects[0]?.id ?? "",
})
const [result, setResult] = useState<GradeImportResult | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const reset = () => {
setForm({ ...INITIAL_FORM, classId: initialClassId, subjectId: subjects[0]?.id ?? "" })
setFile(null)
setResult(null)
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const handleOpenChange = (open: boolean) => {
setIsOpen(open)
if (!open) {
reset()
}
}
const handleDownloadTemplate = async () => {
if (!form.classId) {
toast.error(t("excelImport.errorSelectClass"))
return
}
setIsDownloadingTemplate(true)
const r = await safeActionCall(
() => downloadGradeImportTemplateAction({ classId: form.classId }),
{
onError: () => toast.error(t("excelImport.errorTemplateDownload")),
onFinally: () => setIsDownloadingTemplate(false),
}
)
if (r?.success && r.data) {
downloadBase64File(r.data.buffer, r.data.filename)
toast.success(t("excelImport.templateDownloaded"))
} else if (r) {
toast.error(r.message ?? t("excelImport.errorTemplateDownload"))
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0] ?? null
setFile(f)
setResult(null)
}
const handleSubmit = async () => {
if (!file) {
toast.error(t("excelImport.errorNoFile"))
return
}
if (!form.classId || !form.subjectId || !form.title) {
toast.error(t("excelImport.errorMissingFields"))
return
}
setIsImporting(true)
const formData = new FormData()
formData.append("file", file)
formData.append("classId", form.classId)
formData.append("subjectId", form.subjectId)
formData.append("title", form.title)
if (form.examId) formData.append("examId", form.examId)
if (form.fullScore) formData.append("fullScore", form.fullScore)
if (form.type) formData.append("type", form.type)
if (form.semester) formData.append("semester", form.semester)
const r = await safeActionCall(
() => importGradesFromExcelAction(null, formData),
{
onError: () => toast.error(t("excelImport.errorImport")),
onFinally: () => setIsImporting(false),
}
)
if (r?.success && r.data) {
setResult(r.data)
if (r.data.failedCount === 0) {
toast.success(t("excelImport.successAllImported", { count: r.data.successCount }))
} else {
toast.warning(t("excelImport.successPartial", {
success: r.data.successCount,
failed: r.data.failedCount,
}))
}
onSuccess?.()
} else if (r) {
toast.error(r.message ?? t("excelImport.errorImport"))
if (r.data) {
setResult(r.data)
}
}
}
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
{trigger ? (
<DialogTrigger asChild>
{trigger(() => setIsOpen(true))}
</DialogTrigger>
) : (
<DialogTrigger asChild>
<Button variant="outline">
<Upload className="mr-2 h-4 w-4" aria-hidden="true" />
{t("excelImport.triggerButton")}
</Button>
</DialogTrigger>
)}
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileSpreadsheet className="h-5 w-5" aria-hidden="true" />
{t("excelImport.dialogTitle")}
</DialogTitle>
<DialogDescription>
{t("excelImport.dialogDescription")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* 第一步:下载模板 */}
<section className="rounded-md border border-blue-200 bg-blue-50 p-3 dark:border-blue-900 dark:bg-blue-950/30">
<div className="flex items-center justify-between gap-3">
<div className="flex-1">
<h3 className="text-sm font-medium text-blue-900 dark:text-blue-100">
{t("excelImport.step1Title")}
</h3>
<p className="mt-1 text-xs text-blue-700 dark:text-blue-300">
{t("excelImport.step1Description")}
</p>
</div>
<Button
onClick={handleDownloadTemplate}
variant="outline"
size="sm"
disabled={isDownloadingTemplate || !form.classId}
className="gap-2"
>
{isDownloadingTemplate ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<Download className="h-4 w-4" aria-hidden="true" />
)}
{t("excelImport.downloadTemplate")}
</Button>
</div>
</section>
{/* 第二步:填写参数 */}
<section className="space-y-3">
<h3 className="text-sm font-medium">
{t("excelImport.step2Title")}
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label htmlFor="ei-class" className="text-xs">
{t("filters.class")} *
</Label>
<Select
value={form.classId}
onValueChange={(v) => setForm({ ...form, classId: v })}
>
<SelectTrigger id="ei-class">
<SelectValue placeholder={t("filters.allClasses")} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="ei-subject" className="text-xs">
{t("filters.subject")} *
</Label>
<Select
value={form.subjectId}
onValueChange={(v) => setForm({ ...form, subjectId: v })}
>
<SelectTrigger id="ei-subject">
<SelectValue placeholder={t("filters.allSubjects")} />
</SelectTrigger>
<SelectContent>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5 col-span-2">
<Label htmlFor="ei-title" className="text-xs">
{t("excelImport.assessmentTitle")} *
</Label>
<Input
id="ei-title"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
placeholder={t("excelImport.assessmentTitlePlaceholder")}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="ei-fullscore" className="text-xs">
{t("excelImport.fullScore")}
</Label>
<Input
id="ei-fullscore"
type="number"
min={1}
max={1000}
value={form.fullScore}
onChange={(e) => setForm({ ...form, fullScore: e.target.value })}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="ei-type" className="text-xs">
{t("filters.type")}
</Label>
<Select
value={form.type}
onValueChange={(v) => setForm({ ...form, type: v })}
>
<SelectTrigger id="ei-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exam">{t("type.exam")}</SelectItem>
<SelectItem value="quiz">{t("type.quiz")}</SelectItem>
<SelectItem value="homework">{t("type.homework")}</SelectItem>
<SelectItem value="other">{t("type.other")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="ei-semester" className="text-xs">
{t("filters.semester")}
</Label>
<Select
value={form.semester}
onValueChange={(v) => setForm({ ...form, semester: v })}
>
<SelectTrigger id="ei-semester">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">{t("semester.s1")}</SelectItem>
<SelectItem value="2">{t("semester.s2")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="ei-examid" className="text-xs">
{t("excelImport.examId")}
</Label>
<Input
id="ei-examid"
value={form.examId}
onChange={(e) => setForm({ ...form, examId: e.target.value })}
placeholder={t("excelImport.examIdPlaceholder")}
/>
</div>
</div>
</section>
{/* 第三步:上传文件 */}
<section className="space-y-3">
<h3 className="text-sm font-medium">
{t("excelImport.step3Title")}
</h3>
<div className="grid gap-1.5">
<Label htmlFor="ei-file" className="text-xs">
{t("excelImport.fileLabel")} *
</Label>
<Input
ref={fileInputRef}
id="ei-file"
type="file"
accept=".xlsx,.xls"
onChange={handleFileChange}
/>
<p className="text-xs text-muted-foreground">
{t("excelImport.fileHint")}
</p>
</div>
</section>
{/* 导入结果 */}
{result ? (
<section className="space-y-3 rounded-md border p-3">
<h3 className="flex items-center gap-2 text-sm font-medium">
{result.failedCount === 0 ? (
<CheckCircle2 className="h-4 w-4 text-green-600" aria-hidden="true" />
) : (
<AlertCircle className="h-4 w-4 text-amber-600" aria-hidden="true" />
)}
{t("excelImport.resultTitle")}
</h3>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground">
{t("excelImport.resultSuccess")}
</span>
<span className="ml-2 font-semibold text-green-600">
{result.successCount}
</span>
</div>
<div>
<span className="text-muted-foreground">
{t("excelImport.resultFailed")}
</span>
<span className="ml-2 font-semibold text-red-600">
{result.failedCount}
</span>
</div>
</div>
{result.unmatchedStudents.length > 0 ? (
<div className="text-xs">
<p className="font-medium text-amber-700 dark:text-amber-300">
{t("excelImport.unmatchedStudents")}
</p>
<p className="mt-1 text-muted-foreground">
{result.unmatchedStudents.join(", ")}
</p>
</div>
) : null}
{result.invalidRows.length > 0 ? (
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">
{t("excelImport.columnRow")}
</TableHead>
<TableHead>
{t("excelImport.columnStudentName")}
</TableHead>
<TableHead className="text-right">
{t("excelImport.columnScore")}
</TableHead>
<TableHead>
{t("excelImport.columnErrors")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.invalidRows.slice(0, 20).map((row) => (
<TableRow key={row.row}>
<TableCell className="font-mono">{row.row}</TableCell>
<TableCell>{row.studentName || "—"}</TableCell>
<TableCell className="text-right font-mono">
{row.score}
</TableCell>
<TableCell className="text-xs text-red-600 dark:text-red-400">
{row.errors.join("; ")}
</TableCell>
</TableRow>
))}
{result.invalidRows.length > 20 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-xs text-muted-foreground">
{t("excelImport.moreErrors", {
count: result.invalidRows.length - 20,
})}
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
) : null}
</section>
) : null}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isImporting}
>
{t("excelImport.cancel")}
</Button>
<Button
onClick={handleSubmit}
disabled={isImporting || !file || !form.classId || !form.subjectId || !form.title}
className="gap-2"
>
{isImporting ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<Upload className="h-4 w-4" aria-hidden="true" />
)}
{isImporting
? t("excelImport.importing")
: t("excelImport.startImport")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}