feat(课堂看板): 标出谁在线、谁今天没上过线,请假框里没上线的人分在前面一堆
机房有两人共用一台电脑的,同桌只有一个登着,只看「此刻在不在线」会把旁边的人 当成没来。所以每次记在线时按天再记一笔「今天最后一次上线」: - 看板「在线」列(原「在写」):开着题的照旧写题号,其余写 在线 / N 分钟前在 / 今天没上线 - 还没交过的人里,今天没上过线的排最前 - 请假框分「今天没上线」「今天上过线」两堆,前一堆可以一键全勾上 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
395be1a19b
commit
e7b6e27b85
4 files changed
+141
-16
No files matched your search
@@ -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<number, { at: number; online: boolean }>()
|
||||
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
|
||||
}
|
||||
@@ -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<string>`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,
|
||||
|
||||
@@ -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<string[]>([])
|
||||
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) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="name">学生</th>
|
||||
<th class="narrow-col where-col">在写</th>
|
||||
<th class="narrow-col where-col">在线</th>
|
||||
<th
|
||||
v-for="(stat, i) in problemStats"
|
||||
:key="stat.problem.problemId"
|
||||
@@ -728,8 +758,9 @@ function shortTitle(title: string, size: number) {
|
||||
举手 {{ waitedText(helpOf(student)!.createdAt) }}
|
||||
</span>
|
||||
</td>
|
||||
<!-- 在写:举手的「帮他」;别人在帮的写谁在帮;开着题目的点题号直接打开他正在写的
|
||||
代码(他没举手也行,他那边会显示「xxx 在看你的代码」) -->
|
||||
<!-- 在线:举手的「帮他」;别人在帮的写谁在帮;开着题目的点题号直接打开他正在写的
|
||||
代码(他没举手也行,他那边会显示「xxx 在看你的代码」);剩下的写在不在线、
|
||||
多久前在、今天没上过线 -->
|
||||
<td class="where-col">
|
||||
<n-button
|
||||
v-if="helpOf(student)?.status === 'pending'"
|
||||
@@ -751,7 +782,11 @@ function shortTitle(title: string, size: number) {
|
||||
>
|
||||
{{ student.editing }}
|
||||
</button>
|
||||
<span v-else class="muted">—</span>
|
||||
<span v-else-if="student.online" class="present">在线</span>
|
||||
<span v-else-if="student.seenAt" class="seen">
|
||||
{{ seenText(student.seenAt) }}
|
||||
</span>
|
||||
<span v-else class="seen">今天没上线</span>
|
||||
</td>
|
||||
<td
|
||||
v-for="(cell, i) in student.cells"
|
||||
@@ -814,14 +849,30 @@ function shortTitle(title: string, size: number) {
|
||||
style="width: 640px; max-width: calc(100vw - 32px)"
|
||||
>
|
||||
<n-checkbox-group v-if="leaveCandidates.length" v-model:value="leaveSelected">
|
||||
<div class="leave-grid">
|
||||
<n-checkbox
|
||||
v-for="student in leaveCandidates"
|
||||
:key="student.userId"
|
||||
:value="student.username"
|
||||
:label="student.username"
|
||||
class="leave-option"
|
||||
/>
|
||||
<div v-for="section in leaveSections" :key="section.label" class="leave-section">
|
||||
<div v-if="leaveSections.length > 1 || unseen(section.students[0])" class="leave-head">
|
||||
{{ section.label }} {{ section.students.length }} 人
|
||||
<n-button
|
||||
v-if="unseen(section.students[0])"
|
||||
text
|
||||
type="primary"
|
||||
:disabled="
|
||||
section.students.every((student) => leaveSelected.includes(student.username))
|
||||
"
|
||||
@click="selectAll(section.students)"
|
||||
>
|
||||
全勾上
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="leave-grid">
|
||||
<n-checkbox
|
||||
v-for="student in section.students"
|
||||
:key="student.userId"
|
||||
:value="student.username"
|
||||
:label="student.username"
|
||||
class="leave-option"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</n-checkbox-group>
|
||||
<n-text v-else depth="3">请假的人一定是没交过题的,现在没有人可以标。</n-text>
|
||||
@@ -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));
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user