feat(course): add course-assignment workflow with role-based access and assignment-scoped judging

This commit is contained in:
cfngc4594 2026-05-06 21:16:01 +08:00
parent 03fa169a81
commit 6d4aef9543
24 changed files with 2437 additions and 31 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<AuthenticatedActor> {
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;
}

View File

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

View File

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

View File

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

View File

@ -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(),

View File

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

View File

@ -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<string | null>(null);
const [summary, setSummary] = useState<Awaited<
ReturnType<typeof getStudentAssignmentSummary>
> | 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 <div className="p-6 text-sm text-muted-foreground">...</div>;
}
return (
<div className="space-y-6 p-6">
<Card>
<CardHeader>
<CardTitle>{summary.assignment.title}</CardTitle>
<CardDescription>
{summary.assignment.description || "暂无作业说明"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-sm">
{summary.totalScore}/{summary.maxScore}
</p>
<p className="text-sm">
{summary.solvedCount}/{summary.problemCount}
</p>
<Progress value={summary.completionPercent} className="w-full" />
<p className="text-xs text-muted-foreground">
{summary.completionPercent}%
{summary.assignment.dueAt
? ` · 截止 ${new Date(summary.assignment.dueAt).toLocaleString()}`
: ""}
</p>
{error ? <p className="text-sm text-red-500">{error}</p> : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{summary.rows.map((row) => (
<div
key={row.problemId}
className="flex items-center justify-between rounded border p-3"
>
<div>
<p className="font-medium">
#{row.displayId} {row.title}
</p>
<p className="text-sm text-muted-foreground">
{row.earnedPoints}/{row.maxPoints} · {row.bestStatus} ·
{row.attempts}
</p>
</div>
<Button asChild variant={row.solved ? "secondary" : "outline"}>
<Link href={`/problems/${row.problemId}?assignmentId=${assignmentId}`}>
{row.solved ? "继续练习" : "开始做题"}
</Link>
</Button>
</div>
))}
</CardContent>
</Card>
</div>
);
}

View File

@ -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<string | null>(null);
const [course, setCourse] = useState<Awaited<
ReturnType<typeof getStudentCourseDetail>
> | null>(null);
const [assignments, setAssignments] = useState<Awaited<
ReturnType<typeof listStudentAssignments>
>>([]);
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 <div className="p-6 text-sm text-muted-foreground">...</div>;
}
return (
<div className="space-y-6 p-6">
<Card>
<CardHeader>
<CardTitle>{course.title}</CardTitle>
<CardDescription>
{course.description || "暂无课程简介"} ·
{course.teacher.name || course.teacher.email}
</CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{error ? <p className="text-sm text-red-500">{error}</p> : null}
{assignments.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
assignments.map((assignment) => (
<div
key={assignment.id}
className="flex items-center justify-between rounded border p-3"
>
<div>
<p className="font-medium">{assignment.title}</p>
<p className="text-sm text-muted-foreground">
{assignment._count.problems}
{assignment.dueAt
? ` · 截止 ${new Date(assignment.dueAt).toLocaleString()}`
: " · 无截止时间"}
</p>
</div>
<Button asChild variant="outline">
<Link
href={`/dashboard/student/courses/${courseId}/assignments/${assignment.id}`}
>
</Link>
</Button>
</div>
))
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,9 @@
import { ProtectedLayout } from "@/features/dashboard/layouts/protected-layout";
export default async function StudentCoursesLayout({
children,
}: {
children: React.ReactNode;
}) {
return <ProtectedLayout roles={["GUEST"]}>{children}</ProtectedLayout>;
}

View File

@ -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<Awaited<
ReturnType<typeof listStudentCourses>
>>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadCourses = async () => {
try {
const data = await listStudentCourses();
setCourses(data);
} catch (e) {
setError(e instanceof Error ? e.message : "加载课程失败");
}
};
loadCourses();
}, []);
return (
<div className="space-y-6 p-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{error ? <p className="text-sm text-red-500">{error}</p> : null}
{courses.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
courses.map((item) => (
<div
key={item.course.id}
className="flex items-center justify-between rounded-md border p-3"
>
<div className="space-y-1">
<p className="font-medium">{item.course.title}</p>
<p className="text-sm text-muted-foreground">
{item.course.description || "暂无课程简介"}
</p>
<p className="text-xs text-muted-foreground">
{item.course.teacher.name || item.course.teacher.email} ·
{item.course._count.assignments}
</p>
</div>
<Button asChild variant="outline">
<Link href={`/dashboard/student/courses/${item.course.id}`}>
</Link>
</Button>
</div>
))
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -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<string | null>(null);
const [isPending, startTransition] = useTransition();
const [detail, setDetail] = useState<Awaited<
ReturnType<typeof getAssignmentDetail>
> | null>(null);
const [stats, setStats] = useState<Awaited<
ReturnType<typeof getTeacherAssignmentStats>
> | 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 <div className="p-6 text-sm text-muted-foreground">...</div>;
}
return (
<div className="space-y-6 p-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
{detail.course.title} ·
{detail.published ? "已发布" : "未发布"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
<Input
type="datetime-local"
value={dueAt}
onChange={(e) => setDueAt(e.target.value)}
/>
<div className="flex gap-2">
<Button onClick={handleSave} disabled={isPending || !title.trim()}>
{isPending ? "保存中..." : "保存设置"}
</Button>
<Button onClick={handleTogglePublish} variant="outline" disabled={isPending}>
{detail.published ? "设为未发布" : "发布作业"}
</Button>
</div>
{error ? <p className="text-sm text-red-500">{error}</p> : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
{stats.maxScore} · {stats.problemCount}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{stats.students.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
stats.students.map((student) => (
<div key={student.userId} className="rounded border p-3">
<p className="font-medium">
{student.name || "未命名"} ({student.email})
</p>
<p className="text-sm text-muted-foreground">
{student.totalScore}/{student.maxScore} · {" "}
{student.completionPercent}%
</p>
</div>
))
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle> AC </CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{stats.perProblemCoverage.map((item) => (
<div key={item.problemId} className="rounded border p-3">
<p className="font-medium">
#{item.displayId} {item.title}
</p>
<p className="text-sm text-muted-foreground">
{item.solvedUsers}/{item.totalUsers} · {item.acCoverage}%
</p>
</div>
))}
</CardContent>
</Card>
</div>
);
}

View File

@ -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<string | null>(null);
const [isPending, startTransition] = useTransition();
const [course, setCourse] = useState<Awaited<
ReturnType<typeof getTeacherCourseDetail>
> | null>(null);
const [students, setStudents] = useState<Awaited<ReturnType<typeof getCourseStudents>>>([]);
const [availableStudents, setAvailableStudents] = useState<SelectableStudent[]>([]);
const [assignments, setAssignments] = useState<Awaited<
ReturnType<typeof listCourseAssignments>
>>([]);
const [problems, setProblems] = useState<SelectableProblem[]>([]);
const [selectedStudents, setSelectedStudents] = useState<string[]>([]);
const [assignmentTitle, setAssignmentTitle] = useState("");
const [assignmentDescription, setAssignmentDescription] = useState("");
const [assignmentDueAt, setAssignmentDueAt] = useState("");
const [selectedProblems, setSelectedProblems] = useState<
Record<string, number>
>({});
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 <div className="p-6 text-sm text-muted-foreground">...</div>;
}
return (
<div className="space-y-6 p-6">
<Card>
<CardHeader>
<CardTitle>{course.title}</CardTitle>
<CardDescription>
{course.description || "暂无课程简介"} · {course._count.enrollments} ·
{course._count.assignments}
</CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2 md:grid-cols-2">
{availableStudents.map((student) => (
<label
key={student.id}
className="flex items-center gap-2 rounded-md border p-2 text-sm"
>
<Checkbox
checked={selectedStudents.includes(student.id)}
onCheckedChange={(checked) =>
toggleStudent(student.id, checked === true)
}
/>
<span>
{student.name || "未命名"} ({student.email})
</span>
</label>
))}
</div>
<Button
onClick={handleEnrollStudents}
disabled={isPending || selectedStudents.length === 0}
>
{isPending ? "处理中..." : "加入课程"}
</Button>
<div className="rounded-md border p-3">
<p className="mb-2 text-sm font-medium"></p>
{students.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
students.map((item) => (
<p key={item.user.id} className="text-sm">
{item.user.name || "未命名"} ({item.user.email})
</p>
))
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Input
value={assignmentTitle}
onChange={(e) => setAssignmentTitle(e.target.value)}
placeholder="作业标题"
/>
<Textarea
value={assignmentDescription}
onChange={(e) => setAssignmentDescription(e.target.value)}
placeholder="作业说明(可选)"
/>
<Input
type="datetime-local"
value={assignmentDueAt}
onChange={(e) => setAssignmentDueAt(e.target.value)}
/>
<div className="space-y-2 rounded-md border p-3">
{problems.map((problem) => {
const selected = selectedProblemIds.includes(problem.id);
return (
<div key={problem.id} className="space-y-2 rounded border p-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={selected}
onCheckedChange={(checked) =>
toggleProblem(problem.id, checked === true)
}
/>
<span>
#{problem.displayId}{" "}
{problem.localizations[0]?.content || "未命名题目"} (
{problem.difficulty})
</span>
</label>
{selected ? (
<Input
type="number"
min={1}
value={selectedProblems[problem.id] ?? 100}
onChange={(e) =>
setSelectedProblems((prev) => ({
...prev,
[problem.id]: Number(e.target.value || 100),
}))
}
/>
) : null}
</div>
);
})}
</div>
<Button
onClick={handleCreateAssignment}
disabled={isPending || !assignmentTitle.trim() || selectedProblemIds.length === 0}
>
{isPending ? "创建中..." : "发布作业"}
</Button>
{error ? <p className="text-sm text-red-500">{error}</p> : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{assignments.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
assignments.map((assignment) => (
<div
key={assignment.id}
className="flex items-center justify-between rounded border p-3"
>
<div>
<p className="font-medium">{assignment.title}</p>
<p className="text-sm text-muted-foreground">
{assignment._count.problems} · {" "}
{assignment._count.submissions}
</p>
</div>
<Button asChild variant="outline">
<Link
href={`/dashboard/teacher/courses/${courseId}/assignments/${assignment.id}`}
>
</Link>
</Button>
</div>
))
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,9 @@
import { ProtectedLayout } from "@/features/dashboard/layouts/protected-layout";
export default async function TeacherCoursesLayout({
children,
}: {
children: React.ReactNode;
}) {
return <ProtectedLayout roles={["TEACHER", "ADMIN"]}>{children}</ProtectedLayout>;
}

View File

@ -0,0 +1,138 @@
"use client";
import Link from "next/link";
import { useEffect, useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
createCourse,
listTeacherCourses,
} from "@/app/(protected)/dashboard/actions/teacher-courses";
interface CourseItem {
id: string;
title: string;
description: string | null;
archived: boolean;
_count: {
enrollments: number;
assignments: number;
};
}
export default function TeacherCoursesPage() {
const [courses, setCourses] = useState<CourseItem[]>([]);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const fetchCourses = async () => {
try {
const data = await listTeacherCourses();
setCourses(
data.map((item) => ({
id: item.id,
title: item.title,
description: item.description,
archived: item.archived,
_count: item._count,
}))
);
} catch (e) {
setError(e instanceof Error ? e.message : "加载课程失败");
}
};
useEffect(() => {
fetchCourses();
}, []);
const handleCreateCourse = () => {
setError(null);
startTransition(async () => {
try {
await createCourse({ title, description });
setTitle("");
setDescription("");
await fetchCourses();
} catch (e) {
setError(e instanceof Error ? e.message : "创建课程失败");
}
});
};
return (
<div className="space-y-6 p-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="课程标题"
/>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="课程简介(可选)"
/>
<Button
onClick={handleCreateCourse}
disabled={isPending || !title.trim()}
>
{isPending ? "创建中..." : "创建课程"}
</Button>
{error ? <p className="text-sm text-red-500">{error}</p> : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{courses.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
courses.map((course) => (
<div
key={course.id}
className="flex items-center justify-between rounded-md border p-3"
>
<div className="space-y-1">
<p className="font-medium">{course.title}</p>
<p className="text-sm text-muted-foreground">
{course.description || "暂无课程简介"}
</p>
<p className="text-xs text-muted-foreground">
{course._count.enrollments} · {" "}
{course._count.assignments}
{course.archived ? " · 已归档" : ""}
</p>
</div>
<Button asChild variant="outline">
<Link href={`/dashboard/teacher/courses/${course.id}`}>
</Link>
</Button>
</div>
))
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -13,7 +13,8 @@ import { createContainer, createTarStream, prepareEnvironment } from "./docker";
export const judge = async (
problemId: string,
language: Language,
content: string
content: string,
assignmentId?: string
): Promise<Status> => {
const session = await auth();
const userId = session?.user?.id;
@ -25,6 +26,102 @@ export const judge = async (
let container: Docker.Container | null = null;
try {
const actor = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, role: true },
});
if (!actor) {
return Status.SE;
}
const createSystemErrorSubmission = async (
message: string,
options?: { assignmentId?: string | null }
) => {
await prisma.submission.create({
data: {
language,
content,
status: Status.SE,
message,
userId,
problemId,
assignmentId: options?.assignmentId ?? null,
},
});
};
let validatedAssignmentId: string | undefined;
if (assignmentId) {
const assignment = await prisma.assignment.findUnique({
where: { id: assignmentId },
select: {
id: true,
published: true,
course: {
select: {
archived: true,
teacherId: true,
enrollments: {
where: { userId },
select: { userId: true },
},
},
},
problems: {
where: { problemId },
select: { problemId: true },
},
},
});
if (!assignment) {
await createSystemErrorSubmission("Assignment not found");
return Status.SE;
}
const isTeacherOwner = assignment.course.teacherId === userId;
const isStudentEnrolled = assignment.course.enrollments.length > 0;
const canAccessAssignment =
actor.role === "ADMIN" ||
(actor.role === "TEACHER" && isTeacherOwner) ||
(actor.role === "GUEST" && isStudentEnrolled);
if (!canAccessAssignment) {
await createSystemErrorSubmission("No permission for assignment", {
assignmentId: assignment.id,
});
return Status.SE;
}
if (assignment.course.archived) {
await createSystemErrorSubmission("Course is archived", {
assignmentId: assignment.id,
});
return Status.SE;
}
if (!assignment.published && actor.role === "GUEST") {
await createSystemErrorSubmission("Assignment is not published", {
assignmentId: assignment.id,
});
return Status.SE;
}
if (assignment.problems.length === 0) {
await createSystemErrorSubmission(
"Problem does not belong to assignment",
{
assignmentId: assignment.id,
}
);
return Status.SE;
}
validatedAssignmentId = assignment.id;
}
const problem = await prisma.problem.findUnique({
where: {
id: problemId,
@ -32,15 +129,8 @@ export const judge = async (
});
if (!problem) {
await prisma.submission.create({
data: {
language,
content,
status: Status.SE,
message: "Problem not found",
userId,
problemId,
},
await createSystemErrorSubmission("Problem not found", {
assignmentId: validatedAssignmentId,
});
return Status.SE;
}
@ -52,16 +142,10 @@ export const judge = async (
});
if (!testcases.length) {
await prisma.submission.create({
data: {
language,
content,
status: Status.SE,
message: "No testcases available for this problem",
userId,
problemId,
},
});
await createSystemErrorSubmission(
"No testcases available for this problem",
{ assignmentId: validatedAssignmentId }
);
return Status.SE;
}
@ -72,16 +156,10 @@ export const judge = async (
});
if (!dockerConfig) {
await prisma.submission.create({
data: {
language,
content,
status: Status.SE,
message: `Docker configuration not found for language: ${language}`,
userId,
problemId,
},
});
await createSystemErrorSubmission(
`Docker configuration not found for language: ${language}`,
{ assignmentId: validatedAssignmentId }
);
return Status.SE;
}
@ -105,6 +183,7 @@ export const judge = async (
message: `Docker image not found: ${dockerConfig.image}:${dockerConfig.tag}`,
userId,
problemId,
assignmentId: validatedAssignmentId,
},
});
return Status.SE;
@ -117,6 +196,7 @@ export const judge = async (
status: Status.PD,
userId,
problemId,
assignmentId: validatedAssignmentId,
},
});

View File

@ -45,6 +45,8 @@ export function DynamicBreadcrumb() {
teacher: "教师平台",
student: "学生平台",
usermanagement: "用户管理",
courses: "课程",
assignments: "作业",
userdashboard: "用户仪表板",
protected: "受保护",
app: "应用",

View File

@ -34,6 +34,10 @@ const data = {
title: "开始做题",
url: "/problemset",
},
{
title: "我的课程",
url: "/dashboard/student/courses",
},
{
title: "个人设置",
url: "/dashboard/management",

View File

@ -37,6 +37,10 @@ const data = {
title: "完成情况",
url: "/dashboard/teacher/dashboard",
},
{
title: "课程管理",
url: "/dashboard/teacher/courses",
},
],
},
],

View File

@ -6,6 +6,7 @@ import { useState } from "react";
import { Actions } from "flexlayout-react";
import { judge } from "@/app/actions/judge";
import { useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
import { LoaderCircleIcon, PlayIcon } from "lucide-react";
import { TooltipButton } from "@/components/tooltip-button";
import { useProblemEditorStore } from "@/stores/problem-editor";
@ -19,6 +20,7 @@ interface JudgeButtonProps {
export const JudgeButton = ({ className }: JudgeButtonProps) => {
const { model } = useProblemFlexLayoutStore();
const { problem, language, value } = useProblemEditorStore();
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = useState<boolean>(false);
const t = useTranslations("PlaygroundHeader.RunCodeButton");
@ -26,7 +28,8 @@ export const JudgeButton = ({ className }: JudgeButtonProps) => {
if (!problem?.problemId) return;
setIsLoading(true);
const status = await judge(problem.problemId, language, value);
const assignmentId = searchParams.get("assignmentId") || undefined;
const status = await judge(problem.problemId, language, value, assignmentId);
toast.custom((t) => <JudgeToast t={t} status={status} />);
if (model) {