feat(课堂看板): 「请假隐藏」不会用——改成班级旁一个「请假」按钮勾名单,请假的人变灰排到最后、今天有效
Deploy / deploy (push) Canceled after 0s

原来要先打开开关、名字变成带 × 的标签再点 ×,× 像删除;标完人从页面上消失,
恢复只能全部恢复。现在:

- 班级下拉旁「请假」按钮,标了人之后按钮直接写「请假 2 人:陈言、黄依露 · 修改」
- 弹框只列今天还没交过的人(请假的人一定在里面,刚上课时就是全班),勾上点确定
- 请假的人在表格里变灰排到最后,不算进进度和「还没交过」
- 交了题或举手就算来了,自动取消标记
- 有效期从两小时改成今天(请假一般是一整天),仍然只记在这台电脑上

顺带修掉:被标过的学生后来交了题,会从表格里整个消失、也不算进进度。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-09 10:38:25 +08:00
1 parent af2ca31de5
commit 1ba8928d41
4 files changed
+262 -129

No files matched your search

+1 -1
View File
@@ -59,7 +59,7 @@ API 调用不按模块分:学生端全在 `oj/api.ts`、后台全在 `admin/ap
Shared logic lives in `shared/`:
- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (题面「你画的流程图」那张 A/S 图的 mermaid 源码)
- `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `darkTransition` (View Transitions,111 以下走降级分支), `hiddenStudents` (课堂看板的「请假隐藏」), `chartTheme`, `breakpoints`, `maxkb`, `learnProgress`, `rarity`
- `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `darkTransition` (View Transitions,111 以下走降级分支), `leaveStudents` (课堂看板的「请假」), `chartTheme`, `breakpoints`, `maxkb`, `learnProgress`, `rarity`
- `layout/` — `default.vue` and `admin.vue` layout wrappers
- `api.ts` — shared API calls (auth, profile, tags, captcha)
+177 -55
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { getClassBoard, setClassLesson } from "oj/api"
import { useHiddenStudents } from "shared/composables/hiddenStudents"
import { useLeaveStudents } from "shared/composables/leaveStudents"
import { useCollabStore } from "shared/store/collab"
import { useConfigStore } from "shared/store/config"
import type { RunnableLanguage } from "@oj2/contract"
@@ -119,7 +119,7 @@ onUnmounted(pause)
// ---------- 分组与排序 ----------
type Group = "help" | "idle" | "stuck" | "working" | "done"
type Group = "help" | "idle" | "stuck" | "working" | "done" | "leave"
/**
* 正在课堂求助的学生。列表是老师端 WebSocket 实时推的(collabStore,全局常驻连接),
@@ -140,6 +140,7 @@ function oftenIdle(student: ClassBoardStudent) {
}
function groupOf(student: ClassBoardStudent): Group {
if (absent(student)) return "leave"
if (helpOf(student)) return "help"
const cells = student.cells
if (cells.length && cells.every((cell) => cell.status === "accepted")) return "done"
@@ -149,41 +150,81 @@ function groupOf(student: ClassBoardStudent): Group {
return "working"
}
const GROUP_ORDER: Record<Group, number> = { help: 0, idle: 1, stuck: 2, working: 3, done: 4 }
const GROUP_ORDER: Record<Group, number> = {
help: 0,
idle: 1,
stuck: 2,
working: 3,
done: 4,
leave: 5,
}
function nameOf(student: ClassBoardStudent) {
return student.realName || student.username
}
/**
* 请假隐藏(原来在「数据统计」弹框里,统计改成回头看之后挪到这儿 —— 盯这节课的是看板)。
* 请假、转班、学号错了的那几个一直挂在「还没交过」里,会盖住真正要去看的人。
* 藏两个小时(够一节课),下节课自动回来;只记在这台电脑上,换人换机器都不继承。
* 键沿用统计弹框那份,老师上午藏的人这里接着藏着。
* 请假:老师在「请假」框里勾上今天不在的人,看板的人数、进度、「还没交过」都不算他们,
* 表格里变灰排到最后(不藏掉 —— 标了谁、标错没有,一眼看得见)。
* 今天有效,只记在这台电脑上(见 leaveStudents.ts)。
*
* **只藏「还没交过」的人**:一个学生只要今天交过、或者正在举手,就不算请假,照常显示、
* 照常算进人数。迟到的学生后来交了题、卡住了、举手了,老师得看得见
* **只有今天还没交过的人能算请假**:请假的人一定在这里面。标过的人后来交了题、或者举手了,
* 就是来了(迟到),自动取消标记,回到正常的分组里 —— 老师得看得见他卡住、举手
*/
const { hideMode, hideStudent, unhide, isHidden } = useHiddenStudents("oj_hidden_students")
function hiddenNow(student: ClassBoardStudent) {
return !student.lastSubmitAt && !helpOf(student) && isHidden(student.username)
const { onLeave, setLeave, cancelLeave } = useLeaveStudents("oj_leave_students")
function canLeave(student: ClassBoardStudent) {
return !student.lastSubmitAt && !helpOf(student)
}
const hiddenStudents = computed(() => (board.value?.students ?? []).filter(hiddenNow))
function restoreHidden() {
unhide(hiddenStudents.value.map((student) => student.username))
function absent(student: ClassBoardStudent) {
return canLeave(student) && onLeave(student.username)
}
const boardStudents = computed(() => board.value?.students ?? [])
const leaveStudents = computed(() => boardStudents.value.filter(absent).sort(byName))
const leaveNames = computed(() => leaveStudents.value.map(nameOf).join("、"))
// 标过请假、后来交了题或举手的:来了,把标记清掉(不然名单里一直挂着一个其实在的人)
watchEffect(() => {
const arrived = boardStudents.value
.filter((student) => !canLeave(student) && onLeave(student.username))
.map((student) => student.username)
if (arrived.length) cancelLeave(arrived)
})
const leaveDialog = ref(false)
/** 弹框里能勾的:今天还没交过的(刚上课时就是全班),按名字排 */
const leaveCandidates = computed(() => boardStudents.value.filter(canLeave).sort(byName))
const leaveSelected = ref<string[]>([])
function openLeaveDialog() {
leaveSelected.value = leaveStudents.value.map((student) => student.username)
leaveDialog.value = true
}
function saveLeave() {
setLeave(
boardStudents.value.map((student) => student.username),
leaveSelected.value,
)
leaveDialog.value = false
}
function byName(a: ClassBoardStudent, b: ClassBoardStudent) {
return nameOf(a).localeCompare(nameOf(b), "zh-CN")
}
const students = computed(() =>
[...(board.value?.students ?? [])]
.filter((student) => !isHidden(student.username))
.sort(
[...boardStudents.value].sort(
(a, b) =>
GROUP_ORDER[groupOf(a)] - GROUP_ORDER[groupOf(b)] ||
// 同样是没交过,经常不动手的排前面
Number(oftenIdle(b)) - Number(oftenIdle(a)) ||
nameOf(a).localeCompare(nameOf(b), "zh-CN"),
byName(a, b),
),
)
/** 到了的人:人数、进度都按这些算 */
const present = computed(() => students.value.filter((student) => groupOf(student) !== "leave"))
/** 表格里第一个请假的人,在他上面插一行说明 */
const firstLeaveId = computed(
() => students.value.find((student) => groupOf(student) === "leave")?.userId,
)
const idle = computed(() => students.value.filter((student) => groupOf(student) === "idle"))
const idleOften = computed(() => idle.value.filter(oftenIdle))
@@ -203,7 +244,7 @@ const stuckCount = computed(
const summary = computed(() => {
// 分母也减掉请假的,不然「做完 30/38」永远到不了头
const all = students.value
const all = present.value
return (board.value?.problems ?? []).map((problem, i) => ({
...problem,
done: all.filter((student) => student.cells[i]?.status === "accepted").length,
@@ -236,6 +277,7 @@ const GROUP_LABEL: Record<
stuck: { text: "卡住了", type: "error" },
working: { text: "在做", type: "info" },
done: { text: "做完了", type: "success" },
leave: { text: "请假", type: "default" },
}
const router = useRouter()
@@ -283,6 +325,20 @@ function submissionsHref(
style="width: 180px"
@update:value="changeClass"
/>
<!-- 投影时不出名字,请假这个按钮也收起来 -->
<n-button
v-if="board?.className && !projector"
:type="leaveStudents.length ? 'warning' : 'default'"
:secondary="!!leaveStudents.length"
class="leave-button"
@click="openLeaveDialog"
>
<template v-if="leaveStudents.length">
<span class="leave-names">请假 {{ leaveStudents.length }} 人:{{ leaveNames }}</span>
<span class="leave-edit">修改</span>
</template>
<template v-else>请假</template>
</n-button>
</n-flex>
<n-flex align="center">
<n-text depth="3" class="meta">
@@ -378,38 +434,14 @@ function submissionsHref(
<template v-if="!projector">
<n-alert
v-if="idle.length || hiddenStudents.length"
v-if="idle.length"
type="warning"
:title="idle.length ? `今天还没交过(${idle.length} 人)` : '今天没交过的都隐藏了'"
:title="`今天还没交过(${idle.length} 人)`"
class="section"
>
<n-flex align="center" :size="10" class="hide-bar">
<n-switch v-model:value="hideMode" size="small">
<template #checked>请假隐藏中 · 点名字旁的 × 藏起来</template>
<template #unchecked>请假隐藏</template>
</n-switch>
<n-button
v-if="hiddenStudents.length"
size="tiny"
text
type="primary"
@click="restoreHidden"
>
隐藏了 {{ hiddenStudents.length }} 位:{{ hiddenStudents.map(nameOf).join("、") }} ·
恢复
</n-button>
</n-flex>
<n-flex v-if="hideMode" :size="8">
<n-tag
v-for="student in idle"
:key="student.userId"
closable
@close="hideStudent(student.username)"
>
{{ nameOf(student) }}
</n-tag>
</n-flex>
<template v-else>
<n-text v-if="leaveStudents.length" depth="3" class="meta">
请假的 {{ leaveStudents.length }} 人不算在里面
</n-text>
<div v-if="idleOften.length">
<b>经常没交</b>(最近 {{ board.recentLessons }} 节课最多交过 1 节):{{
idleOften.map(nameOf).join("、")
@@ -419,7 +451,6 @@ function submissionsHref(
<template v-if="idleOften.length"><b>其他</b>:</template
>{{ idleOthers.map(nameOf).join("、") }}
</div>
</template>
</n-alert>
<n-text v-if="stuckCount" type="error" class="meta">
@@ -443,7 +474,14 @@ function submissionsHref(
</tr>
</thead>
<tbody>
<tr v-for="student in students" :key="student.userId">
<template v-for="student in students" :key="student.userId">
<tr v-if="student.userId === firstLeaveId" class="leave-sep">
<td :colspan="board.problems.length + 4">
请假 {{ leaveStudents.length }} 人 · 不算进上面的人数和进度 ·
交了题或举手就自动回到上面
</td>
</tr>
<tr :class="{ 'leave-row': groupOf(student) === 'leave' }">
<td class="name">
<a
v-if="student.codeToday || student.drawnToday"
@@ -498,18 +536,70 @@ function submissionsHref(
<td class="meta">
{{ student.lastSubmitAt ? parseTime(student.lastSubmitAt, "HH:mm") : "—" }}
</td>
<td :class="{ 'cell-stuck': oftenIdle(student) }">
<td
:class="{
'cell-stuck': oftenIdle(student) && groupOf(student) !== 'leave',
}"
>
{{
board.recentLessons ? `${student.recentAttended}/${board.recentLessons}` : "—"
board.recentLessons
? `${student.recentAttended}/${board.recentLessons}`
: "—"
}}
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</template>
</template>
<n-modal
v-model:show="leaveDialog"
preset="card"
:title="
leaveCandidates.length
? `今天还没交过的 ${leaveCandidates.length} 人里,谁请假了?`
: '今天全班都交过题了'
"
style="width: 640px; max-width: calc(100vw - 32px)"
>
<template v-if="leaveCandidates.length">
<n-text depth="3" class="meta">
勾上不在的人,看板就不算他们。<template
v-if="boardStudents.length > leaveCandidates.length"
>其余
{{ boardStudents.length - leaveCandidates.length }} 人今天交过题,不用标。</template
>
</n-text>
<n-checkbox-group v-model:value="leaveSelected">
<div class="leave-grid">
<n-checkbox
v-for="student in leaveCandidates"
:key="student.userId"
:value="student.username"
:label="nameOf(student)"
class="leave-option"
/>
</div>
</n-checkbox-group>
</template>
<n-text v-else depth="3">请假的人一定是没交过题的,现在没有人可以标。</n-text>
<template #footer>
<n-flex align="center" :wrap="false">
<n-text depth="3" class="meta" style="flex-grow: 1">
已选 {{ leaveSelected.length }} 人 · 只记在这台电脑上,今天有效
</n-text>
<n-button :disabled="!leaveSelected.length" @click="leaveSelected = []">
全部取消
</n-button>
<n-button @click="leaveDialog = false">取消</n-button>
<n-button type="primary" @click="saveLeave">确定</n-button>
</n-flex>
</template>
</n-modal>
</div>
</template>
@@ -525,8 +615,40 @@ function submissionsHref(
text-decoration: underline;
}
.hide-bar {
margin-bottom: 6px;
.leave-button {
max-width: 420px;
}
.leave-names {
overflow: hidden;
text-overflow: ellipsis;
}
.leave-edit {
margin-left: 8px;
text-decoration: underline;
}
.leave-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 10px 8px;
margin-top: 12px;
}
.leave-option {
font-size: 15px;
}
.grid .leave-sep td {
text-align: left;
font-size: 12px;
opacity: 0.6;
padding-top: 12px;
}
.grid .leave-row td {
opacity: 0.45;
}
.board {
@@ -1,73 +0,0 @@
import { onMounted, ref } from "vue"
/**
* 课堂看板的「请假隐藏」:暂时把某个学生从「今天还没交过」里藏起来。
*
* 老师是把看板投在屏幕上盯着看的,没交名单里总有那么几个是请假/转班/学号错了的,
* 一直挂在上面会盖住真正需要盯的人。隐藏是**带过期时间**的(两小时,够一节课),
* 不是永久删除 —— 下节课自动回来,免得有人被无声地漏掉。
*
* 存在 localStorage 而不是后端:这是「这台电脑上这位老师这节课不想看谁」,
* 换个人、换台机器都不该继承。
*
* 原来在提交统计、流程图统计两个弹框里;统计改成回头看的页面之后(2026-09),
* 课上盯人的事归看板,这个也挪了过来。
*
* @param storageKey localStorage 的键
*/
export function useHiddenStudents(storageKey: string) {
/** 隐藏时长:两小时,一节课的量级 */
const HIDE_DURATION = 2 * 60 * 60 * 1000
function load(): Record<string, number> {
try {
return JSON.parse(localStorage.getItem(storageKey) ?? "{}")
} catch {
return {}
}
}
const hiddenStudents = ref<Record<string, number>>(load())
/** 「请假隐藏」开关:打开后名字旁边才出现那个 × */
const hideMode = ref(false)
function save(data: Record<string, number>) {
localStorage.setItem(storageKey, JSON.stringify(data))
}
function hideStudent(username: string) {
hiddenStudents.value = {
...hiddenStudents.value,
[username]: Date.now() + HIDE_DURATION,
}
save(hiddenStudents.value)
}
/** 恢复这几个人。只恢复看板上这个班的,别把别的班藏着的人一起放出来 */
function unhide(usernames: string[]) {
const drop = new Set(usernames)
hiddenStudents.value = Object.fromEntries(
Object.entries(hiddenStudents.value).filter(([name]) => !drop.has(name)),
)
save(hiddenStudents.value)
}
function isHidden(username: string) {
const expiresAt = hiddenStudents.value[username]
return !!expiresAt && expiresAt > Date.now()
}
onMounted(() => {
// 把已经到期的清掉再落一次盘,否则这张表只增不减
const now = Date.now()
const cleaned = Object.fromEntries(
Object.entries(hiddenStudents.value).filter(([, expiresAt]) => expiresAt > now),
)
hiddenStudents.value = cleaned
save(cleaned)
})
// 不导出 hiddenStudents 本身:用的地方要的是「这个人该不该显示」,
// 把那张表递出去只会让判断逻辑又散回组件里
return { hideMode, hideStudent, unhide, isHidden }
}
@@ -0,0 +1,84 @@
import { onMounted, ref } from "vue"
import { parseTime } from "utils/functions"
/**
* 课堂看板的「请假」:老师手动标记今天不在的学生,看板的人数、进度、「还没交过」都不算他们。
*
* **今天有效**(按东八区的日子),第二天自动清掉 —— 请假一般是请一整天,原来按两小时算,
* 连上两节课的第二节中途人又冒出来,得重标一遍。
*
* 存在 localStorage 而不是后端:这是「这台电脑上这位老师今天不想算谁」,
* 换个人、换台机器都不该继承(2026-10 用户定的,不上服务器)。
*
* 原来叫「请假隐藏」(开关 → 名字变成带 × 的标签 → 点 ×),用户说不会用,2026-10 改成
* 看板上一个「请假」按钮勾名单。旧键 `oj_hidden_students` 存的是两小时的过期时刻,
* 口径不同,不迁移,直接删掉。
*
* @param storageKey localStorage 的键
*/
export function useLeaveStudents(storageKey: string) {
/** 用户名 → 标记的那一天(YYYY-MM-DD,东八区) */
type Marks = Record<string, string>
function today() {
return parseTime(new Date(), "YYYY-MM-DD")
}
function load(): Marks {
try {
return JSON.parse(localStorage.getItem(storageKey) ?? "{}")
} catch {
return {}
}
}
function save(data: Marks) {
try {
localStorage.setItem(storageKey, JSON.stringify(data))
} catch {
// 存不下就只在这次打开里有效,不影响看板
}
}
const marks = ref<Marks>(load())
function onLeave(username: string) {
return marks.value[username] === today()
}
/**
* 把 `usernames` 这批人的标记换成 `selected`:在里面但没选的取消,选了的标上。
* 只动这一批(看板上这个班),别的班标着的人不受影响
*/
function setLeave(usernames: string[], selected: string[]) {
const scope = new Set(usernames)
const day = today()
const next = Object.fromEntries(
Object.entries(marks.value).filter(([name]) => !scope.has(name)),
)
for (const name of selected) next[name] = day
marks.value = next
save(next)
}
function cancelLeave(usernames: string[]) {
setLeave(usernames, [])
}
onMounted(() => {
try {
localStorage.removeItem("oj_hidden_students")
} catch {
// 读写不了 localStorage 的环境,没什么可清的
}
// 把不是今天的清掉再落一次盘,否则这张表只增不减
const day = today()
const cleaned = Object.fromEntries(
Object.entries(marks.value).filter(([, marked]) => marked === day),
)
marks.value = cleaned
save(cleaned)
})
return { onLeave, setLeave, cancelLeave }
}