From 1ebb23cdac4df69408ea56178c4f0e779b37a082 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 16 Oct 2026 12:21:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=87=AA=E5=AD=A6=E6=83=85=E5=86=B5):=20?= =?UTF-8?q?=E7=9C=8B=E5=BE=97=E5=88=B0=E9=85=8D=E5=A5=97=E9=A2=98=E5=81=9A?= =?UTF-8?q?=E6=B2=A1=E5=81=9A=E2=80=94=E2=80=94=E6=AF=8F=E4=BA=BA=E4=B8=80?= =?UTF-8?q?=E5=88=97=E3=80=81=E6=AF=8F=E8=AF=BE=E4=B8=80=E4=B8=AA=E9=A1=B5?= =?UTF-8?q?=E7=AD=BE=E3=80=81=E5=AD=A6=E7=94=9F=E6=98=8E=E7=BB=86=E9=87=8C?= =?UTF-8?q?=E9=80=90=E9=A2=98=E5=88=97=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 方块格多一列「配套题」:他打开过的那些课挂的题,做对几道 / 一共几道;格子提示里也带上 - 右栏分「练一练 / 配套题」两个页签:每道题读过这课的人里几人做对,点开看谁没交、谁交了没对, 一键跳到这个人这道题的提交 - 学生明细每课多一段配套题:做对没有、交了几次、最后哪天交的 - 做对不论早晚,读教程之前在课上做对的也算;口径同学生端课文里那个勾(代码 AC 或流程图 A / S) - 1366 宽下 C 语言 20 课也不用横着滑了:「最后学习」只留日期,几列收窄 Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/admin/learn.ts | 116 ++++++++++++- apps/web/src/admin/learn/ProblemReaders.vue | 113 +++++++++++++ apps/web/src/admin/learn/StudentDrawer.vue | 75 ++++++++- apps/web/src/admin/learn/index.vue | 174 +++++++++++++++++--- packages/contract/src/admin.ts | 29 ++++ 5 files changed, 474 insertions(+), 33 deletions(-) create mode 100644 apps/web/src/admin/learn/ProblemReaders.vue diff --git a/apps/api/src/routes/admin/learn.ts b/apps/api/src/routes/admin/learn.ts index a568fa4..d0872f2 100644 --- a/apps/api/src/routes/admin/learn.ts +++ b/apps/api/src/routes/admin/learn.ts @@ -1,4 +1,5 @@ import { + FLOWCHART_PASS_GRADES, STUDENT_ROLES, isLessonDone, type LearnBoard, @@ -14,8 +15,9 @@ import { Hono } from "hono" import { requireTeacher, type AppEnv } from "../../auth/middleware" import { db, schema } from "../../db" import { failure, success } from "../../http" +import { accepted } from "../../services/learning-stats" import { defaultClassFor } from "../../services/ranking" -import { exerciseCountColumn } from "../../services/tutorial" +import { exerciseCountColumn, tutorialProblems } from "../../services/tutorial" import { queryInteger, rounded } from "../helpers" /** @@ -65,6 +67,59 @@ function studentCondition(value: string | null) { ) } +/** + * 这些人在这些题上交过几次、做对没有。代码提交(不含比赛)和流程图提交并起来数, + * 做对 = 代码 AC / AST_CHECK_FAILED 或流程图评到 A / S —— 和学生端课文里那个勾、 + * 课堂看板(routes/classroom.ts 的 activityRows)同一个口径。不论早晚:读这课之前 + * 在课上就做对的照样算。两边都按 `(problem_id, user_id)` 索引走。 + */ +async function problemActivity(userIds: number[], problemIds: number[]) { + if (!userIds.length || !problemIds.length) return [] + const code = db + .select({ + userId: schema.submission.userId, + problemId: schema.submission.problemId, + createTime: schema.submission.createTime, + solved: sql`${inArray(schema.submission.result, accepted)}`.as("solved"), + }) + .from(schema.submission) + .where( + and( + isNull(schema.submission.contestId), + inArray(schema.submission.userId, userIds), + inArray(schema.submission.problemId, problemIds), + ), + ) + const flowchart = db + .select({ + userId: schema.flowchartSubmission.userId, + problemId: schema.flowchartSubmission.problemId, + createTime: schema.flowchartSubmission.createTime, + solved: sql`${and( + eq(schema.flowchartSubmission.status, 2), + inArray(schema.flowchartSubmission.aiGrade, [...FLOWCHART_PASS_GRADES]), + )}`.as("solved"), + }) + .from(schema.flowchartSubmission) + .where( + and( + inArray(schema.flowchartSubmission.userId, userIds), + inArray(schema.flowchartSubmission.problemId, problemIds), + ), + ) + const activity = code.unionAll(flowchart).as("activity") + return db + .select({ + userId: activity.userId, + problemId: activity.problemId, + solved: sql`bool_or(${activity.solved})`, + submissions: count(), + lastSubmitAt: sql`max(${activity.createTime})`, + }) + .from(activity) + .groupBy(activity.userId, activity.problemId) +} + /** * 方块格:一个班的学生 × 这门课的每一课,打开过没有、学完没有(练一练全对)。 * @@ -87,6 +142,18 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => { .from(schema.tutorial) .where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type))) .orderBy(asc(schema.tutorial.order)) + .then(async (rows) => { + const problems = await tutorialProblems( + rows.map((row) => row.id), + true, + ) + return rows.map((row) => ({ + ...row, + problems: problems + .filter((problem) => problem.tutorialId === row.id) + .map(({ id, displayId, title }) => ({ id, displayId, title })), + })) + }) const tutorialIds = tutorials.map((row) => row.id) if (!className) return success(c, { className: null, tutorials, students: [] } satisfies LearnBoard) @@ -104,7 +171,15 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => { .orderBy(asc(schema.user.className), asc(schema.user.username)) const userIds = students.map((row) => row.userId) - // 阅读和练习分两条查再拼:两个一对多挂在同一张表上 join,count 全是错的 + // 阅读、练习、配套题分开查再拼:几个一对多挂在同一张表上 join,count 全是错的 + const problemIds = [...new Set(tutorials.flatMap((row) => row.problems.map((p) => p.id)))] + const problemRows = await problemActivity(userIds, problemIds) + const problemsOf = new Map() + for (const { userId, ...row } of problemRows) { + const list = problemsOf.get(userId) ?? [] + list.push(row) + problemsOf.set(userId, list) + } const [progressRows, solvedRows] = userIds.length && tutorialIds.length ? await Promise.all([ @@ -185,7 +260,16 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => { } if (isLessonDone(lesson)) done.push(tutorial.id) } - return { ...student, opened, done, exerciseSolved, lessons, totalSeconds, lastViewedAt } + return { + ...student, + opened, + done, + exerciseSolved, + lessons, + problems: problemsOf.get(student.userId) ?? [], + totalSeconds, + lastViewedAt, + } }), } satisfies LearnBoard) }) @@ -378,6 +462,19 @@ adminLearnRoutes.get("/learn-analytics/students/:id", requireTeacher, async (c) .orderBy(asc(schema.exercise.order)), ]) + const related = await tutorialProblems( + tutorials.map((row) => row.tutorialId), + true, + ) + const activity = new Map( + ( + await problemActivity( + [id], + related.map((row) => row.id), + ) + ).map((row) => [row.problemId, row]), + ) + const byLesson = new Map() for (const { tutorialId, ...row } of exercises) { const list = byLesson.get(tutorialId) ?? [] @@ -399,6 +496,19 @@ adminLearnRoutes.get("/learn-analytics/students/:id", requireTeacher, async (c) totalSeconds: row.totalSeconds ?? 0, lastViewedAt: row.lastViewedAt, exercises: byLesson.get(row.tutorialId) ?? [], + problems: related + .filter((problem) => problem.tutorialId === row.tutorialId) + .map(({ id: problemId, displayId, title }) => { + const done = activity.get(problemId) + return { + id: problemId, + displayId, + title, + solved: done?.solved ?? false, + submissions: done?.submissions ?? 0, + lastSubmitAt: done?.lastSubmitAt ?? null, + } + }), })) .filter((row) => row.viewCount > 0 || row.exercises.some((ex) => ex.attempts > 0)), } satisfies LearnStudentDetail) diff --git a/apps/web/src/admin/learn/ProblemReaders.vue b/apps/web/src/admin/learn/ProblemReaders.vue new file mode 100644 index 0000000..17221b6 --- /dev/null +++ b/apps/web/src/admin/learn/ProblemReaders.vue @@ -0,0 +1,113 @@ + + + + + + + diff --git a/apps/web/src/admin/learn/StudentDrawer.vue b/apps/web/src/admin/learn/StudentDrawer.vue index d91ffcf..9f4a575 100644 --- a/apps/web/src/admin/learn/StudentDrawer.vue +++ b/apps/web/src/admin/learn/StudentDrawer.vue @@ -13,8 +13,10 @@ import { EXERCISE_KIND } from "./kinds" * 一个学生的自学明细:点方块格里的名字从右边滑出来。 * * 一课一段,标题行后面一串小方块是这课的每道练一练(绿 = 做对,红 = 做过还没对, - * 灰 = 没做);没学完的课默认展开,逐题写着第几次做对、最后错在哪。 + * 灰 = 没做);有没做对的课默认展开,逐题写着第几次做对、最后错在哪,下面接着这课的 + * 配套题做没做对(不论早晚,读这课之前做对的也算)。 */ +const router = useRouter() const props = defineProps<{ userId: number | null type: TutorialType @@ -29,6 +31,7 @@ useBackClose(show, () => !isDesktop.value) type Lesson = LearnStudentDetail["lessons"][number] type Exercise = Lesson["exercises"][number] +type Problem = Lesson["problems"][number] const detail = ref(null) const loading = ref(false) @@ -37,7 +40,11 @@ const expanded = ref(new Set()) const summary = computed(() => { const lessons = detail.value?.lessons ?? [] const exercises = lessons.flatMap((lesson) => lesson.exercises) + // 一道题挂在两课只算一次 + const problems = new Map(lessons.flatMap((lesson) => lesson.problems).map((p) => [p.id, p])) return { + problemSolved: [...problems.values()].filter((p) => p.solved).length, + problemTotal: problems.size, opened: lessons.filter((lesson) => lesson.viewCount > 0).length, seconds: lessons.reduce((n, lesson) => n + lesson.totalSeconds, 0), solved: exercises.filter((ex) => ex.solved).length, @@ -54,6 +61,19 @@ function stateOf(ex: Exercise) { return ex.attempts > 0 ? "wrong" : "todo" } +function problemStateOf(problem: Problem) { + if (problem.solved) return "solved" + return problem.submissions > 0 ? "wrong" : "todo" +} + +/** 提交列表按这个人、这道题筛好,新标签页打开 */ +function submissionsHref(problem: Problem) { + return router.resolve({ + path: "/submission", + query: { problem: problem.displayId, username: detail.value?.username, exactUsername: "1" }, + }).href +} + function toggle(id: number) { const next = new Set(expanded.value) if (next.has(id)) next.delete(id) @@ -74,8 +94,9 @@ watch( data.lessons .filter( (lesson) => - lesson.exercises.some((ex) => ex.attempts > 0) && - lesson.exercises.some((ex) => !ex.solved), + (lesson.exercises.some((ex) => ex.attempts > 0) && + lesson.exercises.some((ex) => !ex.solved)) || + lesson.problems.some((p) => p.submissions > 0 && !p.solved), ) .map((lesson) => lesson.tutorialId), ) @@ -102,6 +123,9 @@ watch( + 自学明细 @@ -148,10 +172,20 @@ watch( + -
+
@@ -171,6 +205,29 @@ watch(
+
配套题
+
+ +
+
+ {{ problem.displayId }} + {{ problem.title }} +
+
+ 做对了 + 交了 {{ problem.submissions }} 次还没对 + 没交 + +
+
+
@@ -294,6 +351,16 @@ watch( .ok { color: var(--ok); } +.sub-head { + margin-top: 4px; +} +.link { + color: #18a058; + text-decoration: none; +} +.link:hover { + text-decoration: underline; +} .bad { color: var(--bad); } diff --git a/apps/web/src/admin/learn/index.vue b/apps/web/src/admin/learn/index.vue index 041435f..0d9676e 100644 --- a/apps/web/src/admin/learn/index.vue +++ b/apps/web/src/admin/learn/index.vue @@ -10,6 +10,7 @@ import { groupClassOptions } from "oj/submission/utils" import { groupByProject, splitTitle, type TutorialType } from "oj/learn/lesson" import "oj/learn/learn.css" import ExerciseAttempts from "./ExerciseAttempts.vue" +import ProblemReaders, { type ProblemReader } from "./ProblemReaders.vue" import StudentDrawer from "./StudentDrawer.vue" import { EXERCISE_KIND } from "./kinds" @@ -18,7 +19,8 @@ import { EXERCISE_KIND } from "./kinds" * * 中间是学生 × 课的方块格(浅绿 = 打开过,绿 = 学完,也就是练一练全对;练一练做了一部分的 * 按做对的比例从下往上涨绿,一道没做对的底下压一条橙),一课没打开过的学生收成一串名单。 - * 点名字看这个人每课每道练一练的明细;右边按课看练一练做对了多少,点一道看每个人最后错在哪。 + * 点名字看这个人每课每道练一练、每道配套题的明细;右边按课看练一练和配套题做对了多少, + * 点一道看每个人的情况。配套题做对不论早晚,读这课之前在课上做对的也算。 * 不选班就按排名页同一套规则猜老师在教哪个班。 */ const configStore = useConfigStore() @@ -30,6 +32,8 @@ const board = ref(null) const exercises = ref([]) const lessonId = ref(null) const expanded = ref(null) +const side = ref<"exercise" | "problem">("exercise") +const problemOpen = ref(null) const studentId = ref(null) const showStudent = ref(false) const loading = ref(false) @@ -67,6 +71,19 @@ const openedCount = computed(() => { } return counts }) +const lessonProblems = computed( + () => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.problems])), +) +/** 学生 → 题 → 交过的情况 */ +const problemStates = computed( + () => + new Map( + (board.value?.students ?? []).map((row) => [ + row.userId, + new Map(row.problems.map((problem) => [problem.problemId, problem])), + ]), + ), +) const exerciseTotal = computed( () => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.exerciseTotal])), ) @@ -88,6 +105,30 @@ const lessonOptions = computed(() => const lessonExercises = computed(() => exercises.value.filter((row) => row.tutorialId === lessonId.value), ) +/** 读过(或做过练一练)这一课的人 */ +function readersOf(id: number) { + return started.value.filter( + (row) => row.opened.includes(id) || row.lessons.some((lesson) => lesson.tutorialId === id), + ) +} +const sideProblems = computed(() => { + const id = lessonId.value + if (id === null) return [] + const readers = readersOf(id) + return (lessonProblems.value.get(id) ?? []).map((problem) => { + const people: ProblemReader[] = readers.map((row) => ({ + userId: row.userId, + username: row.username, + state: problemStates.value.get(row.userId)?.get(problem.id) ?? null, + })) + return { + ...problem, + people, + solved: people.filter((person) => person.state?.solved).length, + } + }) +}) +const openProblem = computed(() => sideProblems.value.find((row) => row.id === problemOpen.value)) function lessonOf(row: LearnBoardStudent, id: number) { return row.lessons.find((lesson) => lesson.tutorialId === id) @@ -114,20 +155,41 @@ function cellTitle(row: LearnBoardStudent, step: number, title: string, id: numb const total = exerciseTotal.value.get(id) ?? 0 const lesson = lessonOf(row, id) const wrong = lesson?.wrongAttempts ? `,错了 ${lesson.wrongAttempts} 次` : "" - if (row.done.includes(id)) return `${head}:学完了${wrong}` - if (lesson) return `${head}:练一练 ${lesson.solved}/${total}${wrong}` - if (row.opened.includes(id)) return `${head}:${total ? "打开过,练一练没做" : "打开过"}` + const problems = lessonProblems.value.get(id) ?? [] + const states = problemStates.value.get(row.userId) + const tail = problems.length + ? ` · 配套题 ${problems.filter((problem) => states?.get(problem.id)?.solved).length}/${problems.length}` + : "" + if (row.done.includes(id)) return `${head}:学完了${wrong}${tail}` + if (lesson) return `${head}:练一练 ${lesson.solved}/${total}${wrong}${tail}` + if (row.opened.includes(id)) return `${head}:${total ? "打开过,练一练没做" : "打开过"}${tail}` return `${head}:没打开` } -/** 应做 = 他打开过或做过练一练的那些课,练一练加起来几道 */ +/** 他打开过或做过练一练的那些课 */ +function touched(row: LearnBoardStudent) { + return new Set([...row.opened, ...row.lessons.map((lesson) => lesson.tutorialId)]) +} + +/** 应做 = 他碰过的那些课,练一练加起来几道 */ function exerciseDue(row: LearnBoardStudent) { - const ids = new Set([...row.opened, ...row.lessons.map((lesson) => lesson.tutorialId)]) let due = 0 - for (const id of ids) due += exerciseTotal.value.get(id) ?? 0 + for (const id of touched(row)) due += exerciseTotal.value.get(id) ?? 0 return due } +/** 他碰过的那些课的配套题,做对几道 / 一共几道(一道题挂在两课只算一次) */ +function problemCount(row: LearnBoardStudent) { + const ids = new Set() + for (const id of touched(row)) { + for (const problem of lessonProblems.value.get(id) ?? []) ids.add(problem.id) + } + const states = problemStates.value.get(row.userId) + let solved = 0 + for (const id of ids) if (states?.get(id)?.solved) solved++ + return { solved, total: ids.size } +} + function openStudent(row: LearnBoardStudent) { studentId.value = row.userId showStudent.value = true @@ -234,6 +296,7 @@ onMounted(load) 练一练 + 配套题 停留 最后学习 @@ -260,9 +323,16 @@ onMounted(load) > + + + + {{ readableDuration(row.totalSeconds) }} {{ - row.lastViewedAt ? parseTime(row.lastViewedAt, "M月D日 HH:mm") : "-" + row.lastViewedAt ? parseTime(row.lastViewedAt, "M月D日") : "-" }}
这个班还没人打开过
@@ -299,8 +369,18 @@ onMounted(load)
- 练一练 - +
+ + +
-
这课没有练一练
-
+ +
这课没有练一练
+
@@ -397,6 +511,15 @@ onMounted(load) border-radius: 0 4px 4px 0; margin-left: -1px; } +.seg.small { + flex-shrink: 0; +} +.seg.small button { + height: 26px; + padding: 0 10px; + font-size: 12px; + white-space: nowrap; +} .seg button.on { border-color: #18a058; color: var(--learn-green-text); @@ -448,7 +571,7 @@ onMounted(load) .line { display: flex; align-items: center; - gap: 10px; + gap: 8px; min-height: 34px; padding: 0 16px; border-top: 1px solid rgba(128, 128, 128, 0.1); @@ -463,7 +586,7 @@ onMounted(load) border-top: 0; } .c-name { - width: 118px; + width: 110px; flex-shrink: 0; white-space: nowrap; overflow: hidden; @@ -473,7 +596,7 @@ onMounted(load) .cells, .cells-head { display: flex; - gap: 8px; + gap: 6px; } .group-head { display: flex; @@ -531,20 +654,20 @@ onMounted(load) text-decoration: underline; } .c-num { - width: 56px; + width: 44px; text-align: right; font-size: 13px; flex-shrink: 0; } .c-stay { - width: 80px; + width: 70px; text-align: right; font-size: 13px; white-space: nowrap; flex-shrink: 0; } .c-last { - width: 92px; + width: 64px; text-align: right; font-size: 13px; white-space: nowrap; @@ -610,12 +733,13 @@ onMounted(load) font-size: 12px; } .side { - width: 270px; + width: 250px; flex-shrink: 0; padding: 14px 16px; } .side-head { display: flex; + flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 4px; @@ -663,10 +787,13 @@ onMounted(load) .flag { color: var(--learn-orange-text); } -.class-select, -.lesson-select { +.class-select { width: 170px; } +.lesson-select { + flex: 1 0 100%; + min-width: 0; +} /* 手机上:方块格在自己的框里横着滑(名字列钉在左边),练一练排到下面 */ @media (max-width: 768px) { @@ -716,10 +843,5 @@ onMounted(load) .side { width: auto; } - .lesson-select { - flex: 1; - width: auto; - min-width: 0; - } } diff --git a/packages/contract/src/admin.ts b/packages/contract/src/admin.ts index a8cf797..9ebc339 100644 --- a/packages/contract/src/admin.ts +++ b/packages/contract/src/admin.ts @@ -639,6 +639,18 @@ export const gradeTrendProblemSchema = z.object({ // ---------------------------------------------------------------- 自学情况 +/** + * 一个学生在一道配套题上的状态。「做对」不论早晚 —— 读这课之前在课上就做对的也算, + * 口径同学生端课文里的勾:代码 AC(含 AST_CHECK_FAILED)或流程图评到 A / S + */ +const learnProblemStateSchema = z.object({ + problemId: z.number().int(), + solved: z.boolean(), + /** 代码和流程图一共交了几次,比赛提交不算 */ + submissions: z.number().int(), + lastSubmitAt: z.string(), +}) + /** * 「自学情况」的方块格:一个班的学生 × 这门课的每一课。 * @@ -666,6 +678,8 @@ export const learnBoardStudentSchema = z.object({ wrongAttempts: z.number().int(), }), ), + /** 交过的配套题(一次没交的不在里面),哪一课的题由 `tutorials[].problems` 对 */ + problems: z.array(learnProblemStateSchema), totalSeconds: z.number().int(), lastViewedAt: z.string().nullable(), }) @@ -679,6 +693,10 @@ export const learnBoardSchema = z.object({ title: z.string(), project: z.string(), exerciseTotal: z.number().int(), + /** 这课的配套题,按老师排的顺序;藏起来的题不算 */ + problems: z.array( + z.object({ id: z.number().int(), displayId: z.string(), title: z.string() }), + ), }), ), students: z.array(learnBoardStudentSchema), @@ -757,6 +775,17 @@ export const learnStudentDetailSchema = z.object({ lastAttemptAt: z.string().nullable(), }), ), + /** 配套题,没交过的 `submissions` 为 0、`lastSubmitAt` 为空 */ + problems: z.array( + z.object({ + id: z.number().int(), + displayId: z.string(), + title: z.string(), + solved: z.boolean(), + submissions: z.number().int(), + lastSubmitAt: z.string().nullable(), + }), + ), }), ), })