mirror of
https://github.com/massbug/judge4c.git
synced 2026-05-20 13:18:52 +00:00
feat(course): add course-assignment workflow with role-based access and assignment-scoped judging
This commit is contained in:
parent
03fa169a81
commit
6d4aef9543
@ -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;
|
||||||
@ -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;
|
||||||
@ -56,6 +56,10 @@ enum ProblemContentType {
|
|||||||
SOLUTION
|
SOLUTION
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum CourseEnrollmentRole {
|
||||||
|
STUDENT
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String?
|
name String?
|
||||||
@ -71,6 +75,9 @@ model User {
|
|||||||
Authenticator Authenticator[]
|
Authenticator Authenticator[]
|
||||||
problems Problem[]
|
problems Problem[]
|
||||||
submissions Submission[]
|
submissions Submission[]
|
||||||
|
teachingCourses Course[] @relation("CourseTeacher")
|
||||||
|
courseEnrollments CourseEnrollment[]
|
||||||
|
createdAssignments Assignment[] @relation("AssignmentCreator")
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@ -89,6 +96,7 @@ model Problem {
|
|||||||
templates Template[]
|
templates Template[]
|
||||||
testcases Testcase[]
|
testcases Testcase[]
|
||||||
submissions Submission[]
|
submissions Submission[]
|
||||||
|
assignmentProblems AssignmentProblem[]
|
||||||
|
|
||||||
userId String?
|
userId String?
|
||||||
|
|
||||||
@ -135,12 +143,88 @@ model Submission {
|
|||||||
|
|
||||||
userId String
|
userId String
|
||||||
problemId String
|
problemId String
|
||||||
|
assignmentId String?
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
problem Problem @relation(fields: [problemId], 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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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 {
|
enum AnalysisStatus {
|
||||||
|
|||||||
119
prisma/seed.ts
119
prisma/seed.ts
@ -1731,6 +1731,125 @@ export async function main() {
|
|||||||
data: problem,
|
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();
|
main();
|
||||||
|
|||||||
347
src/app/(protected)/dashboard/actions/assignment-stats.ts
Normal file
347
src/app/(protected)/dashboard/actions/assignment-stats.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
104
src/app/(protected)/dashboard/actions/course-auth.ts
Normal file
104
src/app/(protected)/dashboard/actions/course-auth.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { assertStudent } from "./course-auth";
|
||||||
|
|
||||||
export async function getStudentDashboardData() {
|
export async function getStudentDashboardData() {
|
||||||
try {
|
try {
|
||||||
@ -29,6 +30,12 @@ export async function getStudentDashboardData() {
|
|||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
throw new Error("用户不存在");
|
throw new Error("用户不存在");
|
||||||
}
|
}
|
||||||
|
assertStudent({
|
||||||
|
id: currentUser.id,
|
||||||
|
role: currentUser.role,
|
||||||
|
name: currentUser.name,
|
||||||
|
email: currentUser.email,
|
||||||
|
});
|
||||||
|
|
||||||
// 获取所有已发布的题目(包含英文标题)
|
// 获取所有已发布的题目(包含英文标题)
|
||||||
const allProblems = await prisma.problem.findMany({
|
const allProblems = await prisma.problem.findMany({
|
||||||
|
|||||||
315
src/app/(protected)/dashboard/actions/teacher-assignments.ts
Normal file
315
src/app/(protected)/dashboard/actions/teacher-assignments.ts
Normal 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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
272
src/app/(protected)/dashboard/actions/teacher-courses.ts
Normal file
272
src/app/(protected)/dashboard/actions/teacher-courses.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@
|
|||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { getLocale } from "next-intl/server";
|
import { getLocale } from "next-intl/server";
|
||||||
import { Locale, Status, ProblemLocalization } from "@/generated/client";
|
import { Locale, Status, ProblemLocalization } from "@/generated/client";
|
||||||
|
import { assertTeacherOrAdmin, getAuthenticatedActor } from "./course-auth";
|
||||||
|
|
||||||
const getLocalizedTitle = (
|
const getLocalizedTitle = (
|
||||||
localizations: ProblemLocalization[],
|
localizations: ProblemLocalization[],
|
||||||
@ -41,6 +42,9 @@ export interface DifficultProblemData {
|
|||||||
export async function getProblemCompletionData(): Promise<
|
export async function getProblemCompletionData(): Promise<
|
||||||
ProblemCompletionData[]
|
ProblemCompletionData[]
|
||||||
> {
|
> {
|
||||||
|
const actor = await getAuthenticatedActor();
|
||||||
|
assertTeacherOrAdmin(actor);
|
||||||
|
|
||||||
// 获取所有提交记录,按题目分组统计
|
// 获取所有提交记录,按题目分组统计
|
||||||
const submissions = await prisma.submission.findMany({
|
const submissions = await prisma.submission.findMany({
|
||||||
include: {
|
include: {
|
||||||
@ -124,6 +128,9 @@ export async function getProblemCompletionData(): Promise<
|
|||||||
export async function getDifficultProblemsData(): Promise<
|
export async function getDifficultProblemsData(): Promise<
|
||||||
DifficultProblemData[]
|
DifficultProblemData[]
|
||||||
> {
|
> {
|
||||||
|
const actor = await getAuthenticatedActor();
|
||||||
|
assertTeacherOrAdmin(actor);
|
||||||
|
|
||||||
// 获取所有测试用例结果
|
// 获取所有测试用例结果
|
||||||
const testcaseResults = await prisma.testcaseResult.findMany({
|
const testcaseResults = await prisma.testcaseResult.findMany({
|
||||||
include: {
|
include: {
|
||||||
@ -207,6 +214,9 @@ export async function getDifficultProblemsData(): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getDashboardStats() {
|
export async function getDashboardStats() {
|
||||||
|
const actor = await getAuthenticatedActor();
|
||||||
|
assertTeacherOrAdmin(actor);
|
||||||
|
|
||||||
const [problemData, difficultProblems] = await Promise.all([
|
const [problemData, difficultProblems] = await Promise.all([
|
||||||
getProblemCompletionData(),
|
getProblemCompletionData(),
|
||||||
getDifficultProblemsData(),
|
getDifficultProblemsData(),
|
||||||
|
|||||||
@ -260,6 +260,11 @@ export default async function DashboardPage() {
|
|||||||
href: "/dashboard/teacher/dashboard",
|
href: "/dashboard/teacher/dashboard",
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "课程管理",
|
||||||
|
href: "/dashboard/teacher/courses",
|
||||||
|
icon: BookOpen,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
@ -292,6 +297,11 @@ export default async function DashboardPage() {
|
|||||||
href: "/dashboard/student/dashboard",
|
href: "/dashboard/student/dashboard",
|
||||||
icon: TrendingUp,
|
icon: TrendingUp,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "我的课程",
|
||||||
|
href: "/dashboard/student/courses",
|
||||||
|
icon: GraduationCapIcon,
|
||||||
|
},
|
||||||
{ label: "开始做题", href: "/problemset", icon: BookOpen },
|
{ label: "开始做题", href: "/problemset", icon: BookOpen },
|
||||||
{ label: "个人设置", href: "/dashboard/management", icon: Target },
|
{ label: "个人设置", href: "/dashboard/management", icon: Target },
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/app/(protected)/dashboard/student/courses/layout.tsx
Normal file
9
src/app/(protected)/dashboard/student/courses/layout.tsx
Normal 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>;
|
||||||
|
}
|
||||||
72
src/app/(protected)/dashboard/student/courses/page.tsx
Normal file
72
src/app/(protected)/dashboard/student/courses/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/app/(protected)/dashboard/teacher/courses/layout.tsx
Normal file
9
src/app/(protected)/dashboard/teacher/courses/layout.tsx
Normal 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>;
|
||||||
|
}
|
||||||
138
src/app/(protected)/dashboard/teacher/courses/page.tsx
Normal file
138
src/app/(protected)/dashboard/teacher/courses/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -13,7 +13,8 @@ import { createContainer, createTarStream, prepareEnvironment } from "./docker";
|
|||||||
export const judge = async (
|
export const judge = async (
|
||||||
problemId: string,
|
problemId: string,
|
||||||
language: Language,
|
language: Language,
|
||||||
content: string
|
content: string,
|
||||||
|
assignmentId?: string
|
||||||
): Promise<Status> => {
|
): Promise<Status> => {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const userId = session?.user?.id;
|
const userId = session?.user?.id;
|
||||||
@ -25,6 +26,102 @@ export const judge = async (
|
|||||||
let container: Docker.Container | null = null;
|
let container: Docker.Container | null = null;
|
||||||
|
|
||||||
try {
|
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({
|
const problem = await prisma.problem.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: problemId,
|
id: problemId,
|
||||||
@ -32,15 +129,8 @@ export const judge = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!problem) {
|
if (!problem) {
|
||||||
await prisma.submission.create({
|
await createSystemErrorSubmission("Problem not found", {
|
||||||
data: {
|
assignmentId: validatedAssignmentId,
|
||||||
language,
|
|
||||||
content,
|
|
||||||
status: Status.SE,
|
|
||||||
message: "Problem not found",
|
|
||||||
userId,
|
|
||||||
problemId,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return Status.SE;
|
return Status.SE;
|
||||||
}
|
}
|
||||||
@ -52,16 +142,10 @@ export const judge = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!testcases.length) {
|
if (!testcases.length) {
|
||||||
await prisma.submission.create({
|
await createSystemErrorSubmission(
|
||||||
data: {
|
"No testcases available for this problem",
|
||||||
language,
|
{ assignmentId: validatedAssignmentId }
|
||||||
content,
|
);
|
||||||
status: Status.SE,
|
|
||||||
message: "No testcases available for this problem",
|
|
||||||
userId,
|
|
||||||
problemId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return Status.SE;
|
return Status.SE;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,16 +156,10 @@ export const judge = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!dockerConfig) {
|
if (!dockerConfig) {
|
||||||
await prisma.submission.create({
|
await createSystemErrorSubmission(
|
||||||
data: {
|
`Docker configuration not found for language: ${language}`,
|
||||||
language,
|
{ assignmentId: validatedAssignmentId }
|
||||||
content,
|
);
|
||||||
status: Status.SE,
|
|
||||||
message: `Docker configuration not found for language: ${language}`,
|
|
||||||
userId,
|
|
||||||
problemId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return Status.SE;
|
return Status.SE;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,6 +183,7 @@ export const judge = async (
|
|||||||
message: `Docker image not found: ${dockerConfig.image}:${dockerConfig.tag}`,
|
message: `Docker image not found: ${dockerConfig.image}:${dockerConfig.tag}`,
|
||||||
userId,
|
userId,
|
||||||
problemId,
|
problemId,
|
||||||
|
assignmentId: validatedAssignmentId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return Status.SE;
|
return Status.SE;
|
||||||
@ -117,6 +196,7 @@ export const judge = async (
|
|||||||
status: Status.PD,
|
status: Status.PD,
|
||||||
userId,
|
userId,
|
||||||
problemId,
|
problemId,
|
||||||
|
assignmentId: validatedAssignmentId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -45,6 +45,8 @@ export function DynamicBreadcrumb() {
|
|||||||
teacher: "教师平台",
|
teacher: "教师平台",
|
||||||
student: "学生平台",
|
student: "学生平台",
|
||||||
usermanagement: "用户管理",
|
usermanagement: "用户管理",
|
||||||
|
courses: "课程",
|
||||||
|
assignments: "作业",
|
||||||
userdashboard: "用户仪表板",
|
userdashboard: "用户仪表板",
|
||||||
protected: "受保护",
|
protected: "受保护",
|
||||||
app: "应用",
|
app: "应用",
|
||||||
|
|||||||
@ -34,6 +34,10 @@ const data = {
|
|||||||
title: "开始做题",
|
title: "开始做题",
|
||||||
url: "/problemset",
|
url: "/problemset",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "我的课程",
|
||||||
|
url: "/dashboard/student/courses",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "个人设置",
|
title: "个人设置",
|
||||||
url: "/dashboard/management",
|
url: "/dashboard/management",
|
||||||
|
|||||||
@ -37,6 +37,10 @@ const data = {
|
|||||||
title: "完成情况",
|
title: "完成情况",
|
||||||
url: "/dashboard/teacher/dashboard",
|
url: "/dashboard/teacher/dashboard",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "课程管理",
|
||||||
|
url: "/dashboard/teacher/courses",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { useState } from "react";
|
|||||||
import { Actions } from "flexlayout-react";
|
import { Actions } from "flexlayout-react";
|
||||||
import { judge } from "@/app/actions/judge";
|
import { judge } from "@/app/actions/judge";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
import { LoaderCircleIcon, PlayIcon } from "lucide-react";
|
import { LoaderCircleIcon, PlayIcon } from "lucide-react";
|
||||||
import { TooltipButton } from "@/components/tooltip-button";
|
import { TooltipButton } from "@/components/tooltip-button";
|
||||||
import { useProblemEditorStore } from "@/stores/problem-editor";
|
import { useProblemEditorStore } from "@/stores/problem-editor";
|
||||||
@ -19,6 +20,7 @@ interface JudgeButtonProps {
|
|||||||
export const JudgeButton = ({ className }: JudgeButtonProps) => {
|
export const JudgeButton = ({ className }: JudgeButtonProps) => {
|
||||||
const { model } = useProblemFlexLayoutStore();
|
const { model } = useProblemFlexLayoutStore();
|
||||||
const { problem, language, value } = useProblemEditorStore();
|
const { problem, language, value } = useProblemEditorStore();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
const t = useTranslations("PlaygroundHeader.RunCodeButton");
|
const t = useTranslations("PlaygroundHeader.RunCodeButton");
|
||||||
|
|
||||||
@ -26,7 +28,8 @@ export const JudgeButton = ({ className }: JudgeButtonProps) => {
|
|||||||
if (!problem?.problemId) return;
|
if (!problem?.problemId) return;
|
||||||
setIsLoading(true);
|
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} />);
|
toast.custom((t) => <JudgeToast t={t} status={status} />);
|
||||||
|
|
||||||
if (model) {
|
if (model) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user