feat(课堂): 老师的课堂看板——布置这节课的题、看全班谁还没开始谁卡住了,布置的题直接出现在学生首页
2025 秋平均每节课有 14 个平时在用的学生一道都没交(三分之一),交了 3 次以上还没过的 平均不到 1 个;而老师原来只能一道题一道题打开统计弹窗看。 - 新表 class_lesson(迁移 0023,仅建表):一个班一天一行,存老师布置的 problem.id 与顺序; day 存东八区日历日文本,created_by CASCADE - GET /classroom/board:这个班今天的题 × 全班学生(通过与否不限今天、今天交了几次、 最后提交时间);不给班级时猜最近两小时提交人数最多的班,不记老师上次选的 - PUT /classroom/lesson:按展示题号布置,对不上的题号点名拒绝,空数组 = 清掉 - /me/class-activity 优先返回老师布置的题(source = teacher),否则照旧推断 - 前端 /classroom:没交过的名单置顶,按 没交过 → 卡住 → 在做 → 做完 排序,15 秒刷新; 投影模式只显示每题完成人数、不出名字;用户菜单给老师加入口 - 学生首页:老师布置的标题写「今天老师布置的题」,没人交时不显示 0/0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7e964fdbdd
commit
80106eaa04
12 files changed
+4992
-69
No files matched your search
@@ -9,6 +9,7 @@ import {
|
||||
type Metrics,
|
||||
type TutorialSummary,
|
||||
type ClassActivity,
|
||||
type ClassBoard,
|
||||
type ClassComparisonResponse,
|
||||
type ClassRankItem,
|
||||
type ClassUserRank,
|
||||
@@ -217,6 +218,16 @@ export function getWeeklyRank(scope: "global" | "class") {
|
||||
return api.get<WeeklyRank>("rankings/weekly", { params: { scope } })
|
||||
}
|
||||
|
||||
/** 课堂看板(老师)。不给班级时后端猜最近两小时在交题的那个班 */
|
||||
export function getClassBoard(className?: string) {
|
||||
return api.get<ClassBoard>("classroom/board", { params: { className } })
|
||||
}
|
||||
|
||||
/** 给这个班布置今天的题,空数组 = 清掉 */
|
||||
export function setClassLesson(className: string, problemDisplayIds: string[]) {
|
||||
return api.put<null>("classroom/lesson", { className, problemDisplayIds })
|
||||
}
|
||||
|
||||
export function getClassActivity() {
|
||||
return api.get<ClassActivity>("me/class-activity")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
<script setup lang="ts">
|
||||
import { getClassBoard, setClassLesson } from "oj/api"
|
||||
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 个。所以排序是
|
||||
* 没交过 → 卡住 → 在做 → 做完,没交过的名单还单独摆在最上面,老师扫一眼就能走过去。
|
||||
*
|
||||
* 班级**不记本地**,默认由后端猜最近两小时在交题的班 —— 理由见 StatisticsPanel.vue
|
||||
* 那段注释(上一节课的班悄悄留在框里,老师看的整个是别人的班)。
|
||||
*/
|
||||
|
||||
/** 同一道题今天交了这么多次还没过,算卡住 */
|
||||
const STUCK_ATTEMPTS = 3
|
||||
const REFRESH_MS = 15_000
|
||||
|
||||
const configStore = useConfigStore()
|
||||
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 = "idle" | "stuck" | "working" | "done"
|
||||
|
||||
function groupOf(student: ClassBoardStudent): Group {
|
||||
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> = { idle: 0, stuck: 1, working: 2, done: 3 }
|
||||
|
||||
function nameOf(student: ClassBoardStudent) {
|
||||
return student.realName || student.username
|
||||
}
|
||||
|
||||
const students = computed(() =>
|
||||
[...(board.value?.students ?? [])].sort(
|
||||
(a, b) =>
|
||||
GROUP_ORDER[groupOf(a)] - GROUP_ORDER[groupOf(b)] ||
|
||||
nameOf(a).localeCompare(nameOf(b), "zh-CN"),
|
||||
),
|
||||
)
|
||||
|
||||
const idle = computed(() => students.value.filter((student) => groupOf(student) === "idle"))
|
||||
const stuckCount = computed(
|
||||
() => students.value.filter((student) => groupOf(student) === "stuck").length,
|
||||
)
|
||||
|
||||
const summary = computed(() => {
|
||||
const all = board.value?.students ?? []
|
||||
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" }
|
||||
> = {
|
||||
idle: { text: "没交过", type: "default" },
|
||||
stuck: { text: "卡住了", type: "error" },
|
||||
working: { text: "在做", type: "info" },
|
||||
done: { text: "做完了", type: "success" },
|
||||
}
|
||||
</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 :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-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"
|
||||
type="warning"
|
||||
:title="`今天还没交过(${idle.length} 人)`"
|
||||
class="section"
|
||||
>
|
||||
{{ idle.map(nameOf).join("、") }}
|
||||
</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="student in students" :key="student.userId">
|
||||
<td class="name">{{ nameOf(student) }}</td>
|
||||
<td>
|
||||
<n-tag
|
||||
size="small"
|
||||
:bordered="false"
|
||||
:type="GROUP_LABEL[groupOf(student)].type"
|
||||
>
|
||||
{{ GROUP_LABEL[groupOf(student)].text }}
|
||||
</n-tag>
|
||||
</td>
|
||||
<td v-for="(cell, i) in student.cells" :key="i" :class="cellClass(cell)">
|
||||
{{ cellText(cell) }}
|
||||
</td>
|
||||
<td class="meta">
|
||||
{{ student.lastSubmitAt ? parseTime(student.lastSubmitAt, "HH:mm") : "—" }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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;
|
||||
}
|
||||
|
||||
.cell-none {
|
||||
opacity: 0.35;
|
||||
}
|
||||
</style>
|
||||
@@ -131,6 +131,8 @@ const classActivityTitle = computed(() => {
|
||||
if (!day) return ""
|
||||
const now = zonedParts(new Date())!
|
||||
const today = `${now.year}-${String(now.month).padStart(2, "0")}-${String(now.day).padStart(2, "0")}`
|
||||
// 老师在课堂看板布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道
|
||||
if (classActivity.value?.source === "teacher") return "今天老师布置的题"
|
||||
if (day === today) return "今天班里在做"
|
||||
const [, month, date] = day.split("-").map(Number)
|
||||
return `${month}月${date}日班里做了`
|
||||
@@ -259,7 +261,8 @@ async function openAnnouncement(item: AnnouncementListItem) {
|
||||
{{ MY_STATUS[item.myStatus].label }}
|
||||
</n-tag>
|
||||
<span class="row-title">{{ item.problemDisplayId }} {{ item.title }}</span>
|
||||
<n-text depth="3" class="row-meta">
|
||||
<!-- 老师刚布置、还没人交的时候不显示「0/0 人通过」 -->
|
||||
<n-text v-if="item.userCount" depth="3" class="row-meta">
|
||||
{{ item.acceptedCount }}/{{ item.userCount }} 人通过
|
||||
</n-text>
|
||||
</router-link>
|
||||
|
||||
Reference in new issue
Block a user