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} 次