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
|
||||
}
|
||||
|
||||
enum CourseEnrollmentRole {
|
||||
STUDENT
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
@ -71,6 +75,9 @@ model User {
|
||||
Authenticator Authenticator[]
|
||||
problems Problem[]
|
||||
submissions Submission[]
|
||||
teachingCourses Course[] @relation("CourseTeacher")
|
||||
courseEnrollments CourseEnrollment[]
|
||||
createdAssignments Assignment[] @relation("AssignmentCreator")
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@ -89,6 +96,7 @@ model Problem {
|
||||
templates Template[]
|
||||
testcases Testcase[]
|
||||
submissions Submission[]
|
||||
assignmentProblems AssignmentProblem[]
|
||||
|
||||
userId String?
|
||||
|
||||
@ -135,12 +143,88 @@ model Submission {
|
||||
|
||||
userId String
|
||||
problemId String
|
||||
assignmentId String?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
problem Problem @relation(fields: [problemId], references: [id], onDelete: Cascade)
|
||||
assignment Assignment? @relation(fields: [assignmentId], references: [id], onDelete: SetNull)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([assignmentId, userId, problemId])
|
||||
@@index([assignmentId, problemId, status])
|
||||
@@index([userId, problemId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model Course {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
description String?
|
||||
archived Boolean @default(false)
|
||||
teacherId String
|
||||
|
||||
teacher User @relation("CourseTeacher", fields: [teacherId], references: [id], onDelete: Cascade)
|
||||
enrollments CourseEnrollment[]
|
||||
assignments Assignment[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([teacherId])
|
||||
}
|
||||
|
||||
model CourseEnrollment {
|
||||
id String @id @default(cuid())
|
||||
role CourseEnrollmentRole @default(STUDENT)
|
||||
courseId String
|
||||
userId String
|
||||
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([courseId, userId])
|
||||
@@index([courseId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Assignment {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
description String?
|
||||
opensAt DateTime?
|
||||
dueAt DateTime?
|
||||
published Boolean @default(false)
|
||||
courseId String
|
||||
createdById String
|
||||
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
createdBy User @relation("AssignmentCreator", fields: [createdById], references: [id], onDelete: Cascade)
|
||||
problems AssignmentProblem[]
|
||||
submissions Submission[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([courseId])
|
||||
@@index([createdById])
|
||||
}
|
||||
|
||||
model AssignmentProblem {
|
||||
assignmentId String
|
||||
problemId String
|
||||
maxPoints Int @default(100)
|
||||
order Int?
|
||||
|
||||
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
||||
problem Problem @relation(fields: [problemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([assignmentId, problemId])
|
||||
@@index([assignmentId])
|
||||
@@index([problemId])
|
||||
}
|
||||
|
||||
enum AnalysisStatus {
|
||||
|
||||
119
prisma/seed.ts
119
prisma/seed.ts
@ -1731,6 +1731,125 @@ export async function main() {
|
||||
data: problem,
|
||||
});
|
||||
}
|
||||
|
||||
// Seed demo users for course/assignment MVP
|
||||
const teacher = await prisma.user.upsert({
|
||||
where: { email: "teacher@judge4c.local" },
|
||||
update: {
|
||||
name: "课程教师",
|
||||
role: "TEACHER",
|
||||
},
|
||||
create: {
|
||||
name: "课程教师",
|
||||
email: "teacher@judge4c.local",
|
||||
role: "TEACHER",
|
||||
},
|
||||
});
|
||||
|
||||
const students = await Promise.all(
|
||||
["student1@judge4c.local", "student2@judge4c.local"].map((email, index) =>
|
||||
prisma.user.upsert({
|
||||
where: { email },
|
||||
update: {
|
||||
name: `学生${index + 1}`,
|
||||
role: "GUEST",
|
||||
},
|
||||
create: {
|
||||
name: `学生${index + 1}`,
|
||||
email,
|
||||
role: "GUEST",
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const selectedProblems = await prisma.problem.findMany({
|
||||
orderBy: { displayId: "asc" },
|
||||
take: 2,
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (selectedProblems.length > 0) {
|
||||
let course = await prisma.course.findFirst({
|
||||
where: {
|
||||
title: "程序设计基础",
|
||||
teacherId: teacher.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!course) {
|
||||
course = await prisma.course.create({
|
||||
data: {
|
||||
title: "程序设计基础",
|
||||
description: "课程作业 MVP 示例课程",
|
||||
teacherId: teacher.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const student of students) {
|
||||
await prisma.courseEnrollment.upsert({
|
||||
where: {
|
||||
courseId_userId: {
|
||||
courseId: course.id,
|
||||
userId: student.id,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
courseId: course.id,
|
||||
userId: student.id,
|
||||
role: "STUDENT",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const dueAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
let assignment = await prisma.assignment.findFirst({
|
||||
where: {
|
||||
courseId: course.id,
|
||||
title: "第一次编程作业",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!assignment) {
|
||||
assignment = await prisma.assignment.create({
|
||||
data: {
|
||||
courseId: course.id,
|
||||
title: "第一次编程作业",
|
||||
description: "完成基础题目,熟悉在线评测流程",
|
||||
dueAt,
|
||||
published: true,
|
||||
createdById: teacher.id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
} else {
|
||||
await prisma.assignment.update({
|
||||
where: { id: assignment.id },
|
||||
data: {
|
||||
dueAt,
|
||||
published: true,
|
||||
description: "完成基础题目,熟悉在线评测流程",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.assignmentProblem.deleteMany({
|
||||
where: { assignmentId: assignment.id },
|
||||
});
|
||||
|
||||
await prisma.assignmentProblem.createMany({
|
||||
data: selectedProblems.map((problem, index) => ({
|
||||
assignmentId: assignment.id,
|
||||
problemId: problem.id,
|
||||
maxPoints: 100,
|
||||
order: index + 1,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
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 { auth } from "@/lib/auth";
|
||||
import { assertStudent } from "./course-auth";
|
||||
|
||||
export async function getStudentDashboardData() {
|
||||
try {
|
||||
@ -29,6 +30,12 @@ export async function getStudentDashboardData() {
|
||||
if (!currentUser) {
|
||||
throw new Error("用户不存在");
|
||||
}
|
||||
assertStudent({
|
||||
id: currentUser.id,
|
||||
role: currentUser.role,
|
||||
name: currentUser.name,
|
||||
email: currentUser.email,
|
||||
});
|
||||
|
||||
// 获取所有已发布的题目(包含英文标题)
|
||||
const allProblems = await prisma.problem.findMany({
|
||||
|
||||
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 { getLocale } from "next-intl/server";
|
||||
import { Locale, Status, ProblemLocalization } from "@/generated/client";
|
||||
import { assertTeacherOrAdmin, getAuthenticatedActor } from "./course-auth";
|
||||
|
||||
const getLocalizedTitle = (
|
||||
localizations: ProblemLocalization[],
|
||||
@ -41,6 +42,9 @@ export interface DifficultProblemData {
|
||||
export async function getProblemCompletionData(): Promise<
|
||||
ProblemCompletionData[]
|
||||
> {
|
||||
const actor = await getAuthenticatedActor();
|
||||
assertTeacherOrAdmin(actor);
|
||||
|
||||
// 获取所有提交记录,按题目分组统计
|
||||
const submissions = await prisma.submission.findMany({
|
||||
include: {
|
||||
@ -124,6 +128,9 @@ export async function getProblemCompletionData(): Promise<
|
||||
export async function getDifficultProblemsData(): Promise<
|
||||
DifficultProblemData[]
|
||||
> {
|
||||
const actor = await getAuthenticatedActor();
|
||||
assertTeacherOrAdmin(actor);
|
||||
|
||||
// 获取所有测试用例结果
|
||||
const testcaseResults = await prisma.testcaseResult.findMany({
|
||||
include: {
|
||||
@ -207,6 +214,9 @@ export async function getDifficultProblemsData(): Promise<
|
||||
}
|
||||
|
||||
export async function getDashboardStats() {
|
||||
const actor = await getAuthenticatedActor();
|
||||
assertTeacherOrAdmin(actor);
|
||||
|
||||
const [problemData, difficultProblems] = await Promise.all([
|
||||
getProblemCompletionData(),
|
||||
getDifficultProblemsData(),
|
||||
|
||||
@ -260,6 +260,11 @@ export default async function DashboardPage() {
|
||||
href: "/dashboard/teacher/dashboard",
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
label: "课程管理",
|
||||
href: "/dashboard/teacher/courses",
|
||||
icon: BookOpen,
|
||||
},
|
||||
],
|
||||
};
|
||||
default:
|
||||
@ -292,6 +297,11 @@ export default async function DashboardPage() {
|
||||
href: "/dashboard/student/dashboard",
|
||||
icon: TrendingUp,
|
||||
},
|
||||
{
|
||||
label: "我的课程",
|
||||
href: "/dashboard/student/courses",
|
||||
icon: GraduationCapIcon,
|
||||
},
|
||||
{ label: "开始做题", href: "/problemset", icon: BookOpen },
|
||||
{ label: "个人设置", href: "/dashboard/management", icon: Target },
|
||||
],
|
||||
|
||||
@ -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 (
|
||||
problemId: string,
|
||||
language: Language,
|
||||
content: string
|
||||
content: string,
|
||||
assignmentId?: string
|
||||
): Promise<Status> => {
|
||||
const session = await auth();
|
||||
const userId = session?.user?.id;
|
||||
@ -25,6 +26,102 @@ export const judge = async (
|
||||
let container: Docker.Container | null = null;
|
||||
|
||||
try {
|
||||
const actor = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, role: true },
|
||||
});
|
||||
|
||||
if (!actor) {
|
||||
return Status.SE;
|
||||
}
|
||||
|
||||
const createSystemErrorSubmission = async (
|
||||
message: string,
|
||||
options?: { assignmentId?: string | null }
|
||||
) => {
|
||||
await prisma.submission.create({
|
||||
data: {
|
||||
language,
|
||||
content,
|
||||
status: Status.SE,
|
||||
message,
|
||||
userId,
|
||||
problemId,
|
||||
assignmentId: options?.assignmentId ?? null,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
let validatedAssignmentId: string | undefined;
|
||||
if (assignmentId) {
|
||||
const assignment = await prisma.assignment.findUnique({
|
||||
where: { id: assignmentId },
|
||||
select: {
|
||||
id: true,
|
||||
published: true,
|
||||
course: {
|
||||
select: {
|
||||
archived: true,
|
||||
teacherId: true,
|
||||
enrollments: {
|
||||
where: { userId },
|
||||
select: { userId: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
problems: {
|
||||
where: { problemId },
|
||||
select: { problemId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!assignment) {
|
||||
await createSystemErrorSubmission("Assignment not found");
|
||||
return Status.SE;
|
||||
}
|
||||
|
||||
const isTeacherOwner = assignment.course.teacherId === userId;
|
||||
const isStudentEnrolled = assignment.course.enrollments.length > 0;
|
||||
const canAccessAssignment =
|
||||
actor.role === "ADMIN" ||
|
||||
(actor.role === "TEACHER" && isTeacherOwner) ||
|
||||
(actor.role === "GUEST" && isStudentEnrolled);
|
||||
|
||||
if (!canAccessAssignment) {
|
||||
await createSystemErrorSubmission("No permission for assignment", {
|
||||
assignmentId: assignment.id,
|
||||
});
|
||||
return Status.SE;
|
||||
}
|
||||
|
||||
if (assignment.course.archived) {
|
||||
await createSystemErrorSubmission("Course is archived", {
|
||||
assignmentId: assignment.id,
|
||||
});
|
||||
return Status.SE;
|
||||
}
|
||||
|
||||
if (!assignment.published && actor.role === "GUEST") {
|
||||
await createSystemErrorSubmission("Assignment is not published", {
|
||||
assignmentId: assignment.id,
|
||||
});
|
||||
return Status.SE;
|
||||
}
|
||||
|
||||
if (assignment.problems.length === 0) {
|
||||
await createSystemErrorSubmission(
|
||||
"Problem does not belong to assignment",
|
||||
{
|
||||
assignmentId: assignment.id,
|
||||
}
|
||||
);
|
||||
return Status.SE;
|
||||
}
|
||||
|
||||
validatedAssignmentId = assignment.id;
|
||||
}
|
||||
|
||||
const problem = await prisma.problem.findUnique({
|
||||
where: {
|
||||
id: problemId,
|
||||
@ -32,15 +129,8 @@ export const judge = async (
|
||||
});
|
||||
|
||||
if (!problem) {
|
||||
await prisma.submission.create({
|
||||
data: {
|
||||
language,
|
||||
content,
|
||||
status: Status.SE,
|
||||
message: "Problem not found",
|
||||
userId,
|
||||
problemId,
|
||||
},
|
||||
await createSystemErrorSubmission("Problem not found", {
|
||||
assignmentId: validatedAssignmentId,
|
||||
});
|
||||
return Status.SE;
|
||||
}
|
||||
@ -52,16 +142,10 @@ export const judge = async (
|
||||
});
|
||||
|
||||
if (!testcases.length) {
|
||||
await prisma.submission.create({
|
||||
data: {
|
||||
language,
|
||||
content,
|
||||
status: Status.SE,
|
||||
message: "No testcases available for this problem",
|
||||
userId,
|
||||
problemId,
|
||||
},
|
||||
});
|
||||
await createSystemErrorSubmission(
|
||||
"No testcases available for this problem",
|
||||
{ assignmentId: validatedAssignmentId }
|
||||
);
|
||||
return Status.SE;
|
||||
}
|
||||
|
||||
@ -72,16 +156,10 @@ export const judge = async (
|
||||
});
|
||||
|
||||
if (!dockerConfig) {
|
||||
await prisma.submission.create({
|
||||
data: {
|
||||
language,
|
||||
content,
|
||||
status: Status.SE,
|
||||
message: `Docker configuration not found for language: ${language}`,
|
||||
userId,
|
||||
problemId,
|
||||
},
|
||||
});
|
||||
await createSystemErrorSubmission(
|
||||
`Docker configuration not found for language: ${language}`,
|
||||
{ assignmentId: validatedAssignmentId }
|
||||
);
|
||||
return Status.SE;
|
||||
}
|
||||
|
||||
@ -105,6 +183,7 @@ export const judge = async (
|
||||
message: `Docker image not found: ${dockerConfig.image}:${dockerConfig.tag}`,
|
||||
userId,
|
||||
problemId,
|
||||
assignmentId: validatedAssignmentId,
|
||||
},
|
||||
});
|
||||
return Status.SE;
|
||||
@ -117,6 +196,7 @@ export const judge = async (
|
||||
status: Status.PD,
|
||||
userId,
|
||||
problemId,
|
||||
assignmentId: validatedAssignmentId,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -45,6 +45,8 @@ export function DynamicBreadcrumb() {
|
||||
teacher: "教师平台",
|
||||
student: "学生平台",
|
||||
usermanagement: "用户管理",
|
||||
courses: "课程",
|
||||
assignments: "作业",
|
||||
userdashboard: "用户仪表板",
|
||||
protected: "受保护",
|
||||
app: "应用",
|
||||
|
||||
@ -34,6 +34,10 @@ const data = {
|
||||
title: "开始做题",
|
||||
url: "/problemset",
|
||||
},
|
||||
{
|
||||
title: "我的课程",
|
||||
url: "/dashboard/student/courses",
|
||||
},
|
||||
{
|
||||
title: "个人设置",
|
||||
url: "/dashboard/management",
|
||||
|
||||
@ -37,6 +37,10 @@ const data = {
|
||||
title: "完成情况",
|
||||
url: "/dashboard/teacher/dashboard",
|
||||
},
|
||||
{
|
||||
title: "课程管理",
|
||||
url: "/dashboard/teacher/courses",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@ -6,6 +6,7 @@ import { useState } from "react";
|
||||
import { Actions } from "flexlayout-react";
|
||||
import { judge } from "@/app/actions/judge";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { LoaderCircleIcon, PlayIcon } from "lucide-react";
|
||||
import { TooltipButton } from "@/components/tooltip-button";
|
||||
import { useProblemEditorStore } from "@/stores/problem-editor";
|
||||
@ -19,6 +20,7 @@ interface JudgeButtonProps {
|
||||
export const JudgeButton = ({ className }: JudgeButtonProps) => {
|
||||
const { model } = useProblemFlexLayoutStore();
|
||||
const { problem, language, value } = useProblemEditorStore();
|
||||
const searchParams = useSearchParams();
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const t = useTranslations("PlaygroundHeader.RunCodeButton");
|
||||
|
||||
@ -26,7 +28,8 @@ export const JudgeButton = ({ className }: JudgeButtonProps) => {
|
||||
if (!problem?.problemId) return;
|
||||
setIsLoading(true);
|
||||
|
||||
const status = await judge(problem.problemId, language, value);
|
||||
const assignmentId = searchParams.get("assignmentId") || undefined;
|
||||
const status = await judge(problem.problemId, language, value, assignmentId);
|
||||
toast.custom((t) => <JudgeToast t={t} status={status} />);
|
||||
|
||||
if (model) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user