diff --git a/.gitignore b/.gitignore index 1f1f1fc..9b55799 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .next node_modules .env.local +src/generated diff --git a/Dockerfile b/Dockerfile index 21856c7..ff2bb6b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 08935ea..fb3d0c8 100644 --- a/README.md +++ b/README.md @@ -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 权限策略,仅允许应用上传指定前缀。 diff --git a/docker-compose.yml b/docker-compose.yml index f550aa2..3650926 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/eslint.config.mjs b/eslint.config.mjs index e552edd..570050f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -18,6 +18,7 @@ export default defineConfig([ 'out/**', 'build/**', 'next-env.d.ts', + 'src/generated/**', ], }, ...compat.extends('next/core-web-vitals', 'next/typescript'), diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 53bcacd..da10888 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/src/app/api/assignments/[assignmentId]/evaluate/route.ts b/src/app/api/assignments/[assignmentId]/evaluate/route.ts index 642a965..6ac4408 100644 --- a/src/app/api/assignments/[assignmentId]/evaluate/route.ts +++ b/src/app/api/assignments/[assignmentId]/evaluate/route.ts @@ -63,6 +63,7 @@ export async function POST( ); const grading = await evaluateAssignment({ scoringCriteria: assignment.gradingCriteria, + studentId: submission.studentId, file: fileBuffer, mimeType: "application/pdf", }); diff --git a/src/app/api/assignments/[assignmentId]/submissions/route.ts b/src/app/api/assignments/[assignmentId]/submissions/route.ts index 147ece7..18f4379 100644 --- a/src/app/api/assignments/[assignmentId]/submissions/route.ts +++ b/src/app/api/assignments/[assignmentId]/submissions/route.ts @@ -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", }); diff --git a/src/app/api/assignments/[assignmentId]/submissions/upload.ts b/src/app/api/assignments/[assignmentId]/submissions/upload.ts index c89f70f..43ead82 100644 --- a/src/app/api/assignments/[assignmentId]/submissions/upload.ts +++ b/src/app/api/assignments/[assignmentId]/submissions/upload.ts @@ -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 }); diff --git a/src/app/api/classrooms/[classroomId]/students/import/route.ts b/src/app/api/classrooms/[classroomId]/students/import/route.ts new file mode 100644 index 0000000..d31bfa2 --- /dev/null +++ b/src/app/api/classrooms/[classroomId]/students/import/route.ts @@ -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 }, +) { + 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>; + + 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()), + ([, 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, + }); +} diff --git a/src/app/api/classrooms/[classroomId]/students/route.ts b/src/app/api/classrooms/[classroomId]/students/route.ts new file mode 100644 index 0000000..4c55df7 --- /dev/null +++ b/src/app/api/classrooms/[classroomId]/students/route.ts @@ -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 }, +) { + 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); +} diff --git a/src/app/api/submissions/[submissionId]/download/route.ts b/src/app/api/submissions/[submissionId]/download/route.ts new file mode 100644 index 0000000..8ca6ff7 --- /dev/null +++ b/src/app/api/submissions/[submissionId]/download/route.ts @@ -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 }, +) { + 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 }); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 1c61064..b0e5e17 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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) => { + 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) => { 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() { { - const selected = event.target.files?.[0]; - setFile(selected ?? null); - }} + onChange={handleFileChange} required /> diff --git a/src/app/teacher/page.tsx b/src/app/teacher/page.tsx index cfd7dc2..75635dd 100644 --- a/src/app/teacher/page.tsx +++ b/src/app/teacher/page.tsx @@ -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([]); const [assignments, setAssignments] = useState([]); const [submissions, setSubmissions] = useState([]); + const [classroomStudents, setClassroomStudents] = useState([]); const [selectedCourse, setSelectedCourse] = useState(null); const [selectedClassroom, setSelectedClassroom] = useState(null); @@ -146,6 +155,7 @@ export default function TeacherPage() { const [info, setInfo] = useState(""); const [error, setError] = useState(""); 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(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) => { event.preventDefault(); setError(""); @@ -345,6 +419,34 @@ export default function TeacherPage() { } }; + const handleRosterImport = async ( + event: React.ChangeEvent, + ) => { + 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, ) => { @@ -540,6 +642,53 @@ export default function TeacherPage() { ))} + +
+ + + + {selectedClassroom + ? `已导入 ${classroomStudents.length} 人${ + isImportingRoster ? ",导入中..." : "" + }` + : "请选择班级后再导入 Excel 名单"} + + {classroomStudents.length > 0 && ( +
+ {classroomStudents.slice(0, 6).map((student) => ( +
+ {student.studentId} + {student.studentName} +
+ ))} + {classroomStudents.length > 6 && ( + + 另有 {classroomStudents.length - 6} 人... + + )} +
+ )} +
@@ -732,7 +881,7 @@ export default function TeacherPage() { 学生学号 学生姓名 文件名 - 文件地址 + 作业文件 提交时间 自动评分 评价评语 @@ -746,9 +895,18 @@ export default function TeacherPage() { {submission.studentName} {submission.originalFilename} - - 查看文件 - + {new Date(submission.submittedAt).toLocaleString()} diff --git a/src/lib/api.ts b/src/lib/api.ts index 68e77c8..96aaf96 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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(response); } + +export async function fetchClassroomStudents( + classroomId: number, +): Promise { + const response = await fetch( + `${API_BASE}/classrooms/${classroomId}/students`, + { cache: "no-store" }, + ); + return handleResponse(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); +} diff --git a/src/lib/grading.ts b/src/lib/grading.ts index 49b7d54..ddda366 100644 --- a/src/lib/grading.ts +++ b/src/lib/grading.ts @@ -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: [ diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 0386c84..2423c94 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -1,4 +1,4 @@ -import { PrismaClient } from "@prisma/client"; +import { PrismaClient } from "@/generated/prisma"; const globalForPrisma = globalThis as typeof globalThis & { prisma?: PrismaClient; diff --git a/src/lib/s3.ts b/src/lib/s3.ts index 495e504..9fd88c1 100644 --- a/src/lib/s3.ts +++ b/src/lib/s3.ts @@ -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 { throw new Error("不支持的 S3 响应流类型"); } + +export async function deleteAssignmentFile(key?: string | null): Promise { + if (!key) { + return; + } + + const config = getConfig(); + const client = getClient(); + + const command = new DeleteObjectCommand({ + Bucket: config.bucket, + Key: key, + }); + + await client.send(command); +} diff --git a/src/types/index.ts b/src/types/index.ts index fd938d4..0ea14ca 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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;