Deploy / deploy (push) Canceled after 0s
老师的提交列表上只剩一个「统计」按钮;什么都没筛时按课列(选「今天」就是原来今日统计看的), 点一行进那节课(班、题、时段都带好),左上「← 全部」回来;只填题号时仍按班级汇总,和题目页 统计页签对得上。数字行加结果条,统计里的「课堂看板」按钮去掉,改成有班正在上课时才引过去。 学生仍是「今日统计」。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
607 lines
20 KiB
Vue
607 lines
20 KiB
Vue
<script setup lang="ts">
|
||
import { getClassBoard, setClassLesson } from "oj/api"
|
||
import { useHiddenStudents } from "shared/composables/hiddenStudents"
|
||
import { useCollabStore } from "shared/store/collab"
|
||
import { useConfigStore } from "shared/store/config"
|
||
import { errorMessage } from "utils/api"
|
||
import { parseTime } from "utils/functions"
|
||
import type { ClassBoard, ClassBoardCell, ClassBoardStudent } from "utils/types"
|
||
|
||
/**
|
||
* 课堂看板(老师):这个班、今天、这节课的几道题 × 全班学生。
|
||
*
|
||
* 重点是「今天还没交过」的那批人,不是「卡住的」:2025 秋平均每节课有 14 个平时在用的
|
||
* 学生一道都没交(占三分之一),交了 3 次以上还没过的平均不到 1 个。所以排序是
|
||
* 没交过 → 卡住 → 在做 → 做完,没交过的名单还单独摆在最上面,老师扫一眼就能走过去。
|
||
*
|
||
* 班级**不记本地**,默认由后端猜最近两小时在交题的班 —— 记本地的话,上一节课的班会
|
||
* 悄悄留在框里,老师看的整个是别人的班。(原来的提交统计弹窗是记一小时的,
|
||
* 那边是老师自己手选的查询;这里后端本来就猜得出,没必要冒这个险。)
|
||
*/
|
||
|
||
/** 同一道题今天交了这么多次还没过,算卡住 */
|
||
const STUCK_ATTEMPTS = 3
|
||
const REFRESH_MS = 15_000
|
||
|
||
const configStore = useConfigStore()
|
||
const collabStore = useCollabStore()
|
||
const message = useMessage()
|
||
|
||
const board = ref<ClassBoard | null>(null)
|
||
const className = ref<string | null>(null)
|
||
const loading = ref(false)
|
||
const updatedAt = ref<Date | null>(null)
|
||
const projector = ref(false)
|
||
|
||
const lessonInput = ref("")
|
||
const saving = ref(false)
|
||
|
||
function classLabel(name: string) {
|
||
return /^\d{3,}$/.test(name) ? `${name.slice(0, 2)}计算机${name.slice(2)}班` : name
|
||
}
|
||
|
||
const classOptions = computed(() => {
|
||
const list = configStore.config?.classList ?? []
|
||
const names =
|
||
board.value?.className && !list.includes(board.value.className)
|
||
? [board.value.className, ...list]
|
||
: list
|
||
return names.map((name) => ({ label: classLabel(name), value: name }))
|
||
})
|
||
|
||
async function load() {
|
||
if (document.hidden) return
|
||
loading.value = true
|
||
try {
|
||
const data = await getClassBoard(className.value ?? undefined)
|
||
board.value = data
|
||
// 第一次由后端猜,之后就固定在这个班上,别每次刷新都换
|
||
className.value = data.className
|
||
updatedAt.value = new Date()
|
||
} catch (error) {
|
||
message.error(errorMessage(error))
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 换了班要把输入框换成那个班今天布置的题
|
||
watch(
|
||
() => [board.value?.className, board.value?.source] as const,
|
||
() => {
|
||
lessonInput.value =
|
||
board.value?.source === "teacher"
|
||
? board.value.problems.map((problem) => problem.problemDisplayId).join(" ")
|
||
: ""
|
||
},
|
||
)
|
||
|
||
function changeClass(value: string) {
|
||
className.value = value
|
||
load()
|
||
}
|
||
|
||
async function saveLesson() {
|
||
if (!className.value) return
|
||
const ids = lessonInput.value.split(/[\s,,、;;]+/).filter(Boolean)
|
||
saving.value = true
|
||
try {
|
||
await setClassLesson(className.value, ids)
|
||
message.success(ids.length ? "已布置,学生首页会显示这几道题" : "已清掉,学生那边改回自动推断")
|
||
await load()
|
||
} catch (error) {
|
||
message.error(errorMessage(error))
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(load)
|
||
const { pause } = useIntervalFn(load, REFRESH_MS)
|
||
onUnmounted(pause)
|
||
|
||
// ---------- 分组与排序 ----------
|
||
|
||
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"
|
||
if (cells.some((cell) => cell.status === "tried" && cell.attempts >= STUCK_ATTEMPTS))
|
||
return "stuck"
|
||
return "working"
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
/**
|
||
* 请假隐藏(原来在「数据统计」弹框里,统计改成回头看之后挪到这儿 —— 盯这节课的是看板)。
|
||
* 请假、转班、学号错了的那几个一直挂在「还没交过」里,会盖住真正要去看的人。
|
||
* 藏两个小时(够一节课),下节课自动回来;只记在这台电脑上,换人换机器都不继承。
|
||
* 键沿用统计弹框那份,老师上午藏的人这里接着藏着。
|
||
*
|
||
* **只藏「还没交过」的人**:一个学生只要今天交过、或者正在举手,就不算请假,照常显示、
|
||
* 照常算进人数。迟到的学生后来交了题、卡住了、举手了,老师得看得见
|
||
*/
|
||
const { hideMode, hideStudent, unhide, isHidden } = useHiddenStudents("oj_hidden_students")
|
||
function hiddenNow(student: ClassBoardStudent) {
|
||
return !student.lastSubmitAt && !helpOf(student) && isHidden(student.username)
|
||
}
|
||
const hiddenStudents = computed(() => (board.value?.students ?? []).filter(hiddenNow))
|
||
function restoreHidden() {
|
||
unhide(hiddenStudents.value.map((student) => student.username))
|
||
}
|
||
|
||
const students = computed(() =>
|
||
[...(board.value?.students ?? [])]
|
||
.filter((student) => !isHidden(student.username))
|
||
.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,
|
||
)
|
||
|
||
const summary = computed(() => {
|
||
// 分母也减掉请假的,不然「做完 30/38」永远到不了头
|
||
const all = students.value
|
||
return (board.value?.problems ?? []).map((problem, i) => ({
|
||
...problem,
|
||
done: all.filter((student) => student.cells[i]?.status === "accepted").length,
|
||
total: all.length,
|
||
}))
|
||
})
|
||
|
||
/** 今天之前就通过了的,显示「之前」而不是一个日期 */
|
||
function cellText(cell: ClassBoardCell) {
|
||
if (cell.status === "accepted") {
|
||
const day = cell.acceptedAt ? parseTime(cell.acceptedAt, "YYYY-MM-DD") : ""
|
||
return day === board.value?.day ? `✓ ${parseTime(cell.acceptedAt!, "HH:mm")}` : "✓ 之前"
|
||
}
|
||
if (cell.status === "tried") return cell.attempts ? `✗ ${cell.attempts} 次` : "✗ 之前"
|
||
return "·"
|
||
}
|
||
|
||
function cellClass(cell: ClassBoardCell) {
|
||
if (cell.status === "accepted") return "cell-done"
|
||
if (cell.status === "tried") return cell.attempts >= STUCK_ATTEMPTS ? "cell-stuck" : "cell-tried"
|
||
return "cell-none"
|
||
}
|
||
|
||
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" },
|
||
done: { text: "做完了", type: "success" },
|
||
}
|
||
|
||
const router = useRouter()
|
||
|
||
/**
|
||
* 点格子 / 名字到提交列表看他今天交的。新标签打开:看板多半正投在屏幕上,别把它顶掉。
|
||
* 用户名按整名匹配(学号互相包含是常态,ks24a1 会混进 ks24a10)。
|
||
*
|
||
* 今天只画了流程图、没写代码的,跳到列表的「流程图」那边 —— 不然这节课整班在画流程图时,
|
||
* 点进去是一片「没有符合条件的提交」
|
||
*/
|
||
function submissionsHref(
|
||
student: ClassBoardStudent,
|
||
problemDisplayId: string | undefined,
|
||
flowchart: boolean,
|
||
) {
|
||
return router.resolve({
|
||
name: "submissions",
|
||
query: {
|
||
username: student.username,
|
||
exactUsername: "1",
|
||
today: "1",
|
||
...(flowchart ? { language: "Flowchart" } : {}),
|
||
...(problemDisplayId ? { problem: problemDisplayId } : {}),
|
||
},
|
||
}).href
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="board" :class="{ projector }">
|
||
<n-flex align="center" justify="space-between" class="toolbar">
|
||
<n-flex align="center">
|
||
<h2 class="title">课堂看板</h2>
|
||
<n-select
|
||
:value="className"
|
||
:options="classOptions"
|
||
placeholder="选择班级"
|
||
filterable
|
||
style="width: 180px"
|
||
@update:value="changeClass"
|
||
/>
|
||
</n-flex>
|
||
<n-flex align="center">
|
||
<n-text depth="3" class="meta">
|
||
每 15 秒刷新<template v-if="updatedAt">
|
||
· {{ parseTime(updatedAt, "HH:mm:ss") }}</template
|
||
>
|
||
</n-text>
|
||
<n-button :loading="loading" @click="load">刷新</n-button>
|
||
<!-- 看板只管今天;以前几节课的、任意时间段的去统计(带上这个班) -->
|
||
<n-button
|
||
v-if="!projector"
|
||
@click="router.push({ name: 'statistics', query: className ? { className } : {} })"
|
||
>
|
||
回头看 →
|
||
</n-button>
|
||
<n-button :type="projector ? 'primary' : 'default'" @click="projector = !projector">
|
||
{{ projector ? "退出投影" : "投影模式" }}
|
||
</n-button>
|
||
</n-flex>
|
||
</n-flex>
|
||
|
||
<n-empty
|
||
v-if="board && !board.className"
|
||
description="最近两小时没有哪个班在交题,先在上面选一个班"
|
||
style="margin: 60px 0"
|
||
/>
|
||
|
||
<template v-else-if="board">
|
||
<!-- 布置题目:投影时收起来 -->
|
||
<n-card v-if="!projector" size="small" class="section">
|
||
<n-flex align="center" :wrap="false">
|
||
<n-text strong style="flex-shrink: 0">这节课的题</n-text>
|
||
<n-input
|
||
v-model:value="lessonInput"
|
||
placeholder="输入题号,用空格隔开,比如 8019 8020 8021"
|
||
clearable
|
||
@keyup.enter="saveLesson"
|
||
/>
|
||
<n-button type="primary" :loading="saving" @click="saveLesson">布置</n-button>
|
||
</n-flex>
|
||
<n-text depth="3" class="meta">
|
||
<template v-if="board.source === 'teacher'">
|
||
学生首页的「班里在做」显示的就是这几道。清空再点布置就改回自动推断。
|
||
</template>
|
||
<template v-else-if="board.source === 'inferred'">
|
||
还没布置。下面这几道是按今天的提交记录推断的(同班 5 人以上做过的题)。
|
||
</template>
|
||
<template v-else>
|
||
布置之后,学生打开首页就能看到这几道题,没听清题号的也能找到。
|
||
</template>
|
||
</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="还没有题:在上面输入这节课的题号"
|
||
style="margin: 40px 0"
|
||
/>
|
||
|
||
<template v-else>
|
||
<!-- 每道题的完成度。投影模式下只剩这一块,不出现名字 -->
|
||
<div class="summary">
|
||
<div v-for="problem in summary" :key="problem.problemId" class="summary-item">
|
||
<div class="summary-title">{{ problem.problemDisplayId }} {{ problem.title }}</div>
|
||
<n-progress
|
||
type="line"
|
||
status="success"
|
||
:percentage="problem.total ? Math.round((problem.done / problem.total) * 100) : 0"
|
||
:show-indicator="false"
|
||
:height="projector ? 28 : 10"
|
||
/>
|
||
<div class="summary-count">{{ problem.done }} / {{ problem.total }} 人做完</div>
|
||
</div>
|
||
</div>
|
||
|
||
<template v-if="!projector">
|
||
<n-alert
|
||
v-if="idle.length || hiddenStudents.length"
|
||
type="warning"
|
||
:title="idle.length ? `今天还没交过(${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>
|
||
<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>
|
||
</template>
|
||
</n-alert>
|
||
|
||
<n-text v-if="stuckCount" type="error" class="meta">
|
||
有 {{ stuckCount }} 人卡住了(同一道题今天交了
|
||
{{ STUCK_ATTEMPTS }} 次以上还没过),有题的话排在表格最前面
|
||
</n-text>
|
||
|
||
<div class="table-wrap">
|
||
<table class="grid">
|
||
<thead>
|
||
<tr>
|
||
<th class="name">学生</th>
|
||
<th>状态</th>
|
||
<th v-for="problem in board.problems" :key="problem.problemId">
|
||
<router-link :to="`/problem/${problem.problemDisplayId}`" class="plain">
|
||
{{ problem.problemDisplayId }}
|
||
</router-link>
|
||
</th>
|
||
<th>最后提交</th>
|
||
<th>最近 {{ board.recentLessons }} 节</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="student in students" :key="student.userId">
|
||
<td class="name">
|
||
<a
|
||
v-if="student.codeToday || student.drawnToday"
|
||
class="cell-link"
|
||
:href="submissionsHref(student, undefined, !student.codeToday)"
|
||
target="_blank"
|
||
:title="`看 ${nameOf(student)} 今天交的全部`"
|
||
>
|
||
{{ nameOf(student) }}
|
||
</a>
|
||
<template v-else>{{ nameOf(student) }}</template>
|
||
</td>
|
||
<td>
|
||
<n-tag
|
||
size="small"
|
||
:bordered="false"
|
||
:type="GROUP_LABEL[groupOf(student)].type"
|
||
:class="{ 'help-tag': helpOf(student) }"
|
||
@click="helpOf(student) && (collabStore.helpPanelOpen = true)"
|
||
>
|
||
{{
|
||
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)">
|
||
<a
|
||
v-if="cell.attempts"
|
||
class="cell-link"
|
||
:href="
|
||
submissionsHref(
|
||
student,
|
||
board.problems[i]!.problemDisplayId,
|
||
!cell.codeAttempts,
|
||
)
|
||
"
|
||
target="_blank"
|
||
:title="
|
||
cell.codeAttempts
|
||
? `看 ${nameOf(student)} 今天在这道题上交的 ${cell.codeAttempts} 次代码`
|
||
: `看 ${nameOf(student)} 今天在这道题上画的 ${cell.flowchartAttempts} 张流程图`
|
||
"
|
||
>
|
||
{{ cellText(cell) }}
|
||
</a>
|
||
<template v-else>{{ cellText(cell) }}</template>
|
||
</td>
|
||
<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>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 格子、名字能点进提交列表,但别长得像一片蓝色链接 —— 投影上一眼看的是颜色 */
|
||
.cell-link {
|
||
color: inherit;
|
||
text-decoration: none;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.cell-link:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.hide-bar {
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.board {
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.toolbar {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.title {
|
||
margin: 0;
|
||
font-size: 20px;
|
||
}
|
||
|
||
.section {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.meta {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.summary {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||
gap: 12px 24px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.summary-title {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.summary-count {
|
||
font-size: 13px;
|
||
opacity: 0.75;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.projector .summary {
|
||
grid-template-columns: 1fr;
|
||
gap: 28px;
|
||
font-size: 28px;
|
||
}
|
||
|
||
.projector .summary-count {
|
||
font-size: 24px;
|
||
}
|
||
|
||
.table-wrap {
|
||
overflow-x: auto;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.grid {
|
||
border-collapse: collapse;
|
||
width: 100%;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.grid th,
|
||
.grid td {
|
||
padding: 6px 10px;
|
||
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
||
text-align: center;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.grid th {
|
||
font-weight: 500;
|
||
opacity: 0.8;
|
||
}
|
||
|
||
.grid .name {
|
||
text-align: left;
|
||
}
|
||
|
||
.plain {
|
||
color: inherit;
|
||
}
|
||
|
||
.cell-done {
|
||
color: #18a058;
|
||
}
|
||
|
||
.cell-tried {
|
||
color: #f0a020;
|
||
}
|
||
|
||
.cell-stuck {
|
||
color: #d03050;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.help-tag {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.cell-none {
|
||
opacity: 0.35;
|
||
}
|
||
</style>
|