实验课场地区块改为独立卡片布局,校区、教学楼和场地选择不再被嵌套表单挤压。 批量设置新增独立的“批量指定实验课场地”入口,包含场地性质、实验校区、教学楼和具体场地,并会联动筛选。 后端会保存并校验批量实验场地范围,避免跨校区/教学楼的无效设置。
1525 lines
65 KiB
Vue
1525 lines
65 KiB
Vue
<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 experimentVenueNatures = [
|
||
{ value: 1, label: '普通教室' }, { value: 2, label: '实验室' },
|
||
{ value: 4, label: '实训室' }, { value: 8, label: '计算机机房' },
|
||
{ value: 16, label: '语音室' }, { value: 32, label: '体育场地' },
|
||
{ value: 64, label: '艺术场地' },
|
||
]
|
||
const venueNatureValue = (value: unknown) => {
|
||
if (typeof value === 'number') return value
|
||
if (typeof value !== 'string') return 0
|
||
const names: Record<string, number> = {
|
||
GeneralClassroom: 1, Laboratory: 2, TrainingRoom: 4, ComputerLab: 8,
|
||
LanguageLab: 16, SportsVenue: 32, ArtsVenue: 64,
|
||
}
|
||
return value.split(',').reduce((sum, name) => sum | (names[name.trim()] ?? 0), 0)
|
||
}
|
||
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 entryClassrooms = computed(() =>
|
||
classrooms.value.filter((room) =>
|
||
(entryForm.kind === 'Experiment' || !selectedTaskConstraint.value?.requiredCampusId
|
||
|| room.campusId === selectedTaskConstraint.value.requiredCampusId) &&
|
||
(entryForm.kind === 'Experiment' || !selectedTaskConstraint.value?.requiredBuildingId
|
||
|| room.buildingId === selectedTaskConstraint.value.requiredBuildingId) &&
|
||
(entryForm.kind === 'Experiment' || !selectedTaskConstraint.value?.allowedClassroomIds?.length
|
||
|| selectedTaskConstraint.value.allowedClassroomIds.includes(room.id)) &&
|
||
(entryForm.kind !== 'Experiment' || !selectedTaskConstraint.value?.experimentRequiredCampusId
|
||
|| room.campusId === selectedTaskConstraint.value.experimentRequiredCampusId) &&
|
||
(entryForm.kind !== 'Experiment' || !selectedTaskConstraint.value?.experimentRequiredBuildingId
|
||
|| room.buildingId === selectedTaskConstraint.value.experimentRequiredBuildingId) &&
|
||
(entryForm.kind !== 'Experiment' ||
|
||
!selectedTaskConstraint.value?.allowedExperimentClassroomIds?.length ||
|
||
selectedTaskConstraint.value.allowedExperimentClassroomIds.includes(room.id)),
|
||
),
|
||
)
|
||
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 filteredExperimentBuildings = computed(() =>
|
||
constraintForm.experimentRequiredCampusId
|
||
? buildings.value.filter((item) => item.campusId === constraintForm.experimentRequiredCampusId)
|
||
: buildings.value,
|
||
)
|
||
const filteredExperimentClassrooms = computed(() =>
|
||
classrooms.value.filter((item) =>
|
||
(!constraintForm.experimentRequiredCampusId ||
|
||
item.campusId === constraintForm.experimentRequiredCampusId) &&
|
||
(!constraintForm.experimentRequiredBuildingId ||
|
||
item.buildingId === constraintForm.experimentRequiredBuildingId) &&
|
||
(!(constraintForm.allowedExperimentVenueNatures ?? []).length ||
|
||
(venueNatureValue(item.teachingVenueNature) & (constraintForm.allowedExperimentVenueNatures ?? [])
|
||
.reduce((value: number, nature: number) => value | nature, 0)) !== 0),
|
||
),
|
||
)
|
||
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 batchFilteredExperimentBuildings = computed(() =>
|
||
constraintBatchForm.experimentRequiredCampusId
|
||
? buildings.value.filter((item) => item.campusId === constraintBatchForm.experimentRequiredCampusId)
|
||
: buildings.value,
|
||
)
|
||
const batchFilteredExperimentClassrooms = computed(() =>
|
||
classrooms.value.filter((item) =>
|
||
(!constraintBatchForm.experimentRequiredCampusId ||
|
||
item.campusId === constraintBatchForm.experimentRequiredCampusId) &&
|
||
(!constraintBatchForm.experimentRequiredBuildingId ||
|
||
item.buildingId === constraintBatchForm.experimentRequiredBuildingId) &&
|
||
(!(constraintBatchForm.allowedExperimentVenueNatures ?? []).length ||
|
||
(venueNatureValue(item.teachingVenueNature) &
|
||
(constraintBatchForm.allowedExperimentVenueNatures ?? [])
|
||
.reduce((value: number, nature: number) => value | nature, 0)) !== 0),
|
||
),
|
||
)
|
||
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}`,
|
||
coursePracticeHours: item.coursePracticeHours,
|
||
schedulingMode: item.schedulingMode,
|
||
requiresClassroom: item.requiresClassroom,
|
||
requiredCampusId: item.requiredCampusId,
|
||
requiredBuildingId: item.requiredBuildingId,
|
||
experimentRequiredCampusId: item.experimentRequiredCampusId,
|
||
experimentRequiredBuildingId: item.experimentRequiredBuildingId,
|
||
allowedClassroomIds: [...item.allowedClassroomIds],
|
||
allowedExperimentClassroomIds: [...item.allowedExperimentClassroomIds],
|
||
allowedExperimentVenueNatures: experimentVenueNatures
|
||
.filter((nature) => (Number(item.allowedExperimentVenueNatures) & nature.value) !== 0)
|
||
.map((nature) => nature.value),
|
||
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,
|
||
experimentRequiredCampusId: constraintForm.experimentRequiredCampusId || null,
|
||
experimentRequiredBuildingId: constraintForm.experimentRequiredBuildingId || null,
|
||
allowedClassroomIds: constraintForm.allowedClassroomIds ?? [],
|
||
allowedExperimentClassroomIds: constraintForm.allowedExperimentClassroomIds ?? [],
|
||
allowedExperimentVenueNatures: (constraintForm.allowedExperimentVenueNatures ?? [])
|
||
.reduce((value: number, nature: number) => value | nature, 0),
|
||
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: [],
|
||
updateExperimentClassroomScope: false,
|
||
experimentRequiredCampusId: undefined,
|
||
experimentRequiredBuildingId: undefined,
|
||
allowedExperimentVenueNatures: [],
|
||
allowedExperimentClassroomIds: [],
|
||
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.updateExperimentClassroomScope &&
|
||
!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 updateExperimentClassroomScope = !flexible &&
|
||
!(constraintBatchForm.updateRequiresClassroom && !constraintBatchForm.requiresClassroom) &&
|
||
constraintBatchForm.updateExperimentClassroomScope
|
||
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,
|
||
updateExperimentClassroomScope,
|
||
experimentRequiredCampusId: updateExperimentClassroomScope
|
||
? constraintBatchForm.experimentRequiredCampusId || null
|
||
: null,
|
||
experimentRequiredBuildingId: updateExperimentClassroomScope
|
||
? constraintBatchForm.experimentRequiredBuildingId || null
|
||
: null,
|
||
allowedExperimentVenueNatures: updateExperimentClassroomScope
|
||
? (constraintBatchForm.allowedExperimentVenueNatures ?? [])
|
||
.reduce((value: number, nature: number) => value | nature, 0)
|
||
: null,
|
||
allowedExperimentClassroomIds: updateExperimentClassroomScope
|
||
? constraintBatchForm.allowedExperimentClassroomIds
|
||
: 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 && (
|
||
(entryForm.kind !== 'Experiment' && task.requiredCampusId && room.campusId !== task.requiredCampusId) ||
|
||
(entryForm.kind !== 'Experiment' && task.requiredBuildingId && room.buildingId !== task.requiredBuildingId) ||
|
||
(entryForm.kind !== 'Experiment' && task.allowedClassroomIds?.length && !task.allowedClassroomIds.includes(room.id)) ||
|
||
(entryForm.kind === 'Experiment' && task.experimentRequiredCampusId && room.campusId !== task.experimentRequiredCampusId) ||
|
||
(entryForm.kind === 'Experiment' && task.experimentRequiredBuildingId && room.buildingId !== task.experimentRequiredBuildingId) ||
|
||
(entryForm.kind === 'Experiment' && task.allowedExperimentClassroomIds?.length &&
|
||
!task.allowedExperimentClassroomIds.includes(room.id))
|
||
)) {
|
||
entryForm.classroomId = null
|
||
}
|
||
}
|
||
|
||
function changeEntryKind() {
|
||
const room = classrooms.value.find((item) => item.id === entryForm.classroomId)
|
||
const task = selectedTaskConstraint.value
|
||
if (entryForm.kind === 'Experiment' && room && task?.allowedExperimentClassroomIds?.length &&
|
||
!task.allowedExperimentClassroomIds.includes(room.id)) {
|
||
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>
|
||
<el-form-item label="实验课允许的场地性质">
|
||
<el-checkbox-group v-model="constraintForm.allowedExperimentVenueNatures">
|
||
<el-checkbox v-for="nature in experimentVenueNatures" :key="nature.value" :value="nature.value">{{ nature.label }}</el-checkbox>
|
||
</el-checkbox-group>
|
||
<small class="field-hint">仅约束实验课;不勾选时可使用全部实验教学场地。</small>
|
||
</el-form-item>
|
||
<section v-if="constraintForm.coursePracticeHours" class="experiment-classroom-scope">
|
||
<div class="experiment-classroom-scope__title">实验课指定可用场地</div>
|
||
<div class="form-grid">
|
||
<el-form-item label="实验课限定校区">
|
||
<el-select v-model="constraintForm.experimentRequiredCampusId" clearable @change="constraintForm.experimentRequiredBuildingId = undefined; constraintForm.allowedExperimentClassroomIds = []">
|
||
<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.experimentRequiredBuildingId" clearable @change="constraintForm.allowedExperimentClassroomIds = []">
|
||
<el-option v-for="item in filteredExperimentBuildings" :key="item.id" :label="item.name" :value="item.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</div>
|
||
<el-form-item label="实验课指定可用场地">
|
||
<el-select v-model="constraintForm.allowedExperimentClassroomIds" multiple filterable collapse-tags>
|
||
<el-option
|
||
v-for="item in filteredExperimentClassrooms"
|
||
:key="item.id"
|
||
:label="`${item.buildingName} / ${item.name}(${item.roomType},${item.capacity}人)`"
|
||
:value="item.id"
|
||
/>
|
||
</el-select>
|
||
<small class="field-hint">可在上方场地性质范围内指定实验室;不选择时按场地性质自动筛选。</small>
|
||
</el-form-item>
|
||
</section>
|
||
</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.updateExperimentClassroomScope">
|
||
批量指定实验课场地
|
||
</el-checkbox>
|
||
<div v-if="constraintBatchForm.updateExperimentClassroomScope" class="batch-classroom-scope">
|
||
<el-alert
|
||
title="仅作用于含实验学时的教学任务;不选择具体场地时,按场地性质自动分配。"
|
||
type="warning"
|
||
:closable="false"
|
||
show-icon
|
||
/>
|
||
<el-form-item label="统一实验课允许的场地性质">
|
||
<el-checkbox-group v-model="constraintBatchForm.allowedExperimentVenueNatures">
|
||
<el-checkbox v-for="nature in experimentVenueNatures" :key="nature.value" :value="nature.value">{{ nature.label }}</el-checkbox>
|
||
</el-checkbox-group>
|
||
</el-form-item>
|
||
<div class="form-grid">
|
||
<el-form-item label="统一实验课限定校区">
|
||
<el-select
|
||
v-model="constraintBatchForm.experimentRequiredCampusId"
|
||
clearable
|
||
@change="constraintBatchForm.experimentRequiredBuildingId = undefined; constraintBatchForm.allowedExperimentClassroomIds = []"
|
||
>
|
||
<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.experimentRequiredBuildingId"
|
||
clearable
|
||
@change="constraintBatchForm.allowedExperimentClassroomIds = []"
|
||
>
|
||
<el-option v-for="item in batchFilteredExperimentBuildings" :key="item.id" :label="item.name" :value="item.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</div>
|
||
<el-form-item label="统一实验课指定可用场地">
|
||
<el-select v-model="constraintBatchForm.allowedExperimentClassroomIds" multiple filterable collapse-tags placeholder="不选择则允许符合性质的任意实验场地">
|
||
<el-option
|
||
v-for="item in batchFilteredExperimentClassrooms"
|
||
:key="item.id"
|
||
:label="`${item.buildingName} / ${item.name}(${item.roomType},${item.capacity}人)`"
|
||
:value="item.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
</div>
|
||
<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>
|