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:
1 parent
80106eaa04
commit
a6bf845e06
3 files changed
+129
-6
No files matched your search
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -115,6 +115,8 @@ export const classBoardStudentSchema = z.object({
|
||||
cells: z.array(classBoardCellSchema),
|
||||
/** 今天最后一次提交(任何题),没交过为 null */
|
||||
lastSubmitAt: z.string().nullable(),
|
||||
/** 最近几节课(不含今天,个数见 ClassBoard.recentLessons)里交过题的有几节 */
|
||||
recentAttended: z.number().int(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -127,6 +129,11 @@ export const classBoardSchema = z.object({
|
||||
source: z.enum(["teacher", "inferred"]).nullable(),
|
||||
problems: z.array(classBoardProblemSchema),
|
||||
students: z.array(classBoardStudentSchema),
|
||||
/**
|
||||
* 「最近几节课」一共几节(最多 5,不含今天)。一节课 = 这个班同学一起做题的一天
|
||||
* (同班同一天 ≥ 5 人做同一道题,和「班里在做」同一个判据)。新班、开学头几周不满 5。
|
||||
*/
|
||||
recentLessons: z.number().int(),
|
||||
})
|
||||
|
||||
export type ClassRankItem = z.infer<typeof classRankItemSchema>
|
||||
|
||||
Reference in new issue
Block a user