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:
1 parent
871861220e
commit
ef7d065419
4 files changed
+356
-245
No files matched your search
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { getClassBoard, setClassLesson } from "oj/api"
|
||||
import { getClassBoard } from "oj/api"
|
||||
import { useTone } from "shared/composables/tone"
|
||||
import { groupClassOptions } from "utils/classes"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
@@ -9,13 +9,20 @@ import { useLeaveStudents } from "shared/composables/leaveStudents"
|
||||
import { useTeacherHelp } from "shared/composables/teacherHelp"
|
||||
import { useCollabStore } from "shared/store/collab"
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import type { LessonLanguage } from "@oj2/contract"
|
||||
import { errorMessage } from "utils/api"
|
||||
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { ClassBoard, ClassBoardCell, ClassBoardProblem, ClassBoardStudent } from "utils/types"
|
||||
import ExplainModal, { type ExplainPerson } from "./components/ExplainModal.vue"
|
||||
import LeaveModal from "./components/LeaveModal.vue"
|
||||
import LessonForm from "./components/LessonForm.vue"
|
||||
import { errorKind, type ErrorKind } from "utils/errorKind"
|
||||
import {
|
||||
cellStyle as cellStyleOf,
|
||||
cellText,
|
||||
cellTitle as cellTitleOf,
|
||||
STUCK_ATTEMPTS,
|
||||
} from "./boardCells"
|
||||
|
||||
/**
|
||||
* 课堂看板(老师,设计稿「课堂看板重设计」A+B):这个班、今天、这节课的几道题 × 全班学生。
|
||||
@@ -31,8 +38,6 @@ import { errorKind, type ErrorKind } from "utils/errorKind"
|
||||
* 悄悄留在框里,老师看的整个是别人的班。
|
||||
*/
|
||||
|
||||
/** 同一道题今天交了这么多次还没过,算卡住 */
|
||||
const STUCK_ATTEMPTS = 3
|
||||
const REFRESH_MS = 15_000
|
||||
|
||||
const configStore = useConfigStore()
|
||||
@@ -110,61 +115,15 @@ onUnmounted(pause)
|
||||
|
||||
// ---------- 这节课的题:上课时当场填 ----------
|
||||
|
||||
/**
|
||||
* 这份作业用什么语言做,跟着这一次布置走(用户定的:不是给班级定死的设置)。默认是这个班
|
||||
* 上一次布置时选的;选了之后看板和学生那边的「做完」都只认这个语言交对的
|
||||
*/
|
||||
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 editing = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
/** 还没有题(没布置、今天也推断不出来):填题号的框就是这页最大的东西 */
|
||||
const needLesson = computed(() => !!board.value?.className && !board.value.problems.length)
|
||||
|
||||
// 换了班要把输入框换成那个班今天布置的题、布置时选的语言(15 秒一次的刷新不动它们)
|
||||
watch(
|
||||
() => [board.value?.className, board.value?.source] as const,
|
||||
() => resetLessonInput(),
|
||||
)
|
||||
|
||||
function resetLessonInput() {
|
||||
lessonInput.value =
|
||||
board.value?.source === "teacher"
|
||||
? board.value.problems.map((problem) => problem.problemDisplayId).join(" ")
|
||||
: ""
|
||||
lessonLanguage.value = board.value?.language ?? board.value?.lastLanguage ?? "Python"
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
resetLessonInput()
|
||||
// 推断出来的题也带进框里,老师多半只是确认一下
|
||||
if (!lessonInput.value && board.value?.problems.length) {
|
||||
lessonInput.value = board.value.problems.map((problem) => problem.problemDisplayId).join(" ")
|
||||
}
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
async function saveLesson() {
|
||||
if (!className.value) return
|
||||
const ids = lessonInput.value.split(/[\s,,、;;]+/).filter(Boolean)
|
||||
saving.value = true
|
||||
try {
|
||||
await setClassLesson(className.value, ids, ids.length ? lessonLanguage.value : null)
|
||||
message.success(ids.length ? "已布置,学生首页会显示这几道题" : "已清掉")
|
||||
editing.value = false
|
||||
selectedId.value = null
|
||||
await load()
|
||||
} catch (error) {
|
||||
message.error(errorMessage(error))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
async function lessonSaved() {
|
||||
editing.value = false
|
||||
selectedId.value = null
|
||||
await load()
|
||||
}
|
||||
|
||||
// ---------- 分组与排序 ----------
|
||||
@@ -265,32 +224,11 @@ watchEffect(() => {
|
||||
const leaveDialog = ref(false)
|
||||
/** 弹框里能勾的:今天还没交过的(刚上课时就是全班),按名字排 */
|
||||
const leaveCandidates = computed(() => boardStudents.value.filter(canLeave).sort(byName))
|
||||
/** 今天没上过线的分在前面一堆:请假的人基本在这里面,剩下的是和同桌共用电脑的 */
|
||||
const leaveSections = computed(() =>
|
||||
[
|
||||
{ label: "今天没上线", students: leaveCandidates.value.filter(unseen) },
|
||||
{
|
||||
label: "今天上过线",
|
||||
students: leaveCandidates.value.filter((student) => !unseen(student)),
|
||||
},
|
||||
].filter((section) => section.students.length),
|
||||
)
|
||||
const leaveSelected = ref<string[]>([])
|
||||
function selectAll(list: ClassBoardStudent[]) {
|
||||
leaveSelected.value = [
|
||||
...new Set([...leaveSelected.value, ...list.map((student) => student.username)]),
|
||||
]
|
||||
}
|
||||
function openLeaveDialog() {
|
||||
leaveSelected.value = leaveStudents.value.map((student) => student.username)
|
||||
leaveDialog.value = true
|
||||
}
|
||||
function saveLeave() {
|
||||
function saveLeave(selected: string[]) {
|
||||
setLeave(
|
||||
boardStudents.value.map((student) => student.username),
|
||||
leaveSelected.value,
|
||||
selected,
|
||||
)
|
||||
leaveDialog.value = false
|
||||
}
|
||||
|
||||
function byName(a: ClassBoardStudent, b: ClassBoardStudent) {
|
||||
@@ -429,46 +367,16 @@ function openExplain(problemIndex: number, userId: number) {
|
||||
explainShow.value = true
|
||||
}
|
||||
|
||||
// ---------- 格子 ----------
|
||||
// ---------- 格子(显示规则在 boardCells.ts) ----------
|
||||
|
||||
/** 今天之前就通过了的,颜色淡一档 */
|
||||
function acceptedToday(cell: ClassBoardCell) {
|
||||
return !!cell.acceptedAt && parseTime(cell.acceptedAt, "YYYY-MM-DD") === board.value?.day
|
||||
}
|
||||
|
||||
function cellText(cell: ClassBoardCell) {
|
||||
if (cell.status === "accepted") return "✓"
|
||||
if (cell.status === "tried") return cell.attempts ? `✗ ${cell.attempts}` : "✗"
|
||||
return ""
|
||||
}
|
||||
|
||||
function cellStyle(cell: ClassBoardCell) {
|
||||
if (cell.status === "accepted") {
|
||||
const t = tone("success")
|
||||
return acceptedToday(cell)
|
||||
? { background: t.background, color: t.color }
|
||||
: { background: t.background, color: t.color, opacity: 0.5 }
|
||||
}
|
||||
if (cell.status === "tried") {
|
||||
if (cell.attempts >= STUCK_ATTEMPTS) {
|
||||
return { background: theme.value.errorColor, color: "#ffffff" }
|
||||
}
|
||||
const t = tone("warning")
|
||||
return { background: t.background, color: t.color }
|
||||
}
|
||||
return { background: theme.value.actionColor }
|
||||
}
|
||||
|
||||
function cellTitle(cell: ClassBoardCell, student: ClassBoardStudent) {
|
||||
if (cell.status === "accepted") {
|
||||
return acceptedToday(cell) ? `${parseTime(cell.acceptedAt!, "HH:mm")} 做对` : "以前就做对了"
|
||||
}
|
||||
if (cell.status === "tried") {
|
||||
if (cell.latest) return `讲讲 ${student.username} 这道题的错`
|
||||
return cell.attempts ? `今天画了 ${cell.flowchartAttempts} 张流程图` : "以前交过,没做对"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
const cellColors = computed(() => ({
|
||||
tone,
|
||||
errorColor: theme.value.errorColor,
|
||||
actionColor: theme.value.actionColor,
|
||||
}))
|
||||
const cellStyle = (cell: ClassBoardCell) => cellStyleOf(cell, board.value?.day, cellColors.value)
|
||||
const cellTitle = (cell: ClassBoardCell, student: ClassBoardStudent) =>
|
||||
cellTitleOf(cell, student, board.value?.day)
|
||||
|
||||
/**
|
||||
* 点格子 / 名字到提交列表看他今天交的。新标签打开:看板一般开着不关,别把它顶掉。
|
||||
@@ -521,23 +429,15 @@ function lastText(student: ClassBoardStudent) {
|
||||
/>
|
||||
|
||||
<!-- 这节课的题:当场填,填完收成一排小块 -->
|
||||
<div v-if="editing" class="lesson-edit">
|
||||
<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="editing = false">取消</n-button>
|
||||
</div>
|
||||
<LessonForm
|
||||
v-if="editing && board && className"
|
||||
:board="board"
|
||||
:class-name="className"
|
||||
variant="inline"
|
||||
class="lesson-edit"
|
||||
@saved="lessonSaved"
|
||||
@cancel="editing = false"
|
||||
/>
|
||||
<div v-else-if="board?.problems.length" class="lesson">
|
||||
<span class="lesson-label">{{ board.source === "teacher" ? "这节课" : "今天在做" }}</span>
|
||||
<span v-if="board.language" class="chip lang">{{
|
||||
@@ -549,7 +449,7 @@ function lastText(student: ClassBoardStudent) {
|
||||
<span v-if="board.assignedAt" class="assigned">
|
||||
{{ parseTime(board.assignedAt, "HH:mm") }} 布置
|
||||
</span>
|
||||
<n-button text class="edit" @click="startEdit">
|
||||
<n-button text class="edit" @click="editing = true">
|
||||
<template #icon><Icon icon="ph:pencil-simple" /></template>
|
||||
{{ board.source === "teacher" ? "改" : "布置" }}
|
||||
</n-button>
|
||||
@@ -560,7 +460,7 @@ function lastText(student: ClassBoardStudent) {
|
||||
v-if="board?.className"
|
||||
:type="leaveStudents.length ? 'warning' : 'default'"
|
||||
:secondary="!!leaveStudents.length"
|
||||
@click="openLeaveDialog"
|
||||
@click="leaveDialog = true"
|
||||
>
|
||||
{{ leaveStudents.length ? `请假 ${leaveStudents.length} 人` : "请假" }}
|
||||
</n-button>
|
||||
@@ -580,25 +480,14 @@ function lastText(student: ClassBoardStudent) {
|
||||
<!-- 刚上课还没有题:填题号的框是这页最大的东西 -->
|
||||
<section v-if="needLesson" class="start">
|
||||
<div class="start-title">这节课做哪几道题?</div>
|
||||
<div class="start-row">
|
||||
<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>
|
||||
<LessonForm
|
||||
v-if="className"
|
||||
:board="board"
|
||||
:class-name="className"
|
||||
variant="start"
|
||||
class="start-row"
|
||||
@saved="lessonSaved"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<template v-else>
|
||||
@@ -834,57 +723,12 @@ function lastText(student: ClassBoardStudent) {
|
||||
:people="explainPeople"
|
||||
/>
|
||||
|
||||
<n-modal
|
||||
<LeaveModal
|
||||
v-model:show="leaveDialog"
|
||||
preset="card"
|
||||
:title="
|
||||
leaveCandidates.length
|
||||
? `今天还没交过的 ${leaveCandidates.length} 人里,谁请假了?`
|
||||
: '今天全班都交过题了'
|
||||
"
|
||||
style="width: 640px; max-width: calc(100vw - 32px)"
|
||||
>
|
||||
<n-checkbox-group v-if="leaveCandidates.length" v-model:value="leaveSelected">
|
||||
<div v-for="section in leaveSections" :key="section.label" class="leave-section">
|
||||
<div v-if="leaveSections.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) => leaveSelected.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">
|
||||
已选 {{ 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>
|
||||
:candidates="leaveCandidates"
|
||||
:current="leaveStudents.map((student) => student.username)"
|
||||
@save="saveLeave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -924,11 +768,6 @@ function lastText(student: ClassBoardStudent) {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.lang-select {
|
||||
width: 110px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lesson,
|
||||
.lesson-edit {
|
||||
display: flex;
|
||||
@@ -1004,11 +843,6 @@ function lastText(student: ClassBoardStudent) {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.start-input {
|
||||
flex: 1 1 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ---------- 题卡片 ---------- */
|
||||
|
||||
.cards {
|
||||
@@ -1396,32 +1230,6 @@ a.cell {
|
||||
color: v-bind("tone('info').color");
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* ---------- 手机 ---------- */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -1459,11 +1267,6 @@ a.cell {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.start-input {
|
||||
flex-basis: 100%;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.grid .name {
|
||||
padding-left: 4px;
|
||||
padding-right: 8px;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Tone } from "shared/composables/tone"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { ClassBoardCell, ClassBoardStudent } from "utils/types"
|
||||
|
||||
/**
|
||||
* 课堂看板的格子:一个学生 × 一道题。显示什么字、什么颜色、悬停说什么,都是纯函数,
|
||||
* 和 errorKind.ts 一样单放一个文件(看板组件只管排版和交互)。
|
||||
*/
|
||||
|
||||
/** 同一道题今天交了这么多次还没过,算卡住 */
|
||||
export const STUCK_ATTEMPTS = 3
|
||||
|
||||
/** 今天之前就通过了的,颜色淡一档。`day` 是看板的「今天」(东八区日期) */
|
||||
export function acceptedToday(cell: ClassBoardCell, day: string | undefined) {
|
||||
return !!cell.acceptedAt && parseTime(cell.acceptedAt, "YYYY-MM-DD") === day
|
||||
}
|
||||
|
||||
export function cellText(cell: ClassBoardCell) {
|
||||
if (cell.status === "accepted") return "✓"
|
||||
if (cell.status === "tried") return cell.attempts ? `✗ ${cell.attempts}` : "✗"
|
||||
return ""
|
||||
}
|
||||
|
||||
export function cellStyle(
|
||||
cell: ClassBoardCell,
|
||||
day: string | undefined,
|
||||
colors: {
|
||||
tone: (kind: Tone) => { color: string; background: string }
|
||||
errorColor: string
|
||||
actionColor: string
|
||||
},
|
||||
) {
|
||||
if (cell.status === "accepted") {
|
||||
const t = colors.tone("success")
|
||||
return acceptedToday(cell, day)
|
||||
? { background: t.background, color: t.color }
|
||||
: { background: t.background, color: t.color, opacity: 0.5 }
|
||||
}
|
||||
if (cell.status === "tried") {
|
||||
if (cell.attempts >= STUCK_ATTEMPTS) {
|
||||
return { background: colors.errorColor, color: "#ffffff" }
|
||||
}
|
||||
const t = colors.tone("warning")
|
||||
return { background: t.background, color: t.color }
|
||||
}
|
||||
return { background: colors.actionColor }
|
||||
}
|
||||
|
||||
export function cellTitle(
|
||||
cell: ClassBoardCell,
|
||||
student: ClassBoardStudent,
|
||||
day: string | undefined,
|
||||
) {
|
||||
if (cell.status === "accepted") {
|
||||
return acceptedToday(cell, day)
|
||||
? `${parseTime(cell.acceptedAt!, "HH:mm")} 做对`
|
||||
: "以前就做对了"
|
||||
}
|
||||
if (cell.status === "tried") {
|
||||
if (cell.latest) return `讲讲 ${student.username} 这道题的错`
|
||||
return cell.attempts ? `今天画了 ${cell.flowchartAttempts} 张流程图` : "以前交过,没做对"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user