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:
518
src/modules/grades/components/excel-import-dialog.tsx
Normal file
518
src/modules/grades/components/excel-import-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user