diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 8a6d649..3b25cd6 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1301,9 +1301,8 @@ export const problemsetBadge = pgTable( * OJ2 自己建的表,不是 Django 遗留,所以没有代理主键 —— 写入路径只有一条 upsert, * 冲突目标就是 (user_id, tutorial_id),再挂个 id 序列没有任何用处。 * - * **只记「读到哪一课 + 停留多久」,不记练一练的作答。** 练习的对错全在浏览器里判 - * (见 apps/web/src/oj/learn/components/),学生可以随便重试到对为止,上报上来也只是 - * 「他按了几次按钮」,不构成教学证据,还要为此多养一张按人按题膨胀的表。 + * **只记「读到哪一课 + 停留多久」。** 练一练的作答另有一张表(下面的 `exerciseAttempt`), + * 一个学生一道练习一行;「学完」由两张表合起来判(契约的 `isLessonDone`)。 * * 外键这里**用了库级 CASCADE**,和 Django 建的那些 NO ACTION 外键不同:删教程、删用户 * 都不必再记得回来手工清一遍子表(后台删教程的事务里就没清它,靠的就是这里)。 diff --git a/apps/api/src/routes/admin/learn.ts b/apps/api/src/routes/admin/learn.ts index 5064c92..a568fa4 100644 --- a/apps/api/src/routes/admin/learn.ts +++ b/apps/api/src/routes/admin/learn.ts @@ -2,11 +2,13 @@ import { STUDENT_ROLES, isLessonDone, type LearnBoard, - type LearnExerciseAttempt, + type LearnBoardStudent, + type LearnExerciseAttemptList, type LearnExerciseProgress, type LearnExerciseProgressList, + type LearnStudentDetail, } from "@oj2/contract" -import { and, asc, count, desc, eq, inArray, like, sql } from "drizzle-orm" +import { and, asc, count, desc, eq, inArray, isNull, like, sql } from "drizzle-orm" import { Hono } from "hono" import { requireTeacher, type AppEnv } from "../../auth/middleware" @@ -125,7 +127,14 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => { .select({ userId: schema.exerciseAttempt.userId, tutorialId: schema.exercise.tutorialId, - solved: count(), + tried: count(), + solved: sql`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith( + Number, + ), + wrongAttempts: + sql`coalesce(sum(${schema.exerciseAttempt.wrongAttempts}), 0)`.mapWith( + Number, + ), }) .from(schema.exerciseAttempt) .innerJoin(schema.exercise, eq(schema.exercise.id, schema.exerciseAttempt.exerciseId)) @@ -133,7 +142,6 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => { and( inArray(schema.exerciseAttempt.userId, userIds), inArray(schema.exercise.tutorialId, tutorialIds), - eq(schema.exerciseAttempt.solved, true), ), ) .groupBy(schema.exerciseAttempt.userId, schema.exercise.tutorialId), @@ -142,8 +150,8 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => { const progress = new Map() for (const row of progressRows) progress.set(`${row.userId}:${row.tutorialId}`, row) - const solved = new Map() - for (const row of solvedRows) solved.set(`${row.userId}:${row.tutorialId}`, row.solved) + const attempted = new Map() + for (const row of solvedRows) attempted.set(`${row.userId}:${row.tutorialId}`, row) return success(c, { className, @@ -151,14 +159,20 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => { students: students.map((student) => { const opened: number[] = [] const done: number[] = [] + const lessons: LearnBoardStudent["lessons"] = [] let exerciseSolved = 0 let totalSeconds = 0 let lastViewedAt: string | null = null for (const tutorial of tutorials) { const key = `${student.userId}:${tutorial.id}` const row = progress.get(key) - const solvedHere = solved.get(key) ?? 0 + const exercises = attempted.get(key) + const solvedHere = exercises?.solved ?? 0 exerciseSolved += solvedHere + if (exercises) { + const { tried, solved, wrongAttempts } = exercises + lessons.push({ tutorialId: tutorial.id, tried, solved, wrongAttempts }) + } if (row) { opened.push(tutorial.id) totalSeconds += row.totalSeconds @@ -171,7 +185,7 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => { } if (isLessonDone(lesson)) done.push(tutorial.id) } - return { ...student, opened, done, exerciseSolved, totalSeconds, lastViewedAt } + return { ...student, opened, done, exerciseSolved, lessons, totalSeconds, lastViewedAt } }), } satisfies LearnBoard) }) @@ -271,5 +285,121 @@ adminLearnRoutes.get("/learn-analytics/exercises/:id/attempts", requireTeacher, // 没做对的排前面,错得最多的最前 —— 展开这一行的人是来找卡住的学生的 .orderBy(asc(schema.exerciseAttempt.solved), desc(schema.exerciseAttempt.wrongAttempts)) - return success(c, rows satisfies LearnExerciseAttempt[]) + // 读过这课却没碰这道题的人:从这课的阅读记录出发,反连作答表取空 + const untried = await db + .select({ userId: schema.user.id, username: schema.user.username }) + .from(schema.exercise) + .innerJoin( + schema.tutorialProgress, + eq(schema.tutorialProgress.tutorialId, schema.exercise.tutorialId), + ) + .innerJoin(schema.user, eq(schema.user.id, schema.tutorialProgress.userId)) + .leftJoin( + schema.exerciseAttempt, + and( + eq(schema.exerciseAttempt.exerciseId, schema.exercise.id), + eq(schema.exerciseAttempt.userId, schema.user.id), + ), + ) + .where( + and( + eq(schema.exercise.id, id), + isNull(schema.exerciseAttempt.userId), + studentCondition(className.value), + ), + ) + .orderBy(asc(schema.user.username)) + + return success(c, { results: rows, untried } satisfies LearnExerciseAttemptList) +}) + +/** + * 一个学生的自学明细:他打开过或做过练一练的每一课,每道练习做到哪了。 + * + * 不限班级、也不限学生角色 —— 老师从方块格点进来的一定是学生, + * 而按 id 查一个人的自学留痕本来就是老师能看的东西(方块格上全有)。 + */ +adminLearnRoutes.get("/learn-analytics/students/:id", requireTeacher, async (c) => { + const id = queryInteger(c.req.param("id"), 0, { min: 1 }) + const type = tutorialTypeOf(c.req.query("type")) + + const [student] = await db + .select({ + userId: schema.user.id, + username: schema.user.username, + className: schema.user.className, + }) + .from(schema.user) + .where(eq(schema.user.id, id)) + if (!student) return failure(c, 404, "user-not-found", "用户不存在") + + const visible = and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)) + const [tutorials, exercises] = await Promise.all([ + db + .select({ + tutorialId: schema.tutorial.id, + title: schema.tutorial.title, + viewCount: schema.tutorialProgress.viewCount, + totalSeconds: schema.tutorialProgress.totalSeconds, + lastViewedAt: schema.tutorialProgress.lastViewedAt, + }) + .from(schema.tutorial) + .leftJoin( + schema.tutorialProgress, + and( + eq(schema.tutorialProgress.tutorialId, schema.tutorial.id), + eq(schema.tutorialProgress.userId, id), + ), + ) + .where(visible) + .orderBy(asc(schema.tutorial.order)), + db + .select({ + tutorialId: schema.exercise.tutorialId, + exerciseId: schema.exercise.id, + type: schema.exercise.type, + question: sql`coalesce(${schema.exercise.data}->>'question', '')`, + attempts: schema.exerciseAttempt.attempts, + solved: schema.exerciseAttempt.solved, + attemptsToSolve: schema.exerciseAttempt.attemptsToSolve, + lastWrongAnswer: schema.exerciseAttempt.lastWrongAnswer, + lastAttemptAt: schema.exerciseAttempt.lastAttemptAt, + }) + .from(schema.exercise) + .innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId)) + .leftJoin( + schema.exerciseAttempt, + and( + eq(schema.exerciseAttempt.exerciseId, schema.exercise.id), + eq(schema.exerciseAttempt.userId, id), + ), + ) + .where(visible) + .orderBy(asc(schema.exercise.order)), + ]) + + const byLesson = new Map() + for (const { tutorialId, ...row } of exercises) { + const list = byLesson.get(tutorialId) ?? [] + list.push({ + ...row, + attempts: row.attempts ?? 0, + solved: row.solved ?? false, + }) + byLesson.set(tutorialId, list) + } + + return success(c, { + ...student, + lessons: tutorials + .map((row) => ({ + tutorialId: row.tutorialId, + title: row.title, + viewCount: row.viewCount ?? 0, + totalSeconds: row.totalSeconds ?? 0, + lastViewedAt: row.lastViewedAt, + exercises: byLesson.get(row.tutorialId) ?? [], + })) + .filter((row) => row.viewCount > 0 || row.exercises.some((ex) => ex.attempts > 0)), + } satisfies LearnStudentDetail) }) diff --git a/apps/web/src/admin/api.ts b/apps/web/src/admin/api.ts index ef7bf3d..78a4ec9 100644 --- a/apps/web/src/admin/api.ts +++ b/apps/web/src/admin/api.ts @@ -12,7 +12,8 @@ import type { GradeTrendProblem, LearnBoard, LearnExerciseProgressList, - LearnExerciseAttempt, + LearnExerciseAttemptList, + LearnStudentDetail, AdminContestList, AdminUser, AdminUserList, @@ -657,9 +658,14 @@ export function getLearnExercises(params: { type: "python" | "c"; className?: st } export function getLearnExerciseAttempts(exerciseId: number, params: { className?: string }) { - return api.get(`admin/learn-analytics/exercises/${exerciseId}/attempts`, { - params, - }) + return api.get( + `admin/learn-analytics/exercises/${exerciseId}/attempts`, + { params }, + ) +} + +export function getLearnStudent(userId: number, params: { type: "python" | "c" }) { + return api.get(`admin/learn-analytics/students/${userId}`, { params }) } export function getGradeTrend() { diff --git a/apps/web/src/admin/learn/ExerciseAttempts.vue b/apps/web/src/admin/learn/ExerciseAttempts.vue index 9a3307c..bb50312 100644 --- a/apps/web/src/admin/learn/ExerciseAttempts.vue +++ b/apps/web/src/admin/learn/ExerciseAttempts.vue @@ -11,6 +11,7 @@ const { isMobile } = useBreakpoints() const loading = ref(true) const rows = ref([]) +const untried = ref<{ userId: number; username: string }[]>([]) function renderResult(row: LearnExerciseAttempt) { return row.solved @@ -19,13 +20,12 @@ function renderResult(row: LearnExerciseAttempt) { } const desktopColumns: DataTableColumn[] = [ - { title: "班级", key: "className", width: 80 }, - { title: "学号", key: "username", width: 140 }, + // 一个弹框只看一个班,班级列多余;用户名里本来就带名字,不再单列真名 { - title: "姓名", - key: "realName", - width: 100, - render: (row) => row.realName || "-", + title: "学生", + key: "username", + width: 140, + render: (row) => h(UserName, { username: row.username }), }, { title: "结果", @@ -33,7 +33,7 @@ const desktopColumns: DataTableColumn[] = [ width: 130, render: renderResult, }, - { title: "提交次数", key: "attempts", width: 100, sorter: "default" }, + { title: "提交次数", key: "attempts", width: 110, sorter: "default" }, { // 学生最后一次做错时提交的内容,前端拼好的一句人话。选择题看这一列 // 就知道全班是不是都掉进同一个干扰项 @@ -46,12 +46,12 @@ const desktopColumns: DataTableColumn[] = [ { title: "最后作答", key: "lastAttemptAt", - width: 150, + width: 120, render: (row) => parseTime(row.lastAttemptAt, "M月D日 HH:mm"), }, ] -/** 手机上七列放不下:一个人一格,名字和结果一行,下面一行小字是交了几次、最后错在哪 */ +/** 手机上五列放不下:一个人一格,名字和结果一行,下面一行小字是交了几次、最后错在哪 */ const mobileColumns: DataTableColumn[] = [ { title: "学生", @@ -74,9 +74,11 @@ const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns) onMounted(async () => { try { - rows.value = await getLearnExerciseAttempts(props.exerciseId, { + const data = await getLearnExerciseAttempts(props.exerciseId, { className: props.className, }) + rows.value = data.results + untried.value = data.untried } finally { loading.value = false } @@ -98,9 +100,46 @@ onMounted(async () => { size="small" style="padding: 16px 0" /> +
+
+ 读了这课还没做 + {{ untried.length }} 人 +
+
+ +
+
diff --git a/apps/web/src/admin/learn/index.vue b/apps/web/src/admin/learn/index.vue index 0eb67c0..041435f 100644 --- a/apps/web/src/admin/learn/index.vue +++ b/apps/web/src/admin/learn/index.vue @@ -10,33 +10,28 @@ 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 StudentDrawer from "./StudentDrawer.vue" +import { EXERCISE_KIND } from "./kinds" /** * 自学情况(设计稿「后台 · 自学情况」):一个班学到哪了。 * - * 中间是学生 × 课的方块格(浅绿 = 打开过,绿 = 学完,也就是练一练全对),一课没打开过的 - * 学生收成一串名单;右边按课看练一练做对了多少,点一道看每个人最后错在哪。 + * 中间是学生 × 课的方块格(浅绿 = 打开过,绿 = 学完,也就是练一练全对;练一练做了一部分的 + * 按做对的比例从下往上涨绿,一道没做对的底下压一条橙),一课没打开过的学生收成一串名单。 + * 点名字看这个人每课每道练一练的明细;右边按课看练一练做对了多少,点一道看每个人最后错在哪。 * 不选班就按排名页同一套规则猜老师在教哪个班。 */ const configStore = useConfigStore() const theme = useThemeVars() -const KIND: Record = { - mcq: "选择", - sort: "排序", - fill: "填空", - match: "连线", - predict: "预测输出", - debug: "找错", - group: "分组", -} - const type = ref("python") const className = ref(null) const board = ref(null) const exercises = ref([]) const lessonId = ref(null) const expanded = ref(null) +const studentId = ref(null) +const showStudent = ref(false) const loading = ref(false) const classOptions = computed(() => groupClassOptions(configStore.config?.classList ?? [])) @@ -72,6 +67,15 @@ const openedCount = computed(() => { } return counts }) +const exerciseTotal = computed( + () => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.exerciseTotal])), +) +const steps = computed( + () => + new Map( + groups.value.flatMap((group) => group.lessons.map((lesson) => [lesson.id, lesson.step])), + ), +) const maxOpened = computed(() => Math.max(1, ...openedCount.value.values())) const lessonOptions = computed(() => groups.value.flatMap((group) => @@ -85,15 +89,48 @@ const lessonExercises = computed(() => exercises.value.filter((row) => row.tutorialId === lessonId.value), ) +function lessonOf(row: LearnBoardStudent, id: number) { + return row.lessons.find((lesson) => lesson.tutorialId === id) +} + function cellOf(row: LearnBoardStudent, id: number) { if (row.done.includes(id)) return "done" + const lesson = lessonOf(row, id) + if (lesson) return lesson.solved ? "partial" : "partial stuck" if (row.opened.includes(id)) return "opened" return "" } +/** 做了一部分的格子:做对几成,绿就从下往上涨几成 */ +function cellStyle(row: LearnBoardStudent, id: number) { + const lesson = lessonOf(row, id) + const total = exerciseTotal.value.get(id) ?? 0 + if (!lesson || !total || row.done.includes(id)) return undefined + return { "--fill": `${Math.round((lesson.solved / total) * 100)}%` } +} + function cellTitle(row: LearnBoardStudent, step: number, title: string, id: number) { - const state = cellOf(row, id) - return `第 ${step} 课 ${splitTitle(title).kp}:${state === "done" ? "学完了" : state === "opened" ? "打开过" : "没打开"}` + const head = `第 ${step} 课 ${splitTitle(title).kp}` + 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 ? "打开过,练一练没做" : "打开过"}` + return `${head}:没打开` +} + +/** 应做 = 他打开过或做过练一练的那些课,练一练加起来几道 */ +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 + return due +} + +function openStudent(row: LearnBoardStudent) { + studentId.value = row.userId + showStudent.value = true } function flag(row: LearnExerciseProgress) { @@ -201,7 +238,9 @@ onMounted(load) 最后学习
- +
- {{ row.exerciseSolved || "-" }} + + + + {{ readableDuration(row.totalSeconds) }} {{ row.lastViewedAt ? parseTime(row.lastViewedAt, "M月D日 HH:mm") : "-" @@ -266,7 +312,7 @@ onMounted(load)
@@ -464,8 +512,26 @@ onMounted(load) .cell.done { background: #18a058; } +.cell.partial { + background: linear-gradient(to top, #18a058 var(--fill), rgba(24, 160, 88, 0.3) var(--fill)); +} +.cell.stuck { + box-shadow: inset 0 -4px 0 v-bind("theme.warningColor"); +} +.name-btn { + border: 0; + padding: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} +.name-btn:hover { + text-decoration: underline; +} .c-num { - width: 52px; + width: 56px; text-align: right; font-size: 13px; flex-shrink: 0; diff --git a/apps/web/src/admin/learn/kinds.ts b/apps/web/src/admin/learn/kinds.ts new file mode 100644 index 0000000..e33d19e --- /dev/null +++ b/apps/web/src/admin/learn/kinds.ts @@ -0,0 +1,10 @@ +/** 练一练的题型,后台显示用的短名 */ +export const EXERCISE_KIND: Record = { + mcq: "选择", + sort: "排序", + fill: "填空", + match: "连线", + predict: "预测输出", + debug: "找错", + group: "分组", +} diff --git a/apps/web/src/utils/types.ts b/apps/web/src/utils/types.ts index 9106b15..f1305d4 100644 --- a/apps/web/src/utils/types.ts +++ b/apps/web/src/utils/types.ts @@ -335,6 +335,8 @@ export type { LearnExerciseProgress, LearnExerciseProgressList, LearnExerciseAttempt, + LearnExerciseAttemptList, + LearnStudentDetail, } from "@oj2/contract" /** diff --git a/packages/contract/src/admin.ts b/packages/contract/src/admin.ts index 3da729b..a8cf797 100644 --- a/packages/contract/src/admin.ts +++ b/packages/contract/src/admin.ts @@ -654,6 +654,18 @@ export const learnBoardStudentSchema = z.object({ opened: z.array(z.number().int()), done: z.array(z.number().int()), exerciseSolved: z.number().int(), + /** + * 碰过练一练的课,一课一项(一道没做的课不在里面)。方块格靠它分出「做了一部分」, + * `wrongAttempts` 是这课所有练习错的次数加起来 + */ + lessons: z.array( + z.object({ + tutorialId: z.number().int(), + tried: z.number().int(), + solved: z.number().int(), + wrongAttempts: z.number().int(), + }), + ), totalSeconds: z.number().int(), lastViewedAt: z.string().nullable(), }) @@ -712,6 +724,43 @@ export const learnExerciseAttemptSchema = z.object({ lastAttemptAt: z.string(), }) +export const learnExerciseAttemptListSchema = z.object({ + results: z.array(learnExerciseAttemptSchema), + /** 打开过这一课、这道练习却一次没交的人 */ + untried: z.array(z.object({ userId: z.number().int(), username: z.string() })), +}) + +/** + * 一个学生的自学明细(点方块格里的名字):他碰过的每一课,每道练一练做到哪了。 + * 一课没碰过的不下发;练习没做过的 `attempts` 为 0、其余字段为空。 + */ +export const learnStudentDetailSchema = z.object({ + userId: z.number().int(), + username: z.string(), + className: z.string().nullable(), + lessons: z.array( + z.object({ + tutorialId: z.number().int(), + title: z.string(), + viewCount: z.number().int(), + totalSeconds: z.number().int(), + lastViewedAt: z.string().nullable(), + exercises: z.array( + z.object({ + exerciseId: z.number().int(), + type: exerciseTypeSchema, + question: z.string(), + attempts: z.number().int(), + solved: z.boolean(), + attemptsToSolve: z.number().int().nullable(), + lastWrongAnswer: z.string().nullable(), + lastAttemptAt: z.string().nullable(), + }), + ), + }), + ), +}) + export const generateFlowchartRequestSchema = z.object({ python: z .string() @@ -1056,6 +1105,8 @@ export type LearnBoardStudent = z.infer export type LearnExerciseProgress = z.infer export type LearnExerciseProgressList = z.infer export type LearnExerciseAttempt = z.infer +export type LearnExerciseAttemptList = z.infer +export type LearnStudentDetail = z.infer export type AdminTag = z.infer export type RenameTagResponse = z.infer