From 286487acf347ac8a59f27ad00aeea74a4b424a15 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Wed, 2 Sep 2026 20:37:30 -0600 Subject: [PATCH] =?UTF-8?q?fix(=E8=87=AA=E5=AD=A6):=20=E5=B0=91=E4=BA=8E?= =?UTF-8?q?=E4=B8=89=E5=88=86=E9=92=9F=E7=9A=84=E4=B8=8D=E7=AE=97=E5=B7=B2?= =?UTF-8?q?=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来只要打开过(viewCount > 0)就记成已读,于是「点开看一眼就退」和「认真读完」 在老师那张表上长得一模一样,「已读 17/17」并不说明他学了。加一道 3 分钟的门槛 —— 按最短的一课定的下限,读不完还翻不动的课文,扫一眼是到不了这个数的。 阈值 TUTORIAL_READ_SECONDS 放在 contract 里,前后端共用一个数:学生端目录的 ✓、 后台「按学生」的已读课数、「按课程」的读过人数,三处口径必须一致,各写各的迟早分叉。 **累计时长故意不卡**。时长记的是真实停留,不满三分钟的秒数照样算进去,于是 「已读 0 课、累计 25 分钟」成为一种能看见的状态 —— 他翻了很多课,每课都没读满。 这正是这道门槛想让老师看见的东西,把时长一起滤掉反而把信息删了。 「按课程」的人均时长跟着改了分子:分母是读满三分钟的人,分子就得是同一批人的时长, 否则拿全部时长去除达标人数,人均会被翻一眼就走的人凭空抬高。 顺带修掉「✓ 已读 · -」:心跳 15 秒一跳,点开就走会落在 0 秒上,而 readableDuration(0) 返回的是 "-"。新口径下打勾要求 ≥180 秒,这个组合不可能再出现;打开过但没读满的 显示灰色的「读了 N 分钟」,记是记下了,只是还没到「已读」。 后台筛选栏上方写了一行口径说明,免得老师对着「已读 0 课 / 累计 25 分钟」猜是不是坏了。 本机实跑:student 读 A 课 500 秒、B 课 60 秒 → 已读 1/2、累计 560 秒; A 课「读过 2 人、累计 700、人均 350」,B 课「读过 0 人、累计 60、人均 0」。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013rpSKCNpVcMhTFhw21YiL7 --- apps/api/src/routes/admin/learn.ts | 14 +++++++++---- apps/web/src/admin/learn/index.vue | 5 +++++ .../src/oj/learn/components/LessonList.vue | 20 +++++++++++++++++-- packages/contract/src/content.ts | 13 ++++++++++++ 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/apps/api/src/routes/admin/learn.ts b/apps/api/src/routes/admin/learn.ts index d0374f5..fcf2764 100644 --- a/apps/api/src/routes/admin/learn.ts +++ b/apps/api/src/routes/admin/learn.ts @@ -1,4 +1,5 @@ import { + TUTORIAL_READ_SECONDS, learnExerciseAttemptSchema, learnExerciseProgressListSchema, learnExerciseProgressSchema, @@ -93,7 +94,9 @@ adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => { username: schema.user.username, realName: schema.userProfile.realName, className: schema.user.className, - readCount: count(schema.tutorialProgress.tutorialId), + // 「已读」按 TUTORIAL_READ_SECONDS 卡,不是「有这条记录」:点开一眼就退的不算。 + // 累计时长不卡,那些秒数照样算 —— 「已读 0 课、累计 25 分钟」是要看见的一种情况 + readCount: sql`count(${schema.tutorialProgress.tutorialId}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(Number), totalSeconds: sql`coalesce(sum(${schema.tutorialProgress.totalSeconds}), 0)`.mapWith(Number), lastViewedAt: sql`max(${schema.tutorialProgress.lastViewedAt})`, }).from(schema.user) @@ -148,8 +151,11 @@ adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) => // 数的是 user.id 而不是 progress.user_id:join 不上的(老师自己试读的、 // 已禁用的、不在所选班级的)在这一列是 NULL,count(distinct) 正好不算它, // 而 progress.user_id 那边永远非空,会把过滤当没发生 - readers: sql`count(distinct ${schema.user.id})`.mapWith(Number), + readers: sql`count(distinct ${schema.user.id}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(Number), totalSeconds: sql`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.user.id} is not null), 0)`.mapWith(Number), + // 人均时长的分母是 readers(读满 3 分钟的人),分子就得是同一批人的时长, + // 否则拿全部时长去除达标人数,人均会被翻了一眼就走的人凭空抬高 + readSeconds: sql`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS}), 0)`.mapWith(Number), }).from(schema.tutorial) .leftJoin(schema.tutorialProgress, eq(schema.tutorialProgress.tutorialId, schema.tutorial.id)) .leftJoin(schema.user, and( @@ -164,9 +170,9 @@ adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) => return success(c, learnTutorialProgressListSchema.parse({ studentCount, - results: rows.map((row) => learnTutorialProgressSchema.parse({ + results: rows.map(({ readSeconds, ...row }) => learnTutorialProgressSchema.parse({ ...row, - avgSeconds: row.readers ? Math.round(row.totalSeconds / row.readers) : 0, + avgSeconds: row.readers ? Math.round(readSeconds / row.readers) : 0, })), })) }) diff --git a/apps/web/src/admin/learn/index.vue b/apps/web/src/admin/learn/index.vue index ff3427a..6965e3f 100644 --- a/apps/web/src/admin/learn/index.vue +++ b/apps/web/src/admin/learn/index.vue @@ -1,4 +1,5 @@