From a61dacff1a1b0188b36b677be02a2dd74aac003e Mon Sep 17 00:00:00 2001 From: biss Date: Tue, 28 Jul 2026 14:16:24 +0800 Subject: [PATCH] =?UTF-8?q?=E8=80=83=E8=AF=95=E6=8C=89=E5=AE=9E=E9=99=85?= =?UTF-8?q?=E6=97=A5=E6=9C=9F=E3=80=81=E5=91=A8=E6=AC=A1=E5=92=8C=E8=8A=82?= =?UTF-8?q?=E6=AC=A1=E8=9E=8D=E5=85=A5=E5=91=A8/=E6=97=A5/=E6=80=BB?= =?UTF-8?q?=E8=A7=86=E5=9B=BE=EF=BC=8C=E5=B9=B6=E7=94=A8=E6=A9=99=E8=89=B2?= =?UTF-8?q?=E2=80=9C=E8=80=83=E8=AF=95=E2=80=9D=E5=8D=A1=E7=89=87=E5=8C=BA?= =?UTF-8?q?=E5=88=86=E3=80=82=20=E5=8D=B3=E4=BD=BF=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E8=AF=BE=E8=A1=A8=E5=B0=9A=E6=9C=AA=E5=8F=91=E5=B8=83=EF=BC=8C?= =?UTF-8?q?=E5=8F=AA=E8=A6=81=E8=80=83=E8=AF=95=E5=B7=B2=E5=8F=91=E5=B8=83?= =?UTF-8?q?=EF=BC=8C=E4=B9=9F=E8=83=BD=E6=AD=A3=E5=B8=B8=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E3=80=82=20=E8=80=83=E8=AF=95=E8=AE=A1=E5=88=92=E3=80=81?= =?UTF-8?q?=E8=80=83=E5=9C=BA=E3=80=81=E7=9B=91=E8=80=83=E6=95=99=E5=B8=88?= =?UTF-8?q?=E5=92=8C=E6=97=A5=E6=9C=9F=E9=83=BD=E4=BC=9A=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E3=80=82=20Excel=20=E5=AF=BC=E5=87=BA=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=8C=85=E5=90=AB=E8=80=83=E8=AF=95=E5=AE=89=E6=8E=92=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timetables/TimetableExcelExporter.cs | 34 ++++-- .../TimetableExcelExporterTests.cs | 31 ++++- web/src/views/TimetableView.vue | 112 +++++++++++++----- 3 files changed, 139 insertions(+), 38 deletions(-) diff --git a/src/Jiaowu.Api/Infrastructure/Timetables/TimetableExcelExporter.cs b/src/Jiaowu.Api/Infrastructure/Timetables/TimetableExcelExporter.cs index 2cbe23e..27b7276 100644 --- a/src/Jiaowu.Api/Infrastructure/Timetables/TimetableExcelExporter.cs +++ b/src/Jiaowu.Api/Infrastructure/Timetables/TimetableExcelExporter.cs @@ -25,9 +25,14 @@ public static class TimetableExcelExporter sheet.Row(1).Height = 34; sheet.Range("A2:H2").Merge(); + var examSummary = timetable.ExamEntries.Count > 0 + ? $" · 已融入 {timetable.ExamEntries.Count} 场考试" + : ""; sheet.Cell("A2").Value = timetable.Plan is null - ? "本学期暂无可用课表版本" - : $"版本:{timetable.Plan.Version} · 状态:{PlanStatus(timetable.Plan.Status)} · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}"; + ? timetable.ExamEntries.Count > 0 + ? $"固定课表尚未发布{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 .Font.SetFontColor(XLColor.FromHtml("#52677A")) .Fill.SetBackgroundColor(XLColor.FromHtml("#EFF3F6")); @@ -48,8 +53,11 @@ public static class TimetableExcelExporter } sheet.Row(headerRow).Height = 26; + var timedEntries = timetable.Entries + .Concat(timetable.ExamEntries) + .ToArray(); var periods = timetable.Slots.Select(x => x.PeriodNumber) - .Concat(timetable.Entries.SelectMany(x => + .Concat(timedEntries.SelectMany(x => Enumerable.Range(x.StartPeriod, x.PeriodCount))) .Distinct() .Order() @@ -72,7 +80,7 @@ public static class TimetableExcelExporter for (var day = 1; day <= 7; day++) { - var entries = timetable.Entries + var entries = timedEntries .Where(x => x.DayOfWeek == day && x.StartPeriod <= period && @@ -86,7 +94,8 @@ public static class TimetableExcelExporter if (entries.Count > 0) { cell.Style - .Fill.SetBackgroundColor(XLColor.FromHtml("#E8F3F2")) + .Fill.SetBackgroundColor(XLColor.FromHtml( + entries.Any(x => x.IsExam) ? "#FFF1E8" : "#E8F3F2")) .Font.SetFontColor(XLColor.FromHtml("#173F4C")); } } @@ -138,10 +147,17 @@ public static class TimetableExcelExporter } private static string EntryText(TimetableEntryDto entry) => - $"{entry.CourseName}\n" + - $"{string.Join('、', entry.TeacherNames)}\n" + - $"{Location(entry)}\n" + - $"{entry.StartWeek}-{entry.EndWeek} 周"; + 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" + + $"{Location(entry)}\n" + + $"{entry.StartWeek}-{entry.EndWeek} 周"; private static string Location(TimetableEntryDto entry) => string.Join(" · ", new[] diff --git a/tests/Jiaowu.Api.Tests/TimetableExcelExporterTests.cs b/tests/Jiaowu.Api.Tests/TimetableExcelExporterTests.cs index c44d727..268feb6 100644 --- a/tests/Jiaowu.Api.Tests/TimetableExcelExporterTests.cs +++ b/tests/Jiaowu.Api.Tests/TimetableExcelExporterTests.cs @@ -7,7 +7,7 @@ namespace Jiaowu.Api.Tests; public sealed class TimetableExcelExporterTests { [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( new TimetableTermDto( @@ -84,7 +84,31 @@ public sealed class TimetableExcelExporterTests ["计算机科学 2601 班"], 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); @@ -92,7 +116,10 @@ public sealed class TimetableExcelExporterTests using var workbook = new XLWorkbook(stream); var sheet = workbook.Worksheet("课表"); 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, 3).GetString()); + Assert.Contains("2027-01-05", sheet.Cell(5, 3).GetString()); Assert.Contains( sheet.CellsUsed(), cell => cell.GetString().Contains("非排时课程")); diff --git a/web/src/views/TimetableView.vue b/web/src/views/TimetableView.vue index 375321e..bd3b682 100644 --- a/web/src/views/TimetableView.vue +++ b/web/src/views/TimetableView.vue @@ -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>(() => new Map( (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) { @@ -670,7 +708,7 @@ onMounted(async () => {

行政班课程与本人已选课程统一展示,仅采用教务处已发布课表。

查看指定教师本学期的授课安排。

查询班级、教师与场地课表,可切换草稿或正式发布版本。

-

按年级、学院、专业和班级分类查询正式发布的课程安排。

+

按年级、学院、专业和班级分类查询正式发布的课程与考试安排。

@@ -839,6 +877,12 @@ onMounted(async () => { + + + + 固定课表尚未发布 · 已载入 {{ examEntries.length }} 场考试 本学期课表尚未发布
@@ -866,7 +910,7 @@ onMounted(async () => {
@@ -874,12 +918,12 @@ onMounted(async () => { {{ viewMode === 'overview' ? '全学期总览' : selectedWeekLabel }} {{ viewMode === 'overview' - ? '同时段但不同周次的课程并列显示' - : `本周共 ${weekEntries.length} 项课程安排` }} + ? '固定课程与已发布考试按节次合并显示' + : `本周共 ${weekEntries.length} 项课程、考试安排` }}
- 课程卡片保留起止周与单双周信息 - 本周没有课程安排 + 考试卡片显示具体日期,课程卡片保留周次信息 + 本周没有课程或考试安排
{
- {{ entry.courseName }} + + 考试 + {{ entry.courseName }} + + {{ entry.examPlanName }} {{ entry.teacherNames.join('、') || '教师待定' }} {{ location(entry) }} - {{ weeks(entry) }} · 第 {{ entry.startPeriod }}—{{ entry.startPeriod + entry.periodCount - 1 }} 节 + {{ entryTiming(entry) }}
-
+
{{ weekdays[selectedDay] }} · {{ selectedDayDate }} @@ -945,21 +994,26 @@ onMounted(async () => {
- {{ entry.courseName }} + + 考试 + {{ entry.courseName }} + + {{ entry.examPlanName }} {{ entry.teacherNames.join('、') || '教师待定' }} {{ location(entry) }} - {{ weeks(entry) }} · 第 {{ entry.startPeriod }}—{{ entry.startPeriod + entry.periodCount - 1 }} 节 + {{ entryTiming(entry) }}
当日无课程安排
@@ -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; }