diff --git a/src/Jiaowu.Api/Controllers/TimetableManagementController.cs b/src/Jiaowu.Api/Controllers/TimetableManagementController.cs index fac5faa..7bee118 100644 --- a/src/Jiaowu.Api/Controllers/TimetableManagementController.cs +++ b/src/Jiaowu.Api/Controllers/TimetableManagementController.cs @@ -171,6 +171,77 @@ public sealed class TimetableManagementController( return File(bytes, ExcelWorkbookHelper.ContentType, fileName); } + [HttpPost("export/batch.xlsx")] + public async Task ExportBatch( + TimetableBatchExportRequest request, + CancellationToken cancellationToken) + { + var resourceIds = request.ResourceIds.Distinct().ToArray(); + if (resourceIds.Length is 0 or > 100) + return ValidationProblem("请选择 1 至 100 个课表对象进行导出。", statusCode: StatusCodes.Status400BadRequest); + + var timetables = new List(resourceIds.Length); + foreach (var resourceId in resourceIds) + { + var result = await LoadAuthorizedAsync( + request.ResourceType, + resourceId, + request.AcademicTermId, + request.SchedulePlanId, + cancellationToken); + if (result.Result is null) return result.Error!; + timetables.Add(result.Result); + } + + var bytes = TimetableExcelExporter.Create(timetables); + var fileName = $"课表批量导出-{FileName(timetables[0].Term.Name)}.xlsx"; + return File(bytes, ExcelWorkbookHelper.ContentType, fileName); + } + + [HttpGet("export.pdf")] + public async Task ExportPdf( + TimetableResourceType resourceType, + Guid resourceId, + Guid academicTermId, + Guid? schedulePlanId, + CancellationToken cancellationToken) + { + var result = await LoadAuthorizedAsync( + resourceType, + resourceId, + academicTermId, + schedulePlanId, + cancellationToken); + if (result.Result is null) return result.Error!; + return PdfFile(result.Result); + } + + [HttpPost("export/batch.pdf")] + public async Task ExportBatchPdf( + TimetableBatchExportRequest request, + CancellationToken cancellationToken) + { + var resourceIds = request.ResourceIds.Distinct().ToArray(); + if (resourceIds.Length is 0 or > 100) + return ValidationProblem("请选择 1 至 100 个课表对象进行导出。", statusCode: StatusCodes.Status400BadRequest); + + var timetables = new List(resourceIds.Length); + foreach (var resourceId in resourceIds) + { + var result = await LoadAuthorizedAsync( + request.ResourceType, + resourceId, + request.AcademicTermId, + request.SchedulePlanId, + cancellationToken); + if (result.Result is null) return result.Error!; + timetables.Add(result.Result); + } + + var fileName = $"课表批量导出-{FileName(timetables[0].Term.Name)}.pdf"; + return File(TimetablePdfExporter.Create(timetables), "application/pdf", fileName); + } + private async Task<(TimetableData? Result, ActionResult? Error)> LoadAuthorizedAsync( TimetableResourceType resourceType, Guid resourceId, @@ -205,8 +276,20 @@ public sealed class TimetableManagementController( value = value.Replace(character, '-'); return value.Trim(); } + + private ActionResult PdfFile(TimetableData timetable) => + File( + TimetablePdfExporter.Create(timetable), + "application/pdf", + $"{FileName(timetable.Subject.Name)}-{FileName(timetable.Term.Name)}-课表.pdf"); } +public sealed record TimetableBatchExportRequest( + TimetableResourceType ResourceType, + [property: Required, MinLength(1), MaxLength(100)] IReadOnlyCollection ResourceIds, + Guid AcademicTermId, + Guid? SchedulePlanId); + [ApiController] [Route("api/timetables")] public sealed class FreeClassroomsController( diff --git a/src/Jiaowu.Api/Controllers/TimetablesController.cs b/src/Jiaowu.Api/Controllers/TimetablesController.cs index dbe6340..1e8360a 100644 --- a/src/Jiaowu.Api/Controllers/TimetablesController.cs +++ b/src/Jiaowu.Api/Controllers/TimetablesController.cs @@ -100,6 +100,21 @@ public sealed class TimetablesController( return ExcelFile(result); } + [HttpGet("teachers/{teacherId:guid}/export.pdf")] + [AllowAnonymous] + public async Task ExportTeacherTimetablePdf( + Guid teacherId, + Guid? academicTermId, + CancellationToken cancellationToken) + { + var result = await GetPublishedTimetableAsync( + TimetableResourceType.Teacher, + teacherId, + academicTermId, + cancellationToken); + return result is null ? NotFound() : PdfFile(result); + } + [HttpGet("classes/{classId:guid}/export.xlsx")] [AllowAnonymous] public async Task ExportClassTimetable( @@ -116,6 +131,21 @@ public sealed class TimetablesController( return ExcelFile(result); } + [HttpGet("classes/{classId:guid}/export.pdf")] + [AllowAnonymous] + public async Task ExportClassTimetablePdf( + Guid classId, + Guid? academicTermId, + CancellationToken cancellationToken) + { + var result = await GetPublishedTimetableAsync( + TimetableResourceType.Class, + classId, + academicTermId, + cancellationToken); + return result is null ? NotFound() : PdfFile(result); + } + [HttpGet("mine")] [Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)] public async Task GetMyTimetable( @@ -227,6 +257,39 @@ public sealed class TimetablesController( return NotFound(); } + [HttpGet("mine/export.pdf")] + [Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)] + public async Task ExportMyTimetablePdf( + Guid? academicTermId, + CancellationToken cancellationToken) + { + if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId)) + return Unauthorized(); + + var student = await db.Students.AsNoTracking() + .Where(x => x.UserId == userId) + .Select(x => new { x.Id, x.AdministrativeClassId, x.StudentNumber, x.Name }) + .FirstOrDefaultAsync(cancellationToken); + if (student is not null) + { + var result = await timetableDataService.BuildAsync( + TimetableResourceType.Class, student.AdministrativeClassId, academicTermId, null, + false, student.Id, new TimetableStudentDto(student.StudentNumber, student.Name), + cancellationToken); + return result is null ? NotFound() : PdfFile(result); + } + + var teacher = await db.Teachers.AsNoTracking() + .Where(x => x.UserId == userId && x.Status == TeacherStatus.Active) + .Select(x => new { x.Id }) + .FirstOrDefaultAsync(cancellationToken); + if (teacher is null) return NotFound(); + var teacherResult = await timetableDataService.BuildAsync( + TimetableResourceType.Teacher, teacher.Id, academicTermId, null, false, null, null, + cancellationToken); + return teacherResult is null ? NotFound() : PdfFile(teacherResult); + } + [HttpGet("mine/calendar-subscription")] [Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)] public async Task GetMyCalendarSubscription( @@ -450,6 +513,13 @@ public sealed class TimetablesController( return File(bytes, ExcelWorkbookHelper.ContentType, fileName); } + private ActionResult PdfFile(TimetableData result) + { + var fileName = + $"{SafeFileName(result.Subject.Name)}-{SafeFileName(result.Term.Name)}-课表.pdf"; + return File(TimetablePdfExporter.Create(result), "application/pdf", fileName); + } + private static string SafeFileName(string value) { foreach (var character in Path.GetInvalidFileNameChars()) diff --git a/src/Jiaowu.Api/Infrastructure/Timetables/TimetableExcelExporter.cs b/src/Jiaowu.Api/Infrastructure/Timetables/TimetableExcelExporter.cs index 369cdaa..c072b6a 100644 --- a/src/Jiaowu.Api/Infrastructure/Timetables/TimetableExcelExporter.cs +++ b/src/Jiaowu.Api/Infrastructure/Timetables/TimetableExcelExporter.cs @@ -10,7 +10,33 @@ public static class TimetableExcelExporter public static byte[] Create(TimetableData timetable) { using var workbook = new XLWorkbook(); - var sheet = workbook.Worksheets.Add("课表"); + CreateWorksheet(workbook, timetable, "课表"); + using var stream = new MemoryStream(); + workbook.SaveAs(stream); + return stream.ToArray(); + } + + public static byte[] Create(IReadOnlyCollection timetables) + { + if (timetables.Count == 0) + throw new ArgumentException("至少需要一张课表。", nameof(timetables)); + + using var workbook = new XLWorkbook(); + var index = 1; + foreach (var timetable in timetables) + CreateWorksheet(workbook, timetable, WorksheetName(timetable.Subject.Name, index++)); + + using var stream = new MemoryStream(); + workbook.SaveAs(stream); + return stream.ToArray(); + } + + private static void CreateWorksheet( + XLWorkbook workbook, + TimetableData timetable, + string worksheetName) + { + var sheet = workbook.Worksheets.Add(worksheetName); sheet.Style.Font.FontName = "Microsoft YaHei"; sheet.Range("A1:H1").Merge(); @@ -153,9 +179,17 @@ public static class TimetableExcelExporter 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 WorksheetName(string subjectName, int index) + { + var suffix = $"-{index}"; + var name = string.Concat(subjectName.Select(character => + "[]:*?/\\".Contains(character) ? '-' : character)).Trim(); + if (string.IsNullOrWhiteSpace(name)) name = "课表"; + return name.Length > 31 - suffix.Length + ? name[..(31 - suffix.Length)] + suffix + : name + suffix; } private static string EntryText(TimetableEntryDto entry) => diff --git a/src/Jiaowu.Api/Infrastructure/Timetables/TimetablePdfExporter.cs b/src/Jiaowu.Api/Infrastructure/Timetables/TimetablePdfExporter.cs new file mode 100644 index 0000000..df5f9f1 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Timetables/TimetablePdfExporter.cs @@ -0,0 +1,219 @@ +using SkiaSharp; + +namespace Jiaowu.Api.Infrastructure.Timetables; + +public static class TimetablePdfExporter +{ + private const float PageWidth = 842; + private const float PageHeight = 595; + private const float Margin = 24; + private static readonly SKColor Ink = new(27, 53, 74); + private static readonly SKColor Muted = new(92, 111, 126); + private static readonly SKColor Accent = new(32, 105, 99); + private static readonly SKColor Rule = new(206, 220, 226); + private static readonly SKColor Pale = new(241, 247, 247); + + public static byte[] Create(TimetableData timetable) => Create([timetable]); + + public static byte[] Create(IReadOnlyCollection timetables) + { + if (timetables.Count == 0) + throw new ArgumentException("至少需要一张课表。", nameof(timetables)); + + using var typeface = ResolveTypeface(); + using var stream = new MemoryStream(); + using var document = SKDocument.CreatePdf(stream); + var page = 0; + foreach (var timetable in timetables) + { + var periods = Periods(timetable); + foreach (var periodPage in periods.Chunk(9)) + DrawPage(document, typeface, timetable, periodPage, ++page); + } + document.Close(); + return stream.ToArray(); + } + + private static void DrawPage( + SKDocument document, + SKTypeface typeface, + TimetableData timetable, + IReadOnlyList periods, + int page) + { + using var canvas = document.BeginPage(PageWidth, PageHeight); + canvas.Clear(SKColors.White); + using var frame = new SKPaint { Color = Accent, Style = SKPaintStyle.Stroke, StrokeWidth = 1.1f }; + canvas.DrawRect(14, 14, PageWidth - 28, PageHeight - 28, frame); + + DrawText(canvas, typeface, timetable.Subject.Name, Margin, 53, 19, Ink, bold: true); + DrawText(canvas, typeface, "课程表", PageWidth - Margin, 53, 11, Accent, SKTextAlign.Right, true); + DrawText(canvas, typeface, + $"{timetable.Subject.Code} · {timetable.Term.Name}" + + (timetable.Plan is null ? "" : $" · {timetable.Plan.Version} · {PlanStatus(timetable.Plan.Status)}"), + Margin, 74, 9, Muted); + DrawText(canvas, typeface, + $"导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}", + PageWidth - Margin, 74, 8, Muted, SKTextAlign.Right); + using var titleRule = new SKPaint { Color = Rule, StrokeWidth = .8f }; + canvas.DrawLine(Margin, 86, PageWidth - Margin, 86, titleRule); + + var widths = new[] { 84f, 101f, 101f, 101f, 101f, 101f, 101f, 101f }; + var headers = new[] { "节次 / 时间", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日" }; + const float headerHeight = 24; + const float rowHeight = 46; + var y = 103f; + using var header = new SKPaint { Color = Accent, Style = SKPaintStyle.Fill }; + using var grid = new SKPaint { Color = Rule, Style = SKPaintStyle.Stroke, StrokeWidth = .55f }; + canvas.DrawRect(Margin, y, widths.Sum(), headerHeight, header); + var x = Margin; + for (var column = 0; column < headers.Length; column++) + { + DrawText(canvas, typeface, headers[column], x + widths[column] / 2, y + 16, 8.5f, + SKColors.White, SKTextAlign.Center, true); + x += widths[column]; + } + + var entries = timetable.Entries.Concat(timetable.ExamEntries).Concat(timetable.ExperimentEntries).ToArray(); + for (var rowIndex = 0; rowIndex < periods.Count; rowIndex++) + { + var period = periods[rowIndex]; + var rowY = y + headerHeight + rowIndex * rowHeight; + using var leftFill = new SKPaint { Color = Pale, Style = SKPaintStyle.Fill }; + canvas.DrawRect(Margin, rowY, widths[0], rowHeight, leftFill); + var slot = timetable.Slots.FirstOrDefault(item => item.PeriodNumber == period); + DrawText(canvas, typeface, $"第 {period} 节", Margin + widths[0] / 2, rowY + 18, 8.5f, + Ink, SKTextAlign.Center, true); + if (slot is not null) + DrawText(canvas, typeface, $"{slot.StartsAt:HH\\:mm}-{slot.EndsAt:HH\\:mm}", + Margin + widths[0] / 2, rowY + 32, 7, Muted, SKTextAlign.Center); + + x = Margin + widths[0]; + for (var day = 1; day <= 7; day++) + { + var cellEntries = entries.Where(item => + item.DayOfWeek == day && + item.StartPeriod <= period && + item.StartPeriod + item.PeriodCount - 1 >= period).ToArray(); + if (cellEntries.Length > 0) + { + using var fill = new SKPaint + { + Color = cellEntries.Any(item => item.IsExam) + ? new SKColor(255, 243, 230) + : cellEntries.Any(item => item.IsExperiment) + ? new SKColor(232, 246, 241) + : new SKColor(234, 245, 244), + Style = SKPaintStyle.Fill + }; + canvas.DrawRect(x, rowY, widths[day], rowHeight, fill); + DrawCellText(canvas, typeface, cellEntries, x, rowY, widths[day], rowHeight); + } + x += widths[day]; + } + } + + var tableHeight = headerHeight + periods.Count * rowHeight; + canvas.DrawRect(Margin, y, widths.Sum(), tableHeight, grid); + x = Margin; + for (var column = 0; column < widths.Length - 1; column++) + { + x += widths[column]; + canvas.DrawLine(x, y, x, y + tableHeight, grid); + } + for (var row = 0; row < periods.Count; row++) + canvas.DrawLine(Margin, y + headerHeight + row * rowHeight, Margin + widths.Sum(), + y + headerHeight + row * rowHeight, grid); + + var footerY = PageHeight - 31; + canvas.DrawLine(Margin, footerY - 12, PageWidth - Margin, footerY - 12, titleRule); + DrawText(canvas, typeface, "明序教务 · 课表导出", Margin, footerY, 7.5f, Muted); + DrawText(canvas, typeface, $"第 {page} 页", PageWidth - Margin, footerY, 7.5f, Muted, + SKTextAlign.Right); + document.EndPage(); + } + + private static void DrawCellText( + SKCanvas canvas, + SKTypeface typeface, + IReadOnlyList entries, + float x, + float y, + float width, + float height) + { + var text = string.Join("\n", entries.Select(EntryText)); + var lines = WrapText(typeface, text, 6.6f, width - 8).Take(5).ToArray(); + for (var index = 0; index < lines.Length; index++) + DrawText(canvas, typeface, lines[index], x + 4, y + 10 + index * 8, 6.6f, Ink); + } + + private static IReadOnlyList Periods(TimetableData timetable) + { + var entries = timetable.Entries.Concat(timetable.ExamEntries).Concat(timetable.ExperimentEntries); + var periods = timetable.Slots.Select(item => item.PeriodNumber) + .Concat(entries.SelectMany(item => Enumerable.Range(item.StartPeriod, item.PeriodCount))) + .Distinct().Order().ToArray(); + return periods.Length == 0 ? Enumerable.Range(1, 8).ToArray() : periods; + } + + private static string EntryText(TimetableEntryDto entry) => + $"{(entry.IsExam ? "【考试】" : entry.IsExperiment ? "【实验】" : "")}{entry.CourseName} · {string.Join('、', entry.TeacherNames)}" + + $" · {string.Join(" ", new[] { entry.BuildingName, entry.ClassroomName }.Where(item => !string.IsNullOrWhiteSpace(item)))}"; + + private static IReadOnlyList WrapText(SKTypeface typeface, string text, float size, float width) + { + using var font = new SKFont(typeface, size); + var lines = new List(); + foreach (var paragraph in text.Split('\n')) + { + var current = ""; + foreach (var character in paragraph) + { + var candidate = current + character; + if (current.Length > 0 && font.MeasureText(candidate) > width) + { + lines.Add(current); + current = character.ToString(); + } + else current = candidate; + } + if (current.Length > 0) lines.Add(current); + } + return lines; + } + + private static void DrawText(SKCanvas canvas, SKTypeface typeface, string text, float x, float y, + float size, SKColor color, SKTextAlign align = SKTextAlign.Left, bool bold = false) + { + using var font = new SKFont(typeface, size) { Embolden = bold }; + using var paint = new SKPaint { Color = color, IsAntialias = true }; + canvas.DrawText(text, x, y, align, font, paint); + } + + private static SKTypeface ResolveTypeface() + { + var candidates = new[] + { + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "simhei.ttf"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "msyh.ttc"), + "/usr/share/fonts/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc" + }; + foreach (var path in candidates) + { + if (File.Exists(path) && SKTypeface.FromFile(path) is { } typeface) + return typeface; + } + return SKTypeface.FromFamilyName("Microsoft YaHei") ?? + SKTypeface.FromFamilyName("Noto Sans CJK SC") ?? + throw new InvalidOperationException("未找到可用于课表 PDF 的中文字体。"); + } + + private static string PlanStatus(Domain.Academic.SchedulePlanStatus status) => status switch + { + Domain.Academic.SchedulePlanStatus.Draft => "草稿", + Domain.Academic.SchedulePlanStatus.Published => "已发布", + _ => "已归档" + }; +} diff --git a/tests/Jiaowu.Api.Tests/TimetableExcelExporterTests.cs b/tests/Jiaowu.Api.Tests/TimetableExcelExporterTests.cs index 8de9524..5b204eb 100644 --- a/tests/Jiaowu.Api.Tests/TimetableExcelExporterTests.cs +++ b/tests/Jiaowu.Api.Tests/TimetableExcelExporterTests.cs @@ -160,5 +160,17 @@ public sealed class TimetableExcelExporterTests Assert.Contains( sheet.CellsUsed(), cell => cell.GetString().Contains("非排时课程")); + + var batchBytes = TimetableExcelExporter.Create([timetable, timetable]); + using var batchStream = new MemoryStream(batchBytes); + using var batchWorkbook = new XLWorkbook(batchStream); + Assert.Equal(2, batchWorkbook.Worksheets.Count); + Assert.All(batchWorkbook.Worksheets, worksheet => + Assert.Contains("计算机科学 2601 班", worksheet.Cell("A1").GetString())); + + var pdf = TimetablePdfExporter.Create([timetable, timetable]); + Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4)); + Assert.Contains(System.Text.Encoding.ASCII.GetBytes("%%EOF"), pdf); + Assert.True(pdf.Length > 1_000, "生成的 PDF 不应为空。"); } } diff --git a/web/src/api/excel.ts b/web/src/api/excel.ts index 5a253dd..d8b13a4 100644 --- a/web/src/api/excel.ts +++ b/web/src/api/excel.ts @@ -28,6 +28,22 @@ export async function downloadApiFile( URL.revokeObjectURL(url) } +export async function downloadApiPostFile( + path: string, + payload: unknown, + fallbackName: string, +) { + const response = await http.post(path, payload, { responseType: 'blob' }) + const url = URL.createObjectURL(response.data) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = responseFileName(response.headers['content-disposition'], fallbackName) + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + URL.revokeObjectURL(url) +} + export async function importExcel(path: string, file: File) { const form = new FormData() form.append('file', file) diff --git a/web/src/views/TimetableView.vue b/web/src/views/TimetableView.vue index 03fabb1..7b33dd1 100644 --- a/web/src/views/TimetableView.vue +++ b/web/src/views/TimetableView.vue @@ -1,5 +1,5 @@