From 9ba83c3dc1ab96ff692eb9083b727f65a7d786f9 Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 22 Aug 2026 16:24:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=88=90=E7=BB=A9=E5=88=86=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/GradeAnalyticsController.cs | 327 +++++++++++++++++- .../GradeAnalysisWordReportGenerator.cs | 189 +++++++++- .../GradeAnalysisWordReportGeneratorTests.cs | 41 +++ web/src/views/GradeAnalyticsView.vue | 230 +++++++++++- 4 files changed, 754 insertions(+), 33 deletions(-) diff --git a/src/Jiaowu.Api/Controllers/GradeAnalyticsController.cs b/src/Jiaowu.Api/Controllers/GradeAnalyticsController.cs index a22d5c8..8d98c7b 100644 --- a/src/Jiaowu.Api/Controllers/GradeAnalyticsController.cs +++ b/src/Jiaowu.Api/Controllers/GradeAnalyticsController.cs @@ -5,6 +5,7 @@ using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Files; +using Jiaowu.Api.Infrastructure.OfficialDocuments; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -18,6 +19,7 @@ namespace Jiaowu.Api.Controllers; public sealed class GradeAnalyticsController( AppDbContext db, ICurrentUserDataScope currentUserDataScope, + OfficialDocumentOptions officialDocumentOptions, IAppCache? cache = null) : ControllerBase { private const string AnalyticsUsers = @@ -28,6 +30,11 @@ public sealed class GradeAnalyticsController( SystemRoles.Teacher; private const string ScheduleManagers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin; + private const string OverallAnalyticsUsers = + SystemRoles.SuperAdmin + "," + + SystemRoles.AcademicAdmin + "," + + SystemRoles.CollegeAdmin + "," + + SystemRoles.Leader; [HttpGet("refresh-schedule")] [Authorize(Roles = ScheduleManagers)] @@ -90,6 +97,224 @@ public sealed class GradeAnalyticsController( return NoContent(); } + [HttpGet("courses")] + [Authorize(Roles = OverallAnalyticsUsers)] + public async Task GetCourseOverviews( + Guid? academicTermId, + string? keyword, + Guid? collegeId = null, + bool? riskOnly = null, + int page = 1, + int pageSize = 20, + CancellationToken cancellationToken = default) + { + page = Math.Max(1, page); + pageSize = Math.Clamp(pageSize, 1, 100); + keyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim(); + + var source = db.TeachingTaskGradeStatistics.AsNoTracking() + .Where(x => VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId)); + if (academicTermId.HasValue) + source = source.Where(x => x.AcademicTermId == academicTermId.Value); + if (collegeId.HasValue) + source = source.Where(x => x.TeachingTask!.Course!.CollegeId == collegeId.Value); + if (keyword is not null) + source = source.Where(x => + x.TeachingTask!.Course!.Code.Contains(keyword) || + x.TeachingTask.Course.Name.Contains(keyword)); + + var rows = await source.Select(x => new CourseOverviewSourceRow( + x.CourseId, + x.TeachingTask!.Course!.Code, + x.TeachingTask.Course.Name, + x.TeachingTask.Course.CollegeId, + x.TeachingTask.Course.College!.Name, + x.AcademicTermId, + x.StudentCount, + x.PassedCount, + x.ExcellentCount, + x.HighestScore, + x.AverageScore, + x.LowestScore, + x.CalculatedAt)) + .ToListAsync(cancellationToken); + + var overviews = rows.GroupBy(x => new + { x.CourseId, x.CourseCode, x.CourseName, x.CollegeId, x.CollegeName }) + .Select(group => BuildCourseOverview(group.Key.CourseId, group.Key.CourseCode, + group.Key.CourseName, group.Key.CollegeId, group.Key.CollegeName, group)) + .Where(x => riskOnly != true || x.PassRate < 60m || x.AverageScore < 60m) + .OrderBy(x => x.PassRate < 60m || x.AverageScore < 60m ? 0 : 1) + .ThenBy(x => x.CourseCode) + .ToArray(); + var total = overviews.Length; + var items = overviews.Skip((page - 1) * pageSize).Take(pageSize).ToArray(); + return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize }); + } + + [HttpGet("courses/{courseId:guid}")] + [Authorize(Roles = OverallAnalyticsUsers)] + public async Task GetCourseOverview( + Guid courseId, + Guid? academicTermId, + CancellationToken cancellationToken) + { + var report = await BuildCourseOverallAnalysisReportAsync( + courseId, academicTermId, cancellationToken); + return report is null ? NotFound() : Ok(report); + } + + [HttpGet("courses/{courseId:guid}/report.docx")] + [Authorize(Roles = OverallAnalyticsUsers)] + public async Task ExportCourseOverallAnalysisReport( + Guid courseId, + Guid? academicTermId, + CancellationToken cancellationToken) + { + var report = await BuildCourseOverallAnalysisReportAsync( + courseId, academicTermId, cancellationToken); + if (report is null) return NotFound(); + + var content = GradeAnalysisWordReportGenerator.Generate( + report, DateTime.Now, officialDocumentOptions.FontPath); + var fileName = DownloadFileName.Export("科目总体成绩分析报告", ".docx", + report.Summary.CourseCode, academicTermId.HasValue ? "指定学期" : "全部学期"); + return File(content, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + fileName); + } + + private async Task BuildCourseOverallAnalysisReportAsync( + Guid courseId, + Guid? academicTermId, + CancellationToken cancellationToken) + { + var source = db.TeachingTaskGradeStatistics.AsNoTracking() + .Where(x => x.CourseId == courseId && + VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId)); + if (academicTermId.HasValue) + source = source.Where(x => x.AcademicTermId == academicTermId.Value); + + var rows = await source.Select(x => new CourseDetailSourceRow( + x.GradeSheetId, + x.TeachingTaskId, + x.CourseId, + x.TeachingTask!.Course!.Code, + x.TeachingTask.Course.Name, + x.TeachingTask.Course.CollegeId, + x.TeachingTask.Course.College!.Name, + x.AcademicTermId, + x.TeachingTask.AcademicTerm!.Name, + x.TeachingTask.AcademicTerm.StartDate, + x.TeachingTask.TaskNumber, + x.TeachingTask.Name, + x.TeachingTask.Teachers.OrderByDescending(item => item.IsPrimary) + .Select(item => item.Teacher!.Name).ToArray(), + x.StudentCount, + x.PassedCount, + x.ExcellentCount, + x.HighestScore, + x.AverageScore, + x.MedianScore, + x.LowestScore, + x.StandardDeviation, + x.PassRate, + x.ExcellentRate, + x.CalculatedAt, + x.ScoreBands.OrderBy(band => band.SortOrder) + .Select(band => new ScoreBand(band.Label, band.LowerBound, + band.UpperBound, band.StudentCount)).ToArray())) + .ToListAsync(cancellationToken); + if (rows.Count == 0) return null; + + var first = rows[0]; + var summary = BuildCourseOverview(first.CourseId, first.CourseCode, first.CourseName, + first.CollegeId, first.CollegeName, rows.Select(x => new CourseOverviewSourceRow( + x.CourseId, x.CourseCode, x.CourseName, x.CollegeId, x.CollegeName, + x.AcademicTermId, x.StudentCount, x.PassedCount, x.ExcellentCount, + x.HighestScore, x.AverageScore, x.LowestScore, x.CalculatedAt))); + var termIds = rows.Select(x => x.AcademicTermId).Distinct().ToArray(); + var originRows = await db.CourseGradeStatistics.AsNoTracking() + .Where(x => x.CourseId == courseId && + termIds.Contains(x.AcademicTermId) && + x.Scope == CourseGradeStatisticScope.College) + .Join(db.Colleges.AsNoTracking(), x => x.ScopeEntityId, x => x.Id, + (statistic, college) => new { college.Name, statistic.StudentCount, + statistic.PassedCount, statistic.From90To100Count, statistic.AverageScore }) + .ToListAsync(cancellationToken); + + var distribution = rows.SelectMany(x => x.ScoreBands) + .GroupBy(x => new { x.Label, x.LowerBound, x.UpperBound }) + .OrderBy(x => x.Key.LowerBound) + .Select(x => new ScoreBand(x.Key.Label, x.Key.LowerBound, x.Key.UpperBound, + x.Sum(item => item.StudentCount))).ToArray(); + var terms = rows.GroupBy(x => new { x.AcademicTermId, x.TermName, x.StartDate }) + .OrderBy(x => x.Key.StartDate) + .Select(x => BuildCourseTermOverview(x.Key.AcademicTermId, x.Key.TermName, + x.Select(item => new CourseOverviewSourceRow(item.CourseId, item.CourseCode, + item.CourseName, item.CollegeId, item.CollegeName, item.AcademicTermId, + item.StudentCount, item.PassedCount, item.ExcellentCount, + item.HighestScore, item.AverageScore, item.LowestScore, item.CalculatedAt)))) + .ToArray(); + var studentOrigins = originRows.GroupBy(x => x.Name) + .Select(x => new CourseStudentOrigin( + x.Key, + x.Sum(item => item.StudentCount), + WeightedAverage(x.Select(item => (item.AverageScore, item.StudentCount))), + Percentage(x.Sum(item => item.PassedCount), x.Sum(item => item.StudentCount)), + Percentage(x.Sum(item => item.From90To100Count), x.Sum(item => item.StudentCount)))) + .OrderByDescending(x => x.StudentCount).ToArray(); + var teachingClasses = rows.OrderByDescending(x => x.AverageScore) + .Select(x => new CourseTeachingClassOverview(x.GradeSheetId, x.TeachingTaskId, + x.TermName, x.TaskNumber, x.TaskName, string.Join("、", x.TeacherNames), + x.StudentCount, x.HighestScore, x.AverageScore, x.MedianScore, x.LowestScore, + x.StandardDeviation, x.PassRate, x.ExcellentRate)).ToArray(); + return new CourseOverallAnalysisReport(summary, distribution, terms, + studentOrigins, teachingClasses); + } + + private static CourseOverview BuildCourseOverview( + Guid courseId, + string courseCode, + string courseName, + Guid collegeId, + string collegeName, + IEnumerable source) + { + var rows = source.ToArray(); + var studentCount = rows.Sum(x => x.StudentCount); + return new CourseOverview(courseId, courseCode, courseName, collegeId, collegeName, + rows.Select(x => x.AcademicTermId).Distinct().Count(), rows.Length, studentCount, + rows.Sum(x => x.PassedCount), rows.Sum(x => x.ExcellentCount), + rows.Max(x => x.HighestScore), WeightedAverage(rows.Select(x => (x.AverageScore, x.StudentCount))), + rows.Min(x => x.LowestScore), Percentage(rows.Sum(x => x.PassedCount), studentCount), + Percentage(rows.Sum(x => x.ExcellentCount), studentCount), rows.Max(x => x.CalculatedAt)); + } + + private static CourseTermOverview BuildCourseTermOverview( + Guid academicTermId, + string termName, + IEnumerable source) + { + var rows = source.ToArray(); + var studentCount = rows.Sum(x => x.StudentCount); + return new CourseTermOverview(academicTermId, termName, rows.Length, studentCount, + WeightedAverage(rows.Select(x => (x.AverageScore, x.StudentCount))), + Percentage(rows.Sum(x => x.PassedCount), studentCount), + Percentage(rows.Sum(x => x.ExcellentCount), studentCount)); + } + + private static decimal WeightedAverage(IEnumerable<(decimal Value, int Weight)> source) + { + var values = source.ToArray(); + var weight = values.Sum(x => x.Weight); + return weight == 0 ? 0m : Math.Round(values.Sum(x => x.Value * x.Weight) / weight, 1); + } + + private static decimal Percentage(int numerator, int denominator) => denominator == 0 + ? 0m + : Math.Round((decimal)numerator / denominator * 100m, 2); + [HttpGet("teaching-classes")] public async Task GetTeachingClasses( Guid? academicTermId, @@ -227,7 +452,8 @@ public sealed class GradeAnalyticsController( Status = StatusCodes.Status409Conflict }); - var content = GradeAnalysisWordReportGenerator.Generate(report, DateTime.Now); + var content = GradeAnalysisWordReportGenerator.Generate( + report, DateTime.Now, officialDocumentOptions.FontPath); var fileName = DownloadFileName.Export("成绩分析报告", ".docx", report.CourseCode, report.TaskNumber); return File( content, @@ -522,6 +748,105 @@ public sealed class GradeAnalyticsController( Guid CollegeId, string CollegeName); + private sealed record CourseOverviewSourceRow( + Guid CourseId, + string CourseCode, + string CourseName, + Guid CollegeId, + string CollegeName, + Guid AcademicTermId, + int StudentCount, + int PassedCount, + int ExcellentCount, + decimal HighestScore, + decimal AverageScore, + decimal LowestScore, + DateTime CalculatedAt); + + private sealed record CourseDetailSourceRow( + Guid GradeSheetId, + Guid TeachingTaskId, + Guid CourseId, + string CourseCode, + string CourseName, + Guid CollegeId, + string CollegeName, + Guid AcademicTermId, + string TermName, + DateOnly StartDate, + string TaskNumber, + string TaskName, + string[] TeacherNames, + int StudentCount, + int PassedCount, + int ExcellentCount, + decimal HighestScore, + decimal AverageScore, + decimal MedianScore, + decimal LowestScore, + decimal StandardDeviation, + decimal PassRate, + decimal ExcellentRate, + DateTime CalculatedAt, + ScoreBand[] ScoreBands); + + public sealed record CourseOverview( + Guid CourseId, + string CourseCode, + string CourseName, + Guid CollegeId, + string CollegeName, + int TermCount, + int TeachingClassCount, + int StudentCount, + int PassedCount, + int ExcellentCount, + decimal HighestScore, + decimal AverageScore, + decimal LowestScore, + decimal PassRate, + decimal ExcellentRate, + DateTime CalculatedAt); + + public sealed record CourseTermOverview( + Guid AcademicTermId, + string TermName, + int TeachingClassCount, + int StudentCount, + decimal AverageScore, + decimal PassRate, + decimal ExcellentRate); + + public sealed record CourseStudentOrigin( + string CollegeName, + int StudentCount, + decimal AverageScore, + decimal PassRate, + decimal ExcellentRate); + + public sealed record CourseTeachingClassOverview( + Guid GradeSheetId, + Guid TeachingTaskId, + string TermName, + string TaskNumber, + string TaskName, + string TeacherNames, + int StudentCount, + decimal HighestScore, + decimal AverageScore, + decimal MedianScore, + decimal LowestScore, + decimal StandardDeviation, + decimal PassRate, + decimal ExcellentRate); + + public sealed record CourseOverallAnalysisReport( + CourseOverview Summary, + IReadOnlyList Distribution, + IReadOnlyList Terms, + IReadOnlyList StudentOrigins, + IReadOnlyList TeachingClasses); + public sealed record ScoreBand( string Label, decimal LowerBound, diff --git a/src/Jiaowu.Api/Infrastructure/Grades/GradeAnalysisWordReportGenerator.cs b/src/Jiaowu.Api/Infrastructure/Grades/GradeAnalysisWordReportGenerator.cs index f5f624e..dc30bba 100644 --- a/src/Jiaowu.Api/Infrastructure/Grades/GradeAnalysisWordReportGenerator.cs +++ b/src/Jiaowu.Api/Infrastructure/Grades/GradeAnalysisWordReportGenerator.cs @@ -2,6 +2,7 @@ using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; using Jiaowu.Api.Controllers; +using Jiaowu.Api.Infrastructure.Pdf; using SkiaSharp; using A = DocumentFormat.OpenXml.Drawing; using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing; @@ -21,7 +22,8 @@ public static class GradeAnalysisWordReportGenerator public static byte[] Generate( GradeAnalyticsController.TeachingClassAnalysisReport report, - DateTime generatedAt) + DateTime generatedAt, + string? chineseFontPath = null) { ArgumentNullException.ThrowIfNull(report.Summary); using var stream = new MemoryStream(); @@ -39,7 +41,34 @@ public static class GradeAnalysisWordReportGenerator settingsPart.Settings.Save(); AddStyles(mainPart); var headerFooterIds = AddHeaderAndFooter(mainPart); - BuildBody(mainPart, report, generatedAt, headerFooterIds); + BuildBody(mainPart, report, generatedAt, headerFooterIds, chineseFontPath); + mainPart.Document.Save(); + } + return stream.ToArray(); + } + + public static byte[] Generate( + GradeAnalyticsController.CourseOverallAnalysisReport report, + DateTime generatedAt, + string? chineseFontPath = null) + { + ArgumentNullException.ThrowIfNull(report.Summary); + using var stream = new MemoryStream(); + using (var document = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document, true)) + { + document.PackageProperties.Title = $"{report.Summary.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); + BuildCourseOverallBody(mainPart, report, generatedAt, headerFooterIds, chineseFontPath); mainPart.Document.Save(); } return stream.ToArray(); @@ -49,7 +78,8 @@ public static class GradeAnalysisWordReportGenerator MainDocumentPart mainPart, GradeAnalyticsController.TeachingClassAnalysisReport report, DateTime generatedAt, - HeaderFooterIds headerFooterIds) + HeaderFooterIds headerFooterIds, + string? chineseFontPath) { var body = mainPart.Document?.Body ?? throw new InvalidOperationException("The report document body has not been initialized."); @@ -57,14 +87,20 @@ public static class GradeAnalysisWordReportGenerator body.Append(Paragraph("成绩分析报告", 46, true, "000000", 0, 80)); body.Append(Paragraph($"{report.CourseName} · {report.TaskName}", 28, false, Muted, 0, 220)); - body.Append(MetadataTable([ + var metadata = new List<(string Label, string Value)> + { ("课程", $"{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} 份已发布有效成绩") - ])); + }; + var administrativeClasses = report.PeerTeachingClasses + .FirstOrDefault(x => x.IsSelected)?.ClassNames; + if (!string.IsNullOrWhiteSpace(administrativeClasses)) + metadata.Insert(2, ("行政班组成", administrativeClasses)); + body.Append(MetadataTable(metadata)); body.Append(Heading("一、分析摘要", 1)); body.Append(Callout(BuildExecutiveSummary(report))); @@ -72,7 +108,7 @@ public static class GradeAnalysisWordReportGenerator 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(ImageParagraph(mainPart, DrawScoreBands(summary.ScoreBands, chineseFontPath), "分数段分布图", 6.3, 3.0)); body.Append(DataTable( ["分数段", "下限", "上限", "人数", "占比"], summary.ScoreBands.Select(x => new[] @@ -88,7 +124,7 @@ public static class GradeAnalysisWordReportGenerator 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(ImageParagraph(mainPart, DrawPeerAverages(report.PeerTeachingClasses, chineseFontPath), "教学班平均分对比图", 6.3, peerChartHeight)); body.Append(DataTable( ["教学班 / 教师", "人数", "平均分", "中位数", "标准差", "合格率", "优秀率"], report.PeerTeachingClasses.Select(x => new[] @@ -116,7 +152,7 @@ public static class GradeAnalysisWordReportGenerator 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(ImageParagraph(mainPart, DrawHistory(report.History, chineseFontPath), "历年平均分趋势图", 6.3, 3.15)); body.Append(DataTable( ["学期", "全校人数", "全校平均", "全校合格率", "教师人数", "教师平均", "教师合格率"], report.History.Select(x => new[] @@ -146,6 +182,100 @@ public static class GradeAnalysisWordReportGenerator new PageMargin { Top = 1440, Right = 1440, Bottom = 1440, Left = 1440, Header = 708, Footer = 708 })); } + private static void BuildCourseOverallBody( + MainDocumentPart mainPart, + GradeAnalyticsController.CourseOverallAnalysisReport report, + DateTime generatedAt, + HeaderFooterIds headerFooterIds, + string? chineseFontPath) + { + var body = mainPart.Document?.Body + ?? throw new InvalidOperationException("The report document body has not been initialized."); + var summary = report.Summary; + + body.Append(Paragraph("科目总体成绩分析报告", 46, true, "000000", 0, 80)); + body.Append(Paragraph($"{summary.CourseName} · {summary.CollegeName}", 28, false, Muted, 0, 220)); + body.Append(MetadataTable([ + ("课程", $"{summary.CourseCode} {summary.CourseName}"), + ("开课学院", summary.CollegeName), + ("覆盖范围", $"{summary.TermCount} 个学期,{summary.TeachingClassCount} 个教学班"), + ("报告生成", 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(BuildCourseExecutiveSummary(report))); + body.Append(DataTable( + ["指标", "结果", "指标", "结果"], + [ + ["最高分", Score(summary.HighestScore), "最低分", Score(summary.LowestScore)], + ["平均分", Score(summary.AverageScore), "合格率", Rate(summary.PassRate)], + ["合格人数", $"{summary.PassedCount} 人", "优秀率", Rate(summary.ExcellentRate)], + ["优秀人数", $"{summary.ExcellentCount} 人", "教学班数", $"{summary.TeachingClassCount} 个"], + ["覆盖学期", $"{summary.TermCount} 个", "有效成绩", $"{summary.StudentCount} 份"] + ], + [1800, 2880, 1800, 2880])); + + body.Append(Heading("二、分数段分布", 1)); + body.Append(Paragraph("图 1 全部纳入范围内的有效成绩分数段人数", 20, false, Muted, 80, 80)); + body.Append(ImageParagraph(mainPart, DrawScoreBands(report.Distribution, chineseFontPath), "科目总体分数段分布图", 6.3, 3.0)); + body.Append(DataTable( + ["分数段", "下限", "上限", "人数", "占比"], + report.Distribution.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(DataTable( + ["学期", "教学班数", "人数", "平均分", "合格率", "优秀率"], + report.Terms.Select(x => new[] + { + x.TermName, x.TeachingClassCount.ToString(), x.StudentCount.ToString(), + Score(x.AverageScore), Rate(x.PassRate), Rate(x.ExcellentRate) + }), + [2460, 1200, 1050, 1500, 1575, 1575])); + + body.Append(Heading("四、学生来源学院对比", 1)); + body.Append(Paragraph("按学生所属学院汇总,用于识别不同学生来源群体的表现差异。", 22, false, Muted, 0, 100)); + body.Append(DataTable( + ["学生来源学院", "人数", "平均分", "合格率", "优秀率"], + report.StudentOrigins.Select(x => new[] + { + x.CollegeName, x.StudentCount.ToString(), Score(x.AverageScore), + Rate(x.PassRate), Rate(x.ExcellentRate) + }), + [3600, 1200, 1500, 1530, 1530])); + + body.Append(Heading("五、教学班横向比较", 1)); + body.Append(DataTable( + ["学期 / 教学班", "任课教师", "人数", "平均分", "中位数", "标准差", "合格率", "优秀率"], + report.TeachingClasses.Select(x => new[] + { + $"{x.TermName}\n{x.TaskNumber} {x.TaskName}", x.TeacherNames, x.StudentCount.ToString(), + Score(x.AverageScore), Score(x.MedianScore), x.StandardDeviation.ToString("0.00"), + Rate(x.PassRate), Rate(x.ExcellentRate) + }), + [2150, 1450, 720, 950, 950, 950, 1095, 1095])); + + 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!; @@ -162,6 +292,23 @@ public static class GradeAnalysisWordReportGenerator return string.Concat(parts); } + private static string BuildCourseExecutiveSummary(GradeAnalyticsController.CourseOverallAnalysisReport report) + { + var summary = report.Summary; + var mainBand = report.Distribution.OrderByDescending(x => x.StudentCount).FirstOrDefault(); + var latestTerm = report.Terms.LastOrDefault(); + var parts = new List + { + $"本课程共纳入 {summary.StudentCount} 份有效成绩,覆盖 {summary.TermCount} 个学期和 {summary.TeachingClassCount} 个教学班,平均分 {Score(summary.AverageScore)},合格率 {Rate(summary.PassRate)},优秀率 {Rate(summary.ExcellentRate)}。" + }; + if (mainBand is not null) + parts.Add($"人数最多的分数段为 {mainBand.Label},共 {mainBand.StudentCount} 人,占 {Percent(mainBand.StudentCount, summary.StudentCount)}。 "); + if (latestTerm is not null) + parts.Add($"最近纳入学期为 {latestTerm.TermName},平均分 {Score(latestTerm.AverageScore)},合格率 {Rate(latestTerm.PassRate)}。 "); + parts.Add($"成绩范围为 {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}" : "持平"; @@ -372,7 +519,9 @@ public static class GradeAnalysisWordReportGenerator return new Footer(footerParagraph); } - private static byte[] DrawScoreBands(IReadOnlyList rows) + private static byte[] DrawScoreBands( + IReadOnlyList rows, + string? chineseFontPath) { return DrawChart(1200, 540, (canvas, typeface) => { @@ -389,10 +538,12 @@ public static class GradeAnalysisWordReportGenerator 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); } - }); + }, chineseFontPath); } - private static byte[] DrawPeerAverages(IReadOnlyList rows) + private static byte[] DrawPeerAverages( + IReadOnlyList rows, + string? chineseFontPath) { var visible = rows.Take(8).ToArray(); var height = Math.Max(250, 120 + visible.Length * 62); @@ -414,10 +565,12 @@ public static class GradeAnalysisWordReportGenerator 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); } - }); + }, chineseFontPath); } - private static byte[] DrawHistory(IReadOnlyList rows) + private static byte[] DrawHistory( + IReadOnlyList rows, + string? chineseFontPath) { return DrawChart(1200, 570, (canvas, typeface) => { @@ -441,15 +594,19 @@ public static class GradeAnalysisWordReportGenerator canvas.DrawLine(940, 520, 985, 520, gold); DrawText(canvas, typeface, "同课程全校", 815, 528, 20, Ink); DrawText(canvas, typeface, "当前任课教师", 995, 528, 20, Ink); - }); + }, chineseFontPath); } - private static byte[] DrawChart(int width, int height, Action draw) + private static byte[] DrawChart( + int width, + int height, + Action draw, + string? chineseFontPath) { 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; + using var typeface = PdfTypefaceResolver.ResolveChineseTypeface(chineseFontPath); draw(canvas, typeface); using var image = SKImage.FromBitmap(bitmap); using var data = image.Encode(SKEncodedImageFormat.Png, 92); diff --git a/tests/Jiaowu.Api.Tests/GradeAnalysisWordReportGeneratorTests.cs b/tests/Jiaowu.Api.Tests/GradeAnalysisWordReportGeneratorTests.cs index d455374..d619ad7 100644 --- a/tests/Jiaowu.Api.Tests/GradeAnalysisWordReportGeneratorTests.cs +++ b/tests/Jiaowu.Api.Tests/GradeAnalysisWordReportGeneratorTests.cs @@ -1,11 +1,22 @@ using DocumentFormat.OpenXml.Packaging; using Jiaowu.Api.Controllers; using Jiaowu.Api.Infrastructure.Grades; +using Jiaowu.Api.Infrastructure.Pdf; +using SkiaSharp; namespace Jiaowu.Api.Tests; public sealed class GradeAnalysisWordReportGeneratorTests { + [Fact] + public void ChineseTypefaceResolver_ProvidesChineseGlyphsForReportCharts() + { + using var typeface = PdfTypefaceResolver.ResolveChineseTypeface(null); + using var font = new SKFont(typeface, 20); + + Assert.True(font.ContainsGlyphs("人数平均分合格率")); + } + [Fact] public void Generate_CreatesReadableDocxWithAnalysisSectionsAndCharts() { @@ -37,8 +48,38 @@ public sealed class GradeAnalysisWordReportGeneratorTests Assert.Contains("同课程教学班对比", text); 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()); } + + [Fact] + public void Generate_CourseOverallReport_CreatesReadableDocx() + { + var now = new DateTime(2026, 8, 21, 10, 30, 0, DateTimeKind.Local); + var summary = new GradeAnalyticsController.CourseOverview( + Guid.NewGuid(), "CS101", "程序设计", Guid.NewGuid(), "计算机学院", 2, 3, + 30, 26, 8, 99m, 78.5m, 52m, 86.67m, 26.67m, now); + var report = new GradeAnalyticsController.CourseOverallAnalysisReport( + summary, + [new("0–59", 0, 60, 4), new("60–69", 60, 70, 6), + new("70–79", 70, 80, 8), new("80–89", 80, 90, 7), new("90–100", 90, null, 5)], + [new(Guid.NewGuid(), "2025-2026 学年第二学期", 1, 12, 76m, 83.33m, 25m), + new(Guid.NewGuid(), "2026-2027 学年第一学期", 2, 18, 80.2m, 88.89m, 27.78m)], + [new("计算机学院", 18, 80m, 88.89m, 27.78m), new("电子信息学院", 12, 76.3m, 83.33m, 25m)], + [new(Guid.NewGuid(), Guid.NewGuid(), "2026-2027 学年第一学期", "TASK-01", "程序设计一班", "张老师", 18, 99m, 80.2m, 82m, 52m, 11.2m, 88.89m, 27.78m)]); + + var bytes = GradeAnalysisWordReportGenerator.Generate(report, now); + + Assert.True(bytes.Length > 8_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.Single(document.MainDocumentPart.ImageParts); + } } diff --git a/web/src/views/GradeAnalyticsView.vue b/web/src/views/GradeAnalyticsView.vue index 8907cee..2e68396 100644 --- a/web/src/views/GradeAnalyticsView.vue +++ b/web/src/views/GradeAnalyticsView.vue @@ -38,10 +38,31 @@ interface TeachingClassItem { calculatedAt: string } +interface CourseOverviewItem { + courseId: string + courseCode: string + courseName: string + collegeId: string + collegeName: string + termCount: number + teachingClassCount: number + studentCount: number + passedCount: number + excellentCount: number + highestScore: number + averageScore: number + lowestScore: number + passRate: number + excellentRate: number + calculatedAt: string +} + const terms = ref([]) const colleges = ref([]) const auth = useAuthStore() const canManageSchedule = computed(() => auth.user?.roles.some(role => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false) +const canViewCourseOverview = computed(() => auth.user?.roles.some(role => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'].includes(role)) ?? false) +const analysisMode = ref<'classes' | 'courses'>('classes') const classes = ref([]) const selected = ref() const report = ref() @@ -52,6 +73,13 @@ const teacherKeyword = ref('') const riskOnly = ref(false) const page = ref(1) const total = ref(0) +const courses = ref([]) +const selectedCourse = ref() +const courseReport = ref() +const courseTotal = ref(0) +const coursePage = ref(1) +const courseReportLoading = ref(false) +const courseExporting = ref(false) const pageSize = 12 const loading = ref(false) const reportLoading = ref(false) @@ -136,13 +164,82 @@ async function loadClasses(reset = false) { } } +async function loadCourses(reset = false) { + if (reset) coursePage.value = 1 + loading.value = true + try { + const data = (await http.get('/grade-analytics/courses', { + params: { + academicTermId: termId.value, + keyword: keyword.value.trim() || undefined, + collegeId: collegeId.value, + riskOnly: riskOnly.value || undefined, + page: coursePage.value, + pageSize, + }, + })).data + courses.value = data.items + courseTotal.value = data.total + selectedCourse.value = courses.value.find(item => item.courseId === selectedCourse.value?.courseId) ?? courses.value[0] + if (selectedCourse.value) await loadCourseReport(selectedCourse.value) + else courseReport.value = undefined + } catch (error) { + ElMessage.error(apiErrorMessage(error)) + } finally { + loading.value = false + } +} + +async function loadCourseReport(item = selectedCourse.value) { + if (!item) return + selectedCourse.value = item + courseReportLoading.value = true + try { + courseReport.value = (await http.get(`/grade-analytics/courses/${item.courseId}`, { + params: { academicTermId: termId.value }, + })).data + } catch (error) { + ElMessage.error(apiErrorMessage(error)) + } finally { + courseReportLoading.value = false + } +} + +async function exportCourseWordReport() { + if (!selectedCourse.value) return + courseExporting.value = true + try { + await downloadApiFile( + `/grade-analytics/courses/${selectedCourse.value.courseId}/report.docx`, + `${selectedCourse.value.courseCode}-科目总体成绩分析报告.docx`, + { params: { academicTermId: termId.value } }, + ) + ElMessage.success('科目总体成绩分析 Word 报告已导出。') + } catch (error) { + ElMessage.error(apiErrorMessage(error)) + } finally { + courseExporting.value = false + } +} + +async function switchAnalysisMode(mode: 'classes' | 'courses') { + analysisMode.value = mode + if (mode === 'courses') await loadCourses(true) + else await loadClasses(true) +} + +function reloadCurrent(reset = true) { + if (analysisMode.value === 'courses') return loadCourses(reset) + return loadClasses(reset) +} + function resetFilters() { termId.value = defaultAcademicTermId(terms.value) collegeId.value = undefined keyword.value = '' teacherKeyword.value = '' riskOnly.value = false - loadClasses(true) + reloadCurrent(true) } async function loadReport(item = selected.value) { @@ -389,17 +486,15 @@ onBeforeUnmount(() => {
定时刷新设置 - 导出 Word 报告 重新计算当前教学班
+ + 教学班分析 + 科目总体分析 + + @@ -427,20 +522,20 @@ onBeforeUnmount(() => {
- + - + - - - 仅看预警班 - 查询 + + + 仅看预警{{ analysisMode === 'courses' ? '科目' : '班' }} + 查询 重置
-
+
+ +
+ + +
+