成绩分析
This commit is contained in:
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
Reference in new issue
Block a user