Files
NextEdu/src/modules/grades/components/excel-import-dialog.tsx
SpecialX e85a5f05dd 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
2026-07-03 10:25:01 +08:00

519 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
)
}