feat(roster): implement class roster management and submission validation

This commit is contained in:
gameloader
2025-11-18 09:12:04 +08:00
parent 3c80510e9c
commit 35b4d7470f
19 changed files with 583 additions and 34 deletions

View File

@ -63,6 +63,7 @@ export async function POST(
);
const grading = await evaluateAssignment({
scoringCriteria: assignment.gradingCriteria,
studentId: submission.studentId,
file: fileBuffer,
mimeType: "application/pdf",
});

View File

@ -105,6 +105,19 @@ export async function POST(
const studentId = studentIdRaw;
const studentName = studentNameRaw;
const rosterEntry = await prisma.classStudent.findUnique({
where: {
classroomId_studentId: {
classroomId: assignment.classroomId,
studentId,
},
},
});
if (!rosterEntry || rosterEntry.studentName.trim() !== studentName) {
return NextResponse.json({ error: "不在此班级内" }, { status: 403 });
}
let autoEvaluated = false;
try {
const uploadResult = await uploadAssignmentFile(buffer, file.name, assignmentId);
@ -129,6 +142,7 @@ export async function POST(
try {
const grading = await evaluateAssignment({
scoringCriteria: assignment.gradingCriteria!,
studentId,
file: buffer,
mimeType: "application/pdf",
});

View File

@ -1,11 +1,12 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { uploadAssignmentFile } from "@/lib/s3";
import { deleteAssignmentFile, uploadAssignmentFile } from "@/lib/s3";
import { getNumericParam } from "@/lib/route-params";
import { submissionSchema } from "@/lib/validators";
import { evaluateAssignment } from "@/lib/grading";
const ACCEPT_EXTENSIONS = new Set([".pdf"]);
const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024; // 20MB
type AssignmentRouteParams = { assignmentId: string | string[] | undefined };
@ -60,6 +61,10 @@ export async function POST(
return NextResponse.json({ error: "作业不存在" }, { status: 404 });
}
if (file.size > MAX_FILE_SIZE_BYTES) {
return NextResponse.json({ error: "PDF 文件大小不能超过 20MB" }, { status: 400 });
}
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const studentId = parsed.data.studentId.trim();
@ -73,6 +78,19 @@ export async function POST(
return NextResponse.json({ error: "姓名不能为空" }, { status: 400 });
}
const rosterEntry = await prisma.classStudent.findUnique({
where: {
classroomId_studentId: {
classroomId: assignment.classroomId,
studentId,
},
},
});
if (!rosterEntry || rosterEntry.studentName.trim() !== studentName) {
return NextResponse.json({ error: "不在此班级内" }, { status: 403 });
}
const existingSubmission = await prisma.submission.findUnique({
where: {
assignmentId_studentId: {
@ -82,28 +100,38 @@ export async function POST(
},
});
if (existingSubmission) {
return NextResponse.json(
{ error: "该学号已提交,请勿重复提交" },
{ status: 409 },
);
}
let autoEvaluated = false;
try {
const uploadResult = await uploadAssignmentFile(buffer, file.name, assignmentId);
const previousFileKey = existingSubmission?.fileKey ?? null;
const submission = await prisma.submission.create({
data: {
assignmentId,
studentId,
studentName,
originalFilename: file.name,
fileUrl: uploadResult.url,
fileKey: uploadResult.key,
formPayload: parsed.data.formPayload ?? undefined,
},
});
const submission = existingSubmission
? await prisma.submission.update({
where: { id: existingSubmission.id },
data: {
studentId,
studentName,
originalFilename: file.name,
fileUrl: uploadResult.url,
fileKey: uploadResult.key,
formPayload: parsed.data.formPayload ?? undefined,
submittedAt: new Date(),
evaluationScore: null,
evaluationComment: null,
evaluatedAt: null,
},
})
: await prisma.submission.create({
data: {
assignmentId,
studentId,
studentName,
originalFilename: file.name,
fileUrl: uploadResult.url,
fileKey: uploadResult.key,
formPayload: parsed.data.formPayload ?? undefined,
},
});
const shouldAutoEvaluate = Boolean(
assignment.autoEvaluate && assignment.gradingCriteria?.trim(),
@ -113,6 +141,7 @@ export async function POST(
try {
const grading = await evaluateAssignment({
scoringCriteria: assignment.gradingCriteria!,
studentId,
file: buffer,
mimeType: "application/pdf",
});
@ -130,6 +159,14 @@ export async function POST(
console.error("Auto evaluation failed", error);
}
}
if (previousFileKey) {
try {
await deleteAssignmentFile(previousFileKey);
} catch (error) {
console.error("Failed to delete previous submission file", error);
}
}
} catch (error) {
console.error("Failed to upload assignment", error);
return NextResponse.json({ error: "上传失败" }, { status: 500 });

View File

@ -0,0 +1,131 @@
import { auth } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
import { read, utils } from "xlsx";
import { prisma } from "@/lib/prisma";
import { getNumericParam } from "@/lib/route-params";
type ClassroomRouteParams = { classroomId: string | string[] | undefined };
export async function POST(
request: Request,
{ params }: { params: Promise<ClassroomRouteParams> },
) {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "未授权" }, { status: 401 });
}
const resolvedParams = await params;
const classroomId = getNumericParam(resolvedParams.classroomId);
if (Number.isNaN(classroomId)) {
return NextResponse.json({ error: "班级不存在" }, { status: 400 });
}
const classroom = await prisma.class.findFirst({
where: { id: classroomId, teacherId: userId },
select: { id: true },
});
if (!classroom) {
return NextResponse.json({ error: "未找到班级" }, { status: 404 });
}
const formData = await request.formData();
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "请上传 Excel 文件" }, { status: 400 });
}
let records: Array<{ studentId: string; studentName: string }> = [];
try {
const arrayBuffer = await file.arrayBuffer();
const workbook = read(arrayBuffer, { type: "array" });
const sheetName = workbook.SheetNames[0];
if (!sheetName) {
return NextResponse.json({ error: "Excel 文件为空" }, { status: 400 });
}
const sheet = workbook.Sheets[sheetName];
const rows = utils.sheet_to_json(sheet, {
header: 1,
blankrows: false,
}) as Array<Array<unknown>>;
const normalizedRows = rows
.map((row) =>
row.map((cell) =>
typeof cell === "string" ? cell.trim() : cell ?? "",
),
)
.filter((row) =>
row.some(
(cell) => String(cell ?? "").trim().length > 0,
),
);
if (normalizedRows.length === 0) {
return NextResponse.json({ error: "Excel 文件没有可用数据" }, { status: 400 });
}
const [firstRow, ...restRows] = normalizedRows;
const headerValues = firstRow
.map((cell) => String(cell ?? "").trim().toLowerCase())
.filter(Boolean);
const hasHeader =
headerValues.some((value) => value.includes("学号")) ||
headerValues.some((value) => value.includes("student")) ||
headerValues.some((value) => value.includes("id")) ||
headerValues.some((value) => value.includes("姓名")) ||
headerValues.some((value) => value.includes("name"));
const dataRows = hasHeader ? restRows : normalizedRows;
records = dataRows
.map((row) => {
const studentId = String(row[0] ?? "").trim();
const studentName = String(row[1] ?? "").trim();
return { studentId, studentName };
})
.filter(({ studentId, studentName }) => studentId && studentName);
if (records.length === 0) {
return NextResponse.json(
{ error: "未找到有效的学号和姓名数据" },
{ status: 400 },
);
}
} catch (error) {
console.error("Failed to parse roster Excel", error);
return NextResponse.json({ error: "Excel 解析失败" }, { status: 400 });
}
const uniqueRecords = Array.from(
records.reduce((map, record) => {
map.set(record.studentId, record);
return map;
}, new Map<string, { studentId: string; studentName: string }>()),
([, value]) => value,
);
try {
await prisma.$transaction([
prisma.classStudent.deleteMany({ where: { classroomId } }),
prisma.classStudent.createMany({
data: uniqueRecords.map((record) => ({
classroomId,
studentId: record.studentId,
studentName: record.studentName,
})),
}),
]);
} catch (error) {
console.error("Failed to import roster", error);
return NextResponse.json({ error: "导入失败" }, { status: 500 });
}
return NextResponse.json({
imported: uniqueRecords.length,
});
}

View File

@ -0,0 +1,38 @@
import { auth } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getNumericParam } from "@/lib/route-params";
type ClassroomRouteParams = { classroomId: string | string[] | undefined };
export async function GET(
_request: Request,
{ params }: { params: Promise<ClassroomRouteParams> },
) {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "未授权" }, { status: 401 });
}
const resolvedParams = await params;
const classroomId = getNumericParam(resolvedParams.classroomId);
if (Number.isNaN(classroomId)) {
return NextResponse.json({ error: "班级不存在" }, { status: 400 });
}
const classroom = await prisma.class.findFirst({
where: { id: classroomId, teacherId: userId },
select: { id: true },
});
if (!classroom) {
return NextResponse.json({ error: "未找到班级" }, { status: 404 });
}
const students = await prisma.classStudent.findMany({
where: { classroomId },
orderBy: [{ studentId: "asc" }, { studentName: "asc" }],
});
return NextResponse.json(students);
}

View File

@ -0,0 +1,58 @@
import { auth } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getNumericParam } from "@/lib/route-params";
import { loadSubmissionBuffer } from "@/lib/assignment-evaluation";
type SubmissionRouteParams = { submissionId: string | string[] | undefined };
export async function GET(
_request: Request,
{ params }: { params: Promise<SubmissionRouteParams> },
) {
const resolvedParams = await params;
const submissionId = getNumericParam(resolvedParams.submissionId);
if (Number.isNaN(submissionId)) {
return NextResponse.json({ error: "提交不存在" }, { status: 400 });
}
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "未授权" }, { status: 401 });
}
const submission = await prisma.submission.findFirst({
where: {
id: submissionId,
assignment: { teacherId: userId },
},
select: {
id: true,
originalFilename: true,
fileUrl: true,
fileKey: true,
},
});
if (!submission) {
return NextResponse.json({ error: "未找到提交" }, { status: 404 });
}
try {
const buffer = await loadSubmissionBuffer(submission.fileKey, submission.fileUrl);
const filename = submission.originalFilename || `submission-${submission.id}.pdf`;
const encodedFilename = encodeURIComponent(filename);
return new NextResponse(buffer, {
headers: {
"Content-Type": "application/pdf",
"Content-Length": buffer.byteLength.toString(),
"Content-Disposition": `attachment; filename="${encodedFilename}"; filename*=UTF-8''${encodedFilename}`,
"Cache-Control": "private, max-age=0, must-revalidate",
},
});
} catch (error) {
console.error(`Failed to download submission ${submission.id}`, error);
return NextResponse.json({ error: "下载文件失败" }, { status: 500 });
}
}

View File

@ -4,6 +4,9 @@ import { useEffect, useMemo, useState } from "react";
import type { Assignment, Classroom, Course } from "@/types";
import { fetchAssignments, fetchClassrooms, fetchCourses } from "@/lib/api";
const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024; // 20MB
const FILE_SIZE_ERROR_MESSAGE = "上传文件大小不能超过 20MB";
const containerStyle: React.CSSProperties = {
maxWidth: "900px",
margin: "0 auto",
@ -127,6 +130,20 @@ export default function StudentPage() {
".pdf",
]).join(",");
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const selected = event.target.files?.[0];
if (selected && selected.size > MAX_FILE_SIZE_BYTES) {
setError(FILE_SIZE_ERROR_MESSAGE);
event.target.value = "";
setFile(null);
return;
}
setFile(selected ?? null);
setError((prev) => (prev === FILE_SIZE_ERROR_MESSAGE ? "" : prev));
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setMessage("");
@ -142,6 +159,11 @@ export default function StudentPage() {
return;
}
if (file.size > MAX_FILE_SIZE_BYTES) {
setError(FILE_SIZE_ERROR_MESSAGE);
return;
}
for (const field of formFields) {
if (field.required && !formValues[field.name]?.trim()) {
setError(`请输入 ${field.label}`);
@ -305,10 +327,7 @@ export default function StudentPage() {
<input
type="file"
accept={fileAccept}
onChange={(event) => {
const selected = event.target.files?.[0];
setFile(selected ?? null);
}}
onChange={handleFileChange}
required
/>
</label>

View File

@ -1,14 +1,22 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { Assignment, Classroom, Course, Submission } from "@/types";
import type {
Assignment,
Classroom,
ClassroomStudent,
Course,
Submission,
} from "@/types";
import {
createAssignment,
createClassroom,
createCourse,
fetchClassroomStudents,
fetchAssignments,
fetchClassrooms,
fetchCourses,
importClassroomStudents,
fetchSubmissions,
updateAssignment,
evaluateSubmissions,
@ -106,6 +114,7 @@ export default function TeacherPage() {
const [classrooms, setClassrooms] = useState<Classroom[]>([]);
const [assignments, setAssignments] = useState<Assignment[]>([]);
const [submissions, setSubmissions] = useState<Submission[]>([]);
const [classroomStudents, setClassroomStudents] = useState<ClassroomStudent[]>([]);
const [selectedCourse, setSelectedCourse] = useState<number | null>(null);
const [selectedClassroom, setSelectedClassroom] = useState<number | null>(null);
@ -146,6 +155,7 @@ export default function TeacherPage() {
const [info, setInfo] = useState<string>("");
const [error, setError] = useState<string>("");
const [isEvaluating, setIsEvaluating] = useState(false);
const [isImportingRoster, setIsImportingRoster] = useState(false);
const [gradingCriteriaDraft, setGradingCriteriaDraft] = useState("");
const [autoEvaluateDraft, setAutoEvaluateDraft] = useState(false);
const [isExporting, setIsExporting] = useState(false);
@ -153,6 +163,7 @@ export default function TeacherPage() {
const [selectedExportColumns, setSelectedExportColumns] = useState<
Array<(typeof EXPORTABLE_COLUMNS)[number]["key"]>
>(["studentId", "evaluationScore"]);
const [downloadingSubmissionId, setDownloadingSubmissionId] = useState<number | null>(null);
useEffect(() => {
fetchCourses()
@ -194,6 +205,30 @@ export default function TeacherPage() {
.catch(() => setError("加载作业失败"));
}, [selectedClassroom]);
useEffect(() => {
if (selectedClassroom == null) {
setClassroomStudents([]);
return;
}
let cancelled = false;
fetchClassroomStudents(selectedClassroom)
.then((data) => {
if (!cancelled) {
setClassroomStudents(data);
}
})
.catch(() => {
if (!cancelled) {
setError("加载班级名单失败");
}
});
return () => {
cancelled = true;
};
}, [selectedClassroom]);
useEffect(() => {
if (selectedAssignment == null) {
setSubmissions([]);
@ -306,6 +341,45 @@ export default function TeacherPage() {
setIsCustomExportModalVisible(false);
};
const handleDownloadSubmission = useCallback(
async (submission: Submission) => {
setError("");
setInfo("");
setDownloadingSubmissionId(submission.id);
try {
const response = await fetch(`/api/submissions/${submission.id}/download`);
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) ??
submission.originalFilename ??
`submission-${submission.id}.pdf`;
link.href = downloadUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(downloadUrl);
} catch (err) {
setError(err instanceof Error ? err.message : "文件下载失败");
} finally {
setDownloadingSubmissionId(null);
}
},
[],
);
const handleCreateCourse = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError("");
@ -345,6 +419,34 @@ export default function TeacherPage() {
}
};
const handleRosterImport = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
if (selectedClassroom == null) {
setError("请先选择班级");
event.target.value = "";
return;
}
setIsImportingRoster(true);
setError("");
setInfo("");
try {
const result = await importClassroomStudents(selectedClassroom, file);
const refreshed = await fetchClassroomStudents(selectedClassroom);
setClassroomStudents(refreshed);
setInfo(`导入成功,共 ${result.imported}`);
} catch (err) {
setError(err instanceof Error ? err.message : "导入失败");
} finally {
setIsImportingRoster(false);
event.target.value = "";
}
};
const handleCreateAssignment = async (
event: React.FormEvent<HTMLFormElement>,
) => {
@ -540,6 +642,53 @@ export default function TeacherPage() {
))}
</select>
</div>
<div style={{ marginTop: "1.5rem", display: "grid", gap: "0.5rem" }}>
<label style={{ fontWeight: 500 }}>
Excel
</label>
<input
type="file"
accept=".xls,.xlsx,.csv"
style={inputStyle}
onChange={handleRosterImport}
disabled={!selectedClassroom || isImportingRoster}
/>
<small style={{ color: "#6b7280" }}>
{selectedClassroom
? `已导入 ${classroomStudents.length}${
isImportingRoster ? ",导入中..." : ""
}`
: "请选择班级后再导入 Excel 名单"}
</small>
{classroomStudents.length > 0 && (
<div
style={{
background: "#f9fafb",
borderRadius: "0.75rem",
padding: "0.75rem",
border: "1px solid #e5e7eb",
display: "grid",
gap: "0.4rem",
}}
>
{classroomStudents.slice(0, 6).map((student) => (
<div
key={`${student.studentId}-${student.id}`}
style={{ display: "flex", justifyContent: "space-between", fontSize: "0.95rem" }}
>
<span style={{ fontWeight: 500 }}>{student.studentId}</span>
<span>{student.studentName}</span>
</div>
))}
{classroomStudents.length > 6 && (
<span style={{ color: "#9ca3af" }}>
{classroomStudents.length - 6} ...
</span>
)}
</div>
)}
</div>
</section>
<section style={sectionStyle}>
@ -732,7 +881,7 @@ export default function TeacherPage() {
<th style={{ textAlign: "left", padding: "0.75rem" }}></th>
<th style={{ textAlign: "left", padding: "0.75rem" }}></th>
<th style={{ textAlign: "left", padding: "0.75rem" }}></th>
<th style={{ textAlign: "left", padding: "0.75rem" }}></th>
<th style={{ textAlign: "left", padding: "0.75rem" }}></th>
<th style={{ textAlign: "left", padding: "0.75rem" }}></th>
<th style={{ textAlign: "left", padding: "0.75rem" }}></th>
<th style={{ textAlign: "left", padding: "0.75rem" }}></th>
@ -746,9 +895,18 @@ export default function TeacherPage() {
<td style={{ padding: "0.75rem" }}>{submission.studentName}</td>
<td style={{ padding: "0.75rem" }}>{submission.originalFilename}</td>
<td style={{ padding: "0.75rem" }}>
<a href={submission.fileUrl} target="_blank" rel="noreferrer">
</a>
<button
type="button"
style={{
...secondaryButtonStyle,
padding: "0.35rem 0.9rem",
fontSize: "0.9rem",
}}
onClick={() => handleDownloadSubmission(submission)}
disabled={downloadingSubmissionId === submission.id}
>
{downloadingSubmissionId === submission.id ? "获取中..." : "下载 PDF"}
</button>
</td>
<td style={{ padding: "0.75rem" }}>
{new Date(submission.submittedAt).toLocaleString()}