refactor(课堂看板): 布置题号、请假框拆成组件,格子的显示规则挪成纯函数

board.vue 从 1480 行减到 1283 行:
- components/LessonForm.vue:布置这节课的题(顶上一行的小条和刚上课时的大框是同一个组件的
  两种样子,语言默认、推断的题带进框里这些规则跟着走)
- components/LeaveModal.vue:请假框(谁能算请假的规则还在看板,框里只管勾)
- boardCells.ts:格子的字、颜色、悬停说明和「卡住」的次数线,同 errorKind.ts 的做法

纯搬家。看板、选中一道题、请假框、勾了请假、顶上编辑、刚上课的大框(电脑 / 手机)改前改后
截图一致(唯一不同是测试学生的「在线」五分钟后过期变成「5 分钟前在」);顶上一行布置 /
清掉、大框布置、请假确定后「请假 1 人」并排到最下、再打开勾选还在、全部取消都正常。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-19 20:37:13 +08:00
1 parent 871861220e
commit ef7d065419
4 files changed
+356 -245

No files matched your search

@@ -0,0 +1,125 @@
<script setup lang="ts">
import type { ClassBoardStudent } from "utils/types"
/**
* 看板的「请假」框:勾今天不在的人。规则在看板那边(只有今天还没交过的人能算请假、
* 交了题或举手就自动算到,见 board.vue 的 canLeave),这里只管勾。
*/
const props = defineProps<{
/** 能勾的人:今天还没交过的(刚上课时就是全班),按名字排好 */
candidates: ClassBoardStudent[]
/** 打开时已经标着请假的 */
current: string[]
}>()
const emit = defineEmits<{ save: [usernames: string[]] }>()
const show = defineModel<boolean>("show", { required: true })
const selected = ref<string[]>([])
watch(show, (open) => {
if (open) selected.value = [...props.current]
})
/**
* 今天一次都没上过线。机房有两人共用一台电脑的,同桌只有一个登着,所以「谁没来」
* 看今天上没上过线,不看此刻在不在线(见契约 seenAt)
*/
const unseen = (student: ClassBoardStudent | undefined) => !!student && !student.seenAt
/** 今天没上过线的分在前面一堆:请假的人基本在这里面,剩下的是和同桌共用电脑的 */
const sections = computed(() =>
[
{ label: "今天没上线", students: props.candidates.filter(unseen) },
{
label: "今天上过线",
students: props.candidates.filter((student) => !unseen(student)),
},
].filter((section) => section.students.length),
)
function selectAll(list: ClassBoardStudent[]) {
selected.value = [...new Set([...selected.value, ...list.map((student) => student.username)])]
}
function save() {
emit("save", selected.value)
show.value = false
}
</script>
<template>
<n-modal
v-model:show="show"
preset="card"
:title="
candidates.length
? `今天还没交过的 ${candidates.length} 人里,谁请假了?`
: '今天全班都交过题了'
"
style="width: 640px; max-width: calc(100vw - 32px)"
>
<n-checkbox-group v-if="candidates.length" v-model:value="selected">
<div v-for="section in sections" :key="section.label" class="leave-section">
<div v-if="sections.length > 1 || unseen(section.students[0])" class="leave-head">
{{ section.label }} {{ section.students.length }} 人
<n-button
v-if="unseen(section.students[0])"
text
type="primary"
:disabled="section.students.every((student) => selected.includes(student.username))"
@click="selectAll(section.students)"
>
全勾上
</n-button>
</div>
<div class="leave-grid">
<n-checkbox
v-for="student in section.students"
:key="student.userId"
:value="student.username"
:label="student.username"
class="leave-option"
/>
</div>
</div>
</n-checkbox-group>
<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">
已选 {{ selected.length }} 人 · 只记在这台电脑上,今天有效
</n-text>
<n-button :disabled="!selected.length" @click="selected = []">全部取消</n-button>
<n-button @click="show = false">取消</n-button>
<n-button type="primary" @click="save">确定</n-button>
</n-flex>
</template>
</n-modal>
</template>
<style scoped>
.leave-section + .leave-section {
margin-top: 18px;
}
.leave-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
font-weight: 600;
}
.leave-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px 8px;
}
.leave-option {
font-size: 15px;
}
.meta {
font-size: var(--oj-fs-sec);
}
</style>
@@ -0,0 +1,119 @@
<script setup lang="ts">
import type { LessonLanguage } from "@oj2/contract"
import { setClassLesson } from "oj/api"
import { errorMessage } from "utils/api"
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
import type { ClassBoard } from "utils/types"
/**
* 看板上布置「这节课的题」:题号是上课时当场填的。两种样子 ——
* - `inline`:顶上一行里点「改 / 布置」展开的那一小条;
* - `start`:刚上课还没有题时,这页最大的那个框。
*/
const props = defineProps<{
board: ClassBoard
className: string
variant: "inline" | "start"
}>()
const emit = defineEmits<{ saved: []; cancel: [] }>()
const message = useMessage()
/**
* 这份作业用什么语言做,跟着这一次布置走(用户定的:不是给班级定死的设置)。默认是这个班
* 上一次布置时选的;选了之后看板和学生那边的「做完」都只认这个语言交对的
*/
const lessonLanguage = ref<LessonLanguage>("Python")
const LESSON_LANGUAGES: LessonLanguage[] = ["Python", "C", "C++", "SQL"]
const languageOptions = LESSON_LANGUAGES.map((value) => ({
label: LANGUAGE_SHOW_VALUE[value],
value,
}))
const lessonInput = ref("")
const saving = ref(false)
function resetLessonInput() {
lessonInput.value =
props.board.source === "teacher"
? props.board.problems.map((problem) => problem.problemDisplayId).join(" ")
: ""
lessonLanguage.value = props.board.language ?? props.board.lastLanguage ?? "Python"
}
resetLessonInput()
// 推断出来的题也带进框里,老师多半只是确认一下
if (props.variant === "inline" && !lessonInput.value && props.board.problems.length) {
lessonInput.value = props.board.problems.map((problem) => problem.problemDisplayId).join(" ")
}
// 换了班要把输入框换成那个班今天布置的题、布置时选的语言(15 秒一次的刷新不动它们)
watch(() => [props.board.className, props.board.source] as const, resetLessonInput)
async function saveLesson() {
const ids = lessonInput.value.split(/[\s,,、;;]+/).filter(Boolean)
saving.value = true
try {
await setClassLesson(props.className, ids, ids.length ? lessonLanguage.value : null)
message.success(ids.length ? "已布置,学生首页会显示这几道题" : "已清掉")
emit("saved")
} catch (error) {
message.error(errorMessage(error))
} finally {
saving.value = false
}
}
</script>
<template>
<div v-if="variant === 'inline'">
<n-select
v-model:value="lessonLanguage"
:options="languageOptions"
aria-label="用什么语言做"
class="lang-select"
/>
<n-input
v-model:value="lessonInput"
placeholder="这节课的题号,用空格隔开"
clearable
autofocus
@keyup.enter="saveLesson"
/>
<n-button type="primary" :loading="saving" @click="saveLesson">布置</n-button>
<n-button @click="emit('cancel')">取消</n-button>
</div>
<div v-else>
<n-select
v-model:value="lessonLanguage"
:options="languageOptions"
aria-label="用什么语言做"
size="large"
class="lang-select"
/>
<n-input
v-model:value="lessonInput"
size="large"
placeholder="输入题号,用空格隔开"
class="start-input"
@keyup.enter="saveLesson"
/>
<n-button type="primary" size="large" :loading="saving" @click="saveLesson">布置</n-button>
</div>
</template>
<style scoped>
.lang-select {
width: 110px;
flex-shrink: 0;
}
.start-input {
flex: 1 1 0;
font-size: 18px;
}
@media (max-width: 768px) {
.start-input {
flex-basis: 100%;
order: -1;
}
}
</style>