feat(课堂看板): 标出最近几节课经常没交的学生,求助的学生置顶,点一下打开求助列表
Deploy / deploy (push) Canceled after 0s

2025 秋至少上过 5 节课的学生里,12.5% 七成以上的课一道都没交,37% 的学生贡献了
一半以上的「没交」;当堂那份「今天还没交过」把偶尔没来的和一直不动手的排在同一行。

- 看板接口给每个学生加 recentAttended:最近几节课(最多 5 节、不含今天、45 天内;
  一节课 = 同班同日 ≥5 人做同一题)里交过几节,另给 recentLessons
- 「今天还没交过」拆成「经常没交」(≥3 节可比且最多交过 1 节)和其他;表格加一列
  「最近 N 节」;只给老师看,投影模式不出现
- 求助读老师端常驻 WebSocket 的 collabStore.requests(不改后端):求助的学生置顶,
  状态显示「举手了 / 老师在帮」,点一下打开求助列表;求助提示不论布没布置题都显示

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-27 22:47:24 +08:00
1 parent 80106eaa04
commit a6bf845e06
3 files changed
+129 -6

No files matched your search

+45 -1
View File
@@ -343,6 +343,15 @@ classroomRoutes.get("/me/class-activity", requireAuth, async (c) => {
} satisfies ClassActivity)
})
/**
* 看板上的「最近几节课交了几节」:分清「今天没来」和「一直不动手」。2025 秋至少上过
* 5 节课的学生里,12.5% 七成以上的课一道都没交,37% 的学生贡献了一半以上的「没交」
* —— 当堂那份名单把这两种人排在同一行里,老师分不出来。
*/
const RECENT_LESSONS = 5
/** 往回找最近几节课时最多看多远:寒暑假之后开学第一节课,前面几节是上学期的,没意义 */
const RECENT_LESSONS_WINDOW_DAYS = 45
/** 看板没指定班级时,猜「最近这么久里提交人数最多的班」—— 老师多半正在上这个班的课 */
const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000
@@ -389,6 +398,7 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
source: null,
problems: [],
students: [],
recentLessons: 0,
} satisfies ClassBoard)
}
@@ -414,7 +424,21 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
)
const userIds = roster.map((row) => row.userId)
const [cells, lastSubmits] = await Promise.all([
// 最近几节课:这个班同学一起做题的那些天(不含今天)
const windowStart = dayStart(Date.now() - RECENT_LESSONS_WINDOW_DAYS * 86_400_000)
const recentDays = [
...new Set(
(await classDayGroups(className, windowStart, { minUsers: CLASS_ACTIVITY_MIN_USERS })).map(
(row) => row.day,
),
),
]
.filter((d) => d < day)
.sort()
.slice(-RECENT_LESSONS)
const localDay = sql<string>`to_char(${localTime(schema.submission.createTime)}, 'YYYY-MM-DD')`
const [cells, lastSubmits, attendance] = await Promise.all([
ids.length && userIds.length
? db
.select({
@@ -450,9 +474,27 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
)
.groupBy(schema.submission.userId)
: [],
recentDays.length && userIds.length
? db
.select({
userId: schema.submission.userId,
days: sql<number>`count(distinct ${localDay})::int`,
})
.from(schema.submission)
.where(
and(
isNull(schema.submission.contestId),
inArray(schema.submission.userId, userIds),
gte(schema.submission.createTime, windowStart),
inArray(localDay, recentDays),
),
)
.groupBy(schema.submission.userId)
: [],
])
const cellByKey = new Map(cells.map((row) => [`${row.userId}:${row.problemId}`, row]))
const lastByUser = new Map(lastSubmits.map((row) => [row.userId, row.lastAt]))
const attendedByUser = new Map(attendance.map((row) => [row.userId, row.days]))
return success(c, {
className,
@@ -478,8 +520,10 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
}
}),
lastSubmitAt: lastByUser.get(student.userId) ?? null,
recentAttended: attendedByUser.get(student.userId) ?? 0,
}) satisfies ClassBoardStudent,
),
recentLessons: recentDays.length,
} satisfies ClassBoard)
})
+77 -5
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { getClassBoard, setClassLesson } from "oj/api"
import { useCollabStore } from "shared/store/collab"
import { useConfigStore } from "shared/store/config"
import { errorMessage } from "utils/api"
import { parseTime } from "utils/functions"
@@ -21,6 +22,7 @@ const STUCK_ATTEMPTS = 3
const REFRESH_MS = 15_000
const configStore = useConfigStore()
const collabStore = useCollabStore()
const message = useMessage()
const board = ref<ClassBoard | null>(null)
@@ -98,9 +100,28 @@ onUnmounted(pause)
// ---------- 分组与排序 ----------
type Group = "idle" | "stuck" | "working" | "done"
type Group = "help" | "idle" | "stuck" | "working" | "done"
/**
* 正在课堂求助的学生。列表是老师端 WebSocket 实时推的(collabStore,全局常驻连接),
* 不经看板接口 —— 求助只在 serve 进程的内存里,本来就不落库。所以也只看得到连着
* **本站**的学生:服务器和机房各有各的求助队列,和课堂求助本身一样。
*/
function helpOf(student: ClassBoardStudent) {
return collabStore.requests.find((request) => request.studentId === student.userId) ?? null
}
/**
* 经常不动手:最近几节课里最多只交过一节。至少有 3 节课可比才下结论,
* 开学头两节谁都一样。只给老师看,投影模式里不出现。
*/
function oftenIdle(student: ClassBoardStudent) {
const total = board.value?.recentLessons ?? 0
return total >= 3 && student.recentAttended <= 1
}
function groupOf(student: ClassBoardStudent): Group {
if (helpOf(student)) return "help"
const cells = student.cells
if (cells.length && cells.every((cell) => cell.status === "accepted")) return "done"
if (!student.lastSubmitAt) return "idle"
@@ -109,7 +130,7 @@ function groupOf(student: ClassBoardStudent): Group {
return "working"
}
const GROUP_ORDER: Record<Group, number> = { idle: 0, stuck: 1, working: 2, done: 3 }
const GROUP_ORDER: Record<Group, number> = { help: 0, idle: 1, stuck: 2, working: 3, done: 4 }
function nameOf(student: ClassBoardStudent) {
return student.realName || student.username
@@ -119,11 +140,24 @@ const students = computed(() =>
[...(board.value?.students ?? [])].sort(
(a, b) =>
GROUP_ORDER[groupOf(a)] - GROUP_ORDER[groupOf(b)] ||
// 同样是没交过,经常不动手的排前面
Number(oftenIdle(b)) - Number(oftenIdle(a)) ||
nameOf(a).localeCompare(nameOf(b), "zh-CN"),
),
)
const idle = computed(() => students.value.filter((student) => groupOf(student) === "idle"))
const idleOften = computed(() => idle.value.filter(oftenIdle))
const idleOthers = computed(() => idle.value.filter((student) => !oftenIdle(student)))
const helpNames = computed(() =>
students.value
.filter((student) => groupOf(student) === "help")
.map(nameOf)
.join("、"),
)
const helpCount = computed(
() => students.value.filter((student) => groupOf(student) === "help").length,
)
const stuckCount = computed(
() => students.value.filter((student) => groupOf(student) === "stuck").length,
)
@@ -157,6 +191,7 @@ const GROUP_LABEL: Record<
Group,
{ text: string; type: "default" | "error" | "warning" | "info" | "success" }
> = {
help: { text: "在求助", type: "warning" },
idle: { text: "没交过", type: "default" },
stuck: { text: "卡住了", type: "error" },
working: { text: "在做", type: "info" },
@@ -223,6 +258,16 @@ const GROUP_LABEL: Record<
</n-text>
</n-card>
<!-- 求助和布没布置题无关,放在「还没有题」的判断外面;投影时不出名字,这条也收起来 -->
<n-alert
v-if="helpCount && !projector"
type="info"
:title="`有 ${helpCount} 人在求助:${helpNames}`"
class="section"
>
<n-button size="small" @click="collabStore.helpPanelOpen = true">打开求助列表</n-button>
</n-alert>
<n-empty
v-if="!board.problems.length"
description="还没有题:在上面输入这节课的题号"
@@ -252,11 +297,20 @@ const GROUP_LABEL: Record<
:title="`今天还没交过(${idle.length} 人)`"
class="section"
>
{{ idle.map(nameOf).join("、") }}
<div v-if="idleOften.length">
<b>经常没交</b>(最近 {{ board.recentLessons }} 节课最多交过 1 节):{{
idleOften.map(nameOf).join("、")
}}
</div>
<div v-if="idleOthers.length">
<template v-if="idleOften.length"><b>其他</b>:</template
>{{ idleOthers.map(nameOf).join("、") }}
</div>
</n-alert>
<n-text v-if="stuckCount" type="error" class="meta">
有 {{ stuckCount }} 人卡住了(同一道题今天交了
{{ STUCK_ATTEMPTS }} 次以上还没过),排在表格最前面
{{ STUCK_ATTEMPTS }} 次以上还没过),有题的话排在表格最前面
</n-text>
<div class="table-wrap">
@@ -271,6 +325,7 @@ const GROUP_LABEL: Record<
</router-link>
</th>
<th>最后提交</th>
<th>最近 {{ board.recentLessons }} 节</th>
</tr>
</thead>
<tbody>
@@ -281,8 +336,16 @@ const GROUP_LABEL: Record<
size="small"
:bordered="false"
:type="GROUP_LABEL[groupOf(student)].type"
:class="{ 'help-tag': helpOf(student) }"
@click="helpOf(student) && (collabStore.helpPanelOpen = true)"
>
{{ GROUP_LABEL[groupOf(student)].text }}
{{
helpOf(student)
? helpOf(student)!.status === "active"
? "老师在帮"
: "举手了"
: GROUP_LABEL[groupOf(student)].text
}}
</n-tag>
</td>
<td v-for="(cell, i) in student.cells" :key="i" :class="cellClass(cell)">
@@ -291,6 +354,11 @@ const GROUP_LABEL: Record<
<td class="meta">
{{ student.lastSubmitAt ? parseTime(student.lastSubmitAt, "HH:mm") : "—" }}
</td>
<td :class="{ 'cell-stuck': oftenIdle(student) }">
{{
board.recentLessons ? `${student.recentAttended}/${board.recentLessons}` : "—"
}}
</td>
</tr>
</tbody>
</table>
@@ -399,6 +467,10 @@ const GROUP_LABEL: Record<
font-weight: 600;
}
.help-tag {
cursor: pointer;
}
.cell-none {
opacity: 0.35;
}