- 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
519 lines
18 KiB
TypeScript
519 lines
18 KiB
TypeScript
"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>
|
||
)
|
||
}
|