feat(assignments): add PDF submission export for teachers

This commit is contained in:
gameloader
2026-01-23 12:05:23 +08:00
parent 3d6dec4a60
commit 8695ac1b32
4 changed files with 161 additions and 0 deletions

1
installer Submodule

Submodule installer added at e8b1fffdb1

View File

@@ -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",

View File

@@ -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<AssignmentRouteParams> },
) {
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 });
}
}

View File

@@ -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() {
>
</button>
<button
type="button"
style={{
...secondaryButtonStyle,
opacity: isExportingPdf || submissions.length === 0 ? 0.7 : 1,
}}
disabled={isExportingPdf || submissions.length === 0}
onClick={handleExportAllSubmissionsPdf}
>
PDF
</button>
<button
type="button"
style={{