考试按实际日期、周次和节次融入周/日/总视图,并用橙色“考试”卡片区分。

即使固定课表尚未发布,只要考试已发布,也能正常显示。
考试计划、考场、监考教师和日期都会显示。
Excel 导出同步包含考试安排。
This commit is contained in:
2026-07-28 14:16:24 +08:00 Unverified
parent 9a75589282
commit a61dacff1a
3 changed files with 139 additions and 38 deletions
+85 -27
View File
@@ -100,14 +100,6 @@ const selectedResourceId = computed(() => {
if (resourceType.value === 'Classroom') return classroomId.value
return classId.value
})
const maxPeriods = computed(() => {
const slotMaximum = Math.max(0, ...((timetable.value?.slots ?? []).map((x: any) => x.periodNumber)))
const entryMaximum = Math.max(
0,
...((timetable.value?.entries ?? []).map((x: any) => x.startPeriod + x.periodCount - 1)),
)
return Math.max(8, slotMaximum, entryMaximum)
})
const slotMap = computed<Map<number, any>>(() =>
new Map<number, any>(
(timetable.value?.slots ?? []).map((item: any) => [item.periodNumber, item]),
@@ -119,6 +111,36 @@ const termMonday = computed(() => {
const offset = start.getDay() === 0 ? 6 : start.getDay() - 1
return addDays(start, -offset)
})
const examEntries = computed(() => {
if (!termMonday.value) return []
return (timetable.value?.examEntries ?? []).flatMap((entry: any) => {
const examDate = parseDate(entry.examDate)
if (!examDate) return []
const week = Math.floor(
(examDate.getTime() - termMonday.value!.getTime()) / (7 * 86400000),
) + 1
if (week < 1) return []
return [{
...entry,
startWeek: week,
endWeek: week,
weekPattern: 'All',
}]
})
})
const timedEntries = computed(() => [
...(timetable.value?.entries ?? []),
...examEntries.value,
])
const hasTimedEntries = computed(() => timedEntries.value.length > 0)
const maxPeriods = computed(() => {
const slotMaximum = Math.max(0, ...((timetable.value?.slots ?? []).map((x: any) => x.periodNumber)))
const entryMaximum = Math.max(
0,
...(timedEntries.value.map((x: any) => x.startPeriod + x.periodCount - 1)),
)
return Math.max(8, slotMaximum, entryMaximum)
})
const totalWeeks = computed(() => {
const end = parseDate(timetable.value?.term?.endDate)
if (!termMonday.value || !end) return 1
@@ -126,7 +148,7 @@ const totalWeeks = computed(() => {
(7 * 86400000))
const entryWeeks = Math.max(
1,
...((timetable.value?.entries ?? []).map((entry: any) => entry.endWeek)),
...(timedEntries.value.map((entry: any) => entry.endWeek)),
)
return Math.max(1, dateWeeks, entryWeeks)
})
@@ -139,7 +161,7 @@ const currentTermWeek = computed(() => {
return week >= 1 && week <= totalWeeks.value ? week : null
})
const weekEntries = computed(() =>
(timetable.value?.entries ?? []).filter((entry: any) =>
timedEntries.value.filter((entry: any) =>
occursInWeek(entry, selectedWeek.value)),
)
const dayEntries = computed(() =>
@@ -147,7 +169,7 @@ const dayEntries = computed(() =>
)
const visibleGridEntries = computed(() =>
viewMode.value === 'overview'
? (timetable.value?.entries ?? [])
? timedEntries.value
: weekEntries.value,
)
const selectedWeekLabel = computed(() => weekLabel(selectedWeek.value))
@@ -182,6 +204,22 @@ function weeks(entry: any) {
return `${entry.startWeek}${entry.endWeek}${pattern === '每周' ? '' : ` · ${pattern}`}`
}
function entryKey(entry: any) {
return `${entry.isExam ? 'exam' : 'course'}-${entry.id}-${entry.teachingTaskId ?? ''}`
}
function entryTiming(entry: any) {
const periods = `${entry.startPeriod}${entry.startPeriod + entry.periodCount - 1}`
return entry.isExam
? `${formatExamDate(entry.examDate)} · ${periods}`
: `${weeks(entry)} · ${periods}`
}
function formatExamDate(value?: string) {
const date = parseDate(value)
return date ? `${date.getMonth() + 1}${date.getDate()}` : '考试日期待定'
}
function location(entry: any) {
return [entry.campusName, entry.buildingName, entry.classroomName].filter(Boolean).join(' · ')
}
@@ -255,7 +293,7 @@ function laneFor(entry: any, entries: any[]) {
return { item, index }
})
const count = Math.max(1, laneEnds.length)
placed.forEach(({ item, index }) => layouts.set(String(item.id), { index, count }))
placed.forEach(({ item, index }) => layouts.set(entryKey(item), { index, count }))
}
sameDay.forEach((item: any) => {
@@ -269,7 +307,7 @@ function laneFor(entry: any, entries: any[]) {
clusterEnd = Math.max(clusterEnd, itemEnd)
})
placeCluster()
return layouts.get(String(entry.id)) ?? { index: 0, count: 1 }
return layouts.get(entryKey(entry)) ?? { index: 0, count: 1 }
}
function withLaneStyle(entry: any, entries: any[], base: Record<string, string>) {
@@ -670,7 +708,7 @@ onMounted(async () => {
<p v-else-if="isMine">行政班课程与本人已选课程统一展示仅采用教务处已发布课表</p>
<p v-else-if="isTeacherView">查看指定教师本学期的授课安排</p>
<p v-else-if="isManager">查询班级教师与场地课表可切换草稿或正式发布版本</p>
<p v-else>按年级学院专业和班级分类查询正式发布的课程安排</p>
<p v-else>按年级学院专业和班级分类查询正式发布的课程与考试安排</p>
</div>
<div class="timetable-filters">
<el-select v-model="termId" filterable placeholder="选择学期" @change="onTermChanged">
@@ -839,6 +877,12 @@ onMounted(async () => {
<template v-if="timetable.plan.publishedAt">
· 发布于 {{ new Date(timetable.plan.publishedAt).toLocaleString('zh-CN') }}
</template>
<template v-if="examEntries.length">
· 已融入 {{ examEntries.length }} 场考试
</template>
</span>
<span v-else-if="examEntries.length">
固定课表尚未发布 · 已载入 {{ examEntries.length }} 场考试
</span>
<span v-else>本学期课表尚未发布</span>
</div>
@@ -866,7 +910,7 @@ onMounted(async () => {
</section>
<div
v-if="(viewMode === 'overview' || viewMode === 'week') && timetable?.plan && timetable.entries.length"
v-if="(viewMode === 'overview' || viewMode === 'week') && hasTimedEntries"
class="timetable-view-section"
>
<div class="view-context">
@@ -874,12 +918,12 @@ onMounted(async () => {
<strong>{{ viewMode === 'overview' ? '全学期总览' : selectedWeekLabel }}</strong>
<span>
{{ viewMode === 'overview'
? '同时段但不同周次的课程并列显示'
: `本周共 ${weekEntries.length} 项课程安排` }}
? '固定课程与已发布考试按节次合并显示'
: `本周共 ${weekEntries.length} 项课程、考试安排` }}
</span>
</div>
<small v-if="viewMode === 'overview'">课程卡片保留起止周与单双周信息</small>
<small v-else-if="!weekEntries.length">本周没有课程安排</small>
<small v-if="viewMode === 'overview'">考试卡片显示具体日期课程卡片保留周次信息</small>
<small v-else-if="!weekEntries.length">本周没有课程或考试安排</small>
</div>
<div class="timetable-scroll">
<div
@@ -907,19 +951,24 @@ onMounted(async () => {
</template>
<article
v-for="entry in visibleGridEntries"
:key="entry.id"
:key="entryKey(entry)"
class="course-block"
:class="{ 'exam-block': entry.isExam }"
:style="gridEntryStyle(entry)"
>
<strong>{{ entry.courseName }}</strong>
<strong>
<span v-if="entry.isExam" class="entry-kind">考试</span>
{{ entry.courseName }}
</strong>
<span v-if="entry.isExam && entry.examPlanName">{{ entry.examPlanName }}</span>
<span>{{ entry.teacherNames.join('、') || '教师待定' }}</span>
<span>{{ location(entry) }}</span>
<small>{{ weeks(entry) }} · {{ entry.startPeriod }}{{ entry.startPeriod + entry.periodCount - 1 }} </small>
<small>{{ entryTiming(entry) }}</small>
</article>
</div>
</div>
</div>
<div v-else-if="viewMode === 'day' && timetable?.plan" class="day-view">
<div v-else-if="viewMode === 'day' && hasTimedEntries" class="day-view">
<header>
<div>
<strong>{{ weekdays[selectedDay] }} · {{ selectedDayDate }}</strong>
@@ -945,21 +994,26 @@ onMounted(async () => {
</template>
<article
v-for="entry in dayEntries"
:key="entry.id"
:key="entryKey(entry)"
class="day-course-block"
:class="{ 'exam-block': entry.isExam }"
:style="dayEntryStyle(entry)"
>
<strong>{{ entry.courseName }}</strong>
<strong>
<span v-if="entry.isExam" class="entry-kind">考试</span>
{{ entry.courseName }}
</strong>
<span v-if="entry.isExam && entry.examPlanName">{{ entry.examPlanName }}</span>
<span>{{ entry.teacherNames.join('、') || '教师待定' }}</span>
<span>{{ location(entry) }}</span>
<small>{{ weeks(entry) }} · {{ entry.startPeriod }}{{ entry.startPeriod + entry.periodCount - 1 }} </small>
<small>{{ entryTiming(entry) }}</small>
</article>
<span v-if="!dayEntries.length" class="day-no-courses">当日无课程安排</span>
</div>
</div>
<el-empty
v-else-if="timetable && !loading && !timetable.flexibleCourses?.length"
:description="timetable.plan ? '该课表暂时没有课程安排' : '所选学期尚未发布课表'"
:description="timetable.plan ? '该课表暂时没有课程或考试安排' : '所选学期尚未发布课表或考试安排'"
/>
</div>
</section>
@@ -1131,6 +1185,10 @@ onMounted(async () => {
.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; }
.course-block.exam-block, .day-course-block.exam-block { border-left-color: #b65b32; background: #fff1e8; color: #70442f; }
.course-block.exam-block strong, .day-course-block.exam-block strong { color: #78391e; }
.course-block.exam-block small, .day-course-block.exam-block small { color: #8b5b43; }
.entry-kind { display: inline-block; margin-right: 5px; padding: 1px 5px; border-radius: 2px; background: #b65b32; color: #fff; font-size: 10px !important; line-height: 1.5; vertical-align: 1px; }
.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; }