From 6d4aef9543c6db70eb0a2eece3c8961145b189aa Mon Sep 17 00:00:00 2001 From: cfngc4594 Date: Wed, 6 May 2026 21:16:01 +0800 Subject: [PATCH] feat(course): add course-assignment workflow with role-based access and assignment-scoped judging --- .../migration.sql | 115 ++++++ .../migration.sql | 19 + prisma/schema.prisma | 84 +++++ prisma/seed.ts | 119 ++++++ .../dashboard/actions/assignment-stats.ts | 347 ++++++++++++++++++ .../dashboard/actions/course-auth.ts | 104 ++++++ .../dashboard/actions/student-dashboard.ts | 7 + .../dashboard/actions/teacher-assignments.ts | 315 ++++++++++++++++ .../dashboard/actions/teacher-courses.ts | 272 ++++++++++++++ .../dashboard/actions/teacher-dashboard.ts | 10 + src/app/(protected)/dashboard/page.tsx | 10 + .../assignments/[assignmentId]/page.tsx | 99 +++++ .../student/courses/[courseId]/page.tsx | 100 +++++ .../dashboard/student/courses/layout.tsx | 9 + .../dashboard/student/courses/page.tsx | 72 ++++ .../assignments/[assignmentId]/page.tsx | 164 +++++++++ .../teacher/courses/[courseId]/page.tsx | 320 ++++++++++++++++ .../dashboard/teacher/courses/layout.tsx | 9 + .../dashboard/teacher/courses/page.tsx | 138 +++++++ src/app/actions/judge.ts | 140 +++++-- src/components/dynamic-breadcrumb.tsx | 2 + src/components/sidebar/app-sidebar.tsx | 4 + src/components/sidebar/teacher-sidebar.tsx | 4 + .../problems/components/judge-button.tsx | 5 +- 24 files changed, 2437 insertions(+), 31 deletions(-) create mode 100644 prisma/migrations/20260506081816_add_course_assignment_mvp/migration.sql create mode 100644 prisma/migrations/20260506083600_remove_ta_course_role/migration.sql create mode 100644 src/app/(protected)/dashboard/actions/assignment-stats.ts create mode 100644 src/app/(protected)/dashboard/actions/course-auth.ts create mode 100644 src/app/(protected)/dashboard/actions/teacher-assignments.ts create mode 100644 src/app/(protected)/dashboard/actions/teacher-courses.ts create mode 100644 src/app/(protected)/dashboard/student/courses/[courseId]/assignments/[assignmentId]/page.tsx create mode 100644 src/app/(protected)/dashboard/student/courses/[courseId]/page.tsx create mode 100644 src/app/(protected)/dashboard/student/courses/layout.tsx create mode 100644 src/app/(protected)/dashboard/student/courses/page.tsx create mode 100644 src/app/(protected)/dashboard/teacher/courses/[courseId]/assignments/[assignmentId]/page.tsx create mode 100644 src/app/(protected)/dashboard/teacher/courses/[courseId]/page.tsx create mode 100644 src/app/(protected)/dashboard/teacher/courses/layout.tsx create mode 100644 src/app/(protected)/dashboard/teacher/courses/page.tsx diff --git a/prisma/migrations/20260506081816_add_course_assignment_mvp/migration.sql b/prisma/migrations/20260506081816_add_course_assignment_mvp/migration.sql new file mode 100644 index 0000000..8f411f7 --- /dev/null +++ b/prisma/migrations/20260506081816_add_course_assignment_mvp/migration.sql @@ -0,0 +1,115 @@ +-- CreateEnum +CREATE TYPE "CourseEnrollmentRole" AS ENUM ('STUDENT', 'TA'); + +-- AlterTable +ALTER TABLE "Submission" ADD COLUMN "assignmentId" TEXT; + +-- CreateTable +CREATE TABLE "Course" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "archived" BOOLEAN NOT NULL DEFAULT false, + "teacherId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Course_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CourseEnrollment" ( + "id" TEXT NOT NULL, + "role" "CourseEnrollmentRole" NOT NULL DEFAULT 'STUDENT', + "courseId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CourseEnrollment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Assignment" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "opensAt" TIMESTAMP(3), + "dueAt" TIMESTAMP(3), + "published" BOOLEAN NOT NULL DEFAULT false, + "courseId" TEXT NOT NULL, + "createdById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Assignment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AssignmentProblem" ( + "assignmentId" TEXT NOT NULL, + "problemId" TEXT NOT NULL, + "maxPoints" INTEGER NOT NULL DEFAULT 100, + "order" INTEGER, + + CONSTRAINT "AssignmentProblem_pkey" PRIMARY KEY ("assignmentId","problemId") +); + +-- CreateIndex +CREATE INDEX "Course_teacherId_idx" ON "Course"("teacherId"); + +-- CreateIndex +CREATE INDEX "CourseEnrollment_courseId_idx" ON "CourseEnrollment"("courseId"); + +-- CreateIndex +CREATE INDEX "CourseEnrollment_userId_idx" ON "CourseEnrollment"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "CourseEnrollment_courseId_userId_key" ON "CourseEnrollment"("courseId", "userId"); + +-- CreateIndex +CREATE INDEX "Assignment_courseId_idx" ON "Assignment"("courseId"); + +-- CreateIndex +CREATE INDEX "Assignment_createdById_idx" ON "Assignment"("createdById"); + +-- CreateIndex +CREATE INDEX "AssignmentProblem_assignmentId_idx" ON "AssignmentProblem"("assignmentId"); + +-- CreateIndex +CREATE INDEX "AssignmentProblem_problemId_idx" ON "AssignmentProblem"("problemId"); + +-- CreateIndex +CREATE INDEX "Submission_assignmentId_userId_problemId_idx" ON "Submission"("assignmentId", "userId", "problemId"); + +-- CreateIndex +CREATE INDEX "Submission_assignmentId_problemId_status_idx" ON "Submission"("assignmentId", "problemId", "status"); + +-- CreateIndex +CREATE INDEX "Submission_userId_problemId_idx" ON "Submission"("userId", "problemId"); + +-- CreateIndex +CREATE INDEX "Submission_createdAt_idx" ON "Submission"("createdAt"); + +-- AddForeignKey +ALTER TABLE "Submission" ADD CONSTRAINT "Submission_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "Assignment"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Course" ADD CONSTRAINT "Course_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CourseEnrollment" ADD CONSTRAINT "CourseEnrollment_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CourseEnrollment" ADD CONSTRAINT "CourseEnrollment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AssignmentProblem" ADD CONSTRAINT "AssignmentProblem_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "Assignment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AssignmentProblem" ADD CONSTRAINT "AssignmentProblem_problemId_fkey" FOREIGN KEY ("problemId") REFERENCES "Problem"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260506083600_remove_ta_course_role/migration.sql b/prisma/migrations/20260506083600_remove_ta_course_role/migration.sql new file mode 100644 index 0000000..d2be22a --- /dev/null +++ b/prisma/migrations/20260506083600_remove_ta_course_role/migration.sql @@ -0,0 +1,19 @@ +BEGIN; + +-- Remove the unused TA variant from CourseEnrollmentRole. +CREATE TYPE "CourseEnrollmentRole_new" AS ENUM ('STUDENT'); + +ALTER TABLE "CourseEnrollment" +ALTER COLUMN "role" DROP DEFAULT; + +ALTER TABLE "CourseEnrollment" +ALTER COLUMN "role" TYPE "CourseEnrollmentRole_new" +USING ("role"::text::"CourseEnrollmentRole_new"); + +ALTER TABLE "CourseEnrollment" +ALTER COLUMN "role" SET DEFAULT 'STUDENT'; + +DROP TYPE "CourseEnrollmentRole"; +ALTER TYPE "CourseEnrollmentRole_new" RENAME TO "CourseEnrollmentRole"; + +COMMIT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7005118..2e6ff15 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -56,6 +56,10 @@ enum ProblemContentType { SOLUTION } +enum CourseEnrollmentRole { + STUDENT +} + model User { id String @id @default(cuid()) name String? @@ -71,6 +75,9 @@ model User { Authenticator Authenticator[] problems Problem[] submissions Submission[] + teachingCourses Course[] @relation("CourseTeacher") + courseEnrollments CourseEnrollment[] + createdAssignments Assignment[] @relation("AssignmentCreator") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -89,6 +96,7 @@ model Problem { templates Template[] testcases Testcase[] submissions Submission[] + assignmentProblems AssignmentProblem[] userId String? @@ -135,12 +143,88 @@ model Submission { userId String problemId String + assignmentId String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) problem Problem @relation(fields: [problemId], references: [id], onDelete: Cascade) + assignment Assignment? @relation(fields: [assignmentId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + @@index([assignmentId, userId, problemId]) + @@index([assignmentId, problemId, status]) + @@index([userId, problemId]) + @@index([createdAt]) +} + +model Course { + id String @id @default(cuid()) + title String + description String? + archived Boolean @default(false) + teacherId String + + teacher User @relation("CourseTeacher", fields: [teacherId], references: [id], onDelete: Cascade) + enrollments CourseEnrollment[] + assignments Assignment[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([teacherId]) +} + +model CourseEnrollment { + id String @id @default(cuid()) + role CourseEnrollmentRole @default(STUDENT) + courseId String + userId String + + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@unique([courseId, userId]) + @@index([courseId]) + @@index([userId]) +} + +model Assignment { + id String @id @default(cuid()) + title String + description String? + opensAt DateTime? + dueAt DateTime? + published Boolean @default(false) + courseId String + createdById String + + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + createdBy User @relation("AssignmentCreator", fields: [createdById], references: [id], onDelete: Cascade) + problems AssignmentProblem[] + submissions Submission[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([courseId]) + @@index([createdById]) +} + +model AssignmentProblem { + assignmentId String + problemId String + maxPoints Int @default(100) + order Int? + + assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade) + problem Problem @relation(fields: [problemId], references: [id], onDelete: Cascade) + + @@id([assignmentId, problemId]) + @@index([assignmentId]) + @@index([problemId]) } enum AnalysisStatus { diff --git a/prisma/seed.ts b/prisma/seed.ts index 82d24e5..09a7112 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1731,6 +1731,125 @@ export async function main() { data: problem, }); } + + // Seed demo users for course/assignment MVP + const teacher = await prisma.user.upsert({ + where: { email: "teacher@judge4c.local" }, + update: { + name: "课程教师", + role: "TEACHER", + }, + create: { + name: "课程教师", + email: "teacher@judge4c.local", + role: "TEACHER", + }, + }); + + const students = await Promise.all( + ["student1@judge4c.local", "student2@judge4c.local"].map((email, index) => + prisma.user.upsert({ + where: { email }, + update: { + name: `学生${index + 1}`, + role: "GUEST", + }, + create: { + name: `学生${index + 1}`, + email, + role: "GUEST", + }, + }) + ) + ); + + const selectedProblems = await prisma.problem.findMany({ + orderBy: { displayId: "asc" }, + take: 2, + select: { id: true }, + }); + + if (selectedProblems.length > 0) { + let course = await prisma.course.findFirst({ + where: { + title: "程序设计基础", + teacherId: teacher.id, + }, + }); + + if (!course) { + course = await prisma.course.create({ + data: { + title: "程序设计基础", + description: "课程作业 MVP 示例课程", + teacherId: teacher.id, + }, + }); + } + + for (const student of students) { + await prisma.courseEnrollment.upsert({ + where: { + courseId_userId: { + courseId: course.id, + userId: student.id, + }, + }, + update: {}, + create: { + courseId: course.id, + userId: student.id, + role: "STUDENT", + }, + }); + } + + const dueAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + let assignment = await prisma.assignment.findFirst({ + where: { + courseId: course.id, + title: "第一次编程作业", + }, + select: { id: true }, + }); + + if (!assignment) { + assignment = await prisma.assignment.create({ + data: { + courseId: course.id, + title: "第一次编程作业", + description: "完成基础题目,熟悉在线评测流程", + dueAt, + published: true, + createdById: teacher.id, + }, + select: { id: true }, + }); + } else { + await prisma.assignment.update({ + where: { id: assignment.id }, + data: { + dueAt, + published: true, + description: "完成基础题目,熟悉在线评测流程", + }, + }); + } + + await prisma.assignmentProblem.deleteMany({ + where: { assignmentId: assignment.id }, + }); + + await prisma.assignmentProblem.createMany({ + data: selectedProblems.map((problem, index) => ({ + assignmentId: assignment.id, + problemId: problem.id, + maxPoints: 100, + order: index + 1, + })), + skipDuplicates: true, + }); + } } main(); diff --git a/src/app/(protected)/dashboard/actions/assignment-stats.ts b/src/app/(protected)/dashboard/actions/assignment-stats.ts new file mode 100644 index 0000000..741fdb9 --- /dev/null +++ b/src/app/(protected)/dashboard/actions/assignment-stats.ts @@ -0,0 +1,347 @@ +"use server"; + +import prisma from "@/lib/prisma"; +import { + assertCourseManagePermission, + assertCourseStudentPermission, + assertStudent, + assertTeacherOrAdmin, + getAuthenticatedActor, +} from "@/app/(protected)/dashboard/actions/course-auth"; + +interface ProblemScoreRow { + problemId: string; + displayId: number; + title: string; + maxPoints: number; + earnedPoints: number; + solved: boolean; + attempts: number; + bestStatus: string; +} + +function buildProblemScoreRows( + assignmentProblems: { + problemId: string; + maxPoints: number; + problem: { + displayId: number; + localizations: { content: string }[]; + }; + }[], + submissions: { + problemId: string; + status: string; + }[] +): ProblemScoreRow[] { + const grouped = new Map< + string, + { + attempts: number; + solved: boolean; + bestStatus: string; + } + >(); + + for (const submission of submissions) { + const current = grouped.get(submission.problemId) ?? { + attempts: 0, + solved: false, + bestStatus: "PD", + }; + current.attempts += 1; + if (submission.status === "AC") { + current.solved = true; + current.bestStatus = "AC"; + } else if (!current.solved) { + current.bestStatus = submission.status; + } + grouped.set(submission.problemId, current); + } + + return assignmentProblems.map((item) => { + const progress = grouped.get(item.problemId); + const solved = Boolean(progress?.solved); + return { + problemId: item.problemId, + displayId: item.problem.displayId, + title: item.problem.localizations[0]?.content ?? `题目${item.problem.displayId}`, + maxPoints: item.maxPoints, + earnedPoints: solved ? item.maxPoints : 0, + solved, + attempts: progress?.attempts ?? 0, + bestStatus: progress?.bestStatus ?? "-", + }; + }); +} + +export async function getTeacherAssignmentStats(assignmentId: string) { + const actor = await getAuthenticatedActor(); + assertTeacherOrAdmin(actor); + + const assignment = await prisma.assignment.findUnique({ + where: { id: assignmentId }, + select: { + id: true, + title: true, + courseId: true, + dueAt: true, + published: true, + course: { + select: { + id: true, + title: true, + teacherId: true, + enrollments: { + select: { + userId: true, + user: { + select: { + name: true, + email: true, + }, + }, + }, + }, + }, + }, + problems: { + orderBy: [{ order: "asc" }, { problem: { displayId: "asc" } }], + select: { + problemId: true, + maxPoints: true, + problem: { + select: { + displayId: true, + localizations: { + where: { type: "TITLE", locale: "zh" }, + select: { content: true }, + }, + }, + }, + }, + }, + submissions: { + select: { + userId: true, + problemId: true, + status: true, + }, + }, + }, + }); + + if (!assignment) { + throw new Error("作业不存在"); + } + + await assertCourseManagePermission(assignment.courseId, actor); + + const problemCount = assignment.problems.length; + const maxScore = assignment.problems.reduce( + (total, problem) => total + problem.maxPoints, + 0 + ); + + const submissionGroup = new Map< + string, + { userId: string; problemId: string; status: string }[] + >(); + for (const submission of assignment.submissions) { + const bucket = submissionGroup.get(submission.userId) ?? []; + bucket.push(submission); + submissionGroup.set(submission.userId, bucket); + } + + const students = assignment.course.enrollments.map((enrollment) => { + const rows = buildProblemScoreRows( + assignment.problems, + submissionGroup.get(enrollment.userId) ?? [] + ); + const score = rows.reduce((sum, row) => sum + row.earnedPoints, 0); + const solvedCount = rows.filter((row) => row.solved).length; + const completionPercent = + problemCount > 0 ? Math.round((solvedCount / problemCount) * 100) : 0; + + return { + userId: enrollment.userId, + name: enrollment.user.name, + email: enrollment.user.email, + totalScore: score, + maxScore, + solvedCount, + problemCount, + completionPercent, + perProblem: rows, + }; + }); + + const perProblemCoverage = assignment.problems.map((problem) => { + const solvedUsers = students.filter((student) => + student.perProblem.some( + (row) => row.problemId === problem.problemId && row.solved + ) + ).length; + const totalUsers = students.length; + return { + problemId: problem.problemId, + displayId: problem.problem.displayId, + title: + problem.problem.localizations[0]?.content ?? + `题目${problem.problem.displayId}`, + solvedUsers, + totalUsers, + acCoverage: + totalUsers > 0 ? Math.round((solvedUsers / totalUsers) * 100) : 0, + }; + }); + + return { + assignment: { + id: assignment.id, + title: assignment.title, + dueAt: assignment.dueAt, + published: assignment.published, + }, + course: { + id: assignment.course.id, + title: assignment.course.title, + }, + maxScore, + problemCount, + students, + perProblemCoverage, + }; +} + +export async function getStudentAssignmentSummary(assignmentId: string) { + const actor = await getAuthenticatedActor(); + assertStudent(actor); + + const assignment = await prisma.assignment.findUnique({ + where: { id: assignmentId }, + select: { + id: true, + title: true, + description: true, + dueAt: true, + opensAt: true, + published: true, + courseId: true, + course: { + select: { + id: true, + title: true, + }, + }, + problems: { + orderBy: [{ order: "asc" }, { problem: { displayId: "asc" } }], + select: { + problemId: true, + maxPoints: true, + problem: { + select: { + displayId: true, + localizations: { + where: { type: "TITLE", locale: "zh" }, + select: { content: true }, + }, + }, + }, + }, + }, + }, + }); + + if (!assignment) { + throw new Error("作业不存在"); + } + if (!assignment.published) { + throw new Error("作业未发布"); + } + + await assertCourseStudentPermission(assignment.courseId, actor); + + const submissions = await prisma.submission.findMany({ + where: { + assignmentId, + userId: actor.id, + }, + select: { + problemId: true, + status: true, + }, + }); + + const rows = buildProblemScoreRows(assignment.problems, submissions); + const totalScore = rows.reduce((sum, row) => sum + row.earnedPoints, 0); + const maxScore = rows.reduce((sum, row) => sum + row.maxPoints, 0); + const solvedCount = rows.filter((row) => row.solved).length; + const completionPercent = + rows.length > 0 ? Math.round((solvedCount / rows.length) * 100) : 0; + + return { + assignment: { + id: assignment.id, + title: assignment.title, + description: assignment.description, + dueAt: assignment.dueAt, + opensAt: assignment.opensAt, + published: assignment.published, + }, + course: assignment.course, + totalScore, + maxScore, + solvedCount, + problemCount: rows.length, + completionPercent, + rows, + }; +} + +export async function listStudentAssignments(courseId: string) { + const actor = await getAuthenticatedActor(); + assertStudent(actor); + await assertCourseStudentPermission(courseId, actor); + + return prisma.assignment.findMany({ + where: { + courseId, + published: true, + }, + orderBy: [{ dueAt: "asc" }, { createdAt: "desc" }], + select: { + id: true, + title: true, + description: true, + opensAt: true, + dueAt: true, + _count: { + select: { + problems: true, + }, + }, + }, + }); +} + +export async function getStudentCourseDetail(courseId: string) { + const actor = await getAuthenticatedActor(); + assertStudent(actor); + await assertCourseStudentPermission(courseId, actor); + + return prisma.course.findUnique({ + where: { id: courseId }, + select: { + id: true, + title: true, + description: true, + teacher: { + select: { + name: true, + email: true, + }, + }, + }, + }); +} diff --git a/src/app/(protected)/dashboard/actions/course-auth.ts b/src/app/(protected)/dashboard/actions/course-auth.ts new file mode 100644 index 0000000..a7957f3 --- /dev/null +++ b/src/app/(protected)/dashboard/actions/course-auth.ts @@ -0,0 +1,104 @@ +import { auth } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { Role } from "@/generated/client"; + +export interface AuthenticatedActor { + id: string; + role: Role; + name: string | null; + email: string; +} + +export async function getAuthenticatedActor(): Promise { + const session = await auth(); + const userId = session?.user?.id; + + if (!userId) { + throw new Error("用户未登录"); + } + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + role: true, + name: true, + email: true, + }, + }); + + if (!user) { + throw new Error("用户不存在"); + } + + return user; +} + +export function assertTeacherOrAdmin(actor: AuthenticatedActor) { + if (actor.role !== "TEACHER" && actor.role !== "ADMIN") { + throw new Error("无权限执行该操作"); + } +} + +export function assertStudent(actor: AuthenticatedActor) { + if (actor.role !== "GUEST") { + throw new Error("仅学生可访问"); + } +} + +export async function assertCourseManagePermission( + courseId: string, + actor: AuthenticatedActor +) { + const course = await prisma.course.findUnique({ + where: { id: courseId }, + select: { + id: true, + teacherId: true, + title: true, + }, + }); + + if (!course) { + throw new Error("课程不存在"); + } + + if (actor.role !== "ADMIN" && course.teacherId !== actor.id) { + throw new Error("无权限操作该课程"); + } + + return course; +} + +export async function assertCourseStudentPermission( + courseId: string, + actor: AuthenticatedActor +) { + const enrollment = await prisma.courseEnrollment.findUnique({ + where: { + courseId_userId: { + courseId, + userId: actor.id, + }, + }, + select: { + course: { + select: { + id: true, + title: true, + archived: true, + }, + }, + }, + }); + + if (!enrollment) { + throw new Error("你未加入该课程"); + } + + if (enrollment.course.archived) { + throw new Error("课程已归档"); + } + + return enrollment.course; +} diff --git a/src/app/(protected)/dashboard/actions/student-dashboard.ts b/src/app/(protected)/dashboard/actions/student-dashboard.ts index 8880d77..071780b 100644 --- a/src/app/(protected)/dashboard/actions/student-dashboard.ts +++ b/src/app/(protected)/dashboard/actions/student-dashboard.ts @@ -2,6 +2,7 @@ import prisma from "@/lib/prisma"; import { auth } from "@/lib/auth"; +import { assertStudent } from "./course-auth"; export async function getStudentDashboardData() { try { @@ -29,6 +30,12 @@ export async function getStudentDashboardData() { if (!currentUser) { throw new Error("用户不存在"); } + assertStudent({ + id: currentUser.id, + role: currentUser.role, + name: currentUser.name, + email: currentUser.email, + }); // 获取所有已发布的题目(包含英文标题) const allProblems = await prisma.problem.findMany({ diff --git a/src/app/(protected)/dashboard/actions/teacher-assignments.ts b/src/app/(protected)/dashboard/actions/teacher-assignments.ts new file mode 100644 index 0000000..5268257 --- /dev/null +++ b/src/app/(protected)/dashboard/actions/teacher-assignments.ts @@ -0,0 +1,315 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import prisma from "@/lib/prisma"; +import { + assertCourseManagePermission, + assertTeacherOrAdmin, + getAuthenticatedActor, +} from "@/app/(protected)/dashboard/actions/course-auth"; + +interface AssignmentProblemInput { + problemId: string; + maxPoints: number; + order?: number; +} + +interface CreateAssignmentInput { + courseId: string; + title: string; + description?: string; + opensAt?: string; + dueAt?: string; + published?: boolean; + problems: AssignmentProblemInput[]; +} + +interface UpdateAssignmentInput { + title?: string; + description?: string | null; + opensAt?: string | null; + dueAt?: string | null; + published?: boolean; + problems?: AssignmentProblemInput[]; +} + +function normalizePoints(points: number) { + const normalized = Number(points); + if (!Number.isFinite(normalized) || normalized <= 0) { + throw new Error("分值必须是正整数"); + } + return Math.round(normalized); +} + +async function validateProblems(problems: AssignmentProblemInput[]) { + if (!Array.isArray(problems) || problems.length === 0) { + throw new Error("作业至少需要一道题目"); + } + + const uniqueProblemIds = [...new Set(problems.map((item) => item.problemId))]; + const existingProblems = await prisma.problem.findMany({ + where: { + id: { in: uniqueProblemIds }, + isPublished: true, + }, + select: { id: true }, + }); + + if (existingProblems.length !== uniqueProblemIds.length) { + throw new Error("题目不存在或未发布"); + } +} + +function parseDateValue(value?: string | null) { + if (!value) { + return null; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new Error("日期格式不合法"); + } + return date; +} + +export async function createAssignment(input: CreateAssignmentInput) { + const actor = await getAuthenticatedActor(); + assertTeacherOrAdmin(actor); + await assertCourseManagePermission(input.courseId, actor); + await validateProblems(input.problems); + + const title = input.title.trim(); + if (!title) { + throw new Error("作业标题不能为空"); + } + + const opensAt = parseDateValue(input.opensAt); + const dueAt = parseDateValue(input.dueAt); + + if (opensAt && dueAt && opensAt >= dueAt) { + throw new Error("截止时间必须晚于开始时间"); + } + + const assignment = await prisma.assignment.create({ + data: { + courseId: input.courseId, + title, + description: input.description?.trim() || null, + opensAt, + dueAt, + published: Boolean(input.published), + createdById: actor.id, + problems: { + create: input.problems.map((item, index) => ({ + problemId: item.problemId, + maxPoints: normalizePoints(item.maxPoints), + order: item.order ?? index + 1, + })), + }, + }, + select: { + id: true, + courseId: true, + }, + }); + + revalidatePath(`/dashboard/teacher/courses/${assignment.courseId}`); + revalidatePath( + `/dashboard/teacher/courses/${assignment.courseId}/assignments/${assignment.id}` + ); + revalidatePath("/dashboard/student/courses"); + + return assignment; +} + +export async function updateAssignment( + assignmentId: string, + input: UpdateAssignmentInput +) { + const actor = await getAuthenticatedActor(); + assertTeacherOrAdmin(actor); + + const assignment = await prisma.assignment.findUnique({ + where: { id: assignmentId }, + select: { id: true, courseId: true }, + }); + + if (!assignment) { + throw new Error("作业不存在"); + } + + await assertCourseManagePermission(assignment.courseId, actor); + + const nextData: { + title?: string; + description?: string | null; + opensAt?: Date | null; + dueAt?: Date | null; + published?: boolean; + } = {}; + + if (typeof input.title === "string") { + const title = input.title.trim(); + if (!title) { + throw new Error("作业标题不能为空"); + } + nextData.title = title; + } + if (typeof input.description !== "undefined") { + nextData.description = input.description?.trim() || null; + } + if (typeof input.opensAt !== "undefined") { + nextData.opensAt = parseDateValue(input.opensAt); + } + if (typeof input.dueAt !== "undefined") { + nextData.dueAt = parseDateValue(input.dueAt); + } + if (typeof input.published === "boolean") { + nextData.published = input.published; + } + + if (nextData.opensAt && nextData.dueAt && nextData.opensAt >= nextData.dueAt) { + throw new Error("截止时间必须晚于开始时间"); + } + + await prisma.assignment.update({ + where: { id: assignmentId }, + data: nextData, + }); + + if (input.problems) { + await validateProblems(input.problems); + await prisma.assignmentProblem.deleteMany({ + where: { assignmentId }, + }); + await prisma.assignmentProblem.createMany({ + data: input.problems.map((item, index) => ({ + assignmentId, + problemId: item.problemId, + maxPoints: normalizePoints(item.maxPoints), + order: item.order ?? index + 1, + })), + skipDuplicates: true, + }); + } + + revalidatePath(`/dashboard/teacher/courses/${assignment.courseId}`); + revalidatePath( + `/dashboard/teacher/courses/${assignment.courseId}/assignments/${assignmentId}` + ); + revalidatePath( + `/dashboard/student/courses/${assignment.courseId}/assignments/${assignmentId}` + ); +} + +export async function publishAssignment( + assignmentId: string, + published: boolean = true +) { + return updateAssignment(assignmentId, { published }); +} + +export async function getAssignmentDetail(assignmentId: string) { + const actor = await getAuthenticatedActor(); + assertTeacherOrAdmin(actor); + + const assignment = await prisma.assignment.findUnique({ + where: { id: assignmentId }, + select: { + id: true, + title: true, + description: true, + opensAt: true, + dueAt: true, + published: true, + courseId: true, + course: { + select: { + id: true, + title: true, + teacherId: true, + teacher: { + select: { + name: true, + email: true, + }, + }, + }, + }, + problems: { + orderBy: [{ order: "asc" }, { problem: { displayId: "asc" } }], + select: { + problemId: true, + maxPoints: true, + order: true, + problem: { + select: { + displayId: true, + difficulty: true, + localizations: { + where: { type: "TITLE", locale: "zh" }, + select: { content: true }, + }, + }, + }, + }, + }, + _count: { + select: { + submissions: true, + }, + }, + }, + }); + + if (!assignment) { + throw new Error("作业不存在"); + } + + await assertCourseManagePermission(assignment.courseId, actor); + return assignment; +} + +export async function listCourseAssignments(courseId: string) { + const actor = await getAuthenticatedActor(); + assertTeacherOrAdmin(actor); + await assertCourseManagePermission(courseId, actor); + + return prisma.assignment.findMany({ + where: { courseId }, + orderBy: [{ dueAt: "asc" }, { createdAt: "desc" }], + select: { + id: true, + title: true, + description: true, + opensAt: true, + dueAt: true, + published: true, + createdAt: true, + _count: { + select: { + problems: true, + submissions: true, + }, + }, + }, + }); +} + +export async function listAssignableProblems() { + const actor = await getAuthenticatedActor(); + assertTeacherOrAdmin(actor); + + return prisma.problem.findMany({ + where: { isPublished: true }, + orderBy: { displayId: "asc" }, + select: { + id: true, + displayId: true, + difficulty: true, + localizations: { + where: { type: "TITLE", locale: "zh" }, + select: { content: true }, + }, + }, + }); +} diff --git a/src/app/(protected)/dashboard/actions/teacher-courses.ts b/src/app/(protected)/dashboard/actions/teacher-courses.ts new file mode 100644 index 0000000..65499c2 --- /dev/null +++ b/src/app/(protected)/dashboard/actions/teacher-courses.ts @@ -0,0 +1,272 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import prisma from "@/lib/prisma"; +import { + assertCourseManagePermission, + assertStudent, + assertTeacherOrAdmin, + getAuthenticatedActor, +} from "@/app/(protected)/dashboard/actions/course-auth"; + +interface CreateCourseInput { + title: string; + description?: string; + teacherId?: string; +} + +interface UpdateCourseInput { + title?: string; + description?: string | null; + archived?: boolean; +} + +export async function listTeacherCourses() { + const actor = await getAuthenticatedActor(); + assertTeacherOrAdmin(actor); + + return prisma.course.findMany({ + where: actor.role === "ADMIN" ? undefined : { teacherId: actor.id }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + title: true, + description: true, + archived: true, + createdAt: true, + updatedAt: true, + teacher: { + select: { + id: true, + name: true, + email: true, + }, + }, + _count: { + select: { + enrollments: true, + assignments: true, + }, + }, + }, + }); +} + +export async function createCourse(input: CreateCourseInput) { + const actor = await getAuthenticatedActor(); + assertTeacherOrAdmin(actor); + + const title = input.title.trim(); + if (!title) { + throw new Error("课程标题不能为空"); + } + + let teacherId = actor.id; + if (actor.role === "ADMIN" && input.teacherId) { + const teacher = await prisma.user.findUnique({ + where: { id: input.teacherId }, + select: { id: true, role: true }, + }); + if (!teacher || teacher.role !== "TEACHER") { + throw new Error("指定教师不存在"); + } + teacherId = teacher.id; + } + + const course = await prisma.course.create({ + data: { + title, + description: input.description?.trim() || null, + teacherId, + }, + select: { id: true }, + }); + + revalidatePath("/dashboard/teacher/courses"); + revalidatePath("/dashboard/student/courses"); + return course; +} + +export async function updateCourse(courseId: string, input: UpdateCourseInput) { + const actor = await getAuthenticatedActor(); + assertTeacherOrAdmin(actor); + await assertCourseManagePermission(courseId, actor); + + const nextData: UpdateCourseInput = {}; + if (typeof input.title === "string") { + const title = input.title.trim(); + if (!title) { + throw new Error("课程标题不能为空"); + } + nextData.title = title; + } + if (typeof input.description !== "undefined") { + nextData.description = input.description?.trim() || null; + } + if (typeof input.archived === "boolean") { + nextData.archived = input.archived; + } + + await prisma.course.update({ + where: { id: courseId }, + data: nextData, + }); + + revalidatePath("/dashboard/teacher/courses"); + revalidatePath(`/dashboard/teacher/courses/${courseId}`); + revalidatePath("/dashboard/student/courses"); +} + +export async function enrollStudents(courseId: string, studentIds: string[]) { + const actor = await getAuthenticatedActor(); + assertTeacherOrAdmin(actor); + await assertCourseManagePermission(courseId, actor); + + const uniqueStudentIds = [...new Set(studentIds.filter(Boolean))]; + if (uniqueStudentIds.length === 0) { + return { enrolled: 0 }; + } + + const students = await prisma.user.findMany({ + where: { + id: { in: uniqueStudentIds }, + role: "GUEST", + }, + select: { id: true }, + }); + + if (students.length === 0) { + throw new Error("未找到可加入课程的学生"); + } + + await prisma.courseEnrollment.createMany({ + data: students.map((student) => ({ + courseId, + userId: student.id, + role: "STUDENT", + })), + skipDuplicates: true, + }); + + revalidatePath(`/dashboard/teacher/courses/${courseId}`); + revalidatePath("/dashboard/student/courses"); + + return { enrolled: students.length }; +} + +export async function removeStudentFromCourse(courseId: string, studentId: string) { + const actor = await getAuthenticatedActor(); + assertTeacherOrAdmin(actor); + await assertCourseManagePermission(courseId, actor); + + await prisma.courseEnrollment.deleteMany({ + where: { + courseId, + userId: studentId, + }, + }); + + revalidatePath(`/dashboard/teacher/courses/${courseId}`); + revalidatePath("/dashboard/student/courses"); +} + +export async function getCourseStudents(courseId: string) { + const actor = await getAuthenticatedActor(); + assertTeacherOrAdmin(actor); + await assertCourseManagePermission(courseId, actor); + + return prisma.courseEnrollment.findMany({ + where: { courseId }, + orderBy: { createdAt: "asc" }, + select: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + role: true, + createdAt: true, + }, + }); +} + +export async function listAvailableStudents() { + const actor = await getAuthenticatedActor(); + assertTeacherOrAdmin(actor); + + return prisma.user.findMany({ + where: { role: "GUEST" }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + name: true, + email: true, + }, + }); +} + +export async function getTeacherCourseDetail(courseId: string) { + const actor = await getAuthenticatedActor(); + assertTeacherOrAdmin(actor); + await assertCourseManagePermission(courseId, actor); + + return prisma.course.findUnique({ + where: { id: courseId }, + select: { + id: true, + title: true, + description: true, + archived: true, + createdAt: true, + updatedAt: true, + teacher: { + select: { + id: true, + name: true, + email: true, + }, + }, + _count: { + select: { + enrollments: true, + assignments: true, + }, + }, + }, + }); +} + +export async function listStudentCourses() { + const actor = await getAuthenticatedActor(); + assertStudent(actor); + + return prisma.courseEnrollment.findMany({ + where: { userId: actor.id }, + orderBy: { createdAt: "desc" }, + select: { + course: { + select: { + id: true, + title: true, + description: true, + archived: true, + teacher: { + select: { + id: true, + name: true, + email: true, + }, + }, + _count: { + select: { + assignments: true, + }, + }, + }, + }, + createdAt: true, + }, + }); +} diff --git a/src/app/(protected)/dashboard/actions/teacher-dashboard.ts b/src/app/(protected)/dashboard/actions/teacher-dashboard.ts index 874eba3..e787e0c 100644 --- a/src/app/(protected)/dashboard/actions/teacher-dashboard.ts +++ b/src/app/(protected)/dashboard/actions/teacher-dashboard.ts @@ -3,6 +3,7 @@ import prisma from "@/lib/prisma"; import { getLocale } from "next-intl/server"; import { Locale, Status, ProblemLocalization } from "@/generated/client"; +import { assertTeacherOrAdmin, getAuthenticatedActor } from "./course-auth"; const getLocalizedTitle = ( localizations: ProblemLocalization[], @@ -41,6 +42,9 @@ export interface DifficultProblemData { export async function getProblemCompletionData(): Promise< ProblemCompletionData[] > { + const actor = await getAuthenticatedActor(); + assertTeacherOrAdmin(actor); + // 获取所有提交记录,按题目分组统计 const submissions = await prisma.submission.findMany({ include: { @@ -124,6 +128,9 @@ export async function getProblemCompletionData(): Promise< export async function getDifficultProblemsData(): Promise< DifficultProblemData[] > { + const actor = await getAuthenticatedActor(); + assertTeacherOrAdmin(actor); + // 获取所有测试用例结果 const testcaseResults = await prisma.testcaseResult.findMany({ include: { @@ -207,6 +214,9 @@ export async function getDifficultProblemsData(): Promise< } export async function getDashboardStats() { + const actor = await getAuthenticatedActor(); + assertTeacherOrAdmin(actor); + const [problemData, difficultProblems] = await Promise.all([ getProblemCompletionData(), getDifficultProblemsData(), diff --git a/src/app/(protected)/dashboard/page.tsx b/src/app/(protected)/dashboard/page.tsx index b685250..95a5037 100644 --- a/src/app/(protected)/dashboard/page.tsx +++ b/src/app/(protected)/dashboard/page.tsx @@ -260,6 +260,11 @@ export default async function DashboardPage() { href: "/dashboard/teacher/dashboard", icon: BarChart3, }, + { + label: "课程管理", + href: "/dashboard/teacher/courses", + icon: BookOpen, + }, ], }; default: @@ -292,6 +297,11 @@ export default async function DashboardPage() { href: "/dashboard/student/dashboard", icon: TrendingUp, }, + { + label: "我的课程", + href: "/dashboard/student/courses", + icon: GraduationCapIcon, + }, { label: "开始做题", href: "/problemset", icon: BookOpen }, { label: "个人设置", href: "/dashboard/management", icon: Target }, ], diff --git a/src/app/(protected)/dashboard/student/courses/[courseId]/assignments/[assignmentId]/page.tsx b/src/app/(protected)/dashboard/student/courses/[courseId]/assignments/[assignmentId]/page.tsx new file mode 100644 index 0000000..50bd80a --- /dev/null +++ b/src/app/(protected)/dashboard/student/courses/[courseId]/assignments/[assignmentId]/page.tsx @@ -0,0 +1,99 @@ +"use client"; + +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { getStudentAssignmentSummary } from "@/app/(protected)/dashboard/actions/assignment-stats"; + +export default function StudentAssignmentDetailPage() { + const params = useParams<{ assignmentId: string }>(); + const assignmentId = params.assignmentId; + const [error, setError] = useState(null); + const [summary, setSummary] = useState + > | null>(null); + + useEffect(() => { + const loadData = async () => { + try { + const data = await getStudentAssignmentSummary(assignmentId); + setSummary(data); + } catch (e) { + setError(e instanceof Error ? e.message : "加载作业失败"); + } + }; + loadData(); + }, [assignmentId]); + + if (!summary) { + return
加载中...
; + } + + return ( +
+ + + {summary.assignment.title} + + {summary.assignment.description || "暂无作业说明"} + + + +

+ 总分:{summary.totalScore}/{summary.maxScore} +

+

+ 完成:{summary.solvedCount}/{summary.problemCount} +

+ +

+ 完成率 {summary.completionPercent}% + {summary.assignment.dueAt + ? ` · 截止 ${new Date(summary.assignment.dueAt).toLocaleString()}` + : ""} +

+ {error ?

{error}

: null} +
+
+ + + + 题目列表 + 进入题目后会自动按本作业统计提交与得分 + + + {summary.rows.map((row) => ( +
+
+

+ #{row.displayId} {row.title} +

+

+ 得分 {row.earnedPoints}/{row.maxPoints} · 状态 {row.bestStatus} · + 提交 {row.attempts} 次 +

+
+ +
+ ))} +
+
+
+ ); +} diff --git a/src/app/(protected)/dashboard/student/courses/[courseId]/page.tsx b/src/app/(protected)/dashboard/student/courses/[courseId]/page.tsx new file mode 100644 index 0000000..0412cc0 --- /dev/null +++ b/src/app/(protected)/dashboard/student/courses/[courseId]/page.tsx @@ -0,0 +1,100 @@ +"use client"; + +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + getStudentCourseDetail, + listStudentAssignments, +} from "@/app/(protected)/dashboard/actions/assignment-stats"; + +export default function StudentCourseDetailPage() { + const params = useParams<{ courseId: string }>(); + const courseId = params.courseId; + const [error, setError] = useState(null); + const [course, setCourse] = useState + > | null>(null); + const [assignments, setAssignments] = useState + >>([]); + + useEffect(() => { + const loadData = async () => { + try { + const [courseData, assignmentData] = await Promise.all([ + getStudentCourseDetail(courseId), + listStudentAssignments(courseId), + ]); + setCourse(courseData); + setAssignments(assignmentData); + } catch (e) { + setError(e instanceof Error ? e.message : "加载课程失败"); + } + }; + loadData(); + }, [courseId]); + + if (!course) { + return
加载中...
; + } + + return ( +
+ + + {course.title} + + {course.description || "暂无课程简介"} · 教师: + {course.teacher.name || course.teacher.email} + + + + + + + 作业列表 + 点击进入作业查看得分并开始做题 + + + {error ?

{error}

: null} + {assignments.length === 0 ? ( +

暂无已发布作业

+ ) : ( + assignments.map((assignment) => ( +
+
+

{assignment.title}

+

+ 题目 {assignment._count.problems} 道 + {assignment.dueAt + ? ` · 截止 ${new Date(assignment.dueAt).toLocaleString()}` + : " · 无截止时间"} +

+
+ +
+ )) + )} +
+
+
+ ); +} diff --git a/src/app/(protected)/dashboard/student/courses/layout.tsx b/src/app/(protected)/dashboard/student/courses/layout.tsx new file mode 100644 index 0000000..9e42eda --- /dev/null +++ b/src/app/(protected)/dashboard/student/courses/layout.tsx @@ -0,0 +1,9 @@ +import { ProtectedLayout } from "@/features/dashboard/layouts/protected-layout"; + +export default async function StudentCoursesLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/app/(protected)/dashboard/student/courses/page.tsx b/src/app/(protected)/dashboard/student/courses/page.tsx new file mode 100644 index 0000000..6c3a280 --- /dev/null +++ b/src/app/(protected)/dashboard/student/courses/page.tsx @@ -0,0 +1,72 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { listStudentCourses } from "@/app/(protected)/dashboard/actions/teacher-courses"; + +export default function StudentCoursesPage() { + const [courses, setCourses] = useState + >>([]); + const [error, setError] = useState(null); + + useEffect(() => { + const loadCourses = async () => { + try { + const data = await listStudentCourses(); + setCourses(data); + } catch (e) { + setError(e instanceof Error ? e.message : "加载课程失败"); + } + }; + loadCourses(); + }, []); + + return ( +
+ + + 我的课程 + 进入课程查看作业、截止时间和成绩 + + + {error ?

{error}

: null} + {courses.length === 0 ? ( +

暂无已加入课程

+ ) : ( + courses.map((item) => ( +
+
+

{item.course.title}

+

+ {item.course.description || "暂无课程简介"} +

+

+ 教师:{item.course.teacher.name || item.course.teacher.email} · + 作业 {item.course._count.assignments} 个 +

+
+ +
+ )) + )} +
+
+
+ ); +} diff --git a/src/app/(protected)/dashboard/teacher/courses/[courseId]/assignments/[assignmentId]/page.tsx b/src/app/(protected)/dashboard/teacher/courses/[courseId]/assignments/[assignmentId]/page.tsx new file mode 100644 index 0000000..210f8d1 --- /dev/null +++ b/src/app/(protected)/dashboard/teacher/courses/[courseId]/assignments/[assignmentId]/page.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { useCallback, useEffect, useState, useTransition } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + getAssignmentDetail, + updateAssignment, +} from "@/app/(protected)/dashboard/actions/teacher-assignments"; +import { getTeacherAssignmentStats } from "@/app/(protected)/dashboard/actions/assignment-stats"; + +export default function TeacherAssignmentDetailPage() { + const params = useParams<{ assignmentId: string }>(); + const assignmentId = params.assignmentId; + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + const [detail, setDetail] = useState + > | null>(null); + const [stats, setStats] = useState + > | null>(null); + const [title, setTitle] = useState(""); + const [dueAt, setDueAt] = useState(""); + + const loadData = useCallback(async () => { + try { + const [assignmentDetail, assignmentStats] = await Promise.all([ + getAssignmentDetail(assignmentId), + getTeacherAssignmentStats(assignmentId), + ]); + setDetail(assignmentDetail); + setStats(assignmentStats); + setTitle(assignmentDetail.title); + setDueAt( + assignmentDetail.dueAt + ? new Date(assignmentDetail.dueAt).toISOString().slice(0, 16) + : "" + ); + } catch (e) { + setError(e instanceof Error ? e.message : "加载作业失败"); + } + }, [assignmentId]); + + useEffect(() => { + loadData(); + }, [loadData]); + + const handleSave = () => { + startTransition(async () => { + try { + await updateAssignment(assignmentId, { + title, + dueAt: dueAt || null, + }); + await loadData(); + } catch (e) { + setError(e instanceof Error ? e.message : "更新作业失败"); + } + }); + }; + + const handleTogglePublish = () => { + if (!detail) return; + startTransition(async () => { + try { + await updateAssignment(assignmentId, { + published: !detail.published, + }); + await loadData(); + } catch (e) { + setError(e instanceof Error ? e.message : "更新发布状态失败"); + } + }); + }; + + if (!detail || !stats) { + return
加载中...
; + } + + return ( +
+ + + 作业设置 + + 课程:{detail.course.title} · 当前状态: + {detail.published ? "已发布" : "未发布"} + + + + setTitle(e.target.value)} /> + setDueAt(e.target.value)} + /> +
+ + +
+ {error ?

{error}

: null} +
+
+ + + + 班级成绩概览 + + 满分 {stats.maxScore} 分 · 共 {stats.problemCount} 题 + + + + {stats.students.length === 0 ? ( +

暂无选课学生

+ ) : ( + stats.students.map((student) => ( +
+

+ {student.name || "未命名"} ({student.email}) +

+

+ 总分 {student.totalScore}/{student.maxScore} · 完成率{" "} + {student.completionPercent}% +

+
+ )) + )} +
+
+ + + + 每题 AC 覆盖率 + + + {stats.perProblemCoverage.map((item) => ( +
+

+ #{item.displayId} {item.title} +

+

+ {item.solvedUsers}/{item.totalUsers} 人通过 · 覆盖率 {item.acCoverage}% +

+
+ ))} +
+
+
+ ); +} diff --git a/src/app/(protected)/dashboard/teacher/courses/[courseId]/page.tsx b/src/app/(protected)/dashboard/teacher/courses/[courseId]/page.tsx new file mode 100644 index 0000000..7e8364b --- /dev/null +++ b/src/app/(protected)/dashboard/teacher/courses/[courseId]/page.tsx @@ -0,0 +1,320 @@ +"use client"; + +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useState, useTransition } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Textarea } from "@/components/ui/textarea"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + enrollStudents, + getCourseStudents, + getTeacherCourseDetail, + listAvailableStudents, +} from "@/app/(protected)/dashboard/actions/teacher-courses"; +import { + createAssignment, + listAssignableProblems, + listCourseAssignments, +} from "@/app/(protected)/dashboard/actions/teacher-assignments"; + +interface SelectableStudent { + id: string; + name: string | null; + email: string; +} + +interface SelectableProblem { + id: string; + displayId: number; + difficulty: string; + localizations: { content: string }[]; +} + +export default function TeacherCourseDetailPage() { + const params = useParams<{ courseId: string }>(); + const courseId = params.courseId; + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + const [course, setCourse] = useState + > | null>(null); + const [students, setStudents] = useState>>([]); + const [availableStudents, setAvailableStudents] = useState([]); + const [assignments, setAssignments] = useState + >>([]); + const [problems, setProblems] = useState([]); + + const [selectedStudents, setSelectedStudents] = useState([]); + const [assignmentTitle, setAssignmentTitle] = useState(""); + const [assignmentDescription, setAssignmentDescription] = useState(""); + const [assignmentDueAt, setAssignmentDueAt] = useState(""); + const [selectedProblems, setSelectedProblems] = useState< + Record + >({}); + + const selectedProblemIds = useMemo( + () => Object.keys(selectedProblems), + [selectedProblems] + ); + + const loadData = useCallback(async () => { + try { + const [courseData, studentsData, allStudents, assignmentData, problemData] = + await Promise.all([ + getTeacherCourseDetail(courseId), + getCourseStudents(courseId), + listAvailableStudents(), + listCourseAssignments(courseId), + listAssignableProblems(), + ]); + setCourse(courseData); + setStudents(studentsData); + setAvailableStudents(allStudents); + setAssignments(assignmentData); + setProblems(problemData); + } catch (e) { + setError(e instanceof Error ? e.message : "加载课程数据失败"); + } + }, [courseId]); + + useEffect(() => { + loadData(); + }, [loadData]); + + const handleEnrollStudents = () => { + if (selectedStudents.length === 0) { + return; + } + setError(null); + startTransition(async () => { + try { + await enrollStudents(courseId, selectedStudents); + setSelectedStudents([]); + await loadData(); + } catch (e) { + setError(e instanceof Error ? e.message : "添加学生失败"); + } + }); + }; + + const handleCreateAssignment = () => { + if (!assignmentTitle.trim() || selectedProblemIds.length === 0) { + return; + } + setError(null); + startTransition(async () => { + try { + await createAssignment({ + courseId, + title: assignmentTitle, + description: assignmentDescription, + dueAt: assignmentDueAt || undefined, + published: true, + problems: selectedProblemIds.map((problemId, index) => ({ + problemId, + maxPoints: selectedProblems[problemId] ?? 100, + order: index + 1, + })), + }); + setAssignmentTitle(""); + setAssignmentDescription(""); + setAssignmentDueAt(""); + setSelectedProblems({}); + await loadData(); + } catch (e) { + setError(e instanceof Error ? e.message : "创建作业失败"); + } + }); + }; + + const toggleStudent = (studentId: string, checked: boolean) => { + setSelectedStudents((prev) => + checked ? [...prev, studentId] : prev.filter((id) => id !== studentId) + ); + }; + + const toggleProblem = (problemId: string, checked: boolean) => { + setSelectedProblems((prev) => { + if (!checked) { + const next = { ...prev }; + delete next[problemId]; + return next; + } + return { ...prev, [problemId]: prev[problemId] ?? 100 }; + }); + }; + + if (!course) { + return
加载中...
; + } + + return ( +
+ + + {course.title} + + {course.description || "暂无课程简介"} · 学生 {course._count.enrollments} 人 · + 作业 {course._count.assignments} 个 + + + + + + + 学生管理 + 勾选学生后加入课程 + + +
+ {availableStudents.map((student) => ( + + ))} +
+ + +
+

已加入学生

+ {students.length === 0 ? ( +

暂无学生

+ ) : ( + students.map((item) => ( +

+ {item.user.name || "未命名"} ({item.user.email}) +

+ )) + )} +
+
+
+ + + + 创建作业 + 选择题目并设置每题分值 + + + setAssignmentTitle(e.target.value)} + placeholder="作业标题" + /> +