From 1229f3163d30480b45d00c398f5222b9a4dd2670 Mon Sep 17 00:00:00 2001 From: biss Date: Sun, 9 Aug 2026 11:45:06 +0800 Subject: [PATCH] =?UTF-8?q?=E6=88=90=E7=BB=A9=E5=88=86=E6=9E=90=E6=8A=A5?= =?UTF-8?q?=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/GradeAnalyticsController.cs | 51 ++ .../GradeAnalysisWordReportGenerator.cs | 512 ++++++++++++++++++ src/Jiaowu.Api/Jiaowu.Api.csproj | 3 +- .../GradeAnalysisWordReportGeneratorTests.cs | 44 ++ web/package.json | 2 +- web/src/views/GradeAnalyticsView.vue | 31 +- 6 files changed, 639 insertions(+), 4 deletions(-) create mode 100644 src/Jiaowu.Api/Infrastructure/Grades/GradeAnalysisWordReportGenerator.cs create mode 100644 tests/Jiaowu.Api.Tests/GradeAnalysisWordReportGeneratorTests.cs diff --git a/src/Jiaowu.Api/Controllers/GradeAnalyticsController.cs b/src/Jiaowu.Api/Controllers/GradeAnalyticsController.cs index 213d753..833b899 100644 --- a/src/Jiaowu.Api/Controllers/GradeAnalyticsController.cs +++ b/src/Jiaowu.Api/Controllers/GradeAnalyticsController.cs @@ -3,6 +3,7 @@ using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.System; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Caching; +using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -112,6 +113,50 @@ public sealed class GradeAnalyticsController( return Ok(report); } + [HttpGet("teaching-classes/{gradeSheetId:guid}/report.docx")] + public async Task ExportTeachingClassAnalysisReport( + Guid gradeSheetId, + CancellationToken cancellationToken) + { + var sheet = await db.GradeSheets.AsNoTracking() + .Where(x => x.Id == gradeSheetId && + VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId)) + .Select(x => new AnalysisTarget( + x.Id, + x.TeachingTaskId, + x.TeachingTask!.CourseId, + x.TeachingTask.AcademicTermId, + x.TeachingTask.Course!.CollegeId, + x.TeachingTask.TaskNumber, + x.TeachingTask.Name, + x.TeachingTask.Course.Code, + x.TeachingTask.Course.Name, + x.TeachingTask.AcademicTerm!.Name)) + .FirstOrDefaultAsync(cancellationToken); + if (sheet is null) return NotFound(); + + var report = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync( + AppCacheKeys.TeachingTaskGradeAnalytics(gradeSheetId), + token => BuildReportAsync(sheet, token), + AppCacheProfile.Analytics, + [AppCacheTags.CourseGradeStatistics], + cancellationToken); + if (report.IsRefreshing || report.Summary is null) + return Conflict(new ProblemDetails + { + Title = "成绩统计尚未生成", + Detail = "请先重新计算当前教学班,待统计完成后再导出。", + Status = StatusCodes.Status409Conflict + }); + + var content = GradeAnalysisWordReportGenerator.Generate(report, DateTime.Now); + var fileName = $"{SanitizeFileName(report.CourseCode)}-{SanitizeFileName(report.TaskNumber)}-成绩分析报告.docx"; + return File( + content, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + fileName); + } + [HttpPost("teaching-classes/{gradeSheetId:guid}/refresh")] public async Task RefreshTeachingClassAnalysis( Guid gradeSheetId, @@ -373,6 +418,12 @@ public sealed class GradeAnalyticsController( return source.Where(_ => false); } + private static string SanitizeFileName(string value) + { + var invalid = Path.GetInvalidFileNameChars(); + return string.Concat(value.Select(character => invalid.Contains(character) ? '_' : character)); + } + private sealed record AnalysisTarget( Guid GradeSheetId, Guid TeachingTaskId, diff --git a/src/Jiaowu.Api/Infrastructure/Grades/GradeAnalysisWordReportGenerator.cs b/src/Jiaowu.Api/Infrastructure/Grades/GradeAnalysisWordReportGenerator.cs new file mode 100644 index 0000000..0e75e42 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Grades/GradeAnalysisWordReportGenerator.cs @@ -0,0 +1,512 @@ +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using Jiaowu.Api.Controllers; +using SkiaSharp; +using A = DocumentFormat.OpenXml.Drawing; +using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing; +using PIC = DocumentFormat.OpenXml.Drawing.Pictures; +using W = DocumentFormat.OpenXml.Wordprocessing; + +namespace Jiaowu.Api.Infrastructure.Grades; + +public static class GradeAnalysisWordReportGenerator +{ + private const string Blue = "2E74B5"; + private const string DarkBlue = "1F4D78"; + private const string Ink = "263238"; + private const string Muted = "68707A"; + private const string LightFill = "F2F4F7"; + private const int ContentWidth = 9360; + + public static byte[] Generate( + GradeAnalyticsController.TeachingClassAnalysisReport report, + DateTime generatedAt) + { + ArgumentNullException.ThrowIfNull(report.Summary); + using var stream = new MemoryStream(); + using (var document = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document, true)) + { + document.PackageProperties.Title = $"{report.CourseName}成绩分析报告"; + document.PackageProperties.Subject = "教学班成绩统计与对比分析"; + document.PackageProperties.Creator = "教务管理系统"; + document.PackageProperties.Created = generatedAt; + + var mainPart = document.AddMainDocumentPart(); + mainPart.Document = new Document(new Body()); + var settingsPart = mainPart.AddNewPart(); + settingsPart.Settings = new Settings(new EvenAndOddHeaders()); + settingsPart.Settings.Save(); + AddStyles(mainPart); + var headerFooterIds = AddHeaderAndFooter(mainPart); + BuildBody(mainPart, report, generatedAt, headerFooterIds); + mainPart.Document.Save(); + } + return stream.ToArray(); + } + + private static void BuildBody( + MainDocumentPart mainPart, + GradeAnalyticsController.TeachingClassAnalysisReport report, + DateTime generatedAt, + HeaderFooterIds headerFooterIds) + { + var body = mainPart.Document.Body!; + var summary = report.Summary!; + + body.Append(Paragraph("成绩分析报告", 46, true, "000000", 0, 80)); + body.Append(Paragraph($"{report.CourseName} · {report.TaskName}", 28, false, Muted, 0, 220)); + body.Append(MetadataTable([ + ("课程", $"{report.CourseCode} {report.CourseName}"), + ("教学班", $"{report.TaskNumber} {report.TaskName}"), + ("学期", report.TermName), + ("报告生成", generatedAt.ToString("yyyy-MM-dd HH:mm")), + ("统计更新", summary.CalculatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm")), + ("统计对象", $"{summary.StudentCount} 份已发布有效成绩") + ])); + + body.Append(Heading("一、分析摘要", 1)); + body.Append(Callout(BuildExecutiveSummary(report))); + body.Append(MetricsTable(summary)); + + body.Append(Heading("二、分数段分布", 1)); + body.Append(Paragraph("图 1 当前教学班各分数段人数", 20, false, Muted, 80, 80)); + body.Append(ImageParagraph(mainPart, DrawScoreBands(summary.ScoreBands), "分数段分布图", 6.3, 3.0)); + body.Append(DataTable( + ["分数段", "下限", "上限", "人数", "占比"], + summary.ScoreBands.Select(x => new[] + { + x.Label, + x.LowerBound.ToString("0.#"), + x.UpperBound?.ToString("0.#") ?? "无上限", + x.StudentCount.ToString(), + Percent(x.StudentCount, summary.StudentCount) + }), + [1800, 1500, 1500, 1500, 3060])); + + body.Append(Heading("三、同课程教学班对比", 1)); + body.Append(Paragraph("图 2 同学期同课程各教学班平均分", 20, false, Muted, 80, 80)); + var peerChartHeight = Math.Clamp(1.45 + report.PeerTeachingClasses.Count * 0.32, 1.8, 3.35); + body.Append(ImageParagraph(mainPart, DrawPeerAverages(report.PeerTeachingClasses), "教学班平均分对比图", 6.3, peerChartHeight)); + body.Append(DataTable( + ["教学班 / 教师", "人数", "平均分", "中位数", "标准差", "合格率", "优秀率"], + report.PeerTeachingClasses.Select(x => new[] + { + $"{x.TaskNumber}{(x.IsSelected ? "(当前)" : "")}\n{x.TeacherNames}", + x.StudentCount.ToString(), + Score(x.AverageScore), + Score(x.MedianScore), + x.StandardDeviation.ToString("0.00"), + Rate(x.PassRate), + Rate(x.ExcellentRate) + }), + [2600, 820, 1050, 1050, 1050, 1395, 1395])); + + body.Append(Heading("四、各范围基准", 1)); + body.Append(Paragraph("范围基准按当前课程、当前学期聚合;同一教学班包含多个来源行政班时,将分别列示可用基准。", 22, false, Muted, 0, 100)); + body.Append(DataTable( + ["范围", "对象", "人数", "最高分", "平均分", "最低分", "合格率"], + report.ScopeBenchmarks.Select(x => new[] + { + x.Scope, x.Name, x.StudentCount.ToString(), Score(x.HighestScore), + Score(x.AverageScore), Score(x.LowestScore), Rate(x.PassRate) + }), + [980, 2200, 900, 1200, 1200, 1200, 1680])); + + body.Append(Heading("五、历年成绩趋势", 1)); + body.Append(Paragraph("图 3 同课程全校与当前任课教师历年平均分", 20, false, Muted, 80, 80)); + body.Append(ImageParagraph(mainPart, DrawHistory(report.History), "历年平均分趋势图", 6.3, 3.15)); + body.Append(DataTable( + ["学期", "全校人数", "全校平均", "全校合格率", "教师人数", "教师平均", "教师合格率"], + report.History.Select(x => new[] + { + x.TermName, + x.CourseStudentCount.ToString(), + Score(x.CourseAverageScore), + Rate(x.CoursePassRate), + x.Instructor?.StudentCount.ToString() ?? "—", + x.Instructor is null ? "—" : Score(x.Instructor.AverageScore), + x.Instructor is null ? "—" : Rate(x.Instructor.PassRate) + }), + [1700, 1050, 1200, 1450, 1050, 1200, 1710])); + + body.Append(Heading("六、统计口径与使用说明", 1)); + body.Append(Paragraph("1. 本报告仅统计已正式发布且纳入当前统计任务的有效成绩,不包含草稿、未发布成绩或学生逐人成绩明细。", 22, false, Ink, 0, 80)); + body.Append(Paragraph("2. 合格率按成绩达到 60 分计算,优秀率按成绩达到 90 分计算;平均分、中位数和标准差均基于同一批有效成绩。", 22, false, Ink, 0, 80)); + body.Append(Paragraph("3. 同课程教学班对比限定为当前学期;历年对比同时展示课程全校口径和当前任课教师所带教学班的加权汇总。", 22, false, Ink, 0, 80)); + body.Append(Paragraph("4. 统计结果用于教学诊断和质量改进,不应脱离样本量、课程难度、考核方式等背景作单一排名或评价。", 22, false, Ink, 0, 80)); + + body.Append(new SectionProperties( + new HeaderReference { Type = HeaderFooterValues.Default, Id = headerFooterIds.DefaultHeader }, + new HeaderReference { Type = HeaderFooterValues.Even, Id = headerFooterIds.EvenHeader }, + new FooterReference { Type = HeaderFooterValues.Default, Id = headerFooterIds.DefaultFooter }, + new FooterReference { Type = HeaderFooterValues.Even, Id = headerFooterIds.EvenFooter }, + new PageSize { Width = 12240, Height = 15840 }, + new PageMargin { Top = 1440, Right = 1440, Bottom = 1440, Left = 1440, Header = 708, Footer = 708 })); + } + + private static string BuildExecutiveSummary(GradeAnalyticsController.TeachingClassAnalysisReport report) + { + var summary = report.Summary!; + var band = summary.ScoreBands.OrderByDescending(x => x.StudentCount).FirstOrDefault(); + var parts = new List + { + $"本教学班共纳入 {summary.StudentCount} 份有效成绩,平均分 {Score(summary.AverageScore)},中位数 {Score(summary.MedianScore)},合格率 {Rate(summary.PassRate)},优秀率 {Rate(summary.ExcellentRate)}。" + }; + if (band is not null) + parts.Add($"人数最多的分数段为 {band.Label},共 {band.StudentCount} 人,占 {Percent(band.StudentCount, summary.StudentCount)}。 "); + if (report.UniversityDelta is { } delta) + parts.Add($"与本学期全校同课程相比,平均分{Direction(delta.AverageScoreDifference, "分")},合格率{Direction(delta.PassRateDifference, "个百分点")}。 "); + parts.Add($"成绩标准差为 {summary.StandardDeviation:0.00},分数范围 {Score(summary.LowestScore)}–{Score(summary.HighestScore)}。建议结合分数段、同课程教学班和历年趋势综合研判。 "); + return string.Concat(parts); + } + + private static string Direction(decimal value, string unit) => + value > 0 ? $"高 {value:0.0} {unit}" : value < 0 ? $"低 {Math.Abs(value):0.0} {unit}" : "持平"; + + private static W.Table MetadataTable(IEnumerable<(string Label, string Value)> items) + { + var rows = items.Select(x => new[] { x.Label, x.Value }); + return DataTable(["项目", "内容"], rows, [1800, 7560], false); + } + + private static W.Table MetricsTable(GradeAnalyticsController.TeachingClassMetrics value) + { + return DataTable( + ["指标", "结果", "指标", "结果"], + [ + ["最高分", Score(value.HighestScore), "最低分", Score(value.LowestScore)], + ["平均分", Score(value.AverageScore), "中位数", Score(value.MedianScore)], + ["合格人数", $"{value.PassedCount} 人", "合格率", Rate(value.PassRate)], + ["优秀人数", $"{value.ExcellentCount} 人", "优秀率", Rate(value.ExcellentRate)], + ["标准差", value.StandardDeviation.ToString("0.00"), "有效成绩", $"{value.StudentCount} 份"] + ], + [1800, 2880, 1800, 2880]); + } + + private static W.Table DataTable( + IReadOnlyList headers, + IEnumerable rows, + IReadOnlyList widths, + bool shadeHeader = true) + { + var table = new W.Table(); + table.Append(new TableProperties( + new TableWidth { Width = ContentWidth.ToString(), Type = TableWidthUnitValues.Dxa }, + new TableIndentation { Width = 120, Type = TableWidthUnitValues.Dxa }, + new TableLayout { Type = TableLayoutValues.Fixed }, + new TableBorders( + Border(), Border(), Border(), + Border(), Border(), Border()), + new TableCellMarginDefault( + new TopMargin { Width = "80", Type = TableWidthUnitValues.Dxa }, + new TableCellLeftMargin { Width = 120, Type = TableWidthValues.Dxa }, + new BottomMargin { Width = "80", Type = TableWidthUnitValues.Dxa }, + new TableCellRightMargin { Width = 120, Type = TableWidthValues.Dxa }))); + table.Append(new TableGrid(widths.Select(x => new GridColumn { Width = x.ToString() }))); + table.Append(Row(headers, widths, shadeHeader ? LightFill : "FFFFFF", true, true)); + foreach (var row in rows) + table.Append(Row(row, widths, "FFFFFF", false, false)); + return table; + } + + private static TableRow Row( + IReadOnlyList values, + IReadOnlyList widths, + string fill, + bool bold, + bool repeat) + { + var row = new TableRow(); + if (repeat) row.AppendChild(new TableRowProperties(new TableHeader())); + for (var i = 0; i < widths.Count; i++) + { + var cell = new TableCell(); + cell.Append(new TableCellProperties( + new TableCellWidth { Width = widths[i].ToString(), Type = TableWidthUnitValues.Dxa }, + new Shading { Fill = fill, Val = ShadingPatternValues.Clear })); + var lines = (i < values.Count ? values[i] : "").Split('\n'); + foreach (var line in lines) + cell.Append(Paragraph(line, 19, bold, Ink, 0, 0)); + row.Append(cell); + } + return row; + } + + private static T Border() where T : BorderType, new() => + new() { Val = BorderValues.Single, Color = "D6DBE1", Size = 4 }; + + private static W.Table Callout(string text) + { + return DataTable(["核心结论"], [[text]], [ContentWidth]); + } + + private static Paragraph Heading(string text, int level) + { + var paragraph = new Paragraph(new ParagraphProperties(new ParagraphStyleId { Val = $"Heading{level}" })); + paragraph.Append(Run(text, level == 1 ? 32 : 26, true, level == 1 ? Blue : DarkBlue)); + return paragraph; + } + + private static Paragraph Paragraph( + string text, + int size, + bool bold, + string color, + int before, + int after) + { + var paragraph = new Paragraph(new ParagraphProperties( + new SpacingBetweenLines { Before = before.ToString(), After = after.ToString(), Line = "264", LineRule = LineSpacingRuleValues.Auto })); + paragraph.Append(Run(text, size, bold, color)); + return paragraph; + } + + private static Run Run(string text, int size, bool bold, string color) + { + return new Run( + new RunProperties( + new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "Microsoft YaHei" }, + new Bold { Val = bold }, + new Color { Val = color }, + new FontSize { Val = size.ToString() }, + new FontSizeComplexScript { Val = size.ToString() }), + new Text(text) { Space = SpaceProcessingModeValues.Preserve }); + } + + private static Paragraph ImageParagraph( + MainDocumentPart mainPart, + byte[] image, + string description, + double widthInches, + double heightInches) + { + var part = mainPart.AddImagePart(ImagePartType.Png); + using (var stream = new MemoryStream(image)) part.FeedData(stream); + var relationshipId = mainPart.GetIdOfPart(part); + var width = (long)(widthInches * 914400L); + var height = (long)(heightInches * 914400L); + var drawing = new W.Drawing( + new DW.Inline( + new DW.Extent { Cx = width, Cy = height }, + new DW.EffectExtent { LeftEdge = 0, TopEdge = 0, RightEdge = 0, BottomEdge = 0 }, + new DW.DocProperties { Id = (UInt32Value)(uint)(mainPart.ImageParts.Count()), Name = description, Description = description }, + new DW.NonVisualGraphicFrameDrawingProperties(new A.GraphicFrameLocks { NoChangeAspect = true }), + new A.Graphic(new A.GraphicData( + new PIC.Picture( + new PIC.NonVisualPictureProperties( + new PIC.NonVisualDrawingProperties { Id = 0, Name = description, Description = description }, + new PIC.NonVisualPictureDrawingProperties()), + new PIC.BlipFill( + new A.Blip { Embed = relationshipId, CompressionState = A.BlipCompressionValues.Print }, + new A.Stretch(new A.FillRectangle())), + new PIC.ShapeProperties( + new A.Transform2D( + new A.Offset { X = 0, Y = 0 }, + new A.Extents { Cx = width, Cy = height }), + new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle }))) + { Uri = "http://schemas.openxmlformats.org/drawingml/2006/picture" })) + { DistanceFromTop = 0, DistanceFromBottom = 0, DistanceFromLeft = 0, DistanceFromRight = 0 }); + var paragraph = new Paragraph(new ParagraphProperties( + new Justification { Val = JustificationValues.Center }, + new SpacingBetweenLines { Before = "0", After = "120" })); + paragraph.Append(new Run(drawing)); + return paragraph; + } + + private static void AddStyles(MainDocumentPart mainPart) + { + var stylesPart = mainPart.AddNewPart(); + var normal = new Style( + new StyleName { Val = "Normal" }, + new StyleRunProperties( + new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "Microsoft YaHei" }, + new FontSize { Val = "22" }, new Color { Val = Ink }), + new StyleParagraphProperties( + new SpacingBetweenLines { Before = "0", After = "120", Line = "264", LineRule = LineSpacingRuleValues.Auto })) + { Type = StyleValues.Paragraph, StyleId = "Normal", Default = true }; + var h1 = new Style( + new StyleName { Val = "heading 1" }, + new BasedOn { Val = "Normal" }, + new NextParagraphStyle { Val = "Normal" }, + new StyleRunProperties(new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "Microsoft YaHei" }, new Bold(), new Color { Val = Blue }, new FontSize { Val = "32" }), + new StyleParagraphProperties(new KeepNext(), new SpacingBetweenLines { Before = "320", After = "160" })) + { Type = StyleValues.Paragraph, StyleId = "Heading1" }; + stylesPart.Styles = new Styles(normal, h1); + stylesPart.Styles.Save(); + } + + private static HeaderFooterIds AddHeaderAndFooter(MainDocumentPart mainPart) + { + var defaultHeader = mainPart.AddNewPart(); + defaultHeader.Header = CreateHeader(); + defaultHeader.Header.Save(); + var evenHeader = mainPart.AddNewPart(); + evenHeader.Header = CreateHeader(); + evenHeader.Header.Save(); + var defaultFooter = mainPart.AddNewPart(); + defaultFooter.Footer = CreateFooter(); + defaultFooter.Footer.Save(); + var evenFooter = mainPart.AddNewPart(); + evenFooter.Footer = CreateFooter(); + evenFooter.Footer.Save(); + return new HeaderFooterIds( + mainPart.GetIdOfPart(defaultHeader), + mainPart.GetIdOfPart(evenHeader), + mainPart.GetIdOfPart(defaultFooter), + mainPart.GetIdOfPart(evenFooter)); + } + + private static Header CreateHeader() => + new(Paragraph("成绩分析报告 | 教务管理系统", 18, false, Muted, 0, 0)); + + private static Footer CreateFooter() + { + var footerParagraph = new Paragraph(new ParagraphProperties(new Justification { Val = JustificationValues.Right })); + footerParagraph.Append(Run("第 ", 18, false, Muted)); + footerParagraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.Begin })); + footerParagraph.Append(new Run(new FieldCode(" PAGE "))); + footerParagraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.End })); + footerParagraph.Append(Run(" 页", 18, false, Muted)); + return new Footer(footerParagraph); + } + + private static byte[] DrawScoreBands(IReadOnlyList rows) + { + return DrawChart(1200, 540, (canvas, typeface) => + { + DrawAxes(canvas, typeface, "人数", 70, 35, 1080, 430); + var max = Math.Max(1, rows.Max(x => x.StudentCount)); + var barWidth = 150f; + var gap = (1000f - rows.Count * barWidth) / Math.Max(1, rows.Count); + for (var i = 0; i < rows.Count; i++) + { + var x = 105 + gap / 2 + i * (barWidth + gap); + var height = rows[i].StudentCount / (float)max * 330; + using var paint = new SKPaint { Color = new SKColor(46, 116, 181), IsAntialias = true }; + canvas.DrawRoundRect(new SKRect(x, 430 - height, x + barWidth, 430), 8, 8, paint); + DrawText(canvas, typeface, rows[i].StudentCount.ToString(), x + barWidth / 2, 415 - height, 24, Ink, SKTextAlign.Center, true); + DrawText(canvas, typeface, rows[i].Label, x + barWidth / 2, 475, 18, Muted, SKTextAlign.Center); + } + }); + } + + private static byte[] DrawPeerAverages(IReadOnlyList rows) + { + var visible = rows.Take(8).ToArray(); + var height = Math.Max(250, 120 + visible.Length * 62); + return DrawChart(1200, height, (canvas, typeface) => + { + var top = 55f; + var rowHeight = 62f; + DrawText(canvas, typeface, "0", 280, 40, 20, Muted, SKTextAlign.Center); + DrawText(canvas, typeface, "50", 700, 40, 20, Muted, SKTextAlign.Center); + DrawText(canvas, typeface, "100", 1120, 40, 20, Muted, SKTextAlign.Center); + for (var i = 0; i < visible.Length; i++) + { + var y = top + i * rowHeight; + var value = Math.Clamp((float)visible[i].AverageScore, 0, 100); + DrawText(canvas, typeface, visible[i].TaskNumber, 245, y + 28, 21, visible[i].IsSelected ? Blue : Ink, SKTextAlign.Right, visible[i].IsSelected); + using var track = new SKPaint { Color = new SKColor(235, 239, 244) }; + using var fill = new SKPaint { Color = visible[i].IsSelected ? new SKColor(46, 116, 181) : new SKColor(155, 177, 202) }; + canvas.DrawRoundRect(new SKRect(280, y, 1120, y + 34), 6, 6, track); + canvas.DrawRoundRect(new SKRect(280, y, 280 + value / 100 * 840, y + 34), 6, 6, fill); + DrawText(canvas, typeface, value.ToString("0.0"), 290 + value / 100 * 840, y + 27, 20, Ink, SKTextAlign.Left, true); + } + }); + } + + private static byte[] DrawHistory(IReadOnlyList rows) + { + return DrawChart(1200, 570, (canvas, typeface) => + { + DrawAxes(canvas, typeface, "平均分", 70, 35, 1080, 430); + if (rows.Count == 0) return; + var min = 0f; + var max = 100f; + var points = rows.Select((x, i) => new SKPoint( + 110 + (rows.Count == 1 ? 480 : i * 950f / (rows.Count - 1)), + 430 - ((float)x.CourseAverageScore - min) / (max - min) * 350)).ToArray(); + DrawLineSeries(canvas, typeface, points, rows.Select(x => x.CourseAverageScore).ToArray(), new SKColor(46, 116, 181)); + var teacher = rows.Select((x, i) => x.Instructor is null ? (SKPoint?)null : new SKPoint( + 110 + (rows.Count == 1 ? 480 : i * 950f / (rows.Count - 1)), + 430 - ((float)x.Instructor.AverageScore - min) / (max - min) * 350)).ToArray(); + DrawOptionalLineSeries(canvas, typeface, teacher, rows.Select(x => x.Instructor?.AverageScore).ToArray(), new SKColor(211, 133, 45)); + for (var i = 0; i < rows.Count; i++) + DrawText(canvas, typeface, rows[i].TermName, points[i].X, 480, 20, Muted, SKTextAlign.Center); + using var blue = new SKPaint { Color = new SKColor(46, 116, 181), StrokeWidth = 4 }; + using var gold = new SKPaint { Color = new SKColor(211, 133, 45), StrokeWidth = 4 }; + canvas.DrawLine(760, 520, 805, 520, blue); + canvas.DrawLine(940, 520, 985, 520, gold); + DrawText(canvas, typeface, "同课程全校", 815, 528, 20, Ink); + DrawText(canvas, typeface, "当前任课教师", 995, 528, 20, Ink); + }); + } + + private static byte[] DrawChart(int width, int height, Action draw) + { + using var bitmap = new SKBitmap(width, height); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + using var typeface = SKTypeface.FromFamilyName("Microsoft YaHei") ?? SKTypeface.Default; + draw(canvas, typeface); + using var image = SKImage.FromBitmap(bitmap); + using var data = image.Encode(SKEncodedImageFormat.Png, 92); + return data.ToArray(); + } + + private static void DrawAxes(SKCanvas canvas, SKTypeface typeface, string label, float left, float top, float right, float bottom) + { + using var axis = new SKPaint { Color = new SKColor(190, 198, 207), StrokeWidth = 2 }; + canvas.DrawLine(left, bottom, right, bottom, axis); + canvas.DrawLine(left, top, left, bottom, axis); + DrawText(canvas, typeface, label, left, 25, 21, Muted); + } + + private static void DrawLineSeries(SKCanvas canvas, SKTypeface typeface, SKPoint[] points, decimal[] values, SKColor color) + { + using var paint = new SKPaint { Color = color, StrokeWidth = 4, IsAntialias = true, Style = SKPaintStyle.Stroke }; + using var fill = new SKPaint { Color = color, IsAntialias = true }; + using var path = new SKPath(); + path.MoveTo(points[0]); + foreach (var point in points.Skip(1)) path.LineTo(point); + canvas.DrawPath(path, paint); + for (var i = 0; i < points.Length; i++) + { + canvas.DrawCircle(points[i], 7, fill); + DrawText(canvas, typeface, values[i].ToString("0.0"), points[i].X, points[i].Y - 14, 19, Ink, SKTextAlign.Center, true); + } + } + + private static void DrawOptionalLineSeries(SKCanvas canvas, SKTypeface typeface, SKPoint?[] points, decimal?[] values, SKColor color) + { + var available = points.Select((point, index) => (point, index)).Where(x => x.point.HasValue).ToArray(); + if (available.Length == 0) return; + using var paint = new SKPaint { Color = color, StrokeWidth = 4, IsAntialias = true, Style = SKPaintStyle.Stroke }; + using var fill = new SKPaint { Color = color, IsAntialias = true }; + for (var i = 1; i < available.Length; i++) canvas.DrawLine(available[i - 1].point!.Value, available[i].point!.Value, paint); + foreach (var item in available) + { + var point = item.point!.Value; + canvas.DrawCircle(point, 7, fill); + DrawText(canvas, typeface, values[item.index]!.Value.ToString("0.0"), point.X, point.Y + 28, 19, Ink, SKTextAlign.Center, true); + } + } + + private static void DrawText(SKCanvas canvas, SKTypeface typeface, string text, float x, float y, float size, string color, SKTextAlign align = SKTextAlign.Left, bool bold = false) + { + using var font = new SKFont(typeface, size) { Embolden = bold }; + using var paint = new SKPaint { Color = SKColor.Parse(color), IsAntialias = true }; + canvas.DrawText(text, x, y, align, font, paint); + } + + private static string Score(decimal value) => value.ToString("0.0"); + private static string Rate(decimal value) => $"{value:0.0}%"; + private static string Percent(int value, int total) => total == 0 ? "0.0%" : $"{(decimal)value / total * 100m:0.0}%"; + + private sealed record HeaderFooterIds( + string DefaultHeader, + string EvenHeader, + string DefaultFooter, + string EvenFooter); +} diff --git a/src/Jiaowu.Api/Jiaowu.Api.csproj b/src/Jiaowu.Api/Jiaowu.Api.csproj index e93401c..28bf59c 100644 --- a/src/Jiaowu.Api/Jiaowu.Api.csproj +++ b/src/Jiaowu.Api/Jiaowu.Api.csproj @@ -2,7 +2,7 @@ net10.0 - 2.3.1-beta.1 + 2.3.2-beta.1 enable enable $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../../web')) @@ -22,6 +22,7 @@ + diff --git a/tests/Jiaowu.Api.Tests/GradeAnalysisWordReportGeneratorTests.cs b/tests/Jiaowu.Api.Tests/GradeAnalysisWordReportGeneratorTests.cs new file mode 100644 index 0000000..d455374 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/GradeAnalysisWordReportGeneratorTests.cs @@ -0,0 +1,44 @@ +using DocumentFormat.OpenXml.Packaging; +using Jiaowu.Api.Controllers; +using Jiaowu.Api.Infrastructure.Grades; + +namespace Jiaowu.Api.Tests; + +public sealed class GradeAnalysisWordReportGeneratorTests +{ + [Fact] + public void Generate_CreatesReadableDocxWithAnalysisSectionsAndCharts() + { + var now = new DateTime(2026, 8, 9, 10, 30, 0, DateTimeKind.Local); + var summary = new GradeAnalyticsController.TeachingClassMetrics( + 10, 9, 2, 98m, 78.5m, 80m, 52m, 12.34m, 90m, 20m, now, + [ + new("0–59", 0, 60, 1), + new("60–69", 60, 70, 2), + new("70–79", 70, 80, 2), + new("80–89", 80, 90, 3), + new("90–100", 90, null, 2) + ]); + var report = new GradeAnalyticsController.TeachingClassAnalysisReport( + false, Guid.NewGuid(), Guid.NewGuid(), "TASK-01", "计算机一班", + "CS101", "程序设计", "2026-2027 学年第一学期", summary, + [new(Guid.NewGuid(), Guid.NewGuid(), "TASK-01", "计算机一班", "张老师", "计科一班", 10, 98m, 78.5m, 80m, 52m, 12.34m, 90m, 20m, true)], + [new("全校", "全校同课程", 100, 100m, 76m, 45m, 88m)], + [new(Guid.NewGuid(), "2026-2027 学年第一学期", 100, 76m, 88m, 18m, new(10, 78.5m, 90m, 20m))], + new(2.5m, 2m, 76m, 88m)); + + var bytes = GradeAnalysisWordReportGenerator.Generate(report, now); + + Assert.True(bytes.Length > 10_000); + using var stream = new MemoryStream(bytes); + using var document = WordprocessingDocument.Open(stream, false); + var text = document.MainDocumentPart!.Document.InnerText; + Assert.Contains("成绩分析报告", text); + Assert.Contains("同课程教学班对比", text); + Assert.Contains("各范围基准", text); + Assert.Contains("历年成绩趋势", text); + Assert.Equal(3, document.MainDocumentPart.ImageParts.Count()); + Assert.Equal(2, document.MainDocumentPart.HeaderParts.Count()); + Assert.Equal(2, document.MainDocumentPart.FooterParts.Count()); + } +} diff --git a/web/package.json b/web/package.json index f47c4bf..9d25cdc 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "web", "private": true, - "version": "2.3.1-beta.1", + "version": "2.3.2-beta.1", "type": "module", "scripts": { "dev": "vite", diff --git a/web/src/views/GradeAnalyticsView.vue b/web/src/views/GradeAnalyticsView.vue index 7577115..34b65f9 100644 --- a/web/src/views/GradeAnalyticsView.vue +++ b/web/src/views/GradeAnalyticsView.vue @@ -1,8 +1,9 @@