diff --git a/installer b/installer new file mode 160000 index 0000000..e8b1fff --- /dev/null +++ b/installer @@ -0,0 +1 @@ +Subproject commit e8b1fffdb1065f971bee10acde7f28c1547f954b diff --git a/package.json b/package.json index 5380d81..e7b7f49 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@aws-sdk/client-s3": "^3.922.0", "@clerk/nextjs": "^6.34.1", "@prisma/client": "^5.22.0", + "jszip": "^3.10.1", "next": "^15.4.2", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/src/app/api/assignments/[assignmentId]/submissions/export-pdf/route.ts b/src/app/api/assignments/[assignmentId]/submissions/export-pdf/route.ts new file mode 100644 index 0000000..e48c917 --- /dev/null +++ b/src/app/api/assignments/[assignmentId]/submissions/export-pdf/route.ts @@ -0,0 +1,102 @@ +import { auth } from "@clerk/nextjs/server"; +import JSZip from "jszip"; +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { getNumericParam } from "@/lib/route-params"; +import { loadSubmissionBuffer } from "@/lib/assignment-evaluation"; + +type AssignmentRouteParams = { assignmentId: string | string[] | undefined }; + +const sanitizeFilenameSegment = (value: string, fallback: string) => { + const cleaned = value + .replace(/[\\/:*?"<>|]+/g, "_") + .replace(/\s+/g, " ") + .trim() + .replace(/^\.+/, "") + .replace(/\.+$/, ""); + return cleaned || fallback; +}; + +export async function GET( + _request: Request, + { params }: { params: Promise }, +) { + const resolvedParams = await params; + const assignmentId = getNumericParam(resolvedParams.assignmentId); + if (Number.isNaN(assignmentId)) { + return NextResponse.json({ error: "作业不存在" }, { status: 400 }); + } + + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "未授权" }, { status: 401 }); + } + + const assignment = await prisma.assignment.findFirst({ + where: { id: assignmentId, teacherId: userId }, + select: { id: true, title: true }, + }); + + if (!assignment) { + return NextResponse.json({ error: "未找到作业" }, { status: 404 }); + } + + const submissions = await prisma.submission.findMany({ + where: { assignmentId }, + orderBy: { submittedAt: "asc" }, + select: { + id: true, + studentId: true, + studentName: true, + fileKey: true, + fileUrl: true, + }, + }); + + if (submissions.length === 0) { + return NextResponse.json({ error: "暂无提交可导出" }, { status: 404 }); + } + + const assignmentTitle = sanitizeFilenameSegment( + assignment.title ?? `assignment-${assignmentId}`, + `assignment-${assignmentId}`, + ); + + try { + const zip = new JSZip(); + for (const submission of submissions) { + const buffer = await loadSubmissionBuffer(submission.fileKey, submission.fileUrl); + const studentId = sanitizeFilenameSegment( + submission.studentId || `student-${submission.id}`, + `student-${submission.id}`, + ); + const studentName = sanitizeFilenameSegment( + submission.studentName || "unknown", + "unknown", + ); + const filename = `${studentId}-${studentName}-${assignmentTitle}.pdf`; + zip.file(filename, buffer); + } + + const zipBuffer = await zip.generateAsync({ + type: "nodebuffer", + compression: "DEFLATE", + compressionOptions: { level: 6 }, + }); + + const zipFilename = `${assignmentTitle}_submissions_pdf.zip`; + const encodedFilename = encodeURIComponent(zipFilename); + + return new NextResponse(zipBuffer, { + headers: { + "Content-Type": "application/zip", + "Content-Length": zipBuffer.length.toString(), + "Content-Disposition": `attachment; filename="${encodedFilename}"; filename*=UTF-8''${encodedFilename}`, + "Cache-Control": "no-store", + }, + }); + } catch (error) { + console.error(`Failed to export submissions for assignment ${assignmentId}`, error); + return NextResponse.json({ error: "导出压缩包失败" }, { status: 500 }); + } +} diff --git a/src/app/teacher/page.tsx b/src/app/teacher/page.tsx index 75635dd..d6f8fa8 100644 --- a/src/app/teacher/page.tsx +++ b/src/app/teacher/page.tsx @@ -159,6 +159,7 @@ export default function TeacherPage() { const [gradingCriteriaDraft, setGradingCriteriaDraft] = useState(""); const [autoEvaluateDraft, setAutoEvaluateDraft] = useState(false); const [isExporting, setIsExporting] = useState(false); + const [isExportingPdf, setIsExportingPdf] = useState(false); const [isCustomExportModalVisible, setIsCustomExportModalVisible] = useState(false); const [selectedExportColumns, setSelectedExportColumns] = useState< Array<(typeof EXPORTABLE_COLUMNS)[number]["key"]> @@ -341,6 +342,51 @@ export default function TeacherPage() { setIsCustomExportModalVisible(false); }; + const handleExportAllSubmissionsPdf = useCallback(async () => { + if (!selectedAssignment) { + setError("请选择作业"); + return; + } + if (submissions.length === 0) { + setError("暂无提交可导出"); + return; + } + + setIsExportingPdf(true); + setError(""); + setInfo(""); + try { + const response = await fetch( + `/api/assignments/${selectedAssignment}/submissions/export-pdf`, + ); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data?.error ?? "导出失败"); + } + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + const disposition = response.headers.get("Content-Disposition") ?? ""; + const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i); + const asciiMatch = disposition.match(/filename=\"?([^\";]+)\"?/i); + const headerFilename = utf8Match?.[1] ?? asciiMatch?.[1]; + const filename = + (headerFilename ? decodeURIComponent(headerFilename) : null) ?? + `assignment-${selectedAssignment}-submissions.zip`; + link.href = downloadUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(downloadUrl); + setInfo("导出成功"); + } catch (err) { + setError(err instanceof Error ? err.message : "导出失败"); + } finally { + setIsExportingPdf(false); + } + }, [selectedAssignment, submissions.length]); + const handleDownloadSubmission = useCallback( async (submission: Submission) => { setError(""); @@ -861,6 +907,17 @@ export default function TeacherPage() { > 导出学号和分数 +