教学任务按 授课周数 × 周学时 = 课程总学时 双端校验,不匹配会显示具体差额并阻止保存;公共课批量生成同样校验。

排课约束增加课程、教师、学院、授课方式、场地要求、约束状态筛选,并支持批量修改当前筛选结果。
新增“非排时课程”授课方式:不进入自动排课
不占星期、节次和教室
不阻塞课表发布
允许正常选课
在班级课表和学生个人课表中单独展示
This commit is contained in:
2026-07-25 08:18:25 +08:00 Unverified
parent 30c15f89e5
commit fe508054d0
20 changed files with 3785 additions and 136 deletions
+9
View File
@@ -358,6 +358,10 @@ button { cursor: pointer; }
.settings-lead > div:last-child { display: flex; flex-shrink: 0; gap: 8px; }
.settings-table .el-input-number { width: 76px; }
.constraint-list { display: grid; gap: 8px; }
.constraint-filter-grid { display: grid; grid-template-columns: 1.4fr repeat(4, minmax(120px, 1fr)); gap: 8px; margin-bottom: 8px; }
.constraint-result-summary { margin-bottom: 10px; color: var(--muted); font-size: 10px; text-align: right; }
.constraint-batch-form { margin-top: 16px; display: grid; gap: 10px; }
.constraint-batch-form > .el-checkbox { padding: 8px 10px; background: #f7f9fb; border-left: 3px solid #ccd8e1; }
.constraint-list article { min-width: 0; padding: 13px 15px; display: grid; grid-template-columns: minmax(230px, 1fr) minmax(170px, auto) auto; align-items: center; gap: 14px; border: 1px solid var(--line); background: #fff; }
.constraint-list article > div:first-child { min-width: 0; display: grid; gap: 4px; }
.constraint-list article span { color: var(--teal); font: 700 9px/1.2 Consolas, monospace; }
@@ -375,6 +379,10 @@ button { cursor: pointer; }
.generation-preview b { color: var(--indigo); font: 700 25px/1 Consolas, monospace; }
.generation-preview span, .generation-preview small { font-size: 10px; }
.field-hint { display: block; margin-top: 5px; color: var(--muted); font-size: 9px; }
.hours-validation { margin: -5px 0 18px; padding: 10px 13px; display: flex; align-items: center; gap: 12px; border-left: 3px solid #27846f; background: #f1f8f6; color: #46645e; font-size: 11px; }
.hours-validation b { flex-shrink: 0; color: #196b59; }
.hours-validation.invalid { border-left-color: #c75252; background: #fff3f2; color: #835353; }
.hours-validation.invalid b { color: #a83f3f; }
.option-filter-panel { margin: -3px 0 18px; padding: 11px 13px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; border: 1px solid #dce4eb; border-left: 3px solid var(--teal); background: #f8fafb; }
.option-filter-title { color: #3f5367; font-size: 10px; font-weight: 650; white-space: nowrap; }
.option-filter-panel > small { color: var(--muted); font-size: 9px; white-space: nowrap; }
@@ -1028,6 +1036,7 @@ button { cursor: pointer; }
.settings-lead { align-items: stretch; flex-direction: column; }
.settings-lead > div:last-child { flex-wrap: wrap; }
.constraint-list article { grid-template-columns: 1fr; }
.constraint-filter-grid { grid-template-columns: 1fr; }
.constraint-badges { justify-content: flex-start; }
.eligibility-flow { grid-template-columns: 1fr; }
.eligibility-flow i { display: none; }
+5 -2
View File
@@ -584,7 +584,10 @@ onMounted(async () => {
<el-icon><Tickets /></el-icon>
<span>{{ formatSchedule(schedule) }}</span>
</div>
<span v-if="!offering.schedules.length" class="schedule-missing">课表尚未发布</span>
<span v-if="offering.isFlexible" class="schedule-missing">
非排时课程 · 不占正常时间与场地
</span>
<span v-else-if="!offering.schedules.length" class="schedule-missing">课表尚未发布</span>
</div>
<footer>
<div class="seat-meter">
@@ -601,7 +604,7 @@ onMounted(async () => {
<el-button
v-else
type="primary"
:disabled="!selectedRound.isAvailableNow || offering.enrolledCount >= offering.capacity || !offering.schedules.length"
:disabled="!selectedRound.isAvailableNow || offering.enrolledCount >= offering.capacity || (!offering.isFlexible && !offering.schedules.length)"
@click="enroll(offering)"
>{{ offering.enrollmentStatus === 'Withdrawn' ? '重新选择' : '选择课程' }}</el-button>
</footer>
+253 -27
View File
@@ -21,6 +21,8 @@ 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('')
@@ -29,6 +31,14 @@ const planForm = reactive<Record<string, any>>({})
const cloneForm = reactive<Record<string, any>>({})
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
const weekdays = [
@@ -86,6 +96,39 @@ const autoStatusText = computed(() => {
const selectedTaskConstraint = computed(() =>
constraints.value.find((item) => item.id === entryForm.teachingTaskId),
)
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)
@@ -133,7 +176,9 @@ async function loadSchedulingSettings() {
])
timeSlots.value = timeRes.data
constraints.value = constraintRes.data
tasks.value = constraintRes.data
tasks.value = constraintRes.data.filter(
(item: any) => item.schedulingMode === 'Standard',
)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
@@ -183,6 +228,7 @@ 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,
@@ -199,6 +245,7 @@ function openConstraint(item: any) {
async function saveConstraint() {
try {
const payload = {
schedulingMode: constraintForm.schedulingMode,
requiresClassroom: constraintForm.requiresClassroom,
requiredCampusId: constraintForm.requiredCampusId || null,
requiredBuildingId: constraintForm.requiredBuildingId || null,
@@ -216,6 +263,84 @@ async function saveConstraint() {
}
}
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,
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.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 { 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,
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
}
}
async function autoSchedule() {
try {
await ElMessageBox.confirm(
@@ -699,40 +824,84 @@ onBeforeUnmount(clearAutoSchedulePoll)
</el-table>
</el-tab-pane>
<el-tab-pane label="课程排课约束" name="constraints">
<div class="settings-lead">
<div>
<b>课程排课约束</b>
<span>教师来自已发布教学任务;这里限定可用时间、校区、教学楼、指定教室以及是否占用教室。</span>
</div>
</div>
<div class="constraint-list">
<article v-for="item in constraints" :key="item.id">
<div>
<span>{{ item.taskNumber }} · 每周 {{ item.weeklyHours }} 学时</span>
<b>{{ item.name }}</b>
<small>{{ item.teacherNames.join('、') || '未分配教师' }} · {{ item.capacity }} 人</small>
</div>
<div class="constraint-badges">
<el-tag :type="item.requiresClassroom ? 'primary' : 'info'">
{{ item.requiresClassroom ? '占用教室' : '不占教室' }}
</el-tag>
<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>
</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>
<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="需要占用教室"
@@ -779,12 +948,69 @@ onBeforeUnmount(clearAutoSchedulePoll)
<el-option v-for="period in periods" :key="period" :label="`第 ${period} 节 · ${timeLabel(period)}`" :value="period" />
</el-select>
</el-form-item>
</div>
</el-form>
</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="不占用教室"
/>
</el-form-item>
<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>
+71 -6
View File
@@ -77,6 +77,14 @@ const availableClasses = computed(() => {
const selectedCourse = computed(() =>
courses.value.find((item) => item.id === form.courseId),
)
const plannedHours = computed(() =>
form.startWeek && form.endWeek && form.weeklyHours && form.endWeek >= form.startWeek
? (form.endWeek - form.startWeek + 1) * form.weeklyHours
: 0,
)
const hoursMatch = computed(() =>
!selectedCourse.value || plannedHours.value === selectedCourse.value.totalHours,
)
const assignableTeachers = computed(() => {
if (!selectedCourse.value) return teachers.value
const approvedIds = new Set(manualEligibleTeachers.value.map((item) => item.teacherId))
@@ -92,6 +100,21 @@ const publicCourses = computed(() =>
['GeneralRequired', 'GeneralElective'].includes(item.nature),
),
)
const selectedGenerationCourse = computed(() =>
publicCourses.value.find((item) => item.id === generationForm.courseId),
)
const generationPlannedHours = computed(() =>
generationForm.startWeek &&
generationForm.endWeek &&
generationForm.weeklyHours &&
generationForm.endWeek >= generationForm.startWeek
? (generationForm.endWeek - generationForm.startWeek + 1) * generationForm.weeklyHours
: 0,
)
const generationHoursMatch = computed(() =>
!selectedGenerationCourse.value ||
generationPlannedHours.value === selectedGenerationCourse.value.totalHours,
)
const manageableCourses = computed(() => {
if (isSuperAdmin.value) return courses.value
return courses.value.filter((item) => {
@@ -265,7 +288,8 @@ function resetForm(detail?: any) {
capacity: 60,
startWeek: 1,
endWeek: 16,
weeklyHours: 2,
weeklyHours: 2,
schedulingMode: 'Standard',
teacherIds: [],
primaryTeacherId: undefined,
classIds: [],
@@ -325,6 +349,12 @@ async function save() {
ElMessage.warning('开始周不能晚于结束周。')
return
}
if (!hoursMatch.value) {
ElMessage.warning(
`课程总学时为 ${selectedCourse.value.totalHours},当前授课安排合计 ${plannedHours.value} 学时,请调整周次或周学时。`,
)
return
}
try {
if (editingId.value) await http.put(`/teaching-tasks/${editingId.value}`, form)
else await http.post('/teaching-tasks', form)
@@ -466,6 +496,12 @@ async function generatePublicTasks() {
ElMessage.warning('该课程还没有审核通过的可授课教师。')
return
}
if (!generationHoursMatch.value) {
ElMessage.warning(
`课程总学时为 ${selectedGenerationCourse.value.totalHours},当前授课安排合计 ${generationPlannedHours.value} 学时,请调整周次或周学时。`,
)
return
}
try {
await ElMessageBox.confirm(
`${generationForm.classIds.length} 个行政班分成 ${generationGroupCount.value} 个教学班,并在 ${eligibleTeachers.value.length} 名合格教师中随机均衡分配。生成结果为草稿,确定继续吗?`,
@@ -618,7 +654,7 @@ onMounted(async () => {
<el-form-item label="开课学期" required><el-select v-model="form.academicTermId" @change="loadManualEligibleTeachers"><el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
<el-form-item label="课程" required>
<el-select v-model="form.courseId" filterable @change="form.teacherIds = []; form.primaryTeacherId = undefined; loadManualEligibleTeachers()">
<el-option v-for="item in filteredManageableCourses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
<el-option v-for="item in filteredManageableCourses" :key="item.id" :label="`${item.code} · ${item.name}${item.totalHours} 学时)`" :value="item.id" />
</el-select>
</el-form-item>
</div>
@@ -640,7 +676,26 @@ onMounted(async () => {
<el-form-item label="开始周"><el-input-number v-model="form.startWeek" :min="1" :max="30" /></el-form-item>
<el-form-item label="结束周"><el-input-number v-model="form.endWeek" :min="1" :max="30" /></el-form-item>
</div>
<el-form-item label="周学时"><el-input-number v-model="form.weeklyHours" :min="1" :max="40" /></el-form-item>
<el-form-item label="周学时"><el-input-number v-model="form.weeklyHours" :min="1" :max="40" /></el-form-item>
<div
v-if="selectedCourse"
:class="['hours-validation', { invalid: !hoursMatch }]"
>
<b>{{ hoursMatch ? '学时匹配' : '学时不匹配' }}</b>
<span>
{{ form.startWeek }}{{ form.endWeek }} × 每周 {{ form.weeklyHours }} 学时
= {{ plannedHours }} 学时课程库总学时 {{ selectedCourse.totalHours }}
</span>
</div>
<el-form-item label="授课方式">
<el-radio-group v-model="form.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>
<el-form-item label="授课教师">
<el-select v-model="form.teacherIds" multiple filterable @change="onTeachersChanged">
<el-option v-for="item in assignableTeachers" :key="item.id" :label="`${item.teacherNumber} · ${item.name}`" :value="item.id" />
@@ -757,9 +812,19 @@ onMounted(async () => {
<el-input-number v-model="generationForm.endWeek" :min="1" :max="30" />
</el-form-item>
</div>
<el-form-item label="周学时">
<el-input-number v-model="generationForm.weeklyHours" :min="1" :max="40" />
</el-form-item>
<el-form-item label="周学时">
<el-input-number v-model="generationForm.weeklyHours" :min="1" :max="40" />
</el-form-item>
<div
v-if="selectedGenerationCourse"
:class="['hours-validation', { invalid: !generationHoursMatch }]"
>
<b>{{ generationHoursMatch ? '学时匹配' : '学时不匹配' }}</b>
<span>
当前合计 {{ generationPlannedHours }} 学时课程库总学时
{{ selectedGenerationCourse.totalHours }}
</span>
</div>
<div class="generation-preview">
<span>预计生成</span>
<b>{{ generationGroupCount }}</b>
+40 -7
View File
@@ -145,7 +145,28 @@ onMounted(async () => {
<span v-if="timetable.plan">发布于 {{ new Date(timetable.plan.publishedAt).toLocaleString('zh-CN') }}</span>
<span v-else>本学期课表尚未发布</span>
</div>
</div>
</div>
<section v-if="timetable?.flexibleCourses?.length" class="flexible-courses">
<div class="flexible-heading">
<div>
<span>非排时课程</span>
<strong>不占正常上课时间与场地</strong>
</div>
<small> {{ timetable.flexibleCourses.length }} </small>
</div>
<div class="flexible-course-list">
<article v-for="course in timetable.flexibleCourses" :key="course.id">
<span>{{ course.courseCode }} · {{ course.taskNumber }}</span>
<strong>{{ course.courseName }}</strong>
<p>{{ course.teacherNames.join('、') || '教师待定' }}</p>
<small>
{{ course.startWeek }}{{ course.endWeek }} ·
每周 {{ course.weeklyHours }} 学时 · {{ course.totalHours }} 学时
</small>
</article>
</div>
</section>
<div v-if="timetable?.plan && timetable.entries.length" class="timetable-scroll">
<div
@@ -182,7 +203,7 @@ onMounted(async () => {
</div>
</div>
<el-empty
v-else-if="timetable && !loading"
v-else-if="timetable && !loading && !timetable.flexibleCourses?.length"
:description="timetable.plan ? '该课表暂时没有课程安排' : '所选学期尚未发布课表'"
/>
</section>
@@ -190,21 +211,33 @@ onMounted(async () => {
</template>
<style scoped>
.timetable-page { display: grid; gap: 20px; }
.public-timetable { min-height: 100vh; padding: 0 32px 40px; background: #f4f7fa; }
.timetable-page { min-width: 0; width: 100%; display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px; }
.public-timetable { max-width: 100vw; min-height: 100vh; padding: 0 32px 40px; box-sizing: border-box; background: #f4f7fa; }
.public-header { height: 68px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #dce4eb; }
.public-brand { color: #17324d; font-weight: 750; text-decoration: none; letter-spacing: .02em; }
.login-link { color: #176b87; text-decoration: none; font-weight: 650; }
.timetable-heading { display: flex; align-items: end; justify-content: space-between; gap: 24px; padding: 24px 28px; background: #fff; border: 1px solid #dce4eb; }
.timetable-heading { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 24px; padding: 24px 28px; background: #fff; border: 1px solid #dce4eb; }
.timetable-heading h2 { margin: 5px 0 8px; color: #17324d; font-size: 27px; }
.timetable-heading p { margin: 0; color: #647587; }
.timetable-filters { display: flex; gap: 12px; }
.timetable-filters { display: flex; flex-wrap: wrap; gap: 12px; }
.timetable-filters .el-select { width: 270px; }
.timetable-sheet { min-height: 360px; padding: 22px; background: #fff; border: 1px solid #dce4eb; }
.timetable-sheet { min-width: 0; min-height: 360px; padding: 22px; background: #fff; border: 1px solid #dce4eb; }
.sheet-meta { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 18px; padding-bottom: 16px; border-bottom: 1px solid #e6ebf0; }
.sheet-meta div { display: grid; gap: 3px; }
.sheet-meta strong { color: #17324d; }
.sheet-meta span { color: #718191; font-size: 13px; }
.flexible-courses { margin-bottom: 20px; border: 1px solid #cfe1dc; background: #f5faf8; }
.flexible-heading { padding: 12px 15px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #dce9e5; }
.flexible-heading > div { display: flex; align-items: baseline; gap: 10px; }
.flexible-heading span { color: #176b5d; font-size: 12px; font-weight: 750; }
.flexible-heading strong { color: #506c65; font-size: 12px; }
.flexible-heading small { color: #718b84; }
.flexible-course-list { padding: 12px; display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 10px; }
.flexible-course-list article { padding: 12px 13px; display: grid; gap: 4px; border-left: 3px solid #2d8975; background: #fff; }
.flexible-course-list article > span { color: #277766; font: 700 10px/1.3 Consolas, monospace; }
.flexible-course-list article strong { color: #173f38; font-size: 14px; }
.flexible-course-list article p { margin: 0; color: #5e746f; font-size: 12px; }
.flexible-course-list article small { color: #778b86; font-size: 11px; }
.timetable-scroll { overflow: auto; }
.week-grid { min-width: 1120px; display: grid; grid-template-columns: 92px repeat(7, minmax(140px, 1fr)); position: relative; }
.grid-corner, .day-head, .period-head { z-index: 2; background: #f3f6f8; border: 1px solid #dce4eb; color: #3f5366; }