feat(roster): implement class roster management and submission validation
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
.next
|
||||
node_modules
|
||||
.env.local
|
||||
src/generated
|
||||
|
||||
@ -53,6 +53,7 @@ COPY --from=builder --chown=node:node /app/.next ./.next
|
||||
# Copy public assets only if they exist in the project
|
||||
COPY --from=builder --chown=node:node /app/public ./public
|
||||
COPY --from=builder --chown=node:node /app/next.config.js ./next.config.js
|
||||
COPY --from=builder --chown=node:node /app/src/generated/prisma ./src/generated/prisma
|
||||
|
||||
# Keep the filesystem owned by the non-root user
|
||||
USER node
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
使用 Next.js (App Router) 重写的 AutoTeacher 教学辅助应用
|
||||
|
||||
- 学生端:课程 / 班级 / 作业级联选择,动态表单填写,PDF 文件上传(存储于 S3 兼容对象存储),支持填写学号与姓名并限制同一学号仅提交一次,提交信息写入 PostgreSQL。
|
||||
- 教师端:创建课程、班级、作业,自定义表单 JSON 配置,支持设置评分标准并选择学生提交后自动评分或手动触发评分。
|
||||
- 教师端:创建课程、班级、作业,自定义表单 JSON 配置,支持设置评分标准并选择学生提交后自动评分或手动触发评分,支持为班级导入 Excel 名册并强制学生提交校验。
|
||||
- 后端:Next.js API Route + Prisma + PostgreSQL,文件持久化到 S3。
|
||||
|
||||
## 快速开始
|
||||
@ -104,6 +104,12 @@ autoteacher-next/
|
||||
|
||||
如需扩展字段,可添加 `textarea` 或更多 `text` 输入项;学生提交时的字段值会以 JSON 格式存入 `Submission.formPayload`。
|
||||
|
||||
## 班级名单导入与校验
|
||||
|
||||
- **Excel 格式**:仅需两列,第一列是学号、第二列是姓名,表头可选;若保留表头,建议包含 “学号/Student/ID” 与 “姓名/Name” 等字样以便自动识别。
|
||||
- **导入流程**:教师后台 → “班级管理” → 选择班级 → 使用“班级名单导入”上传 Excel。上传后会用文件内容替换该班级的全部名册并展示已导入人数。
|
||||
- **学生提交校验**:学生在提交作业时需先选择班级,其输入的学号与姓名必须与该班级名册中的记录完全匹配,否则返回 “不在此班级内” 并禁止提交。每个学号仍然只能提交一次。
|
||||
|
||||
## 生产部署提示
|
||||
|
||||
- 推荐设置独立的 S3 Bucket 权限策略,仅允许应用上传指定前缀。
|
||||
|
||||
@ -2,6 +2,12 @@ version: "3.8"
|
||||
|
||||
services:
|
||||
autoteacher:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql://postgres:postgres@host.docker.internal:5432/postgres}
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:-}
|
||||
image: autoteacher-next:latest
|
||||
container_name: autoteacher-next
|
||||
restart: unless-stopped
|
||||
|
||||
@ -18,6 +18,7 @@ export default defineConfig([
|
||||
'out/**',
|
||||
'build/**',
|
||||
'next-env.d.ts',
|
||||
'src/generated/**',
|
||||
],
|
||||
},
|
||||
...compat.extends('next/core-web-vitals', 'next/typescript'),
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "../src/generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
@ -29,6 +30,7 @@ model Class {
|
||||
teacherId String
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
assignments Assignment[]
|
||||
students ClassStudent[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@ -73,3 +75,15 @@ model Submission {
|
||||
|
||||
@@unique([assignmentId, studentId])
|
||||
}
|
||||
|
||||
model ClassStudent {
|
||||
id Int @id @default(autoincrement())
|
||||
classroomId Int
|
||||
studentId String
|
||||
studentName String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
classroom Class @relation(fields: [classroomId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([classroomId, studentId])
|
||||
}
|
||||
|
||||
@ -63,6 +63,7 @@ export async function POST(
|
||||
);
|
||||
const grading = await evaluateAssignment({
|
||||
scoringCriteria: assignment.gradingCriteria,
|
||||
studentId: submission.studentId,
|
||||
file: fileBuffer,
|
||||
mimeType: "application/pdf",
|
||||
});
|
||||
|
||||
@ -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",
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
|
||||
131
src/app/api/classrooms/[classroomId]/students/import/route.ts
Normal file
131
src/app/api/classrooms/[classroomId]/students/import/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
38
src/app/api/classrooms/[classroomId]/students/route.ts
Normal file
38
src/app/api/classrooms/[classroomId]/students/route.ts
Normal 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);
|
||||
}
|
||||
58
src/app/api/submissions/[submissionId]/download/route.ts
Normal file
58
src/app/api/submissions/[submissionId]/download/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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()}
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
import { Assignment, Classroom, Course, Submission } from "@/types";
|
||||
import {
|
||||
Assignment,
|
||||
Classroom,
|
||||
ClassroomStudent,
|
||||
Course,
|
||||
Submission,
|
||||
} from "@/types";
|
||||
|
||||
export type EvaluationResult = {
|
||||
evaluated: number;
|
||||
@ -121,3 +127,30 @@ export async function evaluateSubmissions(
|
||||
);
|
||||
return handleResponse<EvaluationResult>(response);
|
||||
}
|
||||
|
||||
export async function fetchClassroomStudents(
|
||||
classroomId: number,
|
||||
): Promise<ClassroomStudent[]> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/classrooms/${classroomId}/students`,
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
return handleResponse<ClassroomStudent[]>(response);
|
||||
}
|
||||
|
||||
export async function importClassroomStudents(
|
||||
classroomId: number,
|
||||
file: File,
|
||||
): Promise<{ imported: number }> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE}/classrooms/${classroomId}/students/import`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
return handleResponse<{ imported: number }>(response);
|
||||
}
|
||||
|
||||
@ -17,10 +17,13 @@ export type GradingResult = {
|
||||
const DEFAULT_BASE_URL =
|
||||
"https://www.chataiapi.com/v1beta/models/gemini-2.5-pro:generateContent";
|
||||
|
||||
export function createGradingPrompt(scoringCriteria: string): string {
|
||||
export function createGradingPrompt(scoringCriteria: string, studentId: string): string {
|
||||
const normalizedStudentId = studentId?.trim() || "未提供";
|
||||
return `
|
||||
# 角色
|
||||
你是一位严谨、细致的AI评分助手。
|
||||
# 学生信息
|
||||
学号:${normalizedStudentId}
|
||||
# 任务
|
||||
你的任务是根据我提供的「评分标准」,对用户上传的PDF文件(作为一份学生作业)进行评估和打分。
|
||||
# 核心指令
|
||||
@ -32,6 +35,7 @@ export function createGradingPrompt(scoringCriteria: string): string {
|
||||
- 首先,仔细阅读并完全理解下方提供的「评分标准」。
|
||||
- 接着,认真、完整地审阅PDF文档的全部内容。
|
||||
- 最后,严格依据「评分标准」中的每一个要点,对PDF内容进行逐项对比、分析,并给出分数。
|
||||
- 若评分标准或作业要求与学号相关,必须结合学号 ${normalizedStudentId} 的信息进行判断和评分。
|
||||
3. **输出格式**:
|
||||
- 你的最终输出**必须**是一个严格的、不包含任何额外解释性文字的JSON对象。
|
||||
- JSON对象必须包含以下两个键(keys):
|
||||
@ -48,6 +52,7 @@ ${scoringCriteria}
|
||||
|
||||
type EvaluateAssignmentOptions = {
|
||||
scoringCriteria: string;
|
||||
studentId: string;
|
||||
file:
|
||||
| Buffer
|
||||
| ArrayBuffer
|
||||
@ -59,6 +64,7 @@ type EvaluateAssignmentOptions = {
|
||||
|
||||
export async function evaluateAssignment({
|
||||
scoringCriteria,
|
||||
studentId,
|
||||
file,
|
||||
mimeType = "application/pdf",
|
||||
apiKey,
|
||||
@ -80,7 +86,7 @@ export async function evaluateAssignment({
|
||||
);
|
||||
}
|
||||
|
||||
const prompt = createGradingPrompt(scoringCriteria);
|
||||
const prompt = createGradingPrompt(scoringCriteria, studentId);
|
||||
const buffer = toBuffer(file);
|
||||
const payload = {
|
||||
contents: [
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaClient } from "@/generated/prisma";
|
||||
|
||||
const globalForPrisma = globalThis as typeof globalThis & {
|
||||
prisma?: PrismaClient;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { Readable } from "node:stream";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
@ -142,3 +142,19 @@ export async function downloadAssignmentFile(key: string): Promise<Buffer> {
|
||||
|
||||
throw new Error("不支持的 S3 响应流类型");
|
||||
}
|
||||
|
||||
export async function deleteAssignmentFile(key?: string | null): Promise<void> {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
const client = getClient();
|
||||
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
await client.send(command);
|
||||
}
|
||||
|
||||
@ -13,6 +13,15 @@ export type Classroom = {
|
||||
teacherId: string;
|
||||
};
|
||||
|
||||
export type ClassroomStudent = {
|
||||
id: number;
|
||||
classroomId: number;
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type Assignment = {
|
||||
id: number;
|
||||
title: string;
|
||||
|
||||
Reference in New Issue
Block a user