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

+21 -1
View File
@@ -1,4 +1,4 @@
import { toAdminType, toProblemPermission } from '@oj2/contract'
import { toAdminType, toProblemPermission, type AdminLessonList, type AdminLessonRequest } from '@oj2/contract'
import type {
AddContestProblemRequest,
ChallengeInput,
@@ -779,3 +779,23 @@ export function updateAchievement(data: Partial<AdminAchievement>) {
export function deleteAchievement(id: number) {
return api.delete(`admin/achievements/${id}`)
}
// ---------- 课堂作业(class_lesson) ----------
export function getAdminLessons(offset: number, limit: number, filters: { className: string; teacher: number | null }) {
return api.get<AdminLessonList>('admin/lessons', {
params: { offset, limit, className: filters.className, teacher: filters.teacher ?? '' },
})
}
export function createAdminLesson(body: AdminLessonRequest) {
return api.post<{ id: number }>('admin/lessons', body)
}
export function updateAdminLesson(id: number, body: AdminLessonRequest) {
return api.put<null>(`admin/lessons/${id}`, body)
}
export function deleteAdminLesson(id: number) {
return api.delete<null>(`admin/lessons/${id}`)
}
@@ -0,0 +1,155 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import type { AdminLesson, LessonLanguage } from '@oj2/contract'
import ProblemPicker from 'oj/classroom/components/ProblemPicker.vue'
import { useConfigStore } from 'shared/store/config'
import { errorMessage } from 'utils/api'
import { groupClassOptions } from 'utils/classes'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
import { parseTime } from 'utils/functions'
import { createAdminLesson, updateAdminLesson } from '../../api'
/**
* 后台改一节 / 补一节。改的时候班级、日子、时刻都能动 —— 看板上点错了班、隔天才想起来布置,
* 都在这里纠正;补的是看板外上的课,补了数据统计才认得到。题号可以敲,也可以点「选题」挑
*/
const props = defineProps<{
/** null = 补一节 */
lesson: AdminLesson | null
}>()
const show = defineModel<boolean>('show', { required: true })
const emit = defineEmits<{ saved: [] }>()
const message = useMessage()
const configStore = useConfigStore()
const LESSON_LANGUAGES: LessonLanguage[] = ['Python', 'C', 'C++', 'SQL']
const languageOptions = LESSON_LANGUAGES.map((value) => ({ label: LANGUAGE_SHOW_VALUE[value], value }))
const classOptions = computed(() => groupClassOptions(configStore.config?.classList ?? []))
const className = ref<string | null>(null)
const day = ref<string | null>(null)
const time = ref<string | null>(null)
const language = ref<LessonLanguage>('Python')
const idsInput = ref('')
const saving = ref(false)
watch(show, (open) => {
if (!open) return
const lesson = props.lesson
const now = new Date()
className.value = lesson?.className ?? null
// 日子、时刻都按东八区:n-date-picker / n-time-picker 绑的是格式化后的文本,不经过浏览器时区
day.value = lesson?.day ?? parseTime(now, 'YYYY-MM-DD')
time.value = parseTime(lesson?.createdAt ?? now, 'HH:mm')
language.value = lesson?.language ?? 'Python'
idsInput.value = lesson?.problems.map((p) => p.problemDisplayId).join(' ') ?? ''
})
function inputIds() {
return idsInput.value.split(/[\s,,、;;]+/).filter(Boolean)
}
// ---- 选题弹框,和看板上那个是同一个 ----
const picking = ref(false)
const pickerInitial = ref<{ id: string; title?: string }[]>([])
function openPicker() {
const titles = new Map(props.lesson?.problems.map((p) => [p.problemDisplayId.toLowerCase(), p.title]) ?? [])
pickerInitial.value = inputIds().map((id) => ({ id, title: titles.get(id.toLowerCase()) }))
picking.value = true
}
function picked(ids: string[]) {
idsInput.value = ids.join(' ')
picking.value = false
}
const ready = computed(() => !!className.value && !!day.value && !!time.value && inputIds().length > 0)
async function save() {
if (!ready.value) return
saving.value = true
const body = {
className: className.value!,
day: day.value!,
time: time.value!,
language: language.value,
problemDisplayIds: inputIds(),
}
try {
if (props.lesson) await updateAdminLesson(props.lesson.id, body)
else await createAdminLesson(body)
message.success(props.lesson ? '改好了' : '补上了')
show.value = false
emit('saved')
} catch (error) {
message.error(errorMessage(error))
} finally {
saving.value = false
}
}
</script>
<template>
<n-modal v-model:show="show" preset="card" :title="lesson ? '改这一节' : '补一节'" :bordered="false" :style="{ width: 'min(520px, calc(100vw - 32px))' }">
<n-form label-placement="left" label-width="56">
<n-form-item label="班级">
<n-select v-model:value="className" :options="classOptions" filterable placeholder="选班级" />
</n-form-item>
<n-form-item label="上课">
<div class="when">
<n-date-picker v-model:formatted-value="day" value-format="yyyy-MM-dd" type="date" class="day" />
<n-time-picker v-model:formatted-value="time" value-format="HH:mm" format="HH:mm" class="time" />
</div>
</n-form-item>
<n-form-item label="语言">
<n-select v-model:value="language" :options="languageOptions" class="lang" />
</n-form-item>
<n-form-item label="题目">
<div class="ids">
<n-input v-model:value="idsInput" placeholder="题号,用空格隔开" @keyup.enter="save" />
<n-button @click="openPicker">
<template #icon><Icon icon="ph:list-checks" /></template>
选题
</n-button>
</div>
</n-form-item>
</n-form>
<template #footer>
<div class="foot">
<n-button @click="show = false">取消</n-button>
<n-button type="primary" :disabled="!ready" :loading="saving" @click="save">{{ lesson ? '保存' : '补上' }}</n-button>
</div>
</template>
<ProblemPicker v-model:show="picking" v-model:language="language" :initial="pickerInitial" :saving="false" mode="pick" @confirm="picked" />
</n-modal>
</template>
<style scoped>
.when,
.ids {
display: flex;
gap: 8px;
width: 100%;
}
.day {
flex: 1 1 0;
}
.time {
width: 110px;
}
.lang {
width: 140px;
}
.ids .n-input {
flex: 1 1 0;
}
.foot {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
+227
View File
@@ -0,0 +1,227 @@
<script setup lang="ts">
import type { AdminLesson, AdminLessonList } from '@oj2/contract'
import { useRouteQuery } from '@vueuse/router'
import PageHeader from 'admin/components/PageHeader.vue'
import { NButton, NPopconfirm, NTooltip, useThemeVars } from 'naive-ui'
import { dayStartOfDate } from 'oj/statistics/period'
import Pagination from 'shared/components/Pagination.vue'
import { useBreakpoints } from 'shared/composables/breakpoints'
import { useListLoader } from 'shared/composables/listLoader'
import { usePagination } from 'shared/composables/pagination'
import { useConfigStore } from 'shared/store/config'
import { errorMessage } from 'utils/api'
import { classLabel, groupClassOptions } from 'utils/classes'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
import { parseTime } from 'utils/functions'
import { deleteAdminLesson, getAdminLessons } from '../api'
import LessonEditModal from './components/LessonEditModal.vue'
/**
* 后台「课堂作业」:布置过的每一节课一行,新的在前。课堂看板上当场布置的都在这里,
* 布置错了能改、删;看板外上的课能补一节 —— 数据统计按布置切课,补了才认得到
*/
const router = useRouter()
const message = useMessage()
const theme = useThemeVars()
const configStore = useConfigStore()
const { isMobile } = useBreakpoints()
// 从看板「布置过的课」进来带着 ?className=
const { query } = usePagination<{ className: string; teacher: string }>({
className: useRouteQuery('className', '').value,
teacher: useRouteQuery('teacher', '').value,
})
const data = ref<AdminLessonList | null>(null)
const classOptions = computed(() => [{ label: '全部班级', value: '' }, ...groupClassOptions(configStore.config?.classList ?? [])])
const teacherOptions = computed(() => [
{ label: '全部老师', value: '' },
...(data.value?.teachers ?? []).map((teacher) => ({ label: teacher.name, value: String(teacher.id) })),
])
const { loading, track } = useListLoader()
async function list() {
if (query.page < 1) query.page = 1
const res = await track(
getAdminLessons((query.page - 1) * query.limit, query.limit, {
className: query.className,
teacher: query.teacher ? Number(query.teacher) : null,
}),
)
if (res) data.value = res
}
onMounted(list)
// 换了班级、老师 usePagination 会把页码拨回 1,同一拍里只拉一次
watch(() => [query.page, query.limit, query.className, query.teacher], list)
// ---- 改 / 补 / 删 ----
const editing = ref(false)
const editingLesson = ref<AdminLesson | null>(null)
function edit(lesson: AdminLesson | null) {
editingLesson.value = lesson
editing.value = true
}
async function remove(lesson: AdminLesson) {
try {
await deleteAdminLesson(lesson.id)
message.success('删掉了')
list()
} catch (error) {
message.error(errorMessage(error))
}
}
/** 看统计:这个班、这几道题、那一整天 */
function statistics(lesson: AdminLesson) {
const day = String(dayStartOfDate(lesson.day))
router.push({
name: 'statistics',
query: {
className: lesson.className,
problem: lesson.problems.map((p) => p.problemDisplayId).join(' '),
period: 'custom',
from: day,
to: day,
},
})
}
// ---- 列 ----
const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
function dayText(day: string) {
const [y, m, d] = day.split('-').map(Number) as [number, number, number]
const weekday = WEEKDAYS[new Date(Date.UTC(y, m - 1, d)).getUTCDay()]
const thisYear = parseTime(new Date(), 'YYYY') === String(y)
return `${thisYear ? '' : `${y}年`}${m}月${d}日 ${weekday}`
}
function renderProblems(lesson: AdminLesson) {
return h(
'div',
{ class: 'probs' },
lesson.problems.map((p) =>
h(
NTooltip,
{},
{
trigger: () => h('span', { class: ['chip', { hidden: !p.visible }] }, p.problemDisplayId),
default: () => (p.visible ? p.title : `${p.title}(已藏起来,统计里不算)`),
},
),
),
)
}
function renderDone(lesson: AdminLesson) {
return lesson.classSize ? `${lesson.doneCount} / ${lesson.classSize}` : `${lesson.doneCount}`
}
function renderActions(lesson: AdminLesson) {
const size = isMobile.value ? 'medium' : 'small'
return h('div', { class: 'actions' }, [
h(NButton, { size, secondary: true, onClick: () => edit(lesson) }, { default: () => '改' }),
h(NButton, { size, onClick: () => statistics(lesson) }, { default: () => '看统计' }),
h(
NPopconfirm,
{ onPositiveClick: () => remove(lesson), positiveText: '删掉', negativeText: '不删' },
{
trigger: () => h(NButton, { size, type: 'error', tertiary: true }, { default: () => '删除' }),
default: () => `删掉 ${classLabel(lesson.className)} ${dayText(lesson.day)} 这一节?学生的提交不受影响,数据统计里这节课改回按提交推断`,
},
),
])
}
/** 手机上一节课一张卡:日子 · 时刻 · 班级一行,题号一行,老师 · 语言 · 做完一行小字,操作一行 */
function renderMobileCard(lesson: AdminLesson) {
return h('div', { class: 'm-card' }, [
h('div', { class: 'm-head' }, `${dayText(lesson.day)} ${parseTime(lesson.createdAt, 'HH:mm')} · ${classLabel(lesson.className)}`),
renderProblems(lesson),
h(
'div',
{ class: 'm-meta' },
[lesson.createdBy, lesson.language ? LANGUAGE_SHOW_VALUE[lesson.language] : null, `做完 ${renderDone(lesson)}`].filter(Boolean).join(' · '),
),
renderActions(lesson),
])
}
const desktopColumns: DataTableColumn<AdminLesson>[] = [
{ title: '日子', key: 'day', width: 130, render: (row) => dayText(row.day) },
{ title: '布置', key: 'createdAt', width: 70, render: (row) => parseTime(row.createdAt, 'HH:mm') },
{ title: '班级', key: 'className', width: 130, render: (row) => classLabel(row.className) },
{ title: '老师', key: 'createdBy', width: 90 },
{ title: '语言', key: 'language', width: 80, render: (row) => (row.language ? LANGUAGE_SHOW_VALUE[row.language] : '—') },
{ title: '题目', key: 'problems', width: 300, render: renderProblems },
{ title: '做完 / 全班', key: 'doneCount', width: 100, render: renderDone },
{ title: '', key: 'actions', render: renderActions },
]
const mobileColumns: DataTableColumn<AdminLesson>[] = [{ title: '课', key: 'id', render: renderMobileCard }]
const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns))
</script>
<template>
<PageHeader title="课堂作业">
<template #extra>
<n-text v-if="data" depth="3">{{ data.total }} 节</n-text>
</template>
<template #actions>
<n-button type="primary" @click="edit(null)">补一节</n-button>
</template>
<template #filters>
<n-select v-model:value="query.className" :options="classOptions" filterable aria-label="班级" :style="{ width: isMobile ? '100%' : '160px' }" />
<n-select v-model:value="query.teacher" :options="teacherOptions" aria-label="老师" :style="{ width: isMobile ? '100%' : '140px' }" />
</template>
</PageHeader>
<n-data-table :loading="loading" striped class="lesson-table" :columns="columns" :data="data?.results ?? []" :row-key="(row: AdminLesson) => row.id" />
<Pagination v-if="data" :total="data.total" v-model:limit="query.limit" v-model:page="query.page" />
<LessonEditModal v-model:show="editing" :lesson="editingLesson" @saved="list" />
</template>
<style scoped>
.lesson-table :deep(.probs) {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.lesson-table :deep(.chip) {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 8px;
border-radius: 4px;
/* 描边不填底:斑马纹的隔行底色和 actionColor 是同一档灰,填底的话那几行看不出块 */
border: 1px solid v-bind('theme.borderColor');
font-size: 13px;
font-variant-numeric: tabular-nums;
cursor: default;
}
.lesson-table :deep(.chip.hidden) {
color: v-bind('theme.textColor3');
text-decoration: line-through;
}
.lesson-table :deep(.actions) {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.lesson-table :deep(.m-card) {
display: flex;
flex-direction: column;
gap: 6px;
}
.lesson-table :deep(.m-head) {
font-weight: 500;
}
.lesson-table :deep(.m-meta) {
font-size: 13px;
color: v-bind('theme.textColor3');
}
</style>