Files
Academic-Affairs-System/web/src/views/SchedulesView.vue
T
biss f0f59419d5 实验课现在直接进入普通排课流程,不再要求先到实验排课模块逐个安排。
普通“添加排课”新增“理论课 / 实验课”类型。
自动排课会分别补足理论学时和实验学时。
实验课只能安排到实验室、实训室、机房、语音室等场地。
发布课表时分别校验理论、实验学时;任一未排足都不能发布。
普通课表及 Excel 导出会标注“实验课”。
历史排课保持不变,迁移后默认识别为理论课;后续新建或修订版本时再补充实验课。
2026-08-02 16:54:41 +08:00

1355 lines
55 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { CopyDocument, EditPen, Plus, Promotion, Refresh, Search, Setting } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const plans = ref<any[]>([])
const selected = ref<any | null>(null)
const terms = ref<any[]>([])
const tasks = ref<any[]>([])
const classrooms = ref<any[]>([])
const campuses = ref<any[]>([])
const buildings = ref<any[]>([])
const timeSlots = ref<any[]>([])
const constraints = ref<any[]>([])
const loading = ref(false)
const detailLoading = ref(false)
const autoJob = ref<any | null>(null)
const publishJob = ref<any | null>(null)
const planDialog = ref(false)
const cloneDialog = ref(false)
const entryDialog = ref(false)
const settingsDrawer = ref(false)
const settingsTab = ref('time')
const constraintDialog = ref(false)
const constraintBatchDialog = ref(false)
const constraintBatchSaving = ref(false)
const editingPlanId = ref('')
const editingEntryId = ref('')
const keyword = ref('')
const termId = ref<string | undefined>()
const planForm = reactive<Record<string, any>>({})
const cloneForm = reactive<Record<string, any>>({})
const revisingPublishedPlan = ref(false)
const entryForm = reactive<Record<string, any>>({})
const constraintForm = reactive<Record<string, any>>({})
const constraintBatchForm = reactive<Record<string, any>>({})
const constraintFilters = reactive({
keyword: '',
collegeId: undefined as string | undefined,
schedulingMode: undefined as string | undefined,
classroomMode: undefined as string | undefined,
constraintState: undefined as string | undefined,
})
let autoPollTimer: ReturnType<typeof setTimeout> | undefined
let publishPollTimer: ReturnType<typeof setTimeout> | undefined
const weekdays = [
{ value: 1, label: '星期一' },
{ value: 2, label: '星期二' },
{ value: 3, label: '星期三' },
{ value: 4, label: '星期四' },
{ value: 5, label: '星期五' },
{ value: 6, label: '星期六' },
{ value: 7, label: '星期日' },
]
const periods = computed(() => {
const configured = timeSlots.value
.filter((item) => item.isEnabled)
.map((item) => item.periodNumber)
return configured.length ? configured : Array.from({ length: 12 }, (_, index) => index + 1)
})
const statusLabels: Record<string, string> = {
Draft: '草稿',
Published: '已发布',
Archived: '已归档',
}
const patternLabels: Record<string, string> = {
All: '每周',
Odd: '单周',
Even: '双周',
}
const isDraft = computed(() => selected.value?.status === 'Draft')
const autoLoading = computed(() =>
autoJob.value?.status === 'Queued' || autoJob.value?.status === 'Running',
)
const publishLoading = computed(() =>
publishJob.value?.status === 'Queued' || publishJob.value?.status === 'Running',
)
const scheduleJobLoading = computed(() => autoLoading.value || publishLoading.value)
const autoProgress = computed(() => {
if (!autoJob.value?.totalTasks) return 0
return Math.min(
100,
Math.round(autoJob.value.processedTasks / autoJob.value.totalTasks * 100),
)
})
const autoProgressStatus = computed(() => {
if (autoJob.value?.status === 'Succeeded') return 'success'
if (autoJob.value?.status === 'Failed') return 'exception'
return undefined
})
const autoStatusText = computed(() => {
if (!autoJob.value) return ''
if (autoJob.value.status === 'Queued') return '任务已进入队列,等待后台执行'
if (autoJob.value.status === 'Running') {
return `正在处理 ${autoJob.value.processedTasks}/${autoJob.value.totalTasks} 个教学班,已规划 ${autoJob.value.createdEntries} 条安排`
}
if (autoJob.value.status === 'Succeeded') {
return `后台排课已完成,共生成 ${autoJob.value.createdEntries} 条安排`
}
return autoJob.value.errorMessage || '后台排课失败,请稍后重试'
})
const publishProgress = computed(() => {
if (!publishJob.value?.totalSteps) return 0
return Math.min(
100,
Math.round(publishJob.value.completedSteps / publishJob.value.totalSteps * 100),
)
})
const publishProgressStatus = computed(() => {
if (publishJob.value?.status === 'Succeeded') return 'success'
if (publishJob.value?.status === 'Failed') return 'exception'
return undefined
})
const publishStatusText = computed(() => {
if (!publishJob.value) return ''
if (publishJob.value.status === 'Queued') return '发布任务已进入队列,等待后台检查'
if (publishJob.value.status === 'Running') {
return publishJob.value.currentStep || '正在检查并发布课表'
}
if (publishJob.value.status === 'Succeeded') return '检查已通过,课表发布成功'
return publishJob.value.errorMessage || '课表检查未通过'
})
const selectedTaskConstraint = computed(() =>
constraints.value.find((item) => item.id === entryForm.teachingTaskId),
)
const isExperimentRoom = (room: any) =>
['实验', '实训', '机房', '语音'].some((keyword) => room.roomType?.includes(keyword))
const entryClassrooms = computed(() =>
classrooms.value.filter((room) =>
(!selectedTaskConstraint.value?.requiredCampusId
|| room.campusId === selectedTaskConstraint.value.requiredCampusId) &&
(!selectedTaskConstraint.value?.requiredBuildingId
|| room.buildingId === selectedTaskConstraint.value.requiredBuildingId) &&
(!selectedTaskConstraint.value?.allowedClassroomIds?.length
|| selectedTaskConstraint.value.allowedClassroomIds.includes(room.id)) &&
(entryForm.kind !== 'Experiment' || isExperimentRoom(room)),
),
)
const entryWeekdays = computed(() => {
const allowedDays = selectedTaskConstraint.value?.allowedDayOfWeeks ?? []
return allowedDays.length
? weekdays.filter((day) => allowedDays.includes(day.value))
: weekdays
})
const constraintColleges = computed(() => {
const result = new Map<string, string>()
constraints.value.forEach((item) => result.set(item.collegeId, item.collegeName))
return [...result].map(([id, name]) => ({ id, name })).sort((a, b) =>
a.name.localeCompare(b.name, 'zh-CN'),
)
})
const filteredConstraints = computed(() => {
const text = constraintFilters.keyword.trim().toLowerCase()
return constraints.value.filter((item) => {
const matchesKeyword = !text || [
item.taskNumber,
item.name,
item.courseCode,
item.courseName,
...item.teacherNames,
].some((value) => String(value).toLowerCase().includes(text))
const matchesCollege = !constraintFilters.collegeId ||
item.collegeId === constraintFilters.collegeId
const matchesMode = !constraintFilters.schedulingMode ||
item.schedulingMode === constraintFilters.schedulingMode
const matchesClassroom = !constraintFilters.classroomMode ||
(constraintFilters.classroomMode === 'required'
? item.requiresClassroom
: !item.requiresClassroom)
const matchesState = !constraintFilters.constraintState ||
(constraintFilters.constraintState === 'custom'
? item.hasCustomConstraint
: !item.hasCustomConstraint)
return matchesKeyword && matchesCollege && matchesMode &&
matchesClassroom && matchesState
})
})
const filteredBuildings = computed(() =>
constraintForm.requiredCampusId
? buildings.value.filter((item) => item.campusId === constraintForm.requiredCampusId)
: buildings.value,
)
const filteredClassrooms = computed(() =>
classrooms.value.filter((item) =>
(!constraintForm.requiredCampusId || item.campusId === constraintForm.requiredCampusId) &&
(!constraintForm.requiredBuildingId || item.buildingId === constraintForm.requiredBuildingId),
),
)
const batchFilteredBuildings = computed(() =>
constraintBatchForm.requiredCampusId
? buildings.value.filter((item) => item.campusId === constraintBatchForm.requiredCampusId)
: buildings.value,
)
const batchFilteredClassrooms = computed(() =>
classrooms.value.filter((item) =>
(!constraintBatchForm.requiredCampusId ||
item.campusId === constraintBatchForm.requiredCampusId) &&
(!constraintBatchForm.requiredBuildingId ||
item.buildingId === constraintBatchForm.requiredBuildingId),
),
)
const filteredEntries = computed(() => {
const text = keyword.value.trim().toLowerCase()
if (!text) return selected.value?.entries ?? []
return (selected.value?.entries ?? []).filter((entry: any) =>
[
entry.taskNumber,
entry.taskName,
entry.courseCode,
entry.courseName,
entry.classroomName,
...entry.teacherNames,
...entry.classNames,
].some((value) => String(value).toLowerCase().includes(text)),
)
})
function entriesAt(day: number, period: number) {
return filteredEntries.value.filter(
(entry: any) => entry.dayOfWeek === day && entry.startPeriod === period,
)
}
function timeLabel(period: number) {
const slot = timeSlots.value.find((item) => item.periodNumber === period)
return slot ? `${slot.startsAt}${slot.endsAt}` : `第 ${period} 节`
}
async function loadSchedulingSettings() {
if (!termId.value) return
try {
const [timeRes, constraintRes] = await Promise.all([
http.get('/schedules/time-slots', { params: { academicTermId: termId.value } }),
http.get('/schedules/constraints', { params: { academicTermId: termId.value } }),
])
timeSlots.value = timeRes.data
constraints.value = constraintRes.data
tasks.value = constraintRes.data.filter(
(item: any) => item.schedulingMode === 'Standard',
)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function changeTerm() {
await Promise.all([loadPlans(false), loadSchedulingSettings()])
}
function initializeTimeSlots() {
const defaults = [
['08:00', '08:45'], ['08:55', '09:40'], ['10:00', '10:45'], ['10:55', '11:40'],
['14:00', '14:45'], ['14:55', '15:40'], ['16:00', '16:45'], ['16:55', '17:40'],
['19:00', '19:45'], ['19:55', '20:40'],
]
timeSlots.value = defaults.map(([startsAt, endsAt], index) => ({
periodNumber: index + 1,
name: `第 ${index + 1} 节`,
startsAt,
endsAt,
isEnabled: true,
}))
}
function addTimeSlot() {
const last = timeSlots.value.at(-1)
timeSlots.value.push({
periodNumber: (last?.periodNumber ?? 0) + 1,
name: `第 ${(last?.periodNumber ?? 0) + 1} 节`,
startsAt: '08:00',
endsAt: '08:45',
isEnabled: true,
})
}
async function saveTimeSlots() {
try {
await http.put(`/schedules/time-slots/${termId.value}`, timeSlots.value)
ElMessage.success('上课时间表已保存')
await loadSchedulingSettings()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function openConstraint(item: any) {
Object.assign(constraintForm, {
teachingTaskId: item.id,
title: `${item.taskNumber} · ${item.name}`,
schedulingMode: item.schedulingMode,
requiresClassroom: item.requiresClassroom,
requiredCampusId: item.requiredCampusId,
requiredBuildingId: item.requiredBuildingId,
allowedClassroomIds: [...item.allowedClassroomIds],
allowedDayOfWeeks: item.allowedDayOfWeeks.length
? [...item.allowedDayOfWeeks]
: [1, 2, 3, 4, 5],
earliestPeriod: item.earliestPeriod,
latestPeriod: item.latestPeriod,
})
constraintDialog.value = true
}
async function saveConstraint() {
try {
const payload = {
schedulingMode: constraintForm.schedulingMode,
requiresClassroom: constraintForm.requiresClassroom,
requiredCampusId: constraintForm.requiredCampusId || null,
requiredBuildingId: constraintForm.requiredBuildingId || null,
allowedClassroomIds: constraintForm.allowedClassroomIds ?? [],
allowedDayOfWeeks: constraintForm.allowedDayOfWeeks ?? [],
earliestPeriod: constraintForm.earliestPeriod || null,
latestPeriod: constraintForm.latestPeriod || null,
}
await http.put(`/schedules/constraints/${constraintForm.teachingTaskId}`, payload)
constraintDialog.value = false
ElMessage.success('排课约束已保存')
await loadSchedulingSettings()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function resetConstraintFilters() {
Object.assign(constraintFilters, {
keyword: '',
collegeId: undefined,
schedulingMode: undefined,
classroomMode: undefined,
constraintState: undefined,
})
}
function openConstraintBatch() {
if (!filteredConstraints.value.length) {
ElMessage.warning('当前筛选结果中没有可修改的教学任务。')
return
}
Object.keys(constraintBatchForm).forEach((key) => delete constraintBatchForm[key])
Object.assign(constraintBatchForm, {
updateSchedulingMode: false,
schedulingMode: 'Standard',
updateRequiresClassroom: false,
requiresClassroom: true,
updateClassroomScope: false,
requiredCampusId: undefined,
requiredBuildingId: undefined,
allowedClassroomIds: [],
updateDays: false,
allowedDayOfWeeks: [1, 2, 3, 4, 5],
updatePeriodRange: false,
earliestPeriod: undefined,
latestPeriod: undefined,
})
constraintBatchDialog.value = true
}
async function saveConstraintBatch() {
if (!constraintBatchForm.updateSchedulingMode &&
!constraintBatchForm.updateRequiresClassroom &&
!constraintBatchForm.updateClassroomScope &&
!constraintBatchForm.updateDays &&
!constraintBatchForm.updatePeriodRange) {
ElMessage.warning('请至少勾选一项需要批量修改的设置。')
return
}
const targets = [...filteredConstraints.value]
try {
await ElMessageBox.confirm(
`将修改当前筛选到的 ${targets.length} 个教学任务,确定继续吗?`,
'批量修改排课约束',
{ type: 'warning', confirmButtonText: '修改当前结果', cancelButtonText: '取消' },
)
constraintBatchSaving.value = true
const flexible = constraintBatchForm.updateSchedulingMode &&
constraintBatchForm.schedulingMode === 'Flexible'
const updateClassroomScope = !flexible &&
!(constraintBatchForm.updateRequiresClassroom &&
!constraintBatchForm.requiresClassroom) &&
constraintBatchForm.updateClassroomScope
const { data } = await http.put('/schedules/constraints/batch', {
academicTermId: termId.value,
teachingTaskIds: targets.map((item) => item.id),
schedulingMode: constraintBatchForm.updateSchedulingMode
? constraintBatchForm.schedulingMode
: null,
requiresClassroom: !flexible && constraintBatchForm.updateRequiresClassroom
? constraintBatchForm.requiresClassroom
: null,
updateClassroomScope,
requiredCampusId: updateClassroomScope
? constraintBatchForm.requiredCampusId || null
: null,
requiredBuildingId: updateClassroomScope
? constraintBatchForm.requiredBuildingId || null
: null,
allowedClassroomIds: updateClassroomScope
? constraintBatchForm.allowedClassroomIds
: null,
allowedDayOfWeeks: !flexible && constraintBatchForm.updateDays
? constraintBatchForm.allowedDayOfWeeks
: null,
updatePeriodRange: !flexible && constraintBatchForm.updatePeriodRange,
earliestPeriod: !flexible && constraintBatchForm.updatePeriodRange
? constraintBatchForm.earliestPeriod || null
: null,
latestPeriod: !flexible && constraintBatchForm.updatePeriodRange
? constraintBatchForm.latestPeriod || null
: null,
})
constraintBatchDialog.value = false
ElMessage.success(`已批量更新 ${data.affectedCount} 个教学任务`)
await loadSchedulingSettings()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
} finally {
constraintBatchSaving.value = false
}
}
function openManualHandling() {
settingsTab.value = 'constraints'
settingsDrawer.value = true
}
async function autoSchedule() {
try {
await ElMessageBox.confirm(
'系统会保留当前手工安排,同时补齐理论课和实验课。实验课仅使用实验室、实训室、机房等场地;生成后仍可手工调整。',
'开始自动排课',
{ type: 'warning', confirmButtonText: '生成排课', cancelButtonText: '取消' },
)
const planId = selected.value.id
const { data } = await http.post(`/schedules/plans/${planId}/auto-schedule`)
autoJob.value = data
ElMessage.success('自动排课任务已提交,可留在当前页面查看进度')
scheduleAutoSchedulePoll(data.id, planId)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
function clearPublishPoll() {
if (publishPollTimer) clearTimeout(publishPollTimer)
publishPollTimer = undefined
}
function schedulePublishPoll(jobId: string, planId: string) {
clearPublishPoll()
publishPollTimer = setTimeout(() => pollPublishJob(jobId, planId), 1000)
}
async function pollPublishJob(jobId: string, planId: string) {
try {
const { data } = await http.get(`/schedules/publish-jobs/${jobId}`)
if (selected.value?.id !== planId) return
publishJob.value = data
if (data.status === 'Queued' || data.status === 'Running') {
schedulePublishPoll(jobId, planId)
return
}
clearPublishPoll()
if (data.status === 'Succeeded') {
ElMessage.success('课表检查通过,已发布')
await loadPlans()
} else {
ElMessage.error(data.errorMessage || '课表检查未通过,请调整后重试')
}
} catch {
if (selected.value?.id === planId) {
schedulePublishPoll(jobId, planId)
}
}
}
async function resumePublish(planId: string) {
clearPublishPoll()
publishJob.value = null
const { data } = await http.get(`/schedules/plans/${planId}/publish-job`)
if (selected.value?.id !== planId || !data) return
publishJob.value = data
if (data.status === 'Queued' || data.status === 'Running') {
schedulePublishPoll(data.id, planId)
}
}
function clearAutoSchedulePoll() {
if (autoPollTimer) clearTimeout(autoPollTimer)
autoPollTimer = undefined
}
function scheduleAutoSchedulePoll(jobId: string, planId: string) {
clearAutoSchedulePoll()
autoPollTimer = setTimeout(() => pollAutoScheduleJob(jobId, planId), 1000)
}
async function pollAutoScheduleJob(jobId: string, planId: string) {
try {
const { data } = await http.get(`/schedules/auto-schedule-jobs/${jobId}`)
if (selected.value?.id !== planId) return
autoJob.value = data
if (data.status === 'Queued' || data.status === 'Running') {
scheduleAutoSchedulePoll(jobId, planId)
return
}
clearAutoSchedulePoll()
if (data.status === 'Succeeded') {
await loadDetail(planId, false)
const summary = plans.value.find((item) => item.id === planId)
if (summary) summary.entryCount = selected.value.entries.length
if (data.messages.length) {
ElMessage.warning(
`后台排课完成,已生成 ${data.createdEntries} 条安排,仍有 ${data.messages.length} 个任务需人工处理`,
)
} else {
ElMessage.success(`后台排课完成,共生成 ${data.createdEntries} 条安排`)
}
} else {
ElMessage.error(data.errorMessage || '后台排课失败,请稍后重试')
}
} catch {
if (selected.value?.id === planId) {
scheduleAutoSchedulePoll(jobId, planId)
}
}
}
async function resumeAutoSchedule(planId: string) {
clearAutoSchedulePoll()
autoJob.value = null
const { data } = await http.get(`/schedules/plans/${planId}/auto-schedule-job`)
if (selected.value?.id !== planId || !data) return
autoJob.value = data
scheduleAutoSchedulePoll(data.id, planId)
}
async function loadPlans(keepSelection = true) {
loading.value = true
try {
plans.value = (await http.get('/schedules/plans', {
params: { academicTermId: termId.value },
})).data
const id = keepSelection && selected.value
? selected.value.id
: plans.value[0]?.id
if (id && plans.value.some((item) => item.id === id)) {
await loadDetail(id)
} else {
selected.value = null
clearAutoSchedulePoll()
autoJob.value = null
clearPublishPoll()
publishJob.value = null
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function loadDetail(id: string, resumeJob = true) {
detailLoading.value = true
try {
selected.value = (await http.get(`/schedules/plans/${id}`)).data
if (resumeJob && selected.value.status === 'Draft') {
await Promise.all([resumeAutoSchedule(id), resumePublish(id)])
} else if (resumeJob) {
clearAutoSchedulePoll()
autoJob.value = null
await resumePublish(id)
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
detailLoading.value = false
}
}
function openPlan(plan?: any) {
editingPlanId.value = plan?.id ?? ''
Object.assign(planForm, {
academicTermId: plan?.academicTermId ?? termId.value,
name: plan?.name ?? '',
version: plan?.version ?? `V${plans.value.length + 1}`,
notes: plan?.notes ?? '',
})
planDialog.value = true
}
async function savePlan() {
if (!planForm.academicTermId || !planForm.name?.trim() || !planForm.version?.trim()) {
ElMessage.warning('请填写学期、版本名称和版本号。')
return
}
try {
if (editingPlanId.value) {
await http.put(`/schedules/plans/${editingPlanId.value}`, planForm)
} else {
await http.post('/schedules/plans', planForm)
}
planDialog.value = false
await loadPlans(false)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function openClone() {
revisingPublishedPlan.value = selected.value.status === 'Published'
Object.assign(cloneForm, {
name: `${selected.value.name}(修订版)`,
version: `V${plans.value.length + 1}`,
})
cloneDialog.value = true
}
async function clonePlan() {
try {
const { data } = await http.post(
`/schedules/plans/${selected.value.id}/clone`,
cloneForm,
)
cloneDialog.value = false
ElMessage.success(
revisingPublishedPlan.value
? '已建立修订草稿,原已发布课表继续生效'
: '已复制为可调整的草稿版本',
)
await loadPlans(false)
if (data.id && selected.value?.id !== data.id) await loadDetail(data.id)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function publishPlan() {
try {
await ElMessageBox.confirm(
'系统将再次检查全部教师、行政班和教室冲突;发布后本版本锁定并替换旧课表,后续仍可通过“发布后修改”建立修订版。',
'发布课表',
{ type: 'warning', confirmButtonText: '检查并发布', cancelButtonText: '取消' },
)
const planId = selected.value.id
const { data } = await http.post(`/schedules/plans/${planId}/publish`)
publishJob.value = data
ElMessage.success('检查并发布任务已提交,可在当前页面查看进度')
schedulePublishPoll(data.id, planId)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function deletePlan() {
try {
await ElMessageBox.confirm('删除该草稿及全部排课条目?', '删除排课草稿', {
type: 'warning',
})
await http.delete(`/schedules/plans/${selected.value.id}`)
await loadPlans(false)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
function openEntry(entry?: any, day?: number, period?: number) {
editingEntryId.value = entry?.id ?? ''
Object.assign(entryForm, {
teachingTaskId: entry?.teachingTaskId,
kind: entry?.kind ?? 'Lecture',
classroomId: entry?.classroomId,
dayOfWeek: entry?.dayOfWeek ?? day ?? 1,
startPeriod: entry?.startPeriod ?? period ?? 1,
periodCount: entry?.periodCount ?? 2,
startWeek: entry?.startWeek ?? 1,
endWeek: entry?.endWeek ?? 16,
weekPattern: entry?.weekPattern ?? 'All',
notes: entry?.notes ?? '',
})
entryDialog.value = true
}
function changeEntryTask() {
if (editingEntryId.value) return
const task = selectedTaskConstraint.value
if (!task) return
entryForm.startWeek = task.startWeek
entryForm.endWeek = task.endWeek
if (task.allowedDayOfWeeks?.length &&
!task.allowedDayOfWeeks.includes(entryForm.dayOfWeek)) {
entryForm.dayOfWeek = task.allowedDayOfWeeks[0]
}
if (task.requiresClassroom === false && entryForm.kind !== 'Experiment') {
entryForm.classroomId = null
return
}
const room = classrooms.value.find((item) => item.id === entryForm.classroomId)
if (room && (
(task.requiredCampusId && room.campusId !== task.requiredCampusId) ||
(task.requiredBuildingId && room.buildingId !== task.requiredBuildingId) ||
(task.allowedClassroomIds?.length && !task.allowedClassroomIds.includes(room.id)) ||
(entryForm.kind === 'Experiment' && !isExperimentRoom(room))
)) {
entryForm.classroomId = null
}
}
function changeEntryKind() {
const room = classrooms.value.find((item) => item.id === entryForm.classroomId)
if (entryForm.kind === 'Experiment' && room && !isExperimentRoom(room)) {
entryForm.classroomId = null
}
}
async function saveEntry() {
if (!entryForm.teachingTaskId ||
((entryForm.kind === 'Experiment' ||
selectedTaskConstraint.value?.requiresClassroom !== false) &&
!entryForm.classroomId)) {
ElMessage.warning('请选择教学任务,并按课程要求选择教室。')
return
}
const task = selectedTaskConstraint.value
if (task && (
entryForm.startWeek < task.startWeek ||
entryForm.endWeek > task.endWeek
)) {
ElMessage.warning(
`排课周次必须位于该教学任务的第 ${task.startWeek}${task.endWeek} 周内。`,
)
return
}
if (task?.allowedDayOfWeeks?.length &&
!task.allowedDayOfWeeks.includes(entryForm.dayOfWeek)) {
ElMessage.warning('所选星期不在该教学任务允许的上课日内。')
return
}
if (entryForm.kind !== 'Experiment' &&
selectedTaskConstraint.value?.requiresClassroom === false) entryForm.classroomId = null
try {
const base = `/schedules/plans/${selected.value.id}/entries`
if (editingEntryId.value) await http.put(`${base}/${editingEntryId.value}`, entryForm)
else await http.post(base, entryForm)
entryDialog.value = false
ElMessage.success(editingEntryId.value ? '排课已调整' : '排课已添加')
await loadDetail(selected.value.id)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function deleteEntry(entry: any) {
try {
await ElMessageBox.confirm(`移除“${entry.taskName}”的这条安排?`, '移除排课', {
type: 'warning',
})
await http.delete(`/schedules/plans/${selected.value.id}/entries/${entry.id}`)
await loadDetail(selected.value.id)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
onMounted(async () => {
const [termRes, classroomRes, campusRes, buildingRes] = await Promise.all([
http.get('/base-data/terms'),
http.get('/base-data/classrooms'),
http.get('/base-data/campuses'),
http.get('/base-data/buildings'),
])
terms.value = termRes.data
classrooms.value = classroomRes.data.filter((item: any) => item.isEnabled)
campuses.value = campusRes.data.filter((item: any) => item.isEnabled)
buildings.value = buildingRes.data.filter((item: any) => item.isEnabled)
termId.value = defaultAcademicTermId(terms.value)
await Promise.all([loadPlans(false), loadSchedulingSettings()])
})
onBeforeUnmount(() => {
clearAutoSchedulePoll()
clearPublishPoll()
})
</script>
<template>
<div class="page-stack">
<section class="page-intro">
<div>
<span class="section-kicker">TIMETABLE BOARD</span>
<h2>排课与课表</h2>
<p>用作息时间和课程约束驱动教师班级场地自动分配生成后仍可逐项微调</p>
</div>
<div class="page-actions">
<el-button :icon="Setting" @click="settingsDrawer = true">排课规则与作息</el-button>
<el-button type="primary" :icon="Plus" @click="openPlan()">新建排课版本</el-button>
</div>
</section>
<section class="schedule-toolbar">
<el-select v-model="termId" placeholder="选择学期" @change="changeTerm">
<el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" />
</el-select>
<div class="schedule-version-strip">
<button
v-for="plan in plans"
:key="plan.id"
type="button"
:class="{ active: selected?.id === plan.id }"
@click="loadDetail(plan.id)"
>
<b>{{ plan.version }}</b>
<span>{{ statusLabels[plan.status] }}</span>
<small>{{ plan.entryCount }} 条安排</small>
</button>
<span v-if="plans.length === 0">该学期还没有排课版本</span>
</div>
</section>
<section v-if="selected" v-loading="detailLoading" class="schedule-sheet">
<header class="schedule-sheet-head">
<div>
<span>{{ selected.termName }} · {{ selected.version }}</span>
<h3>{{ selected.name }}</h3>
<p> {{ selected.entries.length }} 条安排 · {{ statusLabels[selected.status] }}</p>
</div>
<div class="plan-actions">
<el-button v-if="isDraft" :disabled="scheduleJobLoading" @click="openPlan(selected)">编辑版本</el-button>
<el-button
v-if="selected.status === 'Published'"
type="warning"
plain
:icon="EditPen"
@click="openClone"
>
发布后修改
</el-button>
<el-button
v-else
:icon="CopyDocument"
:disabled="scheduleJobLoading"
@click="openClone"
>
复制调整
</el-button>
<el-button
v-if="isDraft"
type="warning"
plain
:icon="Promotion"
:loading="autoLoading"
:disabled="publishLoading"
@click="autoSchedule"
>
自动排课
</el-button>
<el-button v-if="isDraft" type="primary" :icon="Plus" :disabled="scheduleJobLoading" @click="openEntry()">添加排课</el-button>
<el-button
v-if="isDraft"
type="success"
:icon="Promotion"
:loading="publishLoading"
:disabled="autoLoading"
@click="publishPlan"
>
{{ publishLoading ? '检查并发布中' : '发布课表' }}
</el-button>
<el-button v-if="isDraft" type="danger" plain :disabled="scheduleJobLoading" @click="deletePlan">删除草稿</el-button>
</div>
</header>
<div
v-if="autoJob"
class="auto-schedule-progress"
:class="`is-${String(autoJob.status).toLowerCase()}`"
>
<div>
<b>{{ autoStatusText }}</b>
<span v-if="autoJob.status === 'Running'">
后台运行中离开页面不会中断任务
</span>
<span v-else-if="autoJob.status === 'Succeeded' && autoJob.messages.length">
{{ autoJob.messages.length }} 个教学任务仍需人工处理
</span>
</div>
<el-progress
:percentage="autoProgress"
:status="autoProgressStatus"
:indeterminate="autoJob.status === 'Queued'"
:duration="2"
/>
<div
v-if="autoJob.status === 'Succeeded' && autoJob.messages.length"
class="auto-schedule-issues"
>
<div class="auto-schedule-issues-head">
<b>未完成任务与处理建议</b>
<el-button size="small" type="warning" plain @click="openManualHandling">
检查排课约束
</el-button>
</div>
<ol>
<li v-for="message in autoJob.messages" :key="message">{{ message }}</li>
</ol>
</div>
</div>
<div
v-if="publishJob"
class="auto-schedule-progress"
:class="`is-${String(publishJob.status).toLowerCase()}`"
>
<div>
<b>{{ publishStatusText }}</b>
<span v-if="publishLoading">后台运行中离开页面不会中断任务</span>
<span v-else-if="publishJob.status === 'Failed'">
草稿未发布请根据上方原因调整后重试
</span>
</div>
<el-progress
:percentage="publishProgress"
:status="publishProgressStatus"
:indeterminate="publishJob.status === 'Queued'"
:duration="2"
/>
</div>
<div class="schedule-search">
<el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="筛选课程、教师、行政班或教室" />
<el-button :icon="Refresh" @click="keyword = ''">清除筛选</el-button>
<span>单双周和周次范围显示在课程卡片内</span>
</div>
<div class="timetable-scroll">
<div class="timetable-grid">
<div class="timetable-corner">节次</div>
<div v-for="day in weekdays" :key="day.value" class="timetable-day">{{ day.label }}</div>
<template v-for="period in periods" :key="period">
<div class="timetable-period">
<b>{{ period }}</b>
<span>{{ timeLabel(period) }}</span>
</div>
<div
v-for="day in weekdays"
:key="`${day.value}-${period}`"
class="timetable-cell"
:class="{ editable: isDraft }"
@dblclick="isDraft && openEntry(undefined, day.value, period)"
>
<article
v-for="entry in entriesAt(day.value, period)"
:key="entry.id"
class="schedule-card"
:class="{ readonly: !isDraft, experiment: entry.kind === 'Experiment' }"
@click="isDraft && openEntry(entry)"
>
<span>
{{ entry.kind === 'Experiment' ? '实验课' : '理论课' }} ·
{{ entry.courseCode }} · {{ patternLabels[entry.weekPattern] }}
</span>
<b>{{ entry.courseName }}</b>
<small>{{ entry.teacherNames.join('、') }} · {{ entry.classroomName || '不占用教室' }}</small>
<i>{{ entry.startWeek }}{{ entry.endWeek }} / 连上 {{ entry.periodCount }} </i>
<button v-if="isDraft" type="button" @click.stop="deleteEntry(entry)">×</button>
</article>
<span v-if="isDraft && entriesAt(day.value, period).length === 0" class="cell-add">双击添加</span>
</div>
</template>
</div>
</div>
</section>
<el-empty v-else description="请选择或新建一个排课版本" />
<el-dialog v-model="planDialog" :title="editingPlanId ? '编辑排课版本' : '新建排课版本'" width="560px">
<el-form label-position="top">
<el-form-item label="学期" required><el-select v-model="planForm.academicTermId"><el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" /></el-select></el-form-item>
<el-form-item label="版本名称" required><el-input v-model="planForm.name" placeholder="如:第一轮正式课表" /></el-form-item>
<el-form-item label="版本号" required><el-input v-model="planForm.version" placeholder="如 V1" /></el-form-item>
<el-form-item label="备注"><el-input v-model="planForm.notes" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer><el-button @click="planDialog = false">取消</el-button><el-button type="primary" @click="savePlan">保存</el-button></template>
</el-dialog>
<el-dialog
v-model="cloneDialog"
:title="revisingPublishedPlan ? '发布后修改课表' : '复制排课版本'"
width="520px"
>
<el-alert
v-if="revisingPublishedPlan"
title="系统会建立一份可编辑的修订草稿;原课表在修订版重新发布前继续生效,不会影响教师和学生查课。"
type="info"
:closable="false"
show-icon
/>
<el-form label-position="top">
<el-form-item label="新版本名称" required><el-input v-model="cloneForm.name" /></el-form-item>
<el-form-item label="新版本号" required><el-input v-model="cloneForm.version" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="cloneDialog = false">取消</el-button>
<el-button type="primary" @click="clonePlan">
{{ revisingPublishedPlan ? '建立修订草稿' : '复制' }}
</el-button>
</template>
</el-dialog>
<el-dialog v-model="entryDialog" :title="editingEntryId ? '调整排课' : '添加排课'" width="680px">
<el-form label-position="top">
<el-form-item label="教学任务" required>
<el-select v-model="entryForm.teachingTaskId" filterable @change="changeEntryTask">
<el-option v-for="item in tasks" :key="item.id" :label="`${item.taskNumber} · ${item.name}`" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="课次类型" required>
<el-radio-group v-model="entryForm.kind" @change="changeEntryKind">
<el-radio-button value="Lecture">理论课</el-radio-button>
<el-radio-button value="Experiment" :disabled="!selectedTaskConstraint?.coursePracticeHours">
实验课
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-alert
v-if="selectedTaskConstraint"
:title="`课程共 ${selectedTaskConstraint.courseTotalHours} 学时:理论 ${selectedTaskConstraint.courseTotalHours - selectedTaskConstraint.coursePracticeHours} 学时、实验 ${selectedTaskConstraint.coursePracticeHours} 学时,均须在本课表排足;可排第 ${selectedTaskConstraint.startWeek}—${selectedTaskConstraint.endWeek} 周;允许上课日:${entryWeekdays.map((day) => day.label).join('、')}`"
type="info"
:closable="false"
show-icon
/>
<el-alert
v-if="selectedTaskConstraint?.requiresClassroom === false && entryForm.kind !== 'Experiment'"
title="该课程不占用教室,仍会校验教师和行政班时间冲突。"
type="info"
:closable="false"
show-icon
/>
<el-form-item
v-else
:label="entryForm.kind === 'Experiment' ? '实验室 / 实训室 / 机房' : '教室'"
required
:hint="selectedTaskConstraint?.requiredBuildingId ? '仅显示约束范围内教室' : ''"
>
<el-select v-model="entryForm.classroomId" filterable>
<el-option
v-for="item in entryClassrooms"
:key="item.id"
:label="`${item.campusName} / ${item.buildingName} / ${item.name}${item.roomType}${item.capacity}人)`"
:value="item.id"
/>
</el-select>
</el-form-item>
<div class="form-grid three">
<el-form-item label="星期"><el-select v-model="entryForm.dayOfWeek"><el-option v-for="day in entryWeekdays" :key="day.value" :label="day.label" :value="day.value" /></el-select></el-form-item>
<el-form-item label="开始节次">
<el-select v-model="entryForm.startPeriod">
<el-option
v-for="period in periods"
:key="period"
:label="`第 ${period} 节 · ${timeLabel(period)}`"
:value="period"
/>
</el-select>
</el-form-item>
<el-form-item label="连续节数"><el-input-number v-model="entryForm.periodCount" :min="1" :max="6" /></el-form-item>
</div>
<div class="form-grid three">
<el-form-item label="开始周"><el-input-number v-model="entryForm.startWeek" :min="1" :max="30" /></el-form-item>
<el-form-item label="结束周"><el-input-number v-model="entryForm.endWeek" :min="1" :max="30" /></el-form-item>
<el-form-item label="单双周"><el-select v-model="entryForm.weekPattern"><el-option v-for="(label, value) in patternLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
</div>
<el-form-item label="备注"><el-input v-model="entryForm.notes" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer><el-button @click="entryDialog = false">取消</el-button><el-button type="primary" @click="saveEntry">检查冲突并保存</el-button></template>
</el-dialog>
<el-drawer v-model="settingsDrawer" title="排课规则与作息" size="760px" class="schedule-settings-drawer">
<el-tabs v-model="settingsTab">
<el-tab-pane label="上课时间表" name="time">
<div class="settings-lead">
<div>
<b>上课时间表</b>
<span>当前学期的节次上下课时间和可排课状态自动排课只使用启用节次</span>
</div>
<div>
<el-button v-if="!timeSlots.length" @click="initializeTimeSlots">载入常用作息</el-button>
<el-button @click="addTimeSlot">增加节次</el-button>
<el-button type="primary" @click="saveTimeSlots">保存时间表</el-button>
</div>
</div>
<el-table :data="timeSlots" class="settings-table">
<el-table-column label="节次" width="100">
<template #default="{ row }"><el-input-number v-model="row.periodNumber" :min="1" :max="30" controls-position="right" /></template>
</el-table-column>
<el-table-column label="名称" min-width="130">
<template #default="{ row }"><el-input v-model="row.name" /></template>
</el-table-column>
<el-table-column label="上课" width="130">
<template #default="{ row }"><el-time-select v-model="row.startsAt" start="06:00" step="00:05" end="23:00" /></template>
</el-table-column>
<el-table-column label="下课" width="130">
<template #default="{ row }"><el-time-select v-model="row.endsAt" start="06:00" step="00:05" end="23:00" /></template>
</el-table-column>
<el-table-column label="可排课" width="90">
<template #default="{ row }"><el-switch v-model="row.isEnabled" /></template>
</el-table-column>
<el-table-column width="60">
<template #default="{ $index }"><el-button link type="danger" @click="timeSlots.splice($index, 1)">移除</el-button></template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="课程排课约束" name="constraints">
<div class="settings-lead">
<div>
<b>课程排课约束</b>
<span>筛选后可批量修改当前结果非排时课程不占用正常时间和场地</span>
</div>
<div>
<el-button @click="resetConstraintFilters">重置筛选</el-button>
<el-button
type="primary"
:disabled="!filteredConstraints.length"
@click="openConstraintBatch"
>批量修改当前结果{{ filteredConstraints.length }}</el-button>
</div>
</div>
<div class="constraint-filter-grid">
<el-input
v-model="constraintFilters.keyword"
clearable
placeholder="任务、课程或教师"
:prefix-icon="Search"
/>
<el-select v-model="constraintFilters.collegeId" clearable placeholder="全部开课单位">
<el-option v-for="item in constraintColleges" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="constraintFilters.schedulingMode" clearable placeholder="全部授课方式">
<el-option label="正常排课" value="Standard" />
<el-option label="非排时课程" value="Flexible" />
</el-select>
<el-select v-model="constraintFilters.classroomMode" clearable placeholder="全部场地要求">
<el-option label="需要教室" value="required" />
<el-option label="不占教室" value="not-required" />
</el-select>
<el-select v-model="constraintFilters.constraintState" clearable placeholder="全部约束状态">
<el-option label="已自定义约束" value="custom" />
<el-option label="使用默认约束" value="default" />
</el-select>
</div>
<div class="constraint-result-summary">
{{ constraints.length }} 个教学任务当前显示 {{ filteredConstraints.length }}
</div>
<div class="constraint-list">
<article v-for="item in filteredConstraints" :key="item.id">
<div>
<span>{{ item.taskNumber }} · 每周 {{ item.weeklyHours }} 学时</span>
<b>{{ item.name }} · {{ item.courseName }}</b>
<small>{{ item.collegeName }} · {{ item.teacherNames.join('、') || '未分配教师' }} · {{ item.capacity }} </small>
</div>
<div class="constraint-badges">
<el-tag v-if="item.schedulingMode === 'Flexible'" type="success">非排时课程</el-tag>
<el-tag :type="item.requiresClassroom ? 'primary' : 'info'">
{{ item.requiresClassroom ? '占用教室' : '不占教室' }}
</el-tag>
<el-tag v-if="item.requiredBuildingId" type="warning">限定教学楼</el-tag>
<el-tag v-if="item.allowedClassroomIds.length" type="warning">
指定 {{ item.allowedClassroomIds.length }} 间教室
</el-tag>
</div>
<el-button @click="openConstraint(item)">设置约束</el-button>
</article>
<el-empty v-if="!filteredConstraints.length" description="没有符合当前筛选条件的教学任务" />
</div>
</el-tab-pane>
</el-tabs>
</el-drawer>
<el-dialog v-model="constraintDialog" title="设置课程排课约束" width="720px">
<div class="constraint-title">{{ constraintForm.title }}</div>
<el-form label-position="top">
<el-form-item label="授课方式">
<el-radio-group v-model="constraintForm.schedulingMode">
<el-radio-button value="Standard">正常排课</el-radio-button>
<el-radio-button value="Flexible">非排时课程</el-radio-button>
</el-radio-group>
<small class="field-hint">非排时课程不进入自动排课并在班级和个人课表中单独显示</small>
</el-form-item>
<template v-if="constraintForm.schedulingMode === 'Standard'">
<el-form-item>
<el-switch
v-model="constraintForm.requiresClassroom"
active-text="需要占用教室"
inactive-text="不占用教室"
/>
</el-form-item>
<template v-if="constraintForm.requiresClassroom">
<div class="form-grid">
<el-form-item label="限定校区">
<el-select v-model="constraintForm.requiredCampusId" clearable @change="constraintForm.requiredBuildingId = undefined; constraintForm.allowedClassroomIds = []">
<el-option v-for="item in campuses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="限定教学楼">
<el-select v-model="constraintForm.requiredBuildingId" clearable @change="constraintForm.allowedClassroomIds = []">
<el-option v-for="item in filteredBuildings" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
</div>
<el-form-item label="指定可用教室">
<el-select v-model="constraintForm.allowedClassroomIds" multiple filterable collapse-tags>
<el-option
v-for="item in filteredClassrooms"
:key="item.id"
:label="`${item.buildingName} / ${item.name}${item.capacity}人)`"
:value="item.id"
/>
</el-select>
</el-form-item>
</template>
<el-form-item label="允许上课日">
<el-checkbox-group v-model="constraintForm.allowedDayOfWeeks">
<el-checkbox v-for="day in weekdays" :key="day.value" :value="day.value">{{ day.label }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
<div class="form-grid">
<el-form-item label="最早开始节次">
<el-select v-model="constraintForm.earliestPeriod" clearable>
<el-option v-for="period in periods" :key="period" :label="`第 ${period} 节 · ${timeLabel(period)}`" :value="period" />
</el-select>
</el-form-item>
<el-form-item label="最晚结束节次">
<el-select v-model="constraintForm.latestPeriod" clearable>
<el-option v-for="period in periods" :key="period" :label="`第 ${period} 节 · ${timeLabel(period)}`" :value="period" />
</el-select>
</el-form-item>
</div>
</template>
</el-form>
<template #footer>
<el-button @click="constraintDialog = false">取消</el-button>
<el-button type="primary" @click="saveConstraint">保存约束</el-button>
</template>
</el-dialog>
<el-dialog v-model="constraintBatchDialog" title="批量修改当前筛选结果" width="720px">
<div class="constraint-title">
将作用于当前筛选到的 {{ filteredConstraints.length }} 个教学任务
</div>
<el-alert
title="仅勾选需要修改的项目,未勾选的设置保持原值。"
type="info"
:closable="false"
show-icon
/>
<el-form label-position="top" class="constraint-batch-form">
<el-checkbox v-model="constraintBatchForm.updateSchedulingMode">修改授课方式</el-checkbox>
<el-form-item v-if="constraintBatchForm.updateSchedulingMode" label="统一授课方式">
<el-radio-group v-model="constraintBatchForm.schedulingMode">
<el-radio-button value="Standard">正常排课</el-radio-button>
<el-radio-button value="Flexible">非排时课程</el-radio-button>
</el-radio-group>
</el-form-item>
<template v-if="!(constraintBatchForm.updateSchedulingMode && constraintBatchForm.schedulingMode === 'Flexible')">
<el-checkbox v-model="constraintBatchForm.updateRequiresClassroom">修改场地要求</el-checkbox>
<el-form-item v-if="constraintBatchForm.updateRequiresClassroom" label="统一场地要求">
<el-switch
v-model="constraintBatchForm.requiresClassroom"
active-text="需要占用教室"
inactive-text="不占用教室"
@change="!constraintBatchForm.requiresClassroom && (constraintBatchForm.updateClassroomScope = false)"
/>
</el-form-item>
<template v-if="!(constraintBatchForm.updateRequiresClassroom && !constraintBatchForm.requiresClassroom)">
<el-checkbox v-model="constraintBatchForm.updateClassroomScope">
批量指定教室范围
</el-checkbox>
<div v-if="constraintBatchForm.updateClassroomScope" class="batch-classroom-scope">
<el-alert
title="将统一设置为需要教室;校区、教学楼和教室均不选择时,表示清除原有限定并允许系统自动分配。"
type="warning"
:closable="false"
show-icon
/>
<div class="form-grid">
<el-form-item label="统一限定校区">
<el-select
v-model="constraintBatchForm.requiredCampusId"
clearable
@change="constraintBatchForm.requiredBuildingId = undefined; constraintBatchForm.allowedClassroomIds = []"
>
<el-option v-for="item in campuses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="统一限定教学楼">
<el-select
v-model="constraintBatchForm.requiredBuildingId"
clearable
@change="constraintBatchForm.allowedClassroomIds = []"
>
<el-option v-for="item in batchFilteredBuildings" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
</div>
<el-form-item label="统一指定可用教室">
<el-select
v-model="constraintBatchForm.allowedClassroomIds"
multiple
filterable
collapse-tags
collapse-tags-tooltip
placeholder="不选择则允许范围内任意教室"
>
<el-option
v-for="item in batchFilteredClassrooms"
:key="item.id"
:label="`${item.buildingName} / ${item.name}${item.capacity}人)`"
:value="item.id"
/>
</el-select>
</el-form-item>
</div>
</template>
<el-checkbox v-model="constraintBatchForm.updateDays">修改允许上课日</el-checkbox>
<el-form-item v-if="constraintBatchForm.updateDays" label="统一允许上课日">
<el-checkbox-group v-model="constraintBatchForm.allowedDayOfWeeks">
<el-checkbox v-for="day in weekdays" :key="day.value" :value="day.value">{{ day.label }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-checkbox v-model="constraintBatchForm.updatePeriodRange">修改节次范围</el-checkbox>
<div v-if="constraintBatchForm.updatePeriodRange" class="form-grid">
<el-form-item label="统一最早开始节次">
<el-select v-model="constraintBatchForm.earliestPeriod" clearable>
<el-option v-for="period in periods" :key="period" :label="`第 ${period} 节`" :value="period" />
</el-select>
</el-form-item>
<el-form-item label="统一最晚结束节次">
<el-select v-model="constraintBatchForm.latestPeriod" clearable>
<el-option v-for="period in periods" :key="period" :label="`第 ${period} 节`" :value="period" />
</el-select>
</el-form-item>
</div>
</template>
</el-form>
<template #footer>
<el-button @click="constraintBatchDialog = false">取消</el-button>
<el-button type="primary" :loading="constraintBatchSaving" @click="saveConstraintBatch">
修改当前结果
</el-button>
</template>
</el-dialog>
</div>
</template>