fix(自学): 少于三分钟的不算已读
原来只要打开过(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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rpSKCNpVcMhTFhw21YiL7
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
TUTORIAL_READ_SECONDS,
|
||||||
learnExerciseAttemptSchema,
|
learnExerciseAttemptSchema,
|
||||||
learnExerciseProgressListSchema,
|
learnExerciseProgressListSchema,
|
||||||
learnExerciseProgressSchema,
|
learnExerciseProgressSchema,
|
||||||
@@ -93,7 +94,9 @@ adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
|
|||||||
username: schema.user.username,
|
username: schema.user.username,
|
||||||
realName: schema.userProfile.realName,
|
realName: schema.userProfile.realName,
|
||||||
className: schema.user.className,
|
className: schema.user.className,
|
||||||
readCount: count(schema.tutorialProgress.tutorialId),
|
// 「已读」按 TUTORIAL_READ_SECONDS 卡,不是「有这条记录」:点开一眼就退的不算。
|
||||||
|
// 累计时长不卡,那些秒数照样算 —— 「已读 0 课、累计 25 分钟」是要看见的一种情况
|
||||||
|
readCount: sql<number>`count(${schema.tutorialProgress.tutorialId}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(Number),
|
||||||
totalSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}), 0)`.mapWith(Number),
|
totalSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}), 0)`.mapWith(Number),
|
||||||
lastViewedAt: sql<string | null>`max(${schema.tutorialProgress.lastViewedAt})`,
|
lastViewedAt: sql<string | null>`max(${schema.tutorialProgress.lastViewedAt})`,
|
||||||
}).from(schema.user)
|
}).from(schema.user)
|
||||||
@@ -148,8 +151,11 @@ adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) =>
|
|||||||
// 数的是 user.id 而不是 progress.user_id:join 不上的(老师自己试读的、
|
// 数的是 user.id 而不是 progress.user_id:join 不上的(老师自己试读的、
|
||||||
// 已禁用的、不在所选班级的)在这一列是 NULL,count(distinct) 正好不算它,
|
// 已禁用的、不在所选班级的)在这一列是 NULL,count(distinct) 正好不算它,
|
||||||
// 而 progress.user_id 那边永远非空,会把过滤当没发生
|
// 而 progress.user_id 那边永远非空,会把过滤当没发生
|
||||||
readers: sql<number>`count(distinct ${schema.user.id})`.mapWith(Number),
|
readers: sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(Number),
|
||||||
totalSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.user.id} is not null), 0)`.mapWith(Number),
|
totalSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.user.id} is not null), 0)`.mapWith(Number),
|
||||||
|
// 人均时长的分母是 readers(读满 3 分钟的人),分子就得是同一批人的时长,
|
||||||
|
// 否则拿全部时长去除达标人数,人均会被翻了一眼就走的人凭空抬高
|
||||||
|
readSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS}), 0)`.mapWith(Number),
|
||||||
}).from(schema.tutorial)
|
}).from(schema.tutorial)
|
||||||
.leftJoin(schema.tutorialProgress, eq(schema.tutorialProgress.tutorialId, schema.tutorial.id))
|
.leftJoin(schema.tutorialProgress, eq(schema.tutorialProgress.tutorialId, schema.tutorial.id))
|
||||||
.leftJoin(schema.user, and(
|
.leftJoin(schema.user, and(
|
||||||
@@ -164,9 +170,9 @@ adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) =>
|
|||||||
|
|
||||||
return success(c, learnTutorialProgressListSchema.parse({
|
return success(c, learnTutorialProgressListSchema.parse({
|
||||||
studentCount,
|
studentCount,
|
||||||
results: rows.map((row) => learnTutorialProgressSchema.parse({
|
results: rows.map(({ readSeconds, ...row }) => learnTutorialProgressSchema.parse({
|
||||||
...row,
|
...row,
|
||||||
avgSeconds: row.readers ? Math.round(row.totalSeconds / row.readers) : 0,
|
avgSeconds: row.readers ? Math.round(readSeconds / row.readers) : 0,
|
||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
|
||||||
import { NProgress, NText } from "naive-ui"
|
import { NProgress, NText } from "naive-ui"
|
||||||
import {
|
import {
|
||||||
getLearnStudents,
|
getLearnStudents,
|
||||||
@@ -286,6 +287,10 @@ onMounted(load)
|
|||||||
<n-text depth="3">
|
<n-text depth="3">
|
||||||
{{ studentCount }} 名学生,{{ startedCount }} 人已经开始学
|
{{ studentCount }} 名学生,{{ startedCount }} 人已经开始学
|
||||||
</n-text>
|
</n-text>
|
||||||
|
<!-- 口径写在表上方,免得老师对着「已读 0 课 / 累计 25 分钟」猜是不是坏了 -->
|
||||||
|
<n-text depth="3" style="font-size: 12px">
|
||||||
|
「已读」按累计停留满 {{ TUTORIAL_READ_SECONDS / 60 }} 分钟算,不足的只计时长
|
||||||
|
</n-text>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
|
||||||
<n-tabs v-model:value="tab" type="line" animated>
|
<n-tabs v-model:value="tab" type="line" animated>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
|
||||||
import type { TutorialProgress } from "utils/types"
|
import type { TutorialProgress } from "utils/types"
|
||||||
import { readableDuration } from "utils/functions"
|
import { readableDuration } from "utils/functions"
|
||||||
|
|
||||||
@@ -12,6 +13,12 @@ defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{ select: [lesson: number] }>()
|
const emit = defineEmits<{ select: [lesson: number] }>()
|
||||||
|
|
||||||
|
// 打开过但一秒都没攒够时 readableDuration 给的是 "-",「读了 -」不像人话。
|
||||||
|
// 心跳 15 秒一跳,点开就走确实会落在 0 上
|
||||||
|
function readSoFar(seconds: number) {
|
||||||
|
return seconds > 0 ? readableDuration(seconds) : "不到 1 分钟"
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -31,14 +38,23 @@ const emit = defineEmits<{ select: [lesson: number] }>()
|
|||||||
{{ index + 1 }}. {{ item.title }}
|
{{ index + 1 }}. {{ item.title }}
|
||||||
</n-text>
|
</n-text>
|
||||||
<!-- 每篇教程都有一条进度(没读过的是一行零),所以这里判的是读没读过,
|
<!-- 每篇教程都有一条进度(没读过的是一行零),所以这里判的是读没读过,
|
||||||
不是有没有这条记录 -->
|
不是有没有这条记录。
|
||||||
|
满 TUTORIAL_READ_SECONDS 才打 ✓:打开过但没读满的仍然显示时长,
|
||||||
|
只是不带勾、也不是成功色 —— 记是记下了,还没到「已读」 -->
|
||||||
<n-text
|
<n-text
|
||||||
v-if="progress[item.id]?.viewCount"
|
v-if="progress[item.id]?.totalSeconds >= TUTORIAL_READ_SECONDS"
|
||||||
type="success"
|
type="success"
|
||||||
style="font-size: 12px"
|
style="font-size: 12px"
|
||||||
>
|
>
|
||||||
✓ 已读 · {{ readableDuration(progress[item.id].totalSeconds) }}
|
✓ 已读 · {{ readableDuration(progress[item.id].totalSeconds) }}
|
||||||
</n-text>
|
</n-text>
|
||||||
|
<n-text
|
||||||
|
v-else-if="progress[item.id]?.viewCount"
|
||||||
|
depth="3"
|
||||||
|
style="font-size: 12px"
|
||||||
|
>
|
||||||
|
读了 {{ readSoFar(progress[item.id].totalSeconds) }}
|
||||||
|
</n-text>
|
||||||
<n-text
|
<n-text
|
||||||
v-if="progress[item.id]?.exerciseTotal"
|
v-if="progress[item.id]?.exerciseTotal"
|
||||||
:type="
|
:type="
|
||||||
|
|||||||
@@ -141,3 +141,16 @@ export type Exercise = z.infer<typeof exerciseSchema>
|
|||||||
export type TutorialProgress = z.infer<typeof tutorialProgressSchema>
|
export type TutorialProgress = z.infer<typeof tutorialProgressSchema>
|
||||||
export type TutorialProgressPing = z.infer<typeof tutorialProgressPingSchema>
|
export type TutorialProgressPing = z.infer<typeof tutorialProgressPingSchema>
|
||||||
export type ExerciseAttemptRequest = z.infer<typeof exerciseAttemptRequestSchema>
|
export type ExerciseAttemptRequest = z.infer<typeof exerciseAttemptRequestSchema>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「已读」的门槛:累计停留满 3 分钟才算读过这一课。
|
||||||
|
*
|
||||||
|
* 之前只要打开过(`viewCount > 0`)就记成已读,于是「点开看一眼就退」和「认真读完」
|
||||||
|
* 在老师那张表上长得一模一样,「已读 17/17」并不说明他学了。3 分钟是按最短的一课
|
||||||
|
* 定的下限——读不完还翻不动的课文,扫一眼是到不了这个数的。
|
||||||
|
*
|
||||||
|
* 门槛只管**「已读」这个口径**(学生端的 ✓、后台的已读课数与读过的人数),
|
||||||
|
* 不动累计时长:时长记的是真实停留,不满 3 分钟的那些秒数照样算进去,
|
||||||
|
* 「已读 0 课、累计 25 分钟」正是要让老师看见的一种情况。
|
||||||
|
*/
|
||||||
|
export const TUTORIAL_READ_SECONDS = 180
|
||||||
|
|||||||
Reference in New Issue
Block a user