feat(assignments): add PDF submission export for teachers
This commit is contained in:
1
installer
Submodule
1
installer
Submodule
Submodule installer added at e8b1fffdb1
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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={{
|
||||
|
||||
Reference in New Issue
Block a user