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",
|
"@aws-sdk/client-s3": "^3.922.0",
|
||||||
"@clerk/nextjs": "^6.34.1",
|
"@clerk/nextjs": "^6.34.1",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"next": "^15.4.2",
|
"next": "^15.4.2",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^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 [gradingCriteriaDraft, setGradingCriteriaDraft] = useState("");
|
||||||
const [autoEvaluateDraft, setAutoEvaluateDraft] = useState(false);
|
const [autoEvaluateDraft, setAutoEvaluateDraft] = useState(false);
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
const [isExportingPdf, setIsExportingPdf] = useState(false);
|
||||||
const [isCustomExportModalVisible, setIsCustomExportModalVisible] = useState(false);
|
const [isCustomExportModalVisible, setIsCustomExportModalVisible] = useState(false);
|
||||||
const [selectedExportColumns, setSelectedExportColumns] = useState<
|
const [selectedExportColumns, setSelectedExportColumns] = useState<
|
||||||
Array<(typeof EXPORTABLE_COLUMNS)[number]["key"]>
|
Array<(typeof EXPORTABLE_COLUMNS)[number]["key"]>
|
||||||
@@ -341,6 +342,51 @@ export default function TeacherPage() {
|
|||||||
setIsCustomExportModalVisible(false);
|
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(
|
const handleDownloadSubmission = useCallback(
|
||||||
async (submission: Submission) => {
|
async (submission: Submission) => {
|
||||||
setError("");
|
setError("");
|
||||||
@@ -861,6 +907,17 @@ export default function TeacherPage() {
|
|||||||
>
|
>
|
||||||
导出学号和分数
|
导出学号和分数
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
...secondaryButtonStyle,
|
||||||
|
opacity: isExportingPdf || submissions.length === 0 ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
disabled={isExportingPdf || submissions.length === 0}
|
||||||
|
onClick={handleExportAllSubmissionsPdf}
|
||||||
|
>
|
||||||
|
导出所有提交 PDF
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
Reference in New Issue
Block a user