feat(布置作业): 能按知识点点选题目;一个班一天能布置几节课;数据统计认老师布置的课;后台能管布置过的课
Deploy / deploy (push) Canceled after 0s

- 课堂看板「选题」弹框:左边知识点(加必会题),右边搜题号/题名、点选,按点的先后排;
  只列这次作业的语言能交的题(/problems、/problem-tags 加 ?language=)
- 一天多节课:0040 拿掉 class_lesson (class_name, day) 唯一约束,每次布置一行;
  看板「改」只改当前一节,加「新的一节」;这一节布置超过 100 分钟时先问这节课做哪几道题。
  不带 lessonId/newLesson 的老请求照旧改今天最后一节
- 数据统计「一行一节课」:布置过的课以布置为准(题目和顺序照布置、做完按全班算、标「布置」),
  这些提交不再参与推断切段,也不算零散;详情页「做完」注明是这一天/这节课/这段时间
- 后台「课堂作业」:一节一行,改(班级/日子/时刻/语言/题目)、看统计、删除、补一节;
  看板加「布置过的课」入口。题号校验和「做完」口径抽到 services/lesson.ts,三处共用

⚠️ 机房站点要和服务器一起升级:旧代码布置时 ON CONFLICT (class_name, day) 找不到约束会报错

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-02 20:41:25 +08:00
1 parent 0916015405
commit b344bd1dd6
26 files changed
+5685 -99

No files matched your search

+6 -2
View File
@@ -260,12 +260,16 @@ export function getClassLesson(className?: string) {
return api.get<ClassLesson>('classroom/lesson', { params: { className } })
}
/** 给这个班布置今天的题,空数组 = 清掉 */
export function setClassLesson(className: string, problemDisplayIds: string[], language: LessonLanguage | null) {
/**
* 给这个班布置今天的一节课,空数组 = 清掉这一节。`lessonId` 为 null 是今天新开一节
* (一个班一天可以上几节内容不同的课),否则改那一节
*/
export function setClassLesson(className: string, problemDisplayIds: string[], language: LessonLanguage | null, lessonId: number | null) {
return api.put<null>('classroom/lesson', {
className,
problemDisplayIds,
language,
...(lessonId === null ? { newLesson: true } : { lessonId }),
} satisfies ClassLessonRequest)
}
+54 -7
View File
@@ -15,6 +15,8 @@ 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 AdminLink from 'shared/components/AdminLink.vue'
import { useBreakpoints } from 'shared/composables/breakpoints'
import LessonForm from './components/LessonForm.vue'
import TrailPanel from './components/TrailPanel.vue'
import { errorKind, type ErrorKind } from 'utils/errorKind'
@@ -41,6 +43,7 @@ const collabStore = useCollabStore()
const message = useMessage()
const theme = useThemeVars()
const tone = useTone()
const { isDesktop } = useBreakpoints()
const router = useRouter()
const route = useRoute()
const { help, peek } = useTeacherHelp()
@@ -111,13 +114,34 @@ onUnmounted(pause)
// ---------- 这节课的题:上课时当场填 ----------
const editing = ref(false)
/** 顶上那条布置框开着没有:edit = 改这一节,new = 今天再开一节 */
const editing = ref<false | 'edit' | 'new'>(false)
/** 还没有题(没布置、今天也推断不出来):填题号的框就是这页最大的东西 */
const needLesson = computed(() => !!board.value?.className && !board.value.problems.length)
/**
* 一个班一天可以上几节内容不同的课。打开看板(或换到这个班)时,这一节已经布置了这么久,
* 多半是上一节课的 —— 先问这节课做哪几道题,底下能点回去接着看上一节。
* 只在第一次看到这一节时判一次:老师一直开着看板时,15 秒一刷不会把表突然换掉
*/
const STALE_LESSON_MINUTES = 100
const staleLessonId = ref<number | null>(null)
const seenLessonIds = new Set<number>()
watch(
() => board.value?.lessonId ?? null,
(lessonId) => {
if (lessonId === null || seenLessonIds.has(lessonId)) return
seenLessonIds.add(lessonId)
const assignedAt = board.value?.assignedAt
if (assignedAt && Date.now() - Date.parse(assignedAt) > STALE_LESSON_MINUTES * 60_000) staleLessonId.value = lessonId
},
)
const staleLesson = computed(() => board.value?.lessonId != null && board.value.lessonId === staleLessonId.value)
/** 还没有题(没布置、今天也推断不出来),或者布置的是上一节课的:填题号的框就是这页最大的东西 */
const needLesson = computed(() => !!board.value?.className && (!board.value.problems.length || staleLesson.value))
async function lessonSaved() {
editing.value = false
staleLessonId.value = null
selectedId.value = null
await load()
}
@@ -434,28 +458,36 @@ function lastText(student: ClassBoardStudent) {
v-if="editing && board && className"
:board="board"
:class-name="className"
:lesson-id="editing === 'edit' ? board.lessonId : null"
variant="inline"
class="lesson-edit"
@saved="lessonSaved"
@cancel="editing = false"
/>
<div v-else-if="board?.problems.length" class="lesson">
<div v-else-if="board?.problems.length && !needLesson" class="lesson">
<span class="lesson-label">{{ board.source === 'teacher' ? '这节课' : '今天在做' }}</span>
<span v-if="board.language" class="chip lang">{{ LANGUAGE_SHOW_VALUE[board.language] }}</span>
<span v-for="problem in board.problems" :key="problem.problemId" class="chip">
{{ problem.problemDisplayId }}
</span>
<span v-if="board.assignedAt" class="assigned"> {{ parseTime(board.assignedAt, 'HH:mm') }} {{ board.assignedBy }} 布置 </span>
<n-button text type="primary" class="edit" @click="editing = true">
<n-button text type="primary" class="edit" @click="editing = 'edit'">
<template #icon><Icon icon="ph:pencil-simple" /></template>
{{ board.source === 'teacher' ? '改' : '布置' }}
</n-button>
<!-- 一天上几节内容不同的课:下一节另开一份,别把上一节的改掉(统计按布置切课) -->
<n-button v-if="board.source === 'teacher'" text type="primary" class="edit" @click="editing = 'new'">
<template #icon><Icon icon="ph:plus" /></template>
新的一节
</n-button>
</div>
<div class="spacer"></div>
<n-button v-if="board?.className" :type="leaveStudents.length ? 'warning' : 'default'" :secondary="!!leaveStudents.length" @click="leaveDialog = true">
{{ leaveStudents.length ? `请假 ${leaveStudents.length} 人` : '请假' }}
</n-button>
<!-- 布置过的每一节课:改错了班、隔天想起来补,去后台 -->
<AdminLink v-if="isDesktop" :to="{ name: 'admin lesson list', query: className ? { className } : {} }">布置过的课</AdminLink>
<!-- 看板只管今天;以前几节课的、任意时间段的去统计(带上这个班) -->
<n-button @click="router.push({ name: 'statistics', query: className ? { className } : {} })"> 回头看 → </n-button>
</div>
@@ -466,7 +498,12 @@ function lastText(student: ClassBoardStudent) {
<!-- 刚上课还没有题:填题号的框是这页最大的东西 -->
<section v-if="needLesson" class="start">
<div class="start-title">这节课做哪几道题?</div>
<LessonForm v-if="className" :board="board" :class-name="className" variant="start" class="start-row" @saved="lessonSaved" />
<LessonForm v-if="className" :board="board" :class-name="className" :lesson-id="null" variant="start" class="start-row" @saved="lessonSaved" />
<div v-if="staleLesson" class="earlier">
<span>上一节 {{ board.assignedAt ? parseTime(board.assignedAt, 'HH:mm') : '' }} {{ board.assignedBy }} 布置</span>
<span v-for="problem in board.problems" :key="problem.problemId" class="chip">{{ problem.problemDisplayId }}</span>
<n-button text type="primary" class="edit" @click="staleLessonId = null">接着看这一节</n-button>
</div>
</section>
<template v-else>
@@ -557,7 +594,8 @@ function lastText(student: ClassBoardStudent) {
</section>
</template>
<div class="table-wrap">
<!-- 上一节的表先不摆:一整列都是上一节的题,和上面「这节课做哪几道题」对不上 -->
<div v-if="!staleLesson" class="table-wrap">
<table class="grid">
<thead>
<tr>
@@ -815,6 +853,15 @@ function lastText(student: ClassBoardStudent) {
gap: 10px;
}
.earlier {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 14px;
color: v-bind('theme.textColor3');
}
/* ---------- 题卡片 ---------- */
.cards {
@@ -4,16 +4,21 @@ import { setClassLesson } from 'oj/api'
import { errorMessage } from 'utils/api'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
import type { ClassBoard } from 'utils/types'
import { Icon } from '@iconify/vue'
import ProblemPicker from './ProblemPicker.vue'
/**
* 看板上布置「这节课的题」:题号是上课时当场填的。两种样子 ——
* - `inline`:顶上一行里点「改 / 布置」展开的那一小条;
* - `inline`:顶上一行里点「改 / 新的一节 / 布置」展开的那一小条;
* - `start`:刚上课还没有题时,这页最大的那个框。
*
* 一个班一天可以上几节内容不同的课:`lessonId` 是要改的那一节,null = 今天新开一节
*/
const props = defineProps<{
board: ClassBoard
className: string
variant: 'inline' | 'start'
lessonId: number | null
}>()
const emit = defineEmits<{ saved: []; cancel: [] }>()
const message = useMessage()
@@ -31,27 +36,35 @@ const languageOptions = LESSON_LANGUAGES.map((value) => ({
const lessonInput = ref('')
const saving = ref(false)
/** 改的是老师布置的这一节:框里是它的题;新开一节是空的 */
const editingPlanned = computed(() => props.lessonId !== null && props.lessonId === props.board.lessonId)
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'
lessonInput.value = editingPlanned.value ? props.board.problems.map((problem) => problem.problemDisplayId).join(' ') : ''
lessonLanguage.value = (editingPlanned.value ? props.board.language : null) ?? props.board.lastLanguage ?? 'Python'
}
resetLessonInput()
// 推断出来的题也带进框里,老师多半只是确认一下
if (props.variant === 'inline' && !lessonInput.value && props.board.problems.length) {
if (props.variant === 'inline' && props.board.source === 'inferred' && !lessonInput.value && props.board.problems.length) {
lessonInput.value = props.board.problems.map((problem) => problem.problemDisplayId).join(' ')
}
// 换了班要把输入框换成那个班今天布置的题、布置时选的语言(15 秒一次的刷新不动它们)。
// 必须写成两个来源:一个 getter 返回数组的话,每次刷新整个 board 换新,数组也是新的,
// 换了班、换了一节要把输入框换成那一节的题、布置时选的语言(15 秒一次的刷新不动它们)。
// 必须写成几个单值来源:一个 getter 返回数组的话,每次刷新整个 board 换新,数组也是新的,
// 回调次次都跑,老师打到一半的题号每 15 秒被清一次
watch([() => props.board.className, () => props.board.source], resetLessonInput)
watch([() => props.board.className, () => props.board.source, () => props.lessonId], resetLessonInput)
function inputIds() {
return lessonInput.value.split(/[\s,,、;;]+/).filter(Boolean)
}
async function saveLesson() {
const ids = lessonInput.value.split(/[\s,,、;;]+/).filter(Boolean)
const ids = inputIds()
saving.value = true
try {
await setClassLesson(props.className, ids, ids.length ? lessonLanguage.value : null)
await setClassLesson(props.className, ids, ids.length ? lessonLanguage.value : null, props.lessonId)
message.success(ids.length ? '已布置,学生首页会显示这几道题' : '已清掉')
picking.value = false
emit('saved')
} catch (error) {
message.error(errorMessage(error))
@@ -59,19 +72,43 @@ async function saveLesson() {
saving.value = false
}
}
// ---- 选题弹框:不用记题号,按知识点挑。框里已经敲了的题号带进去,题名能对上的带上题名 ----
const picking = ref(false)
const pickerInitial = ref<{ id: string; title?: string }[]>([])
function openPicker() {
const titles = new Map(props.board.problems.map((p) => [p.problemDisplayId.toLowerCase(), p.title]))
pickerInitial.value = inputIds().map((id) => ({ id, title: titles.get(id.toLowerCase()) }))
picking.value = true
}
function pickerConfirm(ids: string[]) {
lessonInput.value = ids.join(' ')
saveLesson()
}
</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 @click="openPicker">
<template #icon><Icon icon="ph:list-checks" /></template>
选题
</n-button>
<n-button type="primary" :loading="saving" @click="saveLesson">布置</n-button>
<n-button @click="emit('cancel')">取消</n-button>
<!-- 弹层挂在 body 下,放在根节点里面:两个根会让看板传进来的 class 落空 -->
<ProblemPicker v-model:show="picking" v-model:language="lessonLanguage" :initial="pickerInitial" :saving="saving" @confirm="pickerConfirm" />
</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 size="large" @click="openPicker">
<template #icon><Icon icon="ph:list-checks" /></template>
选题
</n-button>
<n-button type="primary" size="large" :loading="saving" @click="saveLesson">布置</n-button>
<ProblemPicker v-model:show="picking" v-model:language="lessonLanguage" :initial="pickerInitial" :saving="saving" @confirm="pickerConfirm" />
</div>
</template>
@@ -0,0 +1,522 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import type { LessonLanguage, Tag } from '@oj2/contract'
import { useThemeVars } from 'naive-ui'
import { getProblemList, getProblemTagList } from 'shared/api'
import { useTone } from 'shared/composables/tone'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
import type { ProblemRow } from 'utils/types'
import ProblemTypeTag from 'oj/problem/components/ProblemTypeTag.vue'
/**
* 看板布置作业的「选题」弹框:左边按知识点分,右边勾题,底下按勾的先后排成这节课的顺序。
* 只列能用这次作业的语言交的题(左栏题数也按这个语言数),不用再一个个记题号。
* 点「布置」直接存,和在输入框里敲题号回车是一回事
*/
const props = defineProps<{
/** 打开时已经在框里的题(题号 + 知道的话带上题名) */
initial: { id: string; title?: string }[]
saving: boolean
/** pick = 只把选好的题交回去(后台改一节时用),不直接布置 */
mode?: 'assign' | 'pick'
}>()
const show = defineModel<boolean>('show', { required: true })
const language = defineModel<LessonLanguage>('language', { required: true })
const emit = defineEmits<{ confirm: [ids: string[]] }>()
const theme = useThemeVars()
const tone = useTone()
const LESSON_LANGUAGES: LessonLanguage[] = ['Python', 'C', 'C++', 'SQL']
const languageOptions = LESSON_LANGUAGES.map((value) => ({ label: LANGUAGE_SHOW_VALUE[value], value }))
const SORTS = [
{ label: '最新出的', value: '' },
{ label: '做过的人最多', value: '-submission_number' },
]
const PAGE_SIZE = 100
/** 老师挑的题,必会题是按课堂用得最多挑出来的,放在知识点前面 */
const MUST_DO = '必会题'
const tags = ref<Tag[]>([])
const tag = ref('')
const keyword = ref('')
const sort = ref('')
const page = ref(1)
const rows = ref<ProblemRow[]>([])
const total = ref(0)
const loading = ref(false)
/** 勾的先后就是这节课的顺序 */
const picked = ref<{ id: string; title?: string }[]>([])
const pickedIndex = computed(() => new Map(picked.value.map((p, i) => [p.id.toLowerCase(), i])))
const knowledge = computed(() => tags.value.filter((t) => t.category === 'knowledge').sort((a, b) => b.problemCount - a.problemCount))
const mustDo = computed(() => tags.value.find((t) => t.name === MUST_DO) ?? null)
const allCount = ref(0)
async function loadTags() {
const list = await getProblemTagList(language.value)
tags.value = list
// 换了语言,原来选的知识点在这个语言下可能一道都没有
if (tag.value && !list.some((t) => t.name === tag.value)) tag.value = ''
}
let seq = 0
async function loadRows() {
const mine = ++seq
loading.value = true
try {
const res = await getProblemList((page.value - 1) * PAGE_SIZE, PAGE_SIZE, {
tag: tag.value,
keyword: keyword.value.trim(),
sort: sort.value,
language: language.value,
})
if (mine !== seq) return
rows.value = res.results
total.value = res.total
if (!tag.value && !keyword.value.trim()) allCount.value = res.total
} finally {
if (mine === seq) loading.value = false
}
}
const listRef = ref<HTMLElement | null>(null)
function reload() {
page.value = 1
listRef.value?.scrollTo({ top: 0 })
loadRows()
}
watch(show, (open) => {
if (!open) return
picked.value = props.initial.map((p) => ({ ...p }))
keyword.value = ''
loadTags()
reload()
})
watch(language, () => {
if (!show.value) return
loadTags()
reload()
})
// 搜题号多半是记得那道题、不知道它在哪个知识点下,所以搜的是整个题库;点了知识点就不再搜
function pickTag(name: string) {
keyword.value = ''
tag.value = name
}
watch(keyword, (text) => {
if (text.trim()) tag.value = ''
})
watch([tag, sort], reload)
watchDebounced(keyword, reload, { debounce: 300 })
watch(page, () => {
listRef.value?.scrollTo({ top: 0 })
loadRows()
})
function toggle(row: ProblemRow) {
const key = row._id.toLowerCase()
if (pickedIndex.value.has(key)) picked.value = picked.value.filter((p) => p.id.toLowerCase() !== key)
else picked.value = [...picked.value, { id: row._id, title: row.title }]
}
function remove(id: string) {
picked.value = picked.value.filter((p) => p.id !== id)
}
const DIFFICULTY_TONE = { 简单: 'success', 中等: 'warning', 困难: 'error' } as const
function difficultyStyle(row: ProblemRow) {
if (!row.difficulty) return {}
const t = tone(DIFFICULTY_TONE[row.difficulty])
return { color: t.color, background: t.background }
}
// n-modal 把内容挂到 body 下,<style> 里的 v-bind() 传不进去,颜色走根节点的内联变量
const vars = computed(() => ({
// 宽度也写在这里:n-modal 的 preset card 上 scoped 的 class 宽度不生效
width: 'min(1040px, calc(100vw - 32px))',
'--text2': theme.value.textColor2,
'--text3': theme.value.textColor3,
'--divider': theme.value.dividerColor,
'--hover': theme.value.hoverColor,
'--action': theme.value.actionColor,
'--primary': theme.value.primaryColor,
'--on-bg': tone('success').background,
'--on-text': tone('success').color,
}))
</script>
<template>
<n-modal v-model:show="show" preset="card" :style="vars" :bordered="false" :auto-focus="false" title="选题">
<template #header-extra>
<div class="head-extra">
<span class="muted">用</span>
<n-select v-model:value="language" :options="languageOptions" aria-label="用什么语言做" size="small" class="lang" />
<span class="muted">做</span>
</div>
</template>
<div class="body">
<nav class="tags">
<button class="tag" :class="{ on: tag === '' && !keyword.trim() }" @click="pickTag('')">
<span>全部</span><span class="count">{{ allCount || '' }}</span>
</button>
<button v-if="mustDo" class="tag" :class="{ on: tag === MUST_DO }" @click="pickTag(MUST_DO)">
<span>{{ MUST_DO }}</span
><span class="count">{{ mustDo.problemCount }}</span>
</button>
<div class="tag-head">知识点</div>
<button v-for="t in knowledge" :key="t.id" class="tag" :class="{ on: tag === t.name }" @click="pickTag(t.name)">
<span>{{ t.name }}</span
><span class="count">{{ t.problemCount }}</span>
</button>
</nav>
<section class="main">
<div class="filters">
<n-input v-model:value="keyword" placeholder="搜题号或题目名" clearable class="search">
<template #prefix><Icon icon="ph:magnifying-glass" /></template>
</n-input>
<n-select v-model:value="sort" :options="SORTS" class="sort" />
</div>
<div ref="listRef" class="list">
<n-spin :show="loading" class="spin">
<n-empty v-if="!loading && !rows.length" :description="`没有能用 ${LANGUAGE_SHOW_VALUE[language]} 做的题`" class="empty" />
<button v-for="row in rows" :key="row.id" type="button" class="row" :class="{ on: pickedIndex.has(row._id.toLowerCase()) }" @click="toggle(row)">
<span class="check">
<template v-if="pickedIndex.has(row._id.toLowerCase())">{{ pickedIndex.get(row._id.toLowerCase())! + 1 }}</template>
</span>
<span class="pid">{{ row._id }}</span>
<span class="title">
<span class="title-text">{{ row.title }}</span>
<ProblemTypeTag v-if="row.allowFlowchart" kind="flowchart" />
<ProblemTypeTag v-if="row.hasAstRules" kind="ast" />
</span>
<span class="diff" :style="difficultyStyle(row)">{{ row.difficulty ?? '' }}</span>
<span class="people">{{ row.solvedUsers }}/{{ row.triedUsers }}</span>
</button>
</n-spin>
</div>
<div v-if="total > PAGE_SIZE" class="pager">
<n-pagination v-model:page="page" :page-size="PAGE_SIZE" :item-count="total" simple />
</div>
</section>
</div>
<template #footer>
<div class="foot">
<div class="picked">
<span v-if="!picked.length" class="muted">还没选题</span>
<span v-for="(p, i) in picked" :key="p.id" class="pick">
<b>{{ i + 1 }}</b>
<span>{{ p.id }}</span>
<span v-if="p.title" class="pick-title">{{ p.title }}</span>
<button type="button" class="x" :aria-label="`去掉 ${p.id}`" @click="remove(p.id)"><Icon icon="ph:x-bold" /></button>
</span>
</div>
<n-button @click="show = false">取消</n-button>
<n-button
type="primary"
:disabled="!picked.length && (mode === 'pick' || !initial.length)"
:loading="saving"
@click="
emit(
'confirm',
picked.map((p) => p.id),
)
"
>
<template v-if="mode === 'pick'">{{ picked.length ? `用这 ${picked.length} 道` : '选好了' }}</template>
<template v-else>{{ picked.length ? `布置这 ${picked.length} 道` : initial.length ? '清掉' : '布置' }}</template>
</n-button>
</div>
</template>
</n-modal>
</template>
<style scoped>
.head-extra {
display: flex;
align-items: center;
gap: 8px;
}
.lang {
width: 110px;
}
.muted {
color: var(--text3);
}
.body {
display: flex;
gap: 16px;
height: min(560px, calc(100vh - 260px));
}
/* ---- 左栏:知识点 ---- */
.tags {
flex: 0 0 168px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
padding-right: 12px;
border-right: 1px solid var(--divider);
}
.tag-head {
margin: 10px 10px 4px;
font-size: 13px;
color: var(--text3);
}
.tag {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 10px;
border: 0;
border-radius: 6px;
background: none;
color: inherit;
font: inherit;
font-size: 15px;
text-align: left;
cursor: pointer;
flex-shrink: 0;
}
.tag:hover {
background: var(--hover);
}
.tag.on {
background: var(--on-bg);
color: var(--on-text);
font-weight: 600;
}
.count {
font-size: 13px;
color: var(--text3);
font-variant-numeric: tabular-nums;
}
.tag.on .count {
color: inherit;
}
/* ---- 右边:题 ---- */
.main {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.filters {
display: flex;
gap: 8px;
}
.search {
flex: 1 1 0;
}
.sort {
width: 150px;
}
.list {
flex: 1 1 0;
overflow-y: auto;
}
.spin {
min-height: 100%;
}
.empty {
padding: 60px 0;
}
.row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
height: 44px;
padding: 0 10px;
border: 0;
border-bottom: 1px solid var(--divider);
background: none;
color: inherit;
font: inherit;
font-size: 15px;
text-align: left;
cursor: pointer;
}
.row:hover {
background: var(--hover);
}
.row.on {
background: var(--on-bg);
}
.check {
flex: 0 0 22px;
height: 22px;
border: 1.5px solid var(--text3);
border-radius: 5px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
color: #fff;
}
.row.on .check {
border-color: var(--primary);
background: var(--primary);
}
.pid {
flex: 0 0 56px;
font-variant-numeric: tabular-nums;
color: var(--text2);
}
.title {
flex: 1 1 0;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
}
.title-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.diff {
flex: 0 0 auto;
padding: 1px 8px;
border-radius: 4px;
font-size: 13px;
}
.people {
flex: 0 0 76px;
text-align: right;
font-size: 14px;
color: var(--text3);
font-variant-numeric: tabular-nums;
}
.pager {
display: flex;
justify-content: flex-end;
}
/* ---- 底下:选好的,按先后 ---- */
.foot {
display: flex;
align-items: center;
gap: 8px;
}
.picked {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pick {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
padding: 0 4px 0 10px;
border-radius: 6px;
background: var(--action);
font-size: 14px;
font-variant-numeric: tabular-nums;
max-width: 260px;
}
.pick b {
color: var(--text3);
}
.pick-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text2);
}
.x {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: 0;
border-radius: 4px;
background: none;
color: var(--text3);
cursor: pointer;
flex-shrink: 0;
}
.x:hover {
background: var(--hover);
color: inherit;
}
@media (max-width: 768px) {
.body {
flex-direction: column;
height: calc(100vh - 280px);
}
.tags {
flex: 0 0 auto;
flex-direction: row;
overflow-x: auto;
padding: 0 0 8px;
border-right: 0;
border-bottom: 1px solid var(--divider);
}
.tag-head {
display: none;
}
.people {
display: none;
}
.foot {
flex-wrap: wrap;
}
.picked {
flex-basis: 100%;
}
}
</style>
@@ -79,12 +79,17 @@ function clock(lesson: Lesson) {
return `${parseTime(lesson.start, 'HH:mm')}–${parseTime(lesson.end, 'HH:mm')}`
}
/** 23 人来了、花名册却只有 22(有个号不是学生角色)时,不写「23 / 22」 */
/**
* 布置的课知道要做什么,写全班几人做完;推断的课只知道谁交了,写来了几人。
* 23 人来了、花名册却只有 22(有个号不是学生角色)时,不写「23 / 22」
*/
function attendance(lesson: Lesson) {
if (lesson.doneCount !== null) return lesson.classSize ? `做完 ${lesson.doneCount} / ${lesson.classSize}` : `做完 ${lesson.doneCount} 人`
return lesson.classSize >= lesson.userCount ? `${lesson.userCount} / ${lesson.classSize} 人` : `${lesson.userCount} 人`
}
function problemText(lesson: Lesson) {
if (lesson.assigned) return `布置 ${lesson.problems.length} 道`
return `${lesson.fromProblemSet ? '题单 ' : ''}${lesson.problems.length} 道`
}
@@ -152,7 +157,7 @@ function percent(value: number, total: number) {
<span v-if="nowHour !== null" class="now" :style="{ left: axisLeft(nowHour) }"></span>
</span>
<b class="c-class">{{ classLabel(lesson.className) }}</b>
<span class="c-came">{{ attendance(lesson) }}</span>
<span class="c-came" :title="lesson.assigned ? `交过的 ${lesson.userCount} 人` : undefined">{{ attendance(lesson) }}</span>
<span class="c-probs">{{ problemText(lesson) }}</span>
<span class="c-rate">
{{ lesson.total }} 次 ·
@@ -65,6 +65,17 @@ const custom = computed<[number, number] | null>(() => {
return from.value && to.value && Number.isFinite(a) && Number.isFinite(b) ? [a, b] : null
})
/**
* 「做完」只数所选时段之内做对的,和看板、后台「课堂作业」、一行一节课的「做完」不一样
* (那几处以前做对的也算)—— 从那边点进来数字会变小,所以写明是哪段时间里做完的
*/
const doneLabel = computed(() => {
const value = periodValue.value
if (value === 'today' || (value === 'custom' && custom.value && custom.value[0] === custom.value[1])) return '这一天做完'
if (value === 'lesson' || value === '1h' || value === '2h') return '这节课做完'
return '这段时间做完'
})
const periodValue = computed<Period>(() => {
// 「那节课」要带着精确的起止时刻,地址栏里缺了就退回默认
if (period.value === 'lesson') return custom.value ? 'lesson' : DEFAULT_PERIOD
@@ -377,7 +388,7 @@ function resultWidth(value: number) {
<StatItem label="上了几节课" :value="`${lessons.lessons.length}${lessons.hasMore ? '+' : ''}`" unit="节" />
</template>
<template v-if="personCount">
<StatItem :label="hasProblems ? '做完' : '做对过题'" :value="`${doneCount} / ${personCount}`" unit="人" tone="success" />
<StatItem :label="hasProblems ? doneLabel : '做对过题'" :value="`${doneCount} / ${personCount}`" unit="人" tone="success" />
<StatItem v-if="hasProblems" label="完成度" :value="`${personCount ? Math.round((doneCount / personCount) * 100) : 0}%`" />
<StatItem label="一道没交" :value="stats.dataUnaccepted.length" unit="人" tone="error" />
</template>