From f0ea48ef06954f7a4bc4c02dcd6f97f0e1f3bae2 Mon Sep 17 00:00:00 2001
From: cfngc4594
Date: Fri, 29 May 2026 14:04:30 +0800
Subject: [PATCH] refactor(dashboard): track assignment progress by testcases
---
.../migration.sql | 8 ++
prisma/schema.prisma | 1 -
prisma/seed.ts | 1 -
.../dashboard/actions/assignment-stats.ts | 130 +++++++++++++-----
.../dashboard/actions/student-dashboard.ts | 20 +--
.../dashboard/actions/teacher-assignments.ts | 12 --
.../dashboard/actions/teacher-dashboard.ts | 4 +-
src/app/(protected)/dashboard/page.tsx | 20 +--
.../assignments/[assignmentId]/page.tsx | 19 ++-
.../student/courses/[courseId]/page.tsx | 2 +-
.../dashboard/student/courses/page.tsx | 2 +-
.../dashboard/student/dashboard/page.tsx | 12 +-
.../assignments/[assignmentId]/page.tsx | 15 +-
.../teacher/courses/[courseId]/page.tsx | 61 +++-----
.../dashboard/teacher/dashboard/page.tsx | 18 +--
src/components/sidebar/teacher-sidebar.tsx | 2 +-
16 files changed, 180 insertions(+), 147 deletions(-)
create mode 100644 prisma/migrations/20260529055652_remove_assignment_problem_max_points/migration.sql
diff --git a/prisma/migrations/20260529055652_remove_assignment_problem_max_points/migration.sql b/prisma/migrations/20260529055652_remove_assignment_problem_max_points/migration.sql
new file mode 100644
index 0000000..edd0a13
--- /dev/null
+++ b/prisma/migrations/20260529055652_remove_assignment_problem_max_points/migration.sql
@@ -0,0 +1,8 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `maxPoints` on the `AssignmentProblem` table. All the data in the column will be lost.
+
+*/
+-- AlterTable
+ALTER TABLE "AssignmentProblem" DROP COLUMN "maxPoints";
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 2769a32..c37ff4d 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -275,7 +275,6 @@ model Assignment {
model AssignmentProblem {
assignmentId String
problemId String
- maxPoints Int @default(100)
order Int?
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
diff --git a/prisma/seed.ts b/prisma/seed.ts
index 71eed22..f429eaf 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -3128,7 +3128,6 @@ export async function main() {
data: selectedProblems.map((problem, index) => ({
assignmentId: assignment.id,
problemId: problem.id,
- maxPoints: 100,
order: index + 1,
})),
skipDuplicates: true,
diff --git a/src/app/(protected)/dashboard/actions/assignment-stats.ts b/src/app/(protected)/dashboard/actions/assignment-stats.ts
index 741fdb9..5fd737e 100644
--- a/src/app/(protected)/dashboard/actions/assignment-stats.ts
+++ b/src/app/(protected)/dashboard/actions/assignment-stats.ts
@@ -9,37 +9,44 @@ import {
getAuthenticatedActor,
} from "@/app/(protected)/dashboard/actions/course-auth";
-interface ProblemScoreRow {
+interface ProblemProgressRow {
problemId: string;
displayId: number;
title: string;
- maxPoints: number;
- earnedPoints: number;
+ testcaseCount: number;
+ passedTestcaseCount: number;
solved: boolean;
attempts: number;
bestStatus: string;
}
-function buildProblemScoreRows(
+function buildProblemProgressRows(
assignmentProblems: {
problemId: string;
- maxPoints: number;
problem: {
displayId: number;
+ testcases: { id: string }[];
localizations: { content: string }[];
};
}[],
submissions: {
problemId: string;
status: string;
+ judge: {
+ judgeRuns: {
+ testcaseId: string;
+ status: string;
+ }[];
+ } | null;
}[]
-): ProblemScoreRow[] {
+): ProblemProgressRow[] {
const grouped = new Map<
string,
{
attempts: number;
solved: boolean;
bestStatus: string;
+ passedTestcaseCount: number;
}
>();
@@ -48,12 +55,27 @@ function buildProblemScoreRows(
attempts: 0,
solved: false,
bestStatus: "PD",
+ passedTestcaseCount: 0,
};
+ const passedTestcaseIds = new Set(
+ submission.judge?.judgeRuns
+ .filter((run) => run.status === "ACCEPTED")
+ .map((run) => run.testcaseId) ?? []
+ );
+
current.attempts += 1;
+ if (passedTestcaseIds.size > current.passedTestcaseCount) {
+ current.passedTestcaseCount = passedTestcaseIds.size;
+ current.bestStatus = submission.status;
+ }
if (submission.status === "AC") {
current.solved = true;
current.bestStatus = "AC";
- } else if (!current.solved) {
+ current.passedTestcaseCount = Math.max(
+ current.passedTestcaseCount,
+ passedTestcaseIds.size
+ );
+ } else if (!current.solved && current.bestStatus === "PD") {
current.bestStatus = submission.status;
}
grouped.set(submission.problemId, current);
@@ -61,13 +83,16 @@ function buildProblemScoreRows(
return assignmentProblems.map((item) => {
const progress = grouped.get(item.problemId);
+ const testcaseCount = item.problem.testcases.length;
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,
+ testcaseCount,
+ passedTestcaseCount: solved
+ ? testcaseCount
+ : progress?.passedTestcaseCount ?? 0,
solved,
attempts: progress?.attempts ?? 0,
bestStatus: progress?.bestStatus ?? "-",
@@ -109,10 +134,12 @@ export async function getTeacherAssignmentStats(assignmentId: string) {
orderBy: [{ order: "asc" }, { problem: { displayId: "asc" } }],
select: {
problemId: true,
- maxPoints: true,
problem: {
select: {
displayId: true,
+ testcases: {
+ select: { id: true },
+ },
localizations: {
where: { type: "TITLE", locale: "zh" },
select: { content: true },
@@ -126,6 +153,16 @@ export async function getTeacherAssignmentStats(assignmentId: string) {
userId: true,
problemId: true,
status: true,
+ judge: {
+ select: {
+ judgeRuns: {
+ select: {
+ testcaseId: true,
+ status: true,
+ },
+ },
+ },
+ },
},
},
},
@@ -138,14 +175,24 @@ export async function getTeacherAssignmentStats(assignmentId: string) {
await assertCourseManagePermission(assignment.courseId, actor);
const problemCount = assignment.problems.length;
- const maxScore = assignment.problems.reduce(
- (total, problem) => total + problem.maxPoints,
+ const totalTestcases = assignment.problems.reduce(
+ (total, problem) => total + problem.problem.testcases.length,
0
);
const submissionGroup = new Map<
string,
- { userId: string; problemId: string; status: string }[]
+ {
+ userId: string;
+ problemId: string;
+ status: string;
+ judge: {
+ judgeRuns: {
+ testcaseId: string;
+ status: string;
+ }[];
+ } | null;
+ }[]
>();
for (const submission of assignment.submissions) {
const bucket = submissionGroup.get(submission.userId) ?? [];
@@ -154,34 +201,41 @@ export async function getTeacherAssignmentStats(assignmentId: string) {
}
const students = assignment.course.enrollments.map((enrollment) => {
- const rows = buildProblemScoreRows(
+ const rows = buildProblemProgressRows(
assignment.problems,
submissionGroup.get(enrollment.userId) ?? []
);
- const score = rows.reduce((sum, row) => sum + row.earnedPoints, 0);
+ const passedTestcaseCount = rows.reduce(
+ (sum, row) => sum + row.passedTestcaseCount,
+ 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,
+ passedTestcaseCount,
+ totalTestcases,
solvedCount,
problemCount,
- completionPercent,
perProblem: rows,
};
});
const perProblemCoverage = assignment.problems.map((problem) => {
+ const testcaseCount = problem.problem.testcases.length;
const solvedUsers = students.filter((student) =>
student.perProblem.some(
(row) => row.problemId === problem.problemId && row.solved
)
).length;
+ const passedTestcaseCount = students.reduce((total, student) => {
+ const row = student.perProblem.find(
+ (item) => item.problemId === problem.problemId
+ );
+ return total + (row?.passedTestcaseCount ?? 0);
+ }, 0);
const totalUsers = students.length;
return {
problemId: problem.problemId,
@@ -189,10 +243,10 @@ export async function getTeacherAssignmentStats(assignmentId: string) {
title:
problem.problem.localizations[0]?.content ??
`题目${problem.problem.displayId}`,
+ passedTestcaseCount,
+ testcaseCount,
solvedUsers,
totalUsers,
- acCoverage:
- totalUsers > 0 ? Math.round((solvedUsers / totalUsers) * 100) : 0,
};
});
@@ -207,8 +261,8 @@ export async function getTeacherAssignmentStats(assignmentId: string) {
id: assignment.course.id,
title: assignment.course.title,
},
- maxScore,
problemCount,
+ totalTestcases,
students,
perProblemCoverage,
};
@@ -238,10 +292,12 @@ export async function getStudentAssignmentSummary(assignmentId: string) {
orderBy: [{ order: "asc" }, { problem: { displayId: "asc" } }],
select: {
problemId: true,
- maxPoints: true,
problem: {
select: {
displayId: true,
+ testcases: {
+ select: { id: true },
+ },
localizations: {
where: { type: "TITLE", locale: "zh" },
select: { content: true },
@@ -270,15 +326,26 @@ export async function getStudentAssignmentSummary(assignmentId: string) {
select: {
problemId: true,
status: true,
+ judge: {
+ select: {
+ judgeRuns: {
+ select: {
+ testcaseId: 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 rows = buildProblemProgressRows(assignment.problems, submissions);
+ const passedTestcaseCount = rows.reduce(
+ (sum, row) => sum + row.passedTestcaseCount,
+ 0
+ );
+ const totalTestcases = rows.reduce((sum, row) => sum + row.testcaseCount, 0);
const solvedCount = rows.filter((row) => row.solved).length;
- const completionPercent =
- rows.length > 0 ? Math.round((solvedCount / rows.length) * 100) : 0;
return {
assignment: {
@@ -290,11 +357,10 @@ export async function getStudentAssignmentSummary(assignmentId: string) {
published: assignment.published,
},
course: assignment.course,
- totalScore,
- maxScore,
+ passedTestcaseCount,
+ totalTestcases,
solvedCount,
problemCount: rows.length,
- completionPercent,
rows,
};
}
diff --git a/src/app/(protected)/dashboard/actions/student-dashboard.ts b/src/app/(protected)/dashboard/actions/student-dashboard.ts
index e7f0ecd..cacaba4 100644
--- a/src/app/(protected)/dashboard/actions/student-dashboard.ts
+++ b/src/app/(protected)/dashboard/actions/student-dashboard.ts
@@ -103,7 +103,7 @@ export async function getStudentDashboardData() {
}))
);
- // 计算题目完成情况
+ // 计算题目通过情况
const completedProblems = new Set();
const attemptedProblems = new Set();
const wrongSubmissions = new Map(); // problemId -> count
@@ -121,10 +121,10 @@ export async function getStudentDashboardData() {
});
console.log("尝试过的题目数:", attemptedProblems.size);
- console.log("完成的题目数:", completedProblems.size);
+ console.log("通过的题目数:", completedProblems.size);
console.log("错误提交统计:", Object.fromEntries(wrongSubmissions));
- // 题目完成比例数据
+ // 题目通过情况数据
const completionData = {
total: allProblems.length,
completed: completedProblems.size,
@@ -134,10 +134,10 @@ export async function getStudentDashboardData() {
: 0,
};
- // 错题比例数据 - 基于已完成的题目计算
+ // 错题比例数据 - 基于已通过的题目计算
const wrongProblems = new Set();
- // 统计在已完成的题目中,哪些题目曾经有过错误提交
+ // 统计在已通过的题目中,哪些题目曾经有过错误提交
userSubmissions.forEach((submission) => {
if (
submission.status !== "AC" &&
@@ -148,8 +148,8 @@ export async function getStudentDashboardData() {
});
const errorData = {
- total: completedProblems.size, // 已完成的题目总数
- wrong: wrongProblems.size, // 在已完成的题目中有过错误的题目数
+ total: completedProblems.size, // 已通过的题目总数
+ wrong: wrongProblems.size, // 在已通过的题目中有过错误的题目数
percentage:
completedProblems.size > 0
? Math.round((wrongProblems.size / completedProblems.size) * 100)
@@ -181,9 +181,9 @@ export async function getStudentDashboardData() {
errorData,
difficultProblems,
pieChartData: [
- { name: "已完成", value: completionData.completed },
+ { name: "已通过", value: completionData.completed },
{
- name: "未完成",
+ name: "未通过",
value: completionData.total - completionData.completed,
},
],
@@ -194,7 +194,7 @@ export async function getStudentDashboardData() {
};
console.log("=== 返回的数据 ===");
- console.log("完成情况:", completionData);
+ console.log("通过情况:", completionData);
console.log("错误情况:", errorData);
console.log("易错题数量:", difficultProblems.length);
console.log("=== 数据获取完成 ===");
diff --git a/src/app/(protected)/dashboard/actions/teacher-assignments.ts b/src/app/(protected)/dashboard/actions/teacher-assignments.ts
index 5268257..218e2fb 100644
--- a/src/app/(protected)/dashboard/actions/teacher-assignments.ts
+++ b/src/app/(protected)/dashboard/actions/teacher-assignments.ts
@@ -10,7 +10,6 @@ import {
interface AssignmentProblemInput {
problemId: string;
- maxPoints: number;
order?: number;
}
@@ -33,14 +32,6 @@ interface UpdateAssignmentInput {
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("作业至少需要一道题目");
@@ -101,7 +92,6 @@ export async function createAssignment(input: CreateAssignmentInput) {
problems: {
create: input.problems.map((item, index) => ({
problemId: item.problemId,
- maxPoints: normalizePoints(item.maxPoints),
order: item.order ?? index + 1,
})),
},
@@ -185,7 +175,6 @@ export async function updateAssignment(
data: input.problems.map((item, index) => ({
assignmentId,
problemId: item.problemId,
- maxPoints: normalizePoints(item.maxPoints),
order: item.order ?? index + 1,
})),
skipDuplicates: true,
@@ -239,7 +228,6 @@ export async function getAssignmentDetail(assignmentId: string) {
orderBy: [{ order: "asc" }, { problem: { displayId: "asc" } }],
select: {
problemId: true,
- maxPoints: true,
order: true,
problem: {
select: {
diff --git a/src/app/(protected)/dashboard/actions/teacher-dashboard.ts b/src/app/(protected)/dashboard/actions/teacher-dashboard.ts
index 888f436..c380092 100644
--- a/src/app/(protected)/dashboard/actions/teacher-dashboard.ts
+++ b/src/app/(protected)/dashboard/actions/teacher-dashboard.ts
@@ -59,7 +59,7 @@ export async function getProblemCompletionData(): Promise<
const locale = await getLocale();
- // 按题目分组统计完成情况(统计独立用户数)
+ // 按题目分组统计通过情况(统计独立用户数)
const problemStats = new Map<
string,
{
@@ -77,7 +77,7 @@ export async function getProblemCompletionData(): Promise<
const problemTitle = title;
const problemDisplayId = submission.problem.displayId;
const userId = submission.userId;
- const isCompleted = submission.status === Status.AC; // 只有 Accepted 才算完成
+ const isCompleted = submission.status === Status.AC; // 只有 Accepted 才算通过
if (!problemStats.has(problemId)) {
problemStats.set(problemId, {
diff --git a/src/app/(protected)/dashboard/page.tsx b/src/app/(protected)/dashboard/page.tsx
index 0d490e1..6b92e8a 100644
--- a/src/app/(protected)/dashboard/page.tsx
+++ b/src/app/(protected)/dashboard/page.tsx
@@ -18,7 +18,6 @@ import prisma from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { Badge } from "@/components/ui/badge";
-import { Progress } from "@/components/ui/progress";
interface Stats {
totalUsers?: number;
@@ -239,7 +238,7 @@ export default async function DashboardPage() {
color: "text-blue-600",
},
{
- label: "已完成",
+ label: "已通过",
value: stats.completedProblems,
icon: CheckCircle,
color: "text-green-600",
@@ -256,13 +255,6 @@ export default async function DashboardPage() {
};
const config = getRoleConfig();
- const completionRate =
- fullUser.role === "STUDENT"
- ? (stats.totalProblems || 0) > 0
- ? ((stats.completedProblems || 0) / (stats.totalProblems || 1)) * 100
- : 0
- : 0;
-
return (
{/* 欢迎区域 */}
@@ -294,7 +286,7 @@ export default async function DashboardPage() {
))}
- {/* 学生进度条 */}
+ {/* 学生通过情况 */}
{fullUser.role === "STUDENT" && (
@@ -303,14 +295,14 @@ export default async function DashboardPage() {
学习进度
- 已完成 {stats.completedProblems || 0} / {stats.totalProblems || 0}{" "}
+ 已通过 {stats.completedProblems || 0} / {stats.totalProblems || 0}{" "}
道题目
-
-
- 完成率: {completionRate.toFixed(1)}%
+
+ 已通过 {stats.completedProblems || 0} / {stats.totalProblems || 0}{" "}
+ 道题目
diff --git a/src/app/(protected)/dashboard/student/courses/[courseId]/assignments/[assignmentId]/page.tsx b/src/app/(protected)/dashboard/student/courses/[courseId]/assignments/[assignmentId]/page.tsx
index 50bd80a..598601b 100644
--- a/src/app/(protected)/dashboard/student/courses/[courseId]/assignments/[assignmentId]/page.tsx
+++ b/src/app/(protected)/dashboard/student/courses/[courseId]/assignments/[assignmentId]/page.tsx
@@ -4,7 +4,6 @@ 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,
@@ -49,17 +48,15 @@ export default function StudentAssignmentDetailPage() {
- 总分:{summary.totalScore}/{summary.maxScore}
+ 通过测试点:{summary.passedTestcaseCount}/{summary.totalTestcases}
- 完成:{summary.solvedCount}/{summary.problemCount}
+ 通过题目:{summary.solvedCount}/{summary.problemCount}
-
- 完成率 {summary.completionPercent}%
{summary.assignment.dueAt
- ? ` · 截止 ${new Date(summary.assignment.dueAt).toLocaleString()}`
- : ""}
+ ? `截止 ${new Date(summary.assignment.dueAt).toLocaleString()}`
+ : "暂无截止时间"}
{error ? {error}
: null}
@@ -68,7 +65,7 @@ export default function StudentAssignmentDetailPage() {
题目列表
- 进入题目后会自动按本作业统计提交与得分
+ 进入题目后会自动按本作业统计提交与测试点
{summary.rows.map((row) => (
@@ -81,8 +78,10 @@ export default function StudentAssignmentDetailPage() {
#{row.displayId} {row.title}
- 得分 {row.earnedPoints}/{row.maxPoints} · 状态 {row.bestStatus} ·
- 提交 {row.attempts} 次
+ {row.testcaseCount > 0
+ ? `通过测试点 ${row.passedTestcaseCount}/${row.testcaseCount}`
+ : "未配置测试点"}{" "}
+ · 状态 {row.bestStatus} · 提交 {row.attempts} 次