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

即使固定课表尚未发布,只要考试已发布,也能正常显示。
考试计划、考场、监考教师和日期都会显示。
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
@@ -25,9 +25,14 @@ public static class TimetableExcelExporter
sheet.Row(1).Height = 34; sheet.Row(1).Height = 34;
sheet.Range("A2:H2").Merge(); sheet.Range("A2:H2").Merge();
var examSummary = timetable.ExamEntries.Count > 0
? $" · 已融入 {timetable.ExamEntries.Count} 场考试"
: "";
sheet.Cell("A2").Value = timetable.Plan is null sheet.Cell("A2").Value = timetable.Plan is null
? "本学期暂无可用课表版本" ? timetable.ExamEntries.Count > 0
: $"版本:{timetable.Plan.Version} · 状态:{PlanStatus(timetable.Plan.Status)} · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}"; ? $"固定课表尚未发布{examSummary} · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}"
: "本学期暂无可用课表版本"
: $"版本:{timetable.Plan.Version} · 状态:{PlanStatus(timetable.Plan.Status)}{examSummary} · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}";
sheet.Cell("A2").Style sheet.Cell("A2").Style
.Font.SetFontColor(XLColor.FromHtml("#52677A")) .Font.SetFontColor(XLColor.FromHtml("#52677A"))
.Fill.SetBackgroundColor(XLColor.FromHtml("#EFF3F6")); .Fill.SetBackgroundColor(XLColor.FromHtml("#EFF3F6"));
@@ -48,8 +53,11 @@ public static class TimetableExcelExporter
} }
sheet.Row(headerRow).Height = 26; sheet.Row(headerRow).Height = 26;
var timedEntries = timetable.Entries
.Concat(timetable.ExamEntries)
.ToArray();
var periods = timetable.Slots.Select(x => x.PeriodNumber) var periods = timetable.Slots.Select(x => x.PeriodNumber)
.Concat(timetable.Entries.SelectMany(x => .Concat(timedEntries.SelectMany(x =>
Enumerable.Range(x.StartPeriod, x.PeriodCount))) Enumerable.Range(x.StartPeriod, x.PeriodCount)))
.Distinct() .Distinct()
.Order() .Order()
@@ -72,7 +80,7 @@ public static class TimetableExcelExporter
for (var day = 1; day <= 7; day++) for (var day = 1; day <= 7; day++)
{ {
var entries = timetable.Entries var entries = timedEntries
.Where(x => .Where(x =>
x.DayOfWeek == day && x.DayOfWeek == day &&
x.StartPeriod <= period && x.StartPeriod <= period &&
@@ -86,7 +94,8 @@ public static class TimetableExcelExporter
if (entries.Count > 0) if (entries.Count > 0)
{ {
cell.Style cell.Style
.Fill.SetBackgroundColor(XLColor.FromHtml("#E8F3F2")) .Fill.SetBackgroundColor(XLColor.FromHtml(
entries.Any(x => x.IsExam) ? "#FFF1E8" : "#E8F3F2"))
.Font.SetFontColor(XLColor.FromHtml("#173F4C")); .Font.SetFontColor(XLColor.FromHtml("#173F4C"));
} }
} }
@@ -138,7 +147,14 @@ public static class TimetableExcelExporter
} }
private static string EntryText(TimetableEntryDto entry) => private static string EntryText(TimetableEntryDto entry) =>
$"{entry.CourseName}\n" + entry.IsExam
? $"【考试】{entry.CourseName}\n" +
$"{entry.ExamPlanName}\n" +
$"{string.Join('、', entry.TeacherNames)}\n" +
$"{Location(entry)}\n" +
$"{entry.ExamDate:yyyy-MM-dd} · 第 {entry.StartPeriod}-" +
$"{entry.StartPeriod + entry.PeriodCount - 1} 节"
: $"{entry.CourseName}\n" +
$"{string.Join('、', entry.TeacherNames)}\n" + $"{string.Join('、', entry.TeacherNames)}\n" +
$"{Location(entry)}\n" + $"{Location(entry)}\n" +
$"{entry.StartWeek}-{entry.EndWeek} 周"; $"{entry.StartWeek}-{entry.EndWeek} 周";
@@ -7,7 +7,7 @@ namespace Jiaowu.Api.Tests;
public sealed class TimetableExcelExporterTests public sealed class TimetableExcelExporterTests
{ {
[Fact] [Fact]
public void Create_builds_week_grid_and_flexible_course_section() public void Create_builds_week_grid_with_exam_and_flexible_course_section()
{ {
var timetable = new TimetableData( var timetable = new TimetableData(
new TimetableTermDto( new TimetableTermDto(
@@ -84,7 +84,31 @@ public sealed class TimetableExcelExporterTests
["计算机科学 2601 班"], ["计算机科学 2601 班"],
null) null)
], ],
[]); [
new TimetableEntryDto(
Guid.NewGuid(),
Guid.NewGuid(),
"TASK-EXAM",
"高等数学期末考试",
"MATH101",
"高等数学",
["张老师", "监考:李老师"],
["计算机科学 2601 班"],
"201",
"第二教学楼",
"主校区",
2,
1,
1,
1,
1,
WeekPattern.All,
null,
true,
"2026-2027 学年第一学期期末考试",
new DateOnly(2027, 1, 5),
DateTime.UtcNow)
]);
var bytes = TimetableExcelExporter.Create(timetable); var bytes = TimetableExcelExporter.Create(timetable);
@@ -92,7 +116,10 @@ public sealed class TimetableExcelExporterTests
using var workbook = new XLWorkbook(stream); using var workbook = new XLWorkbook(stream);
var sheet = workbook.Worksheet("课表"); var sheet = workbook.Worksheet("课表");
Assert.Contains("计算机科学 2601 班", sheet.Cell("A1").GetString()); Assert.Contains("计算机科学 2601 班", sheet.Cell("A1").GetString());
Assert.Contains("已融入 1 场考试", sheet.Cell("A2").GetString());
Assert.Contains("高等数学", sheet.Cell(5, 2).GetString()); Assert.Contains("高等数学", sheet.Cell(5, 2).GetString());
Assert.Contains("【考试】高等数学", sheet.Cell(5, 3).GetString());
Assert.Contains("2027-01-05", sheet.Cell(5, 3).GetString());
Assert.Contains( Assert.Contains(
sheet.CellsUsed(), sheet.CellsUsed(),
cell => cell.GetString().Contains("非排时课程")); cell => cell.GetString().Contains("非排时课程"));
+85 -27
View File
@@ -100,14 +100,6 @@ const selectedResourceId = computed(() => {
if (resourceType.value === 'Classroom') return classroomId.value if (resourceType.value === 'Classroom') return classroomId.value
return classId.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>>(() => const slotMap = computed<Map<number, any>>(() =>
new Map<number, any>( new Map<number, any>(
(timetable.value?.slots ?? []).map((item: any) => [item.periodNumber, item]), (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 const offset = start.getDay() === 0 ? 6 : start.getDay() - 1
return addDays(start, -offset) 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 totalWeeks = computed(() => {
const end = parseDate(timetable.value?.term?.endDate) const end = parseDate(timetable.value?.term?.endDate)
if (!termMonday.value || !end) return 1 if (!termMonday.value || !end) return 1
@@ -126,7 +148,7 @@ const totalWeeks = computed(() => {
(7 * 86400000)) (7 * 86400000))
const entryWeeks = Math.max( const entryWeeks = Math.max(
1, 1,
...((timetable.value?.entries ?? []).map((entry: any) => entry.endWeek)), ...(timedEntries.value.map((entry: any) => entry.endWeek)),
) )
return Math.max(1, dateWeeks, entryWeeks) return Math.max(1, dateWeeks, entryWeeks)
}) })
@@ -139,7 +161,7 @@ const currentTermWeek = computed(() => {
return week >= 1 && week <= totalWeeks.value ? week : null return week >= 1 && week <= totalWeeks.value ? week : null
}) })
const weekEntries = computed(() => const weekEntries = computed(() =>
(timetable.value?.entries ?? []).filter((entry: any) => timedEntries.value.filter((entry: any) =>
occursInWeek(entry, selectedWeek.value)), occursInWeek(entry, selectedWeek.value)),
) )
const dayEntries = computed(() => const dayEntries = computed(() =>
@@ -147,7 +169,7 @@ const dayEntries = computed(() =>
) )
const visibleGridEntries = computed(() => const visibleGridEntries = computed(() =>
viewMode.value === 'overview' viewMode.value === 'overview'
? (timetable.value?.entries ?? []) ? timedEntries.value
: weekEntries.value, : weekEntries.value,
) )
const selectedWeekLabel = computed(() => weekLabel(selectedWeek.value)) const selectedWeekLabel = computed(() => weekLabel(selectedWeek.value))
@@ -182,6 +204,22 @@ function weeks(entry: any) {
return `${entry.startWeek}${entry.endWeek}${pattern === '每周' ? '' : ` · ${pattern}`}` 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) { function location(entry: any) {
return [entry.campusName, entry.buildingName, entry.classroomName].filter(Boolean).join(' · ') return [entry.campusName, entry.buildingName, entry.classroomName].filter(Boolean).join(' · ')
} }
@@ -255,7 +293,7 @@ function laneFor(entry: any, entries: any[]) {
return { item, index } return { item, index }
}) })
const count = Math.max(1, laneEnds.length) 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) => { sameDay.forEach((item: any) => {
@@ -269,7 +307,7 @@ function laneFor(entry: any, entries: any[]) {
clusterEnd = Math.max(clusterEnd, itemEnd) clusterEnd = Math.max(clusterEnd, itemEnd)
}) })
placeCluster() 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>) { 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="isMine">行政班课程与本人已选课程统一展示仅采用教务处已发布课表</p>
<p v-else-if="isTeacherView">查看指定教师本学期的授课安排</p> <p v-else-if="isTeacherView">查看指定教师本学期的授课安排</p>
<p v-else-if="isManager">查询班级教师与场地课表可切换草稿或正式发布版本</p> <p v-else-if="isManager">查询班级教师与场地课表可切换草稿或正式发布版本</p>
<p v-else>按年级学院专业和班级分类查询正式发布的课程安排</p> <p v-else>按年级学院专业和班级分类查询正式发布的课程与考试安排</p>
</div> </div>
<div class="timetable-filters"> <div class="timetable-filters">
<el-select v-model="termId" filterable placeholder="选择学期" @change="onTermChanged"> <el-select v-model="termId" filterable placeholder="选择学期" @change="onTermChanged">
@@ -839,6 +877,12 @@ onMounted(async () => {
<template v-if="timetable.plan.publishedAt"> <template v-if="timetable.plan.publishedAt">
· 发布于 {{ new Date(timetable.plan.publishedAt).toLocaleString('zh-CN') }} · 发布于 {{ new Date(timetable.plan.publishedAt).toLocaleString('zh-CN') }}
</template> </template>
<template v-if="examEntries.length">
· 已融入 {{ examEntries.length }} 场考试
</template>
</span>
<span v-else-if="examEntries.length">
固定课表尚未发布 · 已载入 {{ examEntries.length }} 场考试
</span> </span>
<span v-else>本学期课表尚未发布</span> <span v-else>本学期课表尚未发布</span>
</div> </div>
@@ -866,7 +910,7 @@ onMounted(async () => {
</section> </section>
<div <div
v-if="(viewMode === 'overview' || viewMode === 'week') && timetable?.plan && timetable.entries.length" v-if="(viewMode === 'overview' || viewMode === 'week') && hasTimedEntries"
class="timetable-view-section" class="timetable-view-section"
> >
<div class="view-context"> <div class="view-context">
@@ -874,12 +918,12 @@ onMounted(async () => {
<strong>{{ viewMode === 'overview' ? '全学期总览' : selectedWeekLabel }}</strong> <strong>{{ viewMode === 'overview' ? '全学期总览' : selectedWeekLabel }}</strong>
<span> <span>
{{ viewMode === 'overview' {{ viewMode === 'overview'
? '同时段但不同周次的课程并列显示' ? '固定课程与已发布考试按节次合并显示'
: `本周共 ${weekEntries.length} 项课程安排` }} : `本周共 ${weekEntries.length} 项课程、考试安排` }}
</span> </span>
</div> </div>
<small v-if="viewMode === 'overview'">课程卡片保留起止周与单双周信息</small> <small v-if="viewMode === 'overview'">考试卡片显示具体日期课程卡片保留周次信息</small>
<small v-else-if="!weekEntries.length">本周没有课程安排</small> <small v-else-if="!weekEntries.length">本周没有课程或考试安排</small>
</div> </div>
<div class="timetable-scroll"> <div class="timetable-scroll">
<div <div
@@ -907,19 +951,24 @@ onMounted(async () => {
</template> </template>
<article <article
v-for="entry in visibleGridEntries" v-for="entry in visibleGridEntries"
:key="entry.id" :key="entryKey(entry)"
class="course-block" class="course-block"
:class="{ 'exam-block': entry.isExam }"
:style="gridEntryStyle(entry)" :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>{{ entry.teacherNames.join('、') || '教师待定' }}</span>
<span>{{ location(entry) }}</span> <span>{{ location(entry) }}</span>
<small>{{ weeks(entry) }} · {{ entry.startPeriod }}{{ entry.startPeriod + entry.periodCount - 1 }} </small> <small>{{ entryTiming(entry) }}</small>
</article> </article>
</div> </div>
</div> </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> <header>
<div> <div>
<strong>{{ weekdays[selectedDay] }} · {{ selectedDayDate }}</strong> <strong>{{ weekdays[selectedDay] }} · {{ selectedDayDate }}</strong>
@@ -945,21 +994,26 @@ onMounted(async () => {
</template> </template>
<article <article
v-for="entry in dayEntries" v-for="entry in dayEntries"
:key="entry.id" :key="entryKey(entry)"
class="day-course-block" class="day-course-block"
:class="{ 'exam-block': entry.isExam }"
:style="dayEntryStyle(entry)" :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>{{ entry.teacherNames.join('、') || '教师待定' }}</span>
<span>{{ location(entry) }}</span> <span>{{ location(entry) }}</span>
<small>{{ weeks(entry) }} · {{ entry.startPeriod }}{{ entry.startPeriod + entry.periodCount - 1 }} </small> <small>{{ entryTiming(entry) }}</small>
</article> </article>
<span v-if="!dayEntries.length" class="day-no-courses">当日无课程安排</span> <span v-if="!dayEntries.length" class="day-no-courses">当日无课程安排</span>
</div> </div>
</div> </div>
<el-empty <el-empty
v-else-if="timetable && !loading && !timetable.flexibleCourses?.length" v-else-if="timetable && !loading && !timetable.flexibleCourses?.length"
:description="timetable.plan ? '该课表暂时没有课程安排' : '所选学期尚未发布课表'" :description="timetable.plan ? '该课表暂时没有课程或考试安排' : '所选学期尚未发布课表或考试安排'"
/> />
</div> </div>
</section> </section>
@@ -1131,6 +1185,10 @@ onMounted(async () => {
.course-block strong { color: #123a4b; font-size: 14px; } .course-block strong { color: #123a4b; font-size: 14px; }
.course-block span { font-size: 12px; } .course-block span { font-size: 12px; }
.course-block small { margin-top: auto; color: #5f7885; font-size: 11px; } .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 { 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 { 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 > div { display: grid; gap: 3px; }