成绩分析

This commit is contained in:
biss committed 2026-08-22 16:24:56 +08:00
1 parent 0a0500dd70
commit 9ba83c3dc1
4 files changed
+753 -32

No files matched your search

@@ -5,6 +5,7 @@ using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Files; using Jiaowu.Api.Infrastructure.Files;
using Jiaowu.Api.Infrastructure.OfficialDocuments;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -18,6 +19,7 @@ namespace Jiaowu.Api.Controllers;
public sealed class GradeAnalyticsController( public sealed class GradeAnalyticsController(
AppDbContext db, AppDbContext db,
ICurrentUserDataScope currentUserDataScope, ICurrentUserDataScope currentUserDataScope,
OfficialDocumentOptions officialDocumentOptions,
IAppCache? cache = null) : ControllerBase IAppCache? cache = null) : ControllerBase
{ {
private const string AnalyticsUsers = private const string AnalyticsUsers =
@@ -28,6 +30,11 @@ public sealed class GradeAnalyticsController(
SystemRoles.Teacher; SystemRoles.Teacher;
private const string ScheduleManagers = private const string ScheduleManagers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin; SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
private const string OverallAnalyticsUsers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Leader;
[HttpGet("refresh-schedule")] [HttpGet("refresh-schedule")]
[Authorize(Roles = ScheduleManagers)] [Authorize(Roles = ScheduleManagers)]
@@ -90,6 +97,224 @@ public sealed class GradeAnalyticsController(
return NoContent(); return NoContent();
} }
[HttpGet("courses")]
[Authorize(Roles = OverallAnalyticsUsers)]
public async Task<ActionResult> 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<ActionResult> 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<ActionResult> 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<CourseOverallAnalysisReport?> 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<CourseOverviewSourceRow> 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<CourseOverviewSourceRow> 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")] [HttpGet("teaching-classes")]
public async Task<ActionResult> GetTeachingClasses( public async Task<ActionResult> GetTeachingClasses(
Guid? academicTermId, Guid? academicTermId,
@@ -227,7 +452,8 @@ public sealed class GradeAnalyticsController(
Status = StatusCodes.Status409Conflict 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); var fileName = DownloadFileName.Export("成绩分析报告", ".docx", report.CourseCode, report.TaskNumber);
return File( return File(
content, content,
@@ -522,6 +748,105 @@ public sealed class GradeAnalyticsController(
Guid CollegeId, Guid CollegeId,
string CollegeName); 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<ScoreBand> Distribution,
IReadOnlyList<CourseTermOverview> Terms,
IReadOnlyList<CourseStudentOrigin> StudentOrigins,
IReadOnlyList<CourseTeachingClassOverview> TeachingClasses);
public sealed record ScoreBand( public sealed record ScoreBand(
string Label, string Label,
decimal LowerBound, decimal LowerBound,
@@ -2,6 +2,7 @@ using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing; using DocumentFormat.OpenXml.Wordprocessing;
using Jiaowu.Api.Controllers; using Jiaowu.Api.Controllers;
using Jiaowu.Api.Infrastructure.Pdf;
using SkiaSharp; using SkiaSharp;
using A = DocumentFormat.OpenXml.Drawing; using A = DocumentFormat.OpenXml.Drawing;
using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing; using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing;
@@ -21,7 +22,8 @@ public static class GradeAnalysisWordReportGenerator
public static byte[] Generate( public static byte[] Generate(
GradeAnalyticsController.TeachingClassAnalysisReport report, GradeAnalyticsController.TeachingClassAnalysisReport report,
DateTime generatedAt) DateTime generatedAt,
string? chineseFontPath = null)
{ {
ArgumentNullException.ThrowIfNull(report.Summary); ArgumentNullException.ThrowIfNull(report.Summary);
using var stream = new MemoryStream(); using var stream = new MemoryStream();
@@ -39,7 +41,34 @@ public static class GradeAnalysisWordReportGenerator
settingsPart.Settings.Save(); settingsPart.Settings.Save();
AddStyles(mainPart); AddStyles(mainPart);
var headerFooterIds = AddHeaderAndFooter(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<DocumentSettingsPart>();
settingsPart.Settings = new Settings(new EvenAndOddHeaders());
settingsPart.Settings.Save();
AddStyles(mainPart);
var headerFooterIds = AddHeaderAndFooter(mainPart);
BuildCourseOverallBody(mainPart, report, generatedAt, headerFooterIds, chineseFontPath);
mainPart.Document.Save(); mainPart.Document.Save();
} }
return stream.ToArray(); return stream.ToArray();
@@ -49,7 +78,8 @@ public static class GradeAnalysisWordReportGenerator
MainDocumentPart mainPart, MainDocumentPart mainPart,
GradeAnalyticsController.TeachingClassAnalysisReport report, GradeAnalyticsController.TeachingClassAnalysisReport report,
DateTime generatedAt, DateTime generatedAt,
HeaderFooterIds headerFooterIds) HeaderFooterIds headerFooterIds,
string? chineseFontPath)
{ {
var body = mainPart.Document?.Body var body = mainPart.Document?.Body
?? throw new InvalidOperationException("The report document body has not been initialized."); ?? 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("成绩分析报告", 46, true, "000000", 0, 80));
body.Append(Paragraph($"{report.CourseName} · {report.TaskName}", 28, false, Muted, 0, 220)); 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.CourseCode} {report.CourseName}"),
("教学班", $"{report.TaskNumber} {report.TaskName}"), ("教学班", $"{report.TaskNumber} {report.TaskName}"),
("学期", report.TermName), ("学期", report.TermName),
("报告生成", generatedAt.ToString("yyyy-MM-dd HH:mm")), ("报告生成", generatedAt.ToString("yyyy-MM-dd HH:mm")),
("统计更新", summary.CalculatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm")), ("统计更新", summary.CalculatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm")),
("统计对象", $"{summary.StudentCount} 份已发布有效成绩") ("统计对象", $"{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(Heading("一、分析摘要", 1));
body.Append(Callout(BuildExecutiveSummary(report))); body.Append(Callout(BuildExecutiveSummary(report)));
@@ -72,7 +108,7 @@ public static class GradeAnalysisWordReportGenerator
body.Append(Heading("二、分数段分布", 1)); body.Append(Heading("二、分数段分布", 1));
body.Append(Paragraph("图 1 当前教学班各分数段人数", 20, false, Muted, 80, 80)); 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( body.Append(DataTable(
["分数段", "下限", "上限", "人数", "占比"], ["分数段", "下限", "上限", "人数", "占比"],
summary.ScoreBands.Select(x => new[] summary.ScoreBands.Select(x => new[]
@@ -88,7 +124,7 @@ public static class GradeAnalysisWordReportGenerator
body.Append(Heading("三、同课程教学班对比", 1)); body.Append(Heading("三、同课程教学班对比", 1));
body.Append(Paragraph("图 2 同学期同课程各教学班平均分", 20, false, Muted, 80, 80)); body.Append(Paragraph("图 2 同学期同课程各教学班平均分", 20, false, Muted, 80, 80));
var peerChartHeight = Math.Clamp(1.45 + report.PeerTeachingClasses.Count * 0.32, 1.8, 3.35); 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( body.Append(DataTable(
["教学班 / 教师", "人数", "平均分", "中位数", "标准差", "合格率", "优秀率"], ["教学班 / 教师", "人数", "平均分", "中位数", "标准差", "合格率", "优秀率"],
report.PeerTeachingClasses.Select(x => new[] report.PeerTeachingClasses.Select(x => new[]
@@ -116,7 +152,7 @@ public static class GradeAnalysisWordReportGenerator
body.Append(Heading("五、历年成绩趋势", 1)); body.Append(Heading("五、历年成绩趋势", 1));
body.Append(Paragraph("图 3 同课程全校与当前任课教师历年平均分", 20, false, Muted, 80, 80)); 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( body.Append(DataTable(
["学期", "全校人数", "全校平均", "全校合格率", "教师人数", "教师平均", "教师合格率"], ["学期", "全校人数", "全校平均", "全校合格率", "教师人数", "教师平均", "教师合格率"],
report.History.Select(x => new[] 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 })); 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) private static string BuildExecutiveSummary(GradeAnalyticsController.TeachingClassAnalysisReport report)
{ {
var summary = report.Summary!; var summary = report.Summary!;
@@ -162,6 +292,23 @@ public static class GradeAnalysisWordReportGenerator
return string.Concat(parts); 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<string>
{
$"本课程共纳入 {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) => private static string Direction(decimal value, string unit) =>
value > 0 ? $"高 {value:0.0} {unit}" : value < 0 ? $"低 {Math.Abs(value):0.0} {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); return new Footer(footerParagraph);
} }
private static byte[] DrawScoreBands(IReadOnlyList<GradeAnalyticsController.ScoreBand> rows) private static byte[] DrawScoreBands(
IReadOnlyList<GradeAnalyticsController.ScoreBand> rows,
string? chineseFontPath)
{ {
return DrawChart(1200, 540, (canvas, typeface) => 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].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); DrawText(canvas, typeface, rows[i].Label, x + barWidth / 2, 475, 18, Muted, SKTextAlign.Center);
} }
}); }, chineseFontPath);
} }
private static byte[] DrawPeerAverages(IReadOnlyList<GradeAnalyticsController.TeachingClassComparison> rows) private static byte[] DrawPeerAverages(
IReadOnlyList<GradeAnalyticsController.TeachingClassComparison> rows,
string? chineseFontPath)
{ {
var visible = rows.Take(8).ToArray(); var visible = rows.Take(8).ToArray();
var height = Math.Max(250, 120 + visible.Length * 62); 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); 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); DrawText(canvas, typeface, value.ToString("0.0"), 290 + value / 100 * 840, y + 27, 20, Ink, SKTextAlign.Left, true);
} }
}); }, chineseFontPath);
} }
private static byte[] DrawHistory(IReadOnlyList<GradeAnalyticsController.HistoricalComparison> rows) private static byte[] DrawHistory(
IReadOnlyList<GradeAnalyticsController.HistoricalComparison> rows,
string? chineseFontPath)
{ {
return DrawChart(1200, 570, (canvas, typeface) => return DrawChart(1200, 570, (canvas, typeface) =>
{ {
@@ -441,15 +594,19 @@ public static class GradeAnalysisWordReportGenerator
canvas.DrawLine(940, 520, 985, 520, gold); canvas.DrawLine(940, 520, 985, 520, gold);
DrawText(canvas, typeface, "同课程全校", 815, 528, 20, Ink); DrawText(canvas, typeface, "同课程全校", 815, 528, 20, Ink);
DrawText(canvas, typeface, "当前任课教师", 995, 528, 20, Ink); DrawText(canvas, typeface, "当前任课教师", 995, 528, 20, Ink);
}); }, chineseFontPath);
} }
private static byte[] DrawChart(int width, int height, Action<SKCanvas, SKTypeface> draw) private static byte[] DrawChart(
int width,
int height,
Action<SKCanvas, SKTypeface> draw,
string? chineseFontPath)
{ {
using var bitmap = new SKBitmap(width, height); using var bitmap = new SKBitmap(width, height);
using var canvas = new SKCanvas(bitmap); using var canvas = new SKCanvas(bitmap);
canvas.Clear(SKColors.White); canvas.Clear(SKColors.White);
using var typeface = SKTypeface.FromFamilyName("Microsoft YaHei") ?? SKTypeface.Default; using var typeface = PdfTypefaceResolver.ResolveChineseTypeface(chineseFontPath);
draw(canvas, typeface); draw(canvas, typeface);
using var image = SKImage.FromBitmap(bitmap); using var image = SKImage.FromBitmap(bitmap);
using var data = image.Encode(SKEncodedImageFormat.Png, 92); using var data = image.Encode(SKEncodedImageFormat.Png, 92);
@@ -1,11 +1,22 @@
using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Packaging;
using Jiaowu.Api.Controllers; using Jiaowu.Api.Controllers;
using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Pdf;
using SkiaSharp;
namespace Jiaowu.Api.Tests; namespace Jiaowu.Api.Tests;
public sealed class GradeAnalysisWordReportGeneratorTests 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] [Fact]
public void Generate_CreatesReadableDocxWithAnalysisSectionsAndCharts() 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.Contains("历年成绩趋势", text);
Assert.Contains("行政班组成", text);
Assert.Contains("计科一班", text);
Assert.Equal(3, document.MainDocumentPart.ImageParts.Count()); Assert.Equal(3, document.MainDocumentPart.ImageParts.Count());
Assert.Equal(2, document.MainDocumentPart.HeaderParts.Count()); Assert.Equal(2, document.MainDocumentPart.HeaderParts.Count());
Assert.Equal(2, document.MainDocumentPart.FooterParts.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);
}
} }
Loaded 3 of 4 files, more files were not shown because too many files have changed in this diff. Show more