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

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.next
node_modules
.env.local
src/generated

View File

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

View File

@ -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 权限策略,仅允许应用上传指定前缀。

View File

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

View File

@ -18,6 +18,7 @@ export default defineConfig([
'out/**',
'build/**',
'next-env.d.ts',
'src/generated/**',
],
},
...compat.extends('next/core-web-vitals', 'next/typescript'),

View File

@ -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])
}

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()}

View File

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

View File

@ -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: [

View File

@ -1,4 +1,4 @@
import { PrismaClient } from "@prisma/client";
import { PrismaClient } from "@/generated/prisma";
const globalForPrisma = globalThis as typeof globalThis & {
prisma?: PrismaClient;

View File

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

View File

@ -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;