自动排课完成后逐条显示“需人工处理”的任务和原因,并提供“检查排课约束”入口;刷新页面后仍可读取最近一次结果。

批量排课约束新增校区、教学楼、多教室统一指定,也支持清除原有限定。
课表新增“总视图”,同一时段、不同周次课程会并列显示。
周视图按教学周过滤,支持上一周、下一周、周次下拉和回到本周。
日视图按周次过滤;连续两节课程会跨两行完整占格。
This commit is contained in:
2026-07-25 10:55:09 +08:00 Unverified
parent db91988264
commit e4a12931ea
8 changed files with 652 additions and 51 deletions
+280 -36
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { Document, Download } from '@element-plus/icons-vue'
import { ArrowLeft, ArrowRight, Document, Download } from '@element-plus/icons-vue'
import { useRoute } from 'vue-router'
import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile } from '../api/excel'
@@ -39,8 +39,10 @@ const campusId = ref('')
const buildingId = ref('')
const classroomId = ref('')
const timetable = ref<any | null>(null)
const viewMode = ref<'week' | 'day'>('week')
const viewMode = ref<'overview' | 'week' | 'day'>('week')
const selectedWeek = ref(1)
const selectedDay = ref(1)
const loadedTermId = ref('')
const exportArea = ref<HTMLElement | null>(null)
const weekdays = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
const patternLabels: Record<string, string> = { All: '每周', Odd: '单周', Even: '双周' }
@@ -93,19 +95,68 @@ const slotMap = computed<Map<number, any>>(() =>
(timetable.value?.slots ?? []).map((item: any) => [item.periodNumber, item]),
),
)
const dayEntries = computed(() =>
(timetable.value?.entries ?? []).filter((entry: any) => entry.dayOfWeek === selectedDay.value),
const termMonday = computed(() => {
const start = parseDate(timetable.value?.term?.startDate)
if (!start) return null
const offset = start.getDay() === 0 ? 6 : start.getDay() - 1
return addDays(start, -offset)
})
const totalWeeks = computed(() => {
const end = parseDate(timetable.value?.term?.endDate)
if (!termMonday.value || !end) return 1
const dateWeeks = Math.ceil((end.getTime() - termMonday.value.getTime() + 86400000) /
(7 * 86400000))
const entryWeeks = Math.max(
1,
...((timetable.value?.entries ?? []).map((entry: any) => entry.endWeek)),
)
return Math.max(1, dateWeeks, entryWeeks)
})
const currentTermWeek = computed(() => {
if (!termMonday.value) return null
const now = new Date()
now.setHours(0, 0, 0, 0)
const week = Math.floor((now.getTime() - termMonday.value.getTime()) /
(7 * 86400000)) + 1
return week >= 1 && week <= totalWeeks.value ? week : null
})
const weekEntries = computed(() =>
(timetable.value?.entries ?? []).filter((entry: any) =>
occursInWeek(entry, selectedWeek.value)),
)
const dayEntries = computed(() =>
weekEntries.value.filter((entry: any) => entry.dayOfWeek === selectedDay.value),
)
const visibleGridEntries = computed(() =>
viewMode.value === 'overview'
? (timetable.value?.entries ?? [])
: weekEntries.value,
)
const selectedWeekLabel = computed(() => weekLabel(selectedWeek.value))
const selectedDayDate = computed(() => {
if (!termMonday.value) return ''
return formatMonthDay(addDays(
termMonday.value,
(selectedWeek.value - 1) * 7 + selectedDay.value - 1,
))
})
function formatTime(value: string) {
return value?.slice(0, 5) ?? ''
}
function entryStyle(entry: any) {
return {
function gridEntryStyle(entry: any) {
return withLaneStyle(entry, visibleGridEntries.value, {
gridColumn: String(entry.dayOfWeek + 1),
gridRow: `${entry.startPeriod + 1} / span ${entry.periodCount}`,
}
})
}
function dayEntryStyle(entry: any) {
return withLaneStyle(entry, dayEntries.value, {
gridColumn: '2',
gridRow: `${entry.startPeriod} / span ${entry.periodCount}`,
})
}
function weeks(entry: any) {
@@ -117,8 +168,116 @@ function location(entry: any) {
return [entry.campusName, entry.buildingName, entry.classroomName].filter(Boolean).join(' · ')
}
function dayEntriesAt(period: number) {
return dayEntries.value.filter((entry: any) => entry.startPeriod === period)
function parseDate(value?: string) {
if (!value) return null
const date = new Date(`${value.slice(0, 10)}T00:00:00`)
return Number.isNaN(date.getTime()) ? null : date
}
function addDays(date: Date, days: number) {
const result = new Date(date)
result.setDate(result.getDate() + days)
return result
}
function formatMonthDay(date: Date) {
return `${date.getMonth() + 1}${date.getDate()}`
}
function weekLabel(week: number) {
if (!termMonday.value) return `${week}`
const start = addDays(termMonday.value, (week - 1) * 7)
const termEnd = parseDate(timetable.value?.term?.endDate)
const calculatedEnd = addDays(start, 6)
const end = termEnd && calculatedEnd > termEnd ? termEnd : calculatedEnd
return `${week} 周 · ${formatMonthDay(start)}${formatMonthDay(end)}`
}
function weekdayDate(day: number) {
if (viewMode.value !== 'week' || !termMonday.value) return ''
return formatMonthDay(addDays(
termMonday.value,
(selectedWeek.value - 1) * 7 + day - 1,
))
}
function occursInWeek(entry: any, week: number) {
if (week < entry.startWeek || week > entry.endWeek) return false
if (entry.weekPattern === 'Odd') return week % 2 === 1
if (entry.weekPattern === 'Even') return week % 2 === 0
return true
}
function laneFor(entry: any, entries: any[]) {
const sameDay = entries
.filter((item: any) => item.dayOfWeek === entry.dayOfWeek)
.slice()
.sort((a: any, b: any) =>
a.startPeriod - b.startPeriod ||
b.periodCount - a.periodCount ||
String(a.id).localeCompare(String(b.id)),
)
const layouts = new Map<string, { index: number; count: number }>()
let cluster: any[] = []
let clusterEnd = -1
const placeCluster = () => {
if (!cluster.length) return
const laneEnds: number[] = []
const placed = cluster.map((item: any) => {
const start = item.startPeriod
const end = item.startPeriod + item.periodCount
let index = laneEnds.findIndex((laneEnd) => laneEnd <= start)
if (index === -1) {
index = laneEnds.length
laneEnds.push(end)
} else {
laneEnds[index] = end
}
return { item, index }
})
const count = Math.max(1, laneEnds.length)
placed.forEach(({ item, index }) => layouts.set(String(item.id), { index, count }))
}
sameDay.forEach((item: any) => {
const itemEnd = item.startPeriod + item.periodCount
if (cluster.length && item.startPeriod >= clusterEnd) {
placeCluster()
cluster = []
clusterEnd = -1
}
cluster.push(item)
clusterEnd = Math.max(clusterEnd, itemEnd)
})
placeCluster()
return layouts.get(String(entry.id)) ?? { index: 0, count: 1 }
}
function withLaneStyle(entry: any, entries: any[], base: Record<string, string>) {
const lane = laneFor(entry, entries)
return {
...base,
width: `calc(${100 / lane.count}% - 8px)`,
marginLeft: `calc(${100 / lane.count * lane.index}% + 4px)`,
}
}
function syncSelectedWeek() {
const termKey = timetable.value?.term?.id ?? ''
if (termKey !== loadedTermId.value) {
loadedTermId.value = termKey
selectedWeek.value = currentTermWeek.value ?? 1
} else {
selectedWeek.value = Math.min(Math.max(1, selectedWeek.value), totalWeeks.value)
}
}
function changeWeek(delta: number) {
selectedWeek.value = Math.min(
totalWeeks.value,
Math.max(1, selectedWeek.value + delta),
)
}
function setClassFilters(item: any) {
@@ -243,6 +402,7 @@ async function loadTimetable() {
params: { academicTermId: termId.value },
})).data
}
syncSelectedWeek()
} catch (error) {
timetable.value = null
ElMessage.error(apiErrorMessage(error))
@@ -438,9 +598,44 @@ onMounted(async () => {
<div v-if="timetable" class="timetable-toolbar">
<div>
<el-radio-group v-model="viewMode" size="small">
<el-radio-button value="overview">总视图</el-radio-button>
<el-radio-button value="week">周视图</el-radio-button>
<el-radio-button value="day">日视图</el-radio-button>
</el-radio-group>
<div v-if="viewMode !== 'overview'" class="week-switcher">
<el-button
:icon="ArrowLeft"
circle
size="small"
aria-label="上一周"
:disabled="selectedWeek <= 1"
@click="changeWeek(-1)"
/>
<el-select v-model="selectedWeek" size="small" aria-label="选择教学周">
<el-option
v-for="week in totalWeeks"
:key="week"
:label="weekLabel(week)"
:value="week"
/>
</el-select>
<el-button
:icon="ArrowRight"
circle
size="small"
aria-label="下一周"
:disabled="selectedWeek >= totalWeeks"
@click="changeWeek(1)"
/>
<el-button
v-if="currentTermWeek"
size="small"
:disabled="selectedWeek === currentTermWeek"
@click="selectedWeek = currentTermWeek"
>
回到本周
</el-button>
</div>
<el-select v-if="viewMode === 'day'" v-model="selectedDay" size="small">
<el-option v-for="day in 7" :key="day" :label="weekdays[day]" :value="day" />
</el-select>
@@ -499,13 +694,32 @@ onMounted(async () => {
</div>
</section>
<div v-if="viewMode === 'week' && timetable?.plan && timetable.entries.length" class="timetable-scroll">
<div
v-if="(viewMode === 'overview' || viewMode === 'week') && timetable?.plan && timetable.entries.length"
class="timetable-view-section"
>
<div class="view-context">
<div>
<strong>{{ viewMode === 'overview' ? '全学期总览' : selectedWeekLabel }}</strong>
<span>
{{ viewMode === 'overview'
? '同时段但不同周次的课程并列显示'
: `本周共 ${weekEntries.length} 项课程安排` }}
</span>
</div>
<small v-if="viewMode === 'overview'">课程卡片保留起止周与单双周信息</small>
<small v-else-if="!weekEntries.length">本周没有课程安排</small>
</div>
<div class="timetable-scroll">
<div
class="week-grid"
:style="{ gridTemplateRows: `48px repeat(${maxPeriods}, 110px)` }"
>
<div class="grid-corner">节次</div>
<div v-for="day in 7" :key="`head-${day}`" class="day-head">{{ weekdays[day] }}</div>
<div v-for="day in 7" :key="`head-${day}`" class="day-head">
<strong>{{ weekdays[day] }}</strong>
<small v-if="weekdayDate(day)">{{ weekdayDate(day) }}</small>
</div>
<template v-for="period in maxPeriods" :key="`period-${period}`">
<div class="period-head" :style="{ gridRow: String(period + 1) }">
<strong> {{ period }} </strong>
@@ -521,10 +735,10 @@ onMounted(async () => {
/>
</template>
<article
v-for="entry in timetable.entries"
v-for="entry in visibleGridEntries"
:key="entry.id"
class="course-block"
:style="entryStyle(entry)"
:style="gridEntryStyle(entry)"
>
<strong>{{ entry.courseName }}</strong>
<span>{{ entry.teacherNames.join('、') || '教师待定' }}</span>
@@ -532,28 +746,44 @@ onMounted(async () => {
<small>{{ weeks(entry) }} · {{ entry.startPeriod }}{{ entry.startPeriod + entry.periodCount - 1 }} </small>
</article>
</div>
</div>
</div>
<div v-else-if="viewMode === 'day' && timetable?.plan" class="day-view">
<header>
<strong>{{ weekdays[selectedDay] }}</strong>
<span>按开始节次排列 · {{ dayEntries.length }} 项安排</span>
<div>
<strong>{{ weekdays[selectedDay] }} · {{ selectedDayDate }}</strong>
<small>{{ selectedWeekLabel }}</small>
</div>
<span> {{ dayEntries.length }} 项安排 · 连续课程按实际节数占格</span>
</header>
<div v-for="period in maxPeriods" :key="`day-${period}`" class="day-period-row">
<div class="day-period-label">
<div
class="day-grid"
:style="{ gridTemplateRows: `repeat(${maxPeriods}, 92px)` }"
>
<template v-for="period in maxPeriods" :key="`day-${period}`">
<div class="day-period-label" :style="{ gridRow: String(period) }">
<strong> {{ period }} </strong>
<span v-if="slotMap.get(period)">
{{ formatTime(slotMap.get(period).startsAt) }}{{ formatTime(slotMap.get(period).endsAt) }}
</span>
</div>
<div class="day-course-list">
<article v-for="entry in dayEntriesAt(period)" :key="entry.id">
<strong>{{ entry.courseName }}</strong>
<span>{{ entry.teacherNames.join('、') || '教师待定' }}</span>
<span>{{ location(entry) }}</span>
<small>{{ weeks(entry) }} · 连续 {{ entry.periodCount }} </small>
</article>
<span v-if="!dayEntriesAt(period).length" class="day-empty">无课程安排</span>
</div>
<div
class="day-grid-cell"
:style="{ gridColumn: '2', gridRow: String(period) }"
/>
</template>
<article
v-for="entry in dayEntries"
:key="entry.id"
class="day-course-block"
:style="dayEntryStyle(entry)"
>
<strong>{{ entry.courseName }}</strong>
<span>{{ entry.teacherNames.join('、') || '教师待定' }}</span>
<span>{{ location(entry) }}</span>
<small>{{ weeks(entry) }} · {{ entry.startPeriod }}{{ entry.startPeriod + entry.periodCount - 1 }} </small>
</article>
<span v-if="!dayEntries.length" class="day-no-courses">当日无课程安排</span>
</div>
</div>
<el-empty
@@ -585,6 +815,8 @@ onMounted(async () => {
.timetable-toolbar { margin-bottom: 16px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.timetable-toolbar > div { display: flex; align-items: center; gap: 10px; }
.timetable-toolbar .el-select { width: 120px; }
.timetable-toolbar .week-switcher { display: flex; align-items: center; gap: 6px; padding-left: 2px; }
.timetable-toolbar .week-switcher .el-select { width: 245px; }
.timetable-export-area { min-width: 0; padding: 2px; background: #fff; }
.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; }
@@ -603,9 +835,16 @@ onMounted(async () => {
.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; }
.timetable-view-section { border: 1px solid #dce4eb; }
.view-context { min-height: 54px; padding: 10px 14px; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid #dce4eb; background: #f8fafb; }
.view-context > div { display: grid; gap: 3px; }
.view-context strong { color: #17324d; font-size: 14px; }
.view-context span, .view-context small { color: #718191; font-size: 11px; }
.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; }
.grid-corner, .day-head { display: grid; place-items: center; font-weight: 700; }
.day-head { align-content: center; gap: 2px; }
.day-head small { color: #7a8996; font-size: 10px; font-weight: 500; }
.grid-corner { grid-column: 1; grid-row: 1; }
.day-head { grid-row: 1; }
.day-head:nth-of-type(2) { grid-column: 2; }
@@ -618,22 +857,25 @@ onMounted(async () => {
.period-head { grid-column: 1; display: grid; place-content: center; gap: 4px; text-align: center; }
.period-head span { color: #7c8b98; font-size: 11px; }
.grid-cell { border: 1px solid #e4e9ee; background: #fff; }
.course-block { z-index: 3; margin: 4px; padding: 9px 10px; overflow: hidden; display: flex; flex-direction: column; gap: 4px; border-left: 4px solid #176b87; background: #e9f3f5; color: #24475a; box-shadow: 0 2px 5px rgba(23, 50, 77, .08); }
.course-block { z-index: 3; margin: 4px; padding: 9px 10px; overflow: hidden; box-sizing: border-box; display: flex; flex-direction: column; gap: 4px; border-left: 4px solid #176b87; background: #e9f3f5; color: #24475a; box-shadow: 0 2px 5px rgba(23, 50, 77, .08); }
.course-block strong { color: #123a4b; font-size: 14px; }
.course-block span { font-size: 12px; }
.course-block small { margin-top: auto; color: #5f7885; font-size: 11px; }
.day-view { border: 1px solid #dce4eb; }
.day-view > header { padding: 14px 16px; display: flex; align-items: baseline; justify-content: space-between; background: #173e72; color: #fff; }
.day-view > header > div { display: grid; gap: 3px; }
.day-view > header strong { font-size: 18px; }
.day-view > header small { color: #bed1e4; font-size: 11px; }
.day-view > header span { color: #dce8f3; font-size: 12px; }
.day-period-row { min-height: 88px; display: grid; grid-template-columns: 120px minmax(0, 1fr); border-top: 1px solid #e1e7eb; }
.day-period-label { padding: 12px; display: grid; place-content: center; gap: 4px; text-align: center; background: #f2f5f7; color: #43586a; }
.day-grid { display: grid; grid-template-columns: 120px minmax(0, 1fr); position: relative; }
.day-period-label { z-index: 2; grid-column: 1; padding: 12px; display: grid; place-content: center; gap: 4px; border-top: 1px solid #e1e7eb; text-align: center; background: #f2f5f7; color: #43586a; }
.day-period-label span { color: #7b8b98; font-size: 11px; }
.day-course-list { padding: 9px; display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 8px; align-items: stretch; }
.day-course-list article { padding: 10px 12px; display: grid; gap: 3px; border-left: 4px solid #176b87; background: #e9f3f5; color: #395d6c; }
.day-course-list article strong { color: #123a4b; }
.day-course-list article span, .day-course-list article small { font-size: 11px; }
.day-empty { align-self: center; color: #9aa7b1; font-size: 12px; }
.day-grid-cell { border-top: 1px solid #e1e7eb; background: #fff; }
.day-course-block { z-index: 3; margin: 4px; padding: 10px 12px; overflow: hidden; box-sizing: border-box; display: flex; flex-direction: column; gap: 4px; border-left: 4px solid #176b87; background: #e9f3f5; color: #395d6c; box-shadow: 0 2px 5px rgba(23, 50, 77, .08); }
.day-course-block strong { color: #123a4b; }
.day-course-block span, .day-course-block small { font-size: 11px; }
.day-course-block small { margin-top: auto; color: #5f7885; }
.day-no-courses { z-index: 3; grid-column: 2; grid-row: 1; align-self: center; justify-self: center; color: #9aa7b1; font-size: 12px; }
@media (max-width: 1100px) {
.resource-filter-panel { grid-template-columns: 1fr; }
.hierarchy-filters { grid-template-columns: repeat(2, minmax(150px, 1fr)); }
@@ -647,8 +889,10 @@ onMounted(async () => {
.hierarchy-filters, .hierarchy-filters.compact { grid-template-columns: 1fr; }
.timetable-toolbar { align-items: stretch; flex-direction: column; }
.timetable-toolbar > div { flex-wrap: wrap; }
.timetable-toolbar .week-switcher { width: 100%; padding-left: 0; }
.timetable-toolbar .week-switcher .el-select { min-width: 190px; flex: 1; }
.timetable-sheet { padding: 12px; }
.day-period-row { grid-template-columns: 90px minmax(0, 1fr); }
.day-course-list { grid-template-columns: 1fr; }
.view-context, .day-view > header { align-items: flex-start; flex-direction: column; gap: 5px; }
.day-grid { grid-template-columns: 90px minmax(0, 1fr); }
}
</style>