using ClosedXML.Excel; namespace Jiaowu.Api.Infrastructure.Timetables; public static class TimetableExcelExporter { private static readonly string[] Weekdays = ["", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]; public static byte[] Create(TimetableData timetable) { using var workbook = new XLWorkbook(); var sheet = workbook.Worksheets.Add("课表"); sheet.Style.Font.FontName = "Microsoft YaHei"; sheet.Range("A1:H1").Merge(); sheet.Cell("A1").Value = $"{timetable.Subject.Name} · {timetable.Term.Name}课表"; sheet.Cell("A1").Style .Font.SetBold() .Font.SetFontSize(18) .Font.SetFontColor(XLColor.White) .Fill.SetBackgroundColor(XLColor.FromHtml("#173E72")) .Alignment.SetHorizontal(XLAlignmentHorizontalValues.Left) .Alignment.SetVertical(XLAlignmentVerticalValues.Center); sheet.Row(1).Height = 34; sheet.Range("A2:H2").Merge(); var activitySummary = string.Concat( timetable.ExamEntries.Count > 0 ? $" · 已融入 {timetable.ExamEntries.Count} 场考试" : "", timetable.ExperimentEntries.Count > 0 ? $" · 已融入 {timetable.ExperimentEntries.Count} 场实验" : ""); var hasOneOffActivities = timetable.ExamEntries.Count > 0 || timetable.ExperimentEntries.Count > 0; sheet.Cell("A2").Value = timetable.Plan is null ? hasOneOffActivities ? $"固定课表尚未发布{activitySummary} · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}" : "本学期暂无可用课表版本" : $"版本:{timetable.Plan.Version} · 状态:{PlanStatus(timetable.Plan.Status)}{activitySummary} · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}"; sheet.Cell("A2").Style .Font.SetFontColor(XLColor.FromHtml("#52677A")) .Fill.SetBackgroundColor(XLColor.FromHtml("#EFF3F6")); sheet.Row(2).Height = 24; var headerRow = 4; var headers = new[] { "节次 / 时间" }.Concat(Weekdays.Skip(1)).ToArray(); for (var column = 1; column <= headers.Length; column++) { var cell = sheet.Cell(headerRow, column); cell.Value = headers[column - 1]; cell.Style .Font.SetBold() .Font.SetFontColor(XLColor.White) .Fill.SetBackgroundColor(XLColor.FromHtml("#24706A")) .Alignment.SetHorizontal(XLAlignmentHorizontalValues.Center) .Alignment.SetVertical(XLAlignmentVerticalValues.Center); } sheet.Row(headerRow).Height = 26; var timedEntries = timetable.Entries .Concat(timetable.ExamEntries) .Concat(timetable.ExperimentEntries) .ToArray(); var periods = timetable.Slots.Select(x => x.PeriodNumber) .Concat(timedEntries.SelectMany(x => Enumerable.Range(x.StartPeriod, x.PeriodCount))) .Distinct() .Order() .ToArray(); if (periods.Length == 0) periods = Enumerable.Range(1, 8).ToArray(); for (var index = 0; index < periods.Length; index++) { var period = periods[index]; var row = headerRow + index + 1; var slot = timetable.Slots.FirstOrDefault(x => x.PeriodNumber == period); sheet.Cell(row, 1).Value = slot is null ? $"第 {period} 节" : $"第 {period} 节\n{slot.StartsAt:HH\\:mm}-{slot.EndsAt:HH\\:mm}"; sheet.Cell(row, 1).Style .Font.SetBold() .Fill.SetBackgroundColor(XLColor.FromHtml("#F0F4F6")) .Alignment.SetHorizontal(XLAlignmentHorizontalValues.Center) .Alignment.SetVertical(XLAlignmentVerticalValues.Center) .Alignment.SetWrapText(); for (var day = 1; day <= 7; day++) { var entries = timedEntries .Where(x => x.DayOfWeek == day && x.StartPeriod <= period && x.StartPeriod + x.PeriodCount - 1 >= period) .ToList(); var cell = sheet.Cell(row, day + 1); cell.Value = string.Join("\n\n", entries.Select(EntryText)); cell.Style .Alignment.SetVertical(XLAlignmentVerticalValues.Top) .Alignment.SetWrapText(); if (entries.Count > 0) { cell.Style .Fill.SetBackgroundColor(XLColor.FromHtml( entries.Any(x => x.IsExam) ? "#FFF1E8" : entries.Any(x => x.IsExperiment) ? "#E7F5F1" : "#E8F3F2")) .Font.SetFontColor(XLColor.FromHtml("#173F4C")); } } sheet.Row(row).Height = 64; } var grid = sheet.Range( headerRow, 1, headerRow + periods.Length, headers.Length); grid.Style.Border.InsideBorder = XLBorderStyleValues.Thin; grid.Style.Border.InsideBorderColor = XLColor.FromHtml("#D8E1E6"); grid.Style.Border.OutsideBorder = XLBorderStyleValues.Medium; grid.Style.Border.OutsideBorderColor = XLColor.FromHtml("#AABBC5"); sheet.Column(1).Width = 16; for (var column = 2; column <= 8; column++) sheet.Column(column).Width = 24; sheet.SheetView.FreezeRows(headerRow); if (timetable.FlexibleCourses.Count > 0) { var startRow = headerRow + periods.Length + 3; sheet.Range(startRow, 1, startRow, 8).Merge(); sheet.Cell(startRow, 1).Value = "非排时课程(不占正常上课时间与场地)"; sheet.Cell(startRow, 1).Style .Font.SetBold() .Font.SetFontColor(XLColor.White) .Fill.SetBackgroundColor(XLColor.FromHtml("#805C27")); var row = startRow + 1; foreach (var course in timetable.FlexibleCourses) { sheet.Range(row, 1, row, 8).Merge(); sheet.Cell(row, 1).Value = $"{course.CourseCode} · {course.CourseName} · {string.Join('、', course.TeacherNames)} · 第 {course.StartWeek}-{course.EndWeek} 周 · 每周 {course.WeeklyHours} 学时"; sheet.Cell(row, 1).Style .Fill.SetBackgroundColor(XLColor.FromHtml("#FFF8EA")) .Alignment.SetWrapText(); row++; } } sheet.PageSetup.PageOrientation = XLPageOrientation.Landscape; sheet.PageSetup.PaperSize = XLPaperSize.A4Paper; sheet.PageSetup.FitToPages(1, 0); sheet.PageSetup.Margins.SetLeft(0.25).SetRight(0.25).SetTop(0.35).SetBottom(0.35); using var stream = new MemoryStream(); workbook.SaveAs(stream); return stream.ToArray(); } private static string EntryText(TimetableEntryDto entry) => entry.IsExperiment ? $"【实验】{entry.ExperimentProjectName}\n" + $"{entry.CourseName} · {ArrangementMode(entry)}\n" + $"{string.Join('、', entry.TeacherNames)}\n" + $"{Location(entry)}\n" + $"{entry.ExperimentDate:yyyy-MM-dd} · 第 {entry.StartPeriod}-" + $"{entry.StartPeriod + entry.PeriodCount - 1} 节" : 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.Kind == Domain.Academic.ScheduleEntryKind.Experiment ? "【实验课】" : "")}{entry.CourseName}\n" + $"{string.Join('、', entry.TeacherNames)}\n" + $"{Location(entry)}\n" + $"{entry.StartWeek}-{entry.EndWeek} 周"; private static string ArrangementMode(TimetableEntryDto entry) => entry.ExperimentArrangementMode switch { Domain.Academic.ExperimentArrangementMode.Centralized => "集中安排", Domain.Academic.ExperimentArrangementMode.SelfScheduled => "自主预约", _ => "实验安排" }; private static string Location(TimetableEntryDto entry) => string.Join(" · ", new[] { entry.CampusName, entry.BuildingName, entry.ClassroomName }.Where(x => !string.IsNullOrWhiteSpace(x))); private static string PlanStatus(Domain.Academic.SchedulePlanStatus status) => status switch { Domain.Academic.SchedulePlanStatus.Draft => "草稿", Domain.Academic.SchedulePlanStatus.Published => "已发布", Domain.Academic.SchedulePlanStatus.Archived => "已归档", _ => status.ToString() }; }