From e7b6e27b85cd90fd17c79d706f1b07a82ad6798e Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Thu, 15 Oct 2026 13:50:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=AF=BE=E5=A0=82=E7=9C=8B=E6=9D=BF):=20?= =?UTF-8?q?=E6=A0=87=E5=87=BA=E8=B0=81=E5=9C=A8=E7=BA=BF=E3=80=81=E8=B0=81?= =?UTF-8?q?=E4=BB=8A=E5=A4=A9=E6=B2=A1=E4=B8=8A=E8=BF=87=E7=BA=BF=EF=BC=8C?= =?UTF-8?q?=E8=AF=B7=E5=81=87=E6=A1=86=E9=87=8C=E6=B2=A1=E4=B8=8A=E7=BA=BF?= =?UTF-8?q?=E7=9A=84=E4=BA=BA=E5=88=86=E5=9C=A8=E5=89=8D=E9=9D=A2=E4=B8=80?= =?UTF-8?q?=E5=A0=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 机房有两人共用一台电脑的,同桌只有一个登着,只看「此刻在不在线」会把旁边的人 当成没来。所以每次记在线时按天再记一笔「今天最后一次上线」: - 看板「在线」列(原「在写」):开着题的照旧写题号,其余写 在线 / N 分钟前在 / 今天没上线 - 还没交过的人里,今天没上过线的排最前 - 请假框分「今天没上线」「今天上过线」两堆,前一堆可以一键全勾上 Co-Authored-By: Claude Opus 5.5 --- apps/api/src/auth/presence.ts | 36 +++++++++- apps/api/src/routes/classroom.ts | 8 ++- apps/web/src/oj/classroom/board.vue | 106 ++++++++++++++++++++++++---- packages/contract/src/classroom.ts | 7 ++ 4 files changed, 141 insertions(+), 16 deletions(-) diff --git a/apps/api/src/auth/presence.ts b/apps/api/src/auth/presence.ts index a08d2b1..9503ea0 100644 --- a/apps/api/src/auth/presence.ts +++ b/apps/api/src/auth/presence.ts @@ -1,6 +1,7 @@ import type { ChainableCommander } from "ioredis" import { redis } from "../redis" +import { calendarDay } from "../time" /** * 「谁现在在线」。member = userId,score = 最后一次活动的毫秒时间戳。 @@ -18,9 +19,24 @@ const PRESENCE_KEY = "online-users" */ const ONLINE_WINDOW_MS = 5 * 60 * 1000 +/** + * 「今天谁上过线、最后一次是几点」,一天一个 key,同样是 userId → 毫秒时间戳。 + * + * 课堂看板拿它判断谁今天没来:机房有两人共用一台电脑的,同一时刻只有一个人登着, + * 只看「此刻在不在线」会把坐在旁边的同桌当成没来。上过线的人不管现在登没登着都算来过。 + * 上面那张会被修剪到最近 5 分钟,留不住这个,所以单记一张,放两天自己过期。 + */ +const seenTodayKey = (day: string) => `online-users:${day}` +const SEEN_TODAY_TTL_SECONDS = 2 * 24 * 60 * 60 + /** 记一笔活动。传 pipeline 而不是自己发命令:调用点都在热路径上 */ export function markOnline(pipeline: ChainableCommander, userId: number) { - pipeline.zadd(PRESENCE_KEY, Date.now(), String(userId)) + const now = Date.now() + const seenKey = seenTodayKey(calendarDay(now)) + pipeline + .zadd(PRESENCE_KEY, now, String(userId)) + .zadd(seenKey, now, String(userId)) + .expire(seenKey, SEEN_TODAY_TTL_SECONDS) } /** @@ -51,3 +67,21 @@ export async function isUserOnline(userId: number) { const score = await redis.zscore(PRESENCE_KEY, String(userId)) return score !== null && Number(score) >= Date.now() - ONLINE_WINDOW_MS } + +/** + * 这批人今天最后一次上线的时刻(毫秒),今天没上过线的不在结果里。 + * `online` 和 onlineUserIds 同一个窗口 + */ +export async function seenToday(userIds: number[]) { + const seen = new Map() + if (!userIds.length) return seen + const scores = await redis.zmscore(seenTodayKey(calendarDay()), ...userIds.map(String)) + const cutoff = Date.now() - ONLINE_WINDOW_MS + userIds.forEach((userId, i) => { + const score = scores[i] + if (score === null || score === undefined) return + const at = Number(score) + seen.set(userId, { at, online: at >= cutoff }) + }) + return seen +} diff --git a/apps/api/src/routes/classroom.ts b/apps/api/src/routes/classroom.ts index 4bd0af7..0b8f001 100644 --- a/apps/api/src/routes/classroom.ts +++ b/apps/api/src/routes/classroom.ts @@ -17,6 +17,7 @@ import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, sql } from "dr import { Hono } from "hono" import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware" +import { seenToday } from "../auth/presence" import { getLoginWindow } from "../auth/session" import { presenceOf } from "../collab/state" import { db, schema } from "../db" @@ -568,7 +569,7 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => { // 「来了几次」代码和流程图合起来数:那天只画了流程图的也算来过,和 recentDays 同一个口径 const recentActivity = activityRows({ since: windowStart, users: userIds }) const localDay = sql`to_char(${localTime(recentActivity.createTime)}, 'YYYY-MM-DD')` - const [cells, lastSubmits, lastDrawn, attendance, drawn, latests] = await Promise.all([ + const [cells, lastSubmits, lastDrawn, attendance, drawn, latests, seen] = await Promise.all([ ids.length && userIds.length ? db .select({ @@ -694,6 +695,7 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => { desc(schema.submission.createTime), ) : [], + seenToday(userIds), ]) const latestByKey = new Map(latests.map((row) => [`${row.userId}:${row.problemId}`, row])) const cellByKey = new Map(cells.map((row) => [`${row.userId}:${row.problemId}`, row])) @@ -757,6 +759,10 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => { drawnToday: drawnTodayUsers.has(student.userId), recentAttended: attendedByUser.get(student.userId) ?? 0, editing: presenceOf(student.userId)?.problemId ?? null, + online: seen.get(student.userId)?.online ?? false, + seenAt: seen.has(student.userId) + ? new Date(seen.get(student.userId)!.at).toISOString() + : null, }) satisfies ClassBoardStudent, ), recentLessons: recentDays.length, diff --git a/apps/web/src/oj/classroom/board.vue b/apps/web/src/oj/classroom/board.vue index 0b034ba..fe73266 100644 --- a/apps/web/src/oj/classroom/board.vue +++ b/apps/web/src/oj/classroom/board.vue @@ -189,6 +189,20 @@ function oftenIdle(student: ClassBoardStudent) { return total >= 3 && student.recentAttended <= 1 } +/** + * 今天一次都没上过线。机房有两人共用一台电脑的,同桌只有一个登着,所以「谁没来」 + * 看今天上没上过线,不看此刻在不在线(见契约 seenAt) + */ +function unseen(student: ClassBoardStudent) { + return !student.seenAt +} + +/** 没在线的人最后一次上线是多久前,「在写」那格用 */ +function seenText(seenAt: string) { + const minutes = Math.max(0, Math.floor((now.value.getTime() - Date.parse(seenAt)) / 60_000)) + return minutes < 60 ? `${minutes} 分钟前在` : `${Math.floor(minutes / 60)} 小时前在` +} + function doneCount(student: ClassBoardStudent) { return student.cells.filter((cell) => cell.status === "accepted").length } @@ -251,7 +265,22 @@ watchEffect(() => { const leaveDialog = ref(false) /** 弹框里能勾的:今天还没交过的(刚上课时就是全班),按名字排 */ const leaveCandidates = computed(() => boardStudents.value.filter(canLeave).sort(byName)) +/** 今天没上过线的分在前面一堆:请假的人基本在这里面,剩下的是和同桌共用电脑的 */ +const leaveSections = computed(() => + [ + { label: "今天没上线", students: leaveCandidates.value.filter(unseen) }, + { + label: "今天上过线", + students: leaveCandidates.value.filter((student) => !unseen(student)), + }, + ].filter((section) => section.students.length), +) const leaveSelected = ref([]) +function selectAll(list: ClassBoardStudent[]) { + leaveSelected.value = [ + ...new Set([...leaveSelected.value, ...list.map((student) => student.username)]), + ] +} function openLeaveDialog() { leaveSelected.value = leaveStudents.value.map((student) => student.username) leaveDialog.value = true @@ -272,7 +301,8 @@ const students = computed(() => [...boardStudents.value].sort( (a, b) => GROUP_ORDER[groupOf(a)] - GROUP_ORDER[groupOf(b)] || - // 同样是没交过,经常不动手的排前面 + // 同样是没交过,今天没上过线的排最前(多半没来,好标请假),再是经常不动手的 + Number(unseen(b)) - Number(unseen(a)) || Number(oftenIdle(b)) - Number(oftenIdle(a)) || // 同样在做,做对得少的排前面(只做了一道的那几个) (groupOf(a) === "working" ? doneCount(a) - doneCount(b) : 0) || @@ -679,7 +709,7 @@ function shortTitle(title: string, size: number) { 学生 - 在写 + 在线 - + {{ student.editing }} - — + 在线 + + {{ seenText(student.seenAt) }} + + 今天没上线 -
- +
+
+ {{ section.label }} {{ section.students.length }} 人 + + 全勾上 + +
+
+ +
请假的人一定是没交过题的,现在没有人可以标。 @@ -1305,7 +1356,22 @@ a.cell { cursor: pointer; } -.where::before { +.present { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 13px; + color: v-bind("tone('success').color"); +} + +.seen { + font-size: 13px; + white-space: nowrap; + color: v-bind("theme.textColor3"); +} + +.where::before, +.present::before { content: ""; width: 7px; height: 7px; @@ -1323,6 +1389,18 @@ a.cell { color: v-bind("tone('info').color"); } +.leave-section + .leave-section { + margin-top: 18px; +} + +.leave-head { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 10px; + font-weight: 600; +} + .leave-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); diff --git a/packages/contract/src/classroom.ts b/packages/contract/src/classroom.ts index e8b3080..fd997c0 100644 --- a/packages/contract/src/classroom.ts +++ b/packages/contract/src/classroom.ts @@ -144,6 +144,13 @@ export const classBoardStudentSchema = z.object({ * 代码,不用等他举手。只认连着**本站**的(和课堂求助一样,在 serve 进程的内存里) */ editing: z.string().nullable(), + /** + * 此刻在线(5 分钟内有动静)、今天最后一次上线的时刻(今天没上过线为 null)。 + * 机房两人共用一台电脑时同桌只有一个登着,所以「谁没来」看 seenAt,不看 online。 + * 和 editing 一样只认**本站**:服务器和机房各记各的(在各自的 Redis 里) + */ + online: z.boolean(), + seenAt: z.string().nullable(), }) /**