课表优化
This commit is contained in:
@@ -171,6 +171,77 @@ public sealed class TimetableManagementController(
|
|||||||
return File(bytes, ExcelWorkbookHelper.ContentType, fileName);
|
return File(bytes, ExcelWorkbookHelper.ContentType, fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("export/batch.xlsx")]
|
||||||
|
public async Task<ActionResult> 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<TimetableData>(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<ActionResult> 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<ActionResult> 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<TimetableData>(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(
|
private async Task<(TimetableData? Result, ActionResult? Error)> LoadAuthorizedAsync(
|
||||||
TimetableResourceType resourceType,
|
TimetableResourceType resourceType,
|
||||||
Guid resourceId,
|
Guid resourceId,
|
||||||
@@ -205,8 +276,20 @@ public sealed class TimetableManagementController(
|
|||||||
value = value.Replace(character, '-');
|
value = value.Replace(character, '-');
|
||||||
return value.Trim();
|
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<Guid> ResourceIds,
|
||||||
|
Guid AcademicTermId,
|
||||||
|
Guid? SchedulePlanId);
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/timetables")]
|
[Route("api/timetables")]
|
||||||
public sealed class FreeClassroomsController(
|
public sealed class FreeClassroomsController(
|
||||||
|
|||||||
@@ -100,6 +100,21 @@ public sealed class TimetablesController(
|
|||||||
return ExcelFile(result);
|
return ExcelFile(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("teachers/{teacherId:guid}/export.pdf")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<ActionResult> 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")]
|
[HttpGet("classes/{classId:guid}/export.xlsx")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<ActionResult> ExportClassTimetable(
|
public async Task<ActionResult> ExportClassTimetable(
|
||||||
@@ -116,6 +131,21 @@ public sealed class TimetablesController(
|
|||||||
return ExcelFile(result);
|
return ExcelFile(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("classes/{classId:guid}/export.pdf")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<ActionResult> 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")]
|
[HttpGet("mine")]
|
||||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||||
public async Task<ActionResult> GetMyTimetable(
|
public async Task<ActionResult> GetMyTimetable(
|
||||||
@@ -227,6 +257,39 @@ public sealed class TimetablesController(
|
|||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("mine/export.pdf")]
|
||||||
|
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||||
|
public async Task<ActionResult> 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")]
|
[HttpGet("mine/calendar-subscription")]
|
||||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||||
public async Task<ActionResult> GetMyCalendarSubscription(
|
public async Task<ActionResult> GetMyCalendarSubscription(
|
||||||
@@ -450,6 +513,13 @@ public sealed class TimetablesController(
|
|||||||
return File(bytes, ExcelWorkbookHelper.ContentType, fileName);
|
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)
|
private static string SafeFileName(string value)
|
||||||
{
|
{
|
||||||
foreach (var character in Path.GetInvalidFileNameChars())
|
foreach (var character in Path.GetInvalidFileNameChars())
|
||||||
|
|||||||
@@ -10,7 +10,33 @@ public static class TimetableExcelExporter
|
|||||||
public static byte[] Create(TimetableData timetable)
|
public static byte[] Create(TimetableData timetable)
|
||||||
{
|
{
|
||||||
using var workbook = new XLWorkbook();
|
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<TimetableData> 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.Style.Font.FontName = "Microsoft YaHei";
|
||||||
|
|
||||||
sheet.Range("A1:H1").Merge();
|
sheet.Range("A1:H1").Merge();
|
||||||
@@ -153,9 +179,17 @@ public static class TimetableExcelExporter
|
|||||||
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
|
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
|
||||||
sheet.PageSetup.FitToPages(1, 0);
|
sheet.PageSetup.FitToPages(1, 0);
|
||||||
sheet.PageSetup.Margins.SetLeft(0.25).SetRight(0.25).SetTop(0.35).SetBottom(0.35);
|
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) =>
|
private static string EntryText(TimetableEntryDto entry) =>
|
||||||
|
|||||||
@@ -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<TimetableData> 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<int> 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<TimetableEntryDto> 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<int> 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<string> WrapText(SKTypeface typeface, string text, float size, float width)
|
||||||
|
{
|
||||||
|
using var font = new SKFont(typeface, size);
|
||||||
|
var lines = new List<string>();
|
||||||
|
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 => "已发布",
|
||||||
|
_ => "已归档"
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -160,5 +160,17 @@ public sealed class TimetableExcelExporterTests
|
|||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
sheet.CellsUsed(),
|
sheet.CellsUsed(),
|
||||||
cell => cell.GetString().Contains("非排时课程"));
|
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 不应为空。");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,22 @@ export async function downloadApiFile(
|
|||||||
URL.revokeObjectURL(url)
|
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) {
|
export async function importExcel(path: string, file: File) {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file)
|
form.append('file', file)
|
||||||
|
|||||||
+131
-30
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import http, { apiErrorMessage } from '../api/http'
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
import { downloadApiFile } from '../api/excel'
|
import { downloadApiFile, downloadApiPostFile } from '../api/excel'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
||||||
|
|
||||||
@@ -32,6 +32,8 @@ const isManager = computed(() =>
|
|||||||
)
|
)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const exportingPdf = ref(false)
|
const exportingPdf = ref(false)
|
||||||
|
const batchExporting = ref(false)
|
||||||
|
const batchResourceIds = ref<string[]>([])
|
||||||
const calendarDialogVisible = ref(false)
|
const calendarDialogVisible = ref(false)
|
||||||
const calendarLoading = ref(false)
|
const calendarLoading = ref(false)
|
||||||
const calendarActionLoading = ref(false)
|
const calendarActionLoading = ref(false)
|
||||||
@@ -61,7 +63,6 @@ const viewMode = ref<'overview' | 'week' | 'day'>('week')
|
|||||||
const selectedWeek = ref(1)
|
const selectedWeek = ref(1)
|
||||||
const selectedDay = ref(1)
|
const selectedDay = ref(1)
|
||||||
const loadedTermId = ref('')
|
const loadedTermId = ref('')
|
||||||
const exportArea = ref<HTMLElement | null>(null)
|
|
||||||
const weekdays = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
const weekdays = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||||
const patternLabels: Record<string, string> = { All: '每周', Odd: '单周', Even: '双周' }
|
const patternLabels: Record<string, string> = { All: '每周', Odd: '单周', Even: '双周' }
|
||||||
const planStatusLabels: Record<string, string> = {
|
const planStatusLabels: Record<string, string> = {
|
||||||
@@ -104,6 +105,11 @@ const selectedResourceId = computed(() => {
|
|||||||
if (resourceType.value === 'Classroom') return classroomId.value
|
if (resourceType.value === 'Classroom') return classroomId.value
|
||||||
return classId.value
|
return classId.value
|
||||||
})
|
})
|
||||||
|
const batchResources = computed(() => {
|
||||||
|
if (resourceType.value === 'Teacher') return filteredTeachers.value
|
||||||
|
if (resourceType.value === 'Classroom') return filteredClassrooms.value
|
||||||
|
return filteredClasses.value
|
||||||
|
})
|
||||||
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]),
|
||||||
@@ -444,6 +450,7 @@ async function loadManagementOptions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onTermChanged() {
|
async function onTermChanged() {
|
||||||
|
batchResourceIds.value = []
|
||||||
if (isManager.value) await loadManagementOptions()
|
if (isManager.value) await loadManagementOptions()
|
||||||
await loadTimetable()
|
await loadTimetable()
|
||||||
}
|
}
|
||||||
@@ -472,6 +479,7 @@ function onBuildingChanged() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onResourceTypeChanged() {
|
function onResourceTypeChanged() {
|
||||||
|
batchResourceIds.value = []
|
||||||
collegeId.value = ''
|
collegeId.value = ''
|
||||||
majorId.value = ''
|
majorId.value = ''
|
||||||
grade.value = undefined
|
grade.value = undefined
|
||||||
@@ -487,6 +495,67 @@ function onResourceTypeChanged() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resourceLabel(item: any) {
|
||||||
|
if (resourceType.value === 'Teacher') return `${item.teacherNumber} · ${item.name}`
|
||||||
|
if (resourceType.value === 'Classroom') return `${item.buildingName} · ${item.name}`
|
||||||
|
return `${item.code} · ${item.name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFilteredResources() {
|
||||||
|
if (batchResources.value.length > 100) {
|
||||||
|
ElMessage.warning('单次最多导出 100 项,请进一步缩小筛选范围。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
batchResourceIds.value = batchResources.value.map((item: any) => item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchExportPayload() {
|
||||||
|
if (!batchResourceIds.value.length) {
|
||||||
|
ElMessage.warning('请先从筛选结果中选择要导出的课表。')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
resourceType: resourceType.value,
|
||||||
|
resourceIds: batchResourceIds.value,
|
||||||
|
academicTermId: termId.value,
|
||||||
|
schedulePlanId: planId.value || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportBatchExcel() {
|
||||||
|
const payload = batchExportPayload()
|
||||||
|
if (!payload) return
|
||||||
|
batchExporting.value = true
|
||||||
|
try {
|
||||||
|
await downloadApiPostFile(
|
||||||
|
'/timetables/management/export/batch.xlsx',
|
||||||
|
payload,
|
||||||
|
`课表批量导出-${terms.value.find((item: any) => item.id === termId.value)?.name ?? ''}.xlsx`,
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
batchExporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportBatchPdf() {
|
||||||
|
const payload = batchExportPayload()
|
||||||
|
if (!payload) return
|
||||||
|
batchExporting.value = true
|
||||||
|
try {
|
||||||
|
await downloadApiPostFile(
|
||||||
|
'/timetables/management/export/batch.pdf',
|
||||||
|
payload,
|
||||||
|
`课表批量导出-${terms.value.find((item: any) => item.id === termId.value)?.name ?? ''}.pdf`,
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(`PDF 导出失败:${apiErrorMessage(error)}`)
|
||||||
|
} finally {
|
||||||
|
batchExporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadTimetable() {
|
async function loadTimetable() {
|
||||||
if (!termId.value) {
|
if (!termId.value) {
|
||||||
timetable.value = null
|
timetable.value = null
|
||||||
@@ -564,35 +633,23 @@ async function exportExcel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function exportPdf() {
|
async function exportPdf() {
|
||||||
if (!exportArea.value || !timetable.value) return
|
|
||||||
exportingPdf.value = true
|
exportingPdf.value = true
|
||||||
try {
|
try {
|
||||||
await nextTick()
|
if (isMine.value) {
|
||||||
const [{ default: html2canvas }, { jsPDF }] = await Promise.all([
|
await downloadApiFile(`/timetables/mine/export.pdf?academicTermId=${termId.value}`, '我的课表.pdf')
|
||||||
import('html2canvas'),
|
} else if (isTeacherView.value && teacherIdParam.value) {
|
||||||
import('jspdf'),
|
await downloadApiFile(`/timetables/teachers/${teacherIdParam.value}/export.pdf?academicTermId=${termId.value}`, '教师课表.pdf')
|
||||||
])
|
} else if (isManager.value) {
|
||||||
const canvas = await html2canvas(exportArea.value, {
|
const query = new URLSearchParams({
|
||||||
scale: 2,
|
resourceType: resourceType.value,
|
||||||
useCORS: true,
|
resourceId: selectedResourceId.value,
|
||||||
backgroundColor: '#ffffff',
|
academicTermId: termId.value,
|
||||||
})
|
})
|
||||||
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' })
|
if (planId.value) query.set('schedulePlanId', planId.value)
|
||||||
const pageWidth = 297
|
await downloadApiFile(`/timetables/management/export.pdf?${query}`, '课表.pdf')
|
||||||
const pageHeight = 210
|
} else {
|
||||||
const imageHeight = canvas.height * pageWidth / canvas.width
|
await downloadApiFile(`/timetables/classes/${classId.value}/export.pdf?academicTermId=${termId.value}`, '班级课表.pdf')
|
||||||
const image = canvas.toDataURL('image/png')
|
|
||||||
let remaining = imageHeight
|
|
||||||
let position = 0
|
|
||||||
pdf.addImage(image, 'PNG', 0, position, pageWidth, imageHeight)
|
|
||||||
remaining -= pageHeight
|
|
||||||
while (remaining > 0) {
|
|
||||||
position = remaining - imageHeight
|
|
||||||
pdf.addPage()
|
|
||||||
pdf.addImage(image, 'PNG', 0, position, pageWidth, imageHeight)
|
|
||||||
remaining -= pageHeight
|
|
||||||
}
|
}
|
||||||
pdf.save(`${timetable.value.subject?.name ?? '课表'}-${timetable.value.term.name}.pdf`)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(`PDF 导出失败:${apiErrorMessage(error)}`)
|
ElMessage.error(`PDF 导出失败:${apiErrorMessage(error)}`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -853,6 +910,46 @@ onMounted(async () => {
|
|||||||
{{ resourceType === 'Class' ? filteredClasses.length : resourceType === 'Teacher' ? filteredTeachers.length : filteredClassrooms.length }}
|
{{ resourceType === 'Class' ? filteredClasses.length : resourceType === 'Teacher' ? filteredTeachers.length : filteredClassrooms.length }}
|
||||||
项
|
项
|
||||||
</small>
|
</small>
|
||||||
|
<div v-if="isManager" class="batch-export-panel">
|
||||||
|
<div class="batch-export-heading">
|
||||||
|
<strong>批量导出</strong>
|
||||||
|
<span>按当前筛选结果多选,最多 100 项</span>
|
||||||
|
</div>
|
||||||
|
<el-select
|
||||||
|
v-model="batchResourceIds"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
clearable
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
placeholder="选择要批量导出的课表"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in batchResources"
|
||||||
|
:key="item.id"
|
||||||
|
:label="resourceLabel(item)"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<div class="batch-export-actions">
|
||||||
|
<el-button size="small" @click="selectFilteredResources">全选筛选结果</el-button>
|
||||||
|
<el-button size="small" :disabled="!batchResourceIds.length" @click="batchResourceIds = []">清空</el-button>
|
||||||
|
<span>已选 {{ batchResourceIds.length }} 项</span>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
:loading="batchExporting"
|
||||||
|
:disabled="!batchResourceIds.length"
|
||||||
|
@click="exportBatchExcel"
|
||||||
|
>批量导出 Excel</el-button>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
:loading="batchExporting"
|
||||||
|
:disabled="!batchResourceIds.length"
|
||||||
|
@click="exportBatchPdf"
|
||||||
|
>批量导出 PDF</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-loading="loading" class="timetable-sheet">
|
<section v-loading="loading" class="timetable-sheet">
|
||||||
@@ -910,7 +1007,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="timetable" ref="exportArea" class="timetable-export-area">
|
<div v-if="timetable" class="timetable-export-area">
|
||||||
<div class="sheet-meta">
|
<div class="sheet-meta">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ timetable.subject.name }}</strong>
|
<strong>{{ timetable.subject.name }}</strong>
|
||||||
@@ -1290,6 +1387,10 @@ onMounted(async () => {
|
|||||||
.calendar-coverage ul { margin: 10px 0; padding-left: 20px; color: #526b7d; line-height: 1.8; font-size: 13px; }
|
.calendar-coverage ul { margin: 10px 0; padding-left: 20px; color: #526b7d; line-height: 1.8; font-size: 13px; }
|
||||||
.calendar-coverage p { margin: 0; color: #788895; font-size: 12px; line-height: 1.7; }
|
.calendar-coverage p { margin: 0; color: #788895; font-size: 12px; line-height: 1.7; }
|
||||||
.timetable-export-area { min-width: 0; padding: 2px; background: #fff; }
|
.timetable-export-area { min-width: 0; padding: 2px; background: #fff; }
|
||||||
|
.batch-export-panel { display: grid; gap: 10px; width: min(760px, 100%); margin-top: 8px; padding: 14px; border: 1px solid #d7e3ea; border-radius: 8px; background: #f8fbfc; }
|
||||||
|
.batch-export-heading { display: flex; align-items: baseline; gap: 10px; color: #17324d; }
|
||||||
|
.batch-export-heading span, .batch-export-actions span { color: #718191; font-size: 12px; }
|
||||||
|
.batch-export-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||||
.sheet-meta { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 18px; padding-bottom: 16px; border-bottom: 1px solid #e6ebf0; }
|
.sheet-meta { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 18px; padding-bottom: 16px; border-bottom: 1px solid #e6ebf0; }
|
||||||
.sheet-meta div { display: grid; gap: 3px; }
|
.sheet-meta div { display: grid; gap: 3px; }
|
||||||
.sheet-meta strong { color: #17324d; }
|
.sheet-meta strong { color: #17324d; }
|
||||||
|
|||||||
Reference in New Issue
Block a user