教师成绩报告

This commit is contained in:
2026-08-09 11:09:43 +08:00 Unverified
parent 924574256a
commit ca8232ad5e
12 changed files with 8048 additions and 17 deletions
@@ -0,0 +1,477 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = AnalyticsUsers)]
[Route("api/grade-analytics")]
public sealed class GradeAnalyticsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope,
IAppCache? cache = null) : ControllerBase
{
private const string AnalyticsUsers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Leader + "," +
SystemRoles.Teacher;
[HttpGet("teaching-classes")]
public async Task<ActionResult> GetTeachingClasses(
Guid? academicTermId,
string? keyword,
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);
if (keyword is not null)
source = source.Where(x =>
x.TeachingTask!.TaskNumber.Contains(keyword) ||
x.TeachingTask.Name.Contains(keyword) ||
x.TeachingTask.Course!.Code.Contains(keyword) ||
x.TeachingTask.Course.Name.Contains(keyword));
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
.ThenBy(x => x.TeachingTask!.Course!.Code)
.ThenBy(x => x.TeachingTask!.TaskNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.GradeSheetId,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
TermName = x.TeachingTask.AcademicTerm!.Name,
x.AcademicTermId,
TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
ClassNames = x.TeachingTask.Classes
.Select(item => item.AdministrativeClass!.Name),
x.StudentCount,
x.AverageScore,
x.PassRate,
x.ExcellentRate,
x.CalculatedAt
})
.ToListAsync(cancellationToken);
return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize });
}
[HttpGet("teaching-classes/{gradeSheetId:guid}")]
public async Task<ActionResult> GetTeachingClassAnalysis(
Guid gradeSheetId,
CancellationToken cancellationToken)
{
var sheet = await db.GradeSheets.AsNoTracking()
.Where(x => x.Id == gradeSheetId &&
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId))
.Select(x => new AnalysisTarget(
x.Id,
x.TeachingTaskId,
x.TeachingTask!.CourseId,
x.TeachingTask.AcademicTermId,
x.TeachingTask.Course!.CollegeId,
x.TeachingTask.TaskNumber,
x.TeachingTask.Name,
x.TeachingTask.Course.Code,
x.TeachingTask.Course.Name,
x.TeachingTask.AcademicTerm!.Name))
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
var report = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(
AppCacheKeys.TeachingTaskGradeAnalytics(gradeSheetId),
token => BuildReportAsync(sheet, token),
AppCacheProfile.Analytics,
[AppCacheTags.CourseGradeStatistics],
cancellationToken);
return Ok(report);
}
[HttpPost("teaching-classes/{gradeSheetId:guid}/refresh")]
public async Task<ActionResult> RefreshTeachingClassAnalysis(
Guid gradeSheetId,
CancellationToken cancellationToken)
{
var exists = await db.GradeSheets.AsNoTracking()
.AnyAsync(x => x.Id == gradeSheetId &&
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId),
cancellationToken);
if (!exists) return NotFound();
var job = new CourseGradeStatisticsRefreshJob { GradeSheetId = gradeSheetId };
db.CourseGradeStatisticsRefreshJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
BackgroundJobKind.CourseGradeStatisticsRefresh,
job.Id));
await db.SaveChangesAsync(cancellationToken);
return Accepted(new { job.Id });
}
private async Task<TeachingClassAnalysisReport> BuildReportAsync(
AnalysisTarget target,
CancellationToken cancellationToken)
{
var statistic = await db.TeachingTaskGradeStatistics.AsNoTracking()
.Where(x => x.GradeSheetId == target.GradeSheetId)
.Select(x => new TeachingClassMetrics(
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()))
.FirstOrDefaultAsync(cancellationToken);
if (statistic is null)
return new TeachingClassAnalysisReport(
true,
target.GradeSheetId,
target.TeachingTaskId,
target.TaskNumber,
target.TaskName,
target.CourseCode,
target.CourseName,
target.TermName,
null,
[],
[],
[],
null);
var peerRows = await db.TeachingTaskGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == target.CourseId &&
x.AcademicTermId == target.AcademicTermId)
.OrderByDescending(x => x.AverageScore)
.Select(x => new
{
x.GradeSheetId,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name).ToArray(),
ClassNames = x.TeachingTask.Classes
.Select(item => item.AdministrativeClass!.Name).ToArray(),
x.StudentCount,
x.HighestScore,
x.AverageScore,
x.MedianScore,
x.LowestScore,
x.StandardDeviation,
x.PassRate,
x.ExcellentRate
})
.ToListAsync(cancellationToken);
var peers = peerRows.Select(x => new TeachingClassComparison(
x.GradeSheetId,
x.TeachingTaskId,
x.TaskNumber,
x.TaskName,
string.Join("、", x.TeacherNames),
string.Join("、", x.ClassNames),
x.StudentCount,
x.HighestScore,
x.AverageScore,
x.MedianScore,
x.LowestScore,
x.StandardDeviation,
x.PassRate,
x.ExcellentRate,
x.GradeSheetId == target.GradeSheetId)).ToArray();
var classProfiles = await db.GradeRecords.AsNoTracking()
.Where(x => x.GradeSheetId == target.GradeSheetId)
.Select(x => new ClassProfile(
x.Student!.AdministrativeClassId,
x.Student.AdministrativeClass!.Name,
x.Student.AdministrativeClass.MajorId,
x.Student.AdministrativeClass.Major!.Name,
x.Student.AdministrativeClass.Major.CollegeId,
x.Student.AdministrativeClass.Major.College!.Name))
.Distinct()
.ToListAsync(cancellationToken);
var scopeStatistics = await db.CourseGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == target.CourseId &&
x.AcademicTermId == target.AcademicTermId)
.ToListAsync(cancellationToken);
var benchmarks = BuildBenchmarks(classProfiles, scopeStatistics);
var selectedTeacherIds = await db.TeachingTaskTeachers.AsNoTracking()
.Where(x => x.TeachingTaskId == target.TeachingTaskId)
.Select(x => x.TeacherId)
.ToListAsync(cancellationToken);
var historicalTaskRows = await db.TeachingTaskGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == target.CourseId &&
x.TeachingTask!.Teachers.Any(link =>
selectedTeacherIds.Contains(link.TeacherId)))
.Select(x => new
{
x.AcademicTermId,
TermName = x.TeachingTask!.AcademicTerm!.Name,
x.TeachingTask.AcademicTerm.StartDate,
x.StudentCount,
x.PassedCount,
x.ExcellentCount,
x.AverageScore
})
.ToListAsync(cancellationToken);
var courseHistory = await db.CourseGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == target.CourseId &&
x.Scope == CourseGradeStatisticScope.University)
.Select(x => new
{
x.AcademicTermId,
TermName = db.AcademicTerms.Where(term => term.Id == x.AcademicTermId)
.Select(term => term.Name).First(),
StartDate = db.AcademicTerms.Where(term => term.Id == x.AcademicTermId)
.Select(term => term.StartDate).First(),
x.StudentCount,
x.AverageScore,
x.PassRate,
ExcellentRate = x.StudentCount == 0 ? 0m :
Math.Round((decimal)x.From90To100Count / x.StudentCount * 100m, 2)
})
.ToListAsync(cancellationToken);
var teacherByTerm = historicalTaskRows
.GroupBy(x => new { x.AcademicTermId, x.TermName, x.StartDate })
.ToDictionary(group => group.Key.AcademicTermId, group =>
{
var count = group.Sum(x => x.StudentCount);
return new HistoricalSeriesValue(
count,
count == 0 ? 0m : Math.Round(
group.Sum(x => x.AverageScore * x.StudentCount) / count, 1),
count == 0 ? 0m : Math.Round(
(decimal)group.Sum(x => x.PassedCount) / count * 100m, 2),
count == 0 ? 0m : Math.Round(
(decimal)group.Sum(x => x.ExcellentCount) / count * 100m, 2));
});
var history = courseHistory
.OrderBy(x => x.StartDate)
.Select(x => new HistoricalComparison(
x.AcademicTermId,
x.TermName,
x.StudentCount,
x.AverageScore,
x.PassRate,
x.ExcellentRate,
teacherByTerm.GetValueOrDefault(x.AcademicTermId)))
.ToArray();
var university = scopeStatistics.FirstOrDefault(x =>
x.Scope == CourseGradeStatisticScope.University);
return new TeachingClassAnalysisReport(
false,
target.GradeSheetId,
target.TeachingTaskId,
target.TaskNumber,
target.TaskName,
target.CourseCode,
target.CourseName,
target.TermName,
statistic,
peers,
benchmarks,
history,
university is null ? null : new ComparisonDelta(
Math.Round(statistic.AverageScore - university.AverageScore, 1),
Math.Round(statistic.PassRate - university.PassRate, 2),
university.AverageScore,
university.PassRate));
}
private static ScopeBenchmark[] BuildBenchmarks(
IEnumerable<ClassProfile> classProfiles,
IReadOnlyCollection<CourseGradeStatistic> statistics)
{
var profiles = classProfiles.ToArray();
var rows = new List<ScopeBenchmark>();
foreach (var profile in profiles)
AddBenchmark(rows, statistics, CourseGradeStatisticScope.AdministrativeClass,
profile.ClassId, "行政班", profile.ClassName);
foreach (var profile in profiles.GroupBy(x => x.MajorId).Select(x => x.First()))
AddBenchmark(rows, statistics, CourseGradeStatisticScope.Major,
profile.MajorId, "专业", profile.MajorName);
foreach (var profile in profiles.GroupBy(x => x.CollegeId).Select(x => x.First()))
AddBenchmark(rows, statistics, CourseGradeStatisticScope.College,
profile.CollegeId, "学院", profile.CollegeName);
AddBenchmark(rows, statistics, CourseGradeStatisticScope.University,
null, "全校", "全校同课程");
return rows.ToArray();
}
private static void AddBenchmark(
ICollection<ScopeBenchmark> target,
IEnumerable<CourseGradeStatistic> source,
CourseGradeStatisticScope scope,
Guid? entityId,
string scopeLabel,
string name)
{
var item = source.FirstOrDefault(x =>
x.Scope == scope && x.ScopeEntityId == entityId);
if (item is null || target.Any(x => x.Scope == scopeLabel && x.Name == name)) return;
target.Add(new ScopeBenchmark(
scopeLabel,
name,
item.StudentCount,
item.HighestScore,
item.AverageScore,
item.LowestScore,
item.PassRate));
}
private IQueryable<TeachingTask> VisibleTeachingTasks()
{
var scope = currentUserDataScope.Current;
var source = db.TeachingTasks.AsQueryable();
if (scope.Scope == DataScope.All) return source;
if (scope.Scope == DataScope.College)
return source.Where(x => x.Course!.CollegeId == scope.RestrictedCollegeId);
if (scope.IsInRole(SystemRoles.Teacher))
return source.Where(x =>
x.Teachers.Any(link => link.Teacher!.UserId == scope.UserId));
return source.Where(_ => false);
}
private sealed record AnalysisTarget(
Guid GradeSheetId,
Guid TeachingTaskId,
Guid CourseId,
Guid AcademicTermId,
Guid CourseCollegeId,
string TaskNumber,
string TaskName,
string CourseCode,
string CourseName,
string TermName);
private sealed record ClassProfile(
Guid ClassId,
string ClassName,
Guid MajorId,
string MajorName,
Guid CollegeId,
string CollegeName);
public sealed record ScoreBand(
string Label,
decimal LowerBound,
decimal? UpperBound,
int StudentCount);
public sealed record TeachingClassMetrics(
int StudentCount,
int PassedCount,
int ExcellentCount,
decimal HighestScore,
decimal AverageScore,
decimal MedianScore,
decimal LowestScore,
decimal StandardDeviation,
decimal PassRate,
decimal ExcellentRate,
DateTime CalculatedAt,
IReadOnlyList<ScoreBand> ScoreBands);
public sealed record TeachingClassComparison(
Guid GradeSheetId,
Guid TeachingTaskId,
string TaskNumber,
string TaskName,
string TeacherNames,
string ClassNames,
int StudentCount,
decimal HighestScore,
decimal AverageScore,
decimal MedianScore,
decimal LowestScore,
decimal StandardDeviation,
decimal PassRate,
decimal ExcellentRate,
bool IsSelected);
public sealed record ScopeBenchmark(
string Scope,
string Name,
int StudentCount,
decimal HighestScore,
decimal AverageScore,
decimal LowestScore,
decimal PassRate);
public sealed record HistoricalSeriesValue(
int StudentCount,
decimal AverageScore,
decimal PassRate,
decimal ExcellentRate);
public sealed record HistoricalComparison(
Guid AcademicTermId,
string TermName,
int CourseStudentCount,
decimal CourseAverageScore,
decimal CoursePassRate,
decimal CourseExcellentRate,
HistoricalSeriesValue? Instructor);
public sealed record ComparisonDelta(
decimal AverageScoreDifference,
decimal PassRateDifference,
decimal UniversityAverageScore,
decimal UniversityPassRate);
public sealed record TeachingClassAnalysisReport(
bool IsRefreshing,
Guid GradeSheetId,
Guid TeachingTaskId,
string TaskNumber,
string TaskName,
string CourseCode,
string CourseName,
string TermName,
TeachingClassMetrics? Summary,
IReadOnlyList<TeachingClassComparison> PeerTeachingClasses,
IReadOnlyList<ScopeBenchmark> ScopeBenchmarks,
IReadOnlyList<HistoricalComparison> History,
ComparisonDelta? UniversityDelta);
}
@@ -79,6 +79,48 @@ public sealed class CourseGradeStatistic : EntityBase
public DateTime CalculatedAt { get; set; }
}
/// <summary>
/// Materialized analysis for one published teaching class. Course/term
/// organizational benchmarks stay in <see cref="CourseGradeStatistic"/>;
/// this table is the grain used for peer-class and historical comparisons.
/// </summary>
public sealed class TeachingTaskGradeStatistic : EntityBase
{
public Guid GradeSheetId { get; set; }
public GradeSheet? GradeSheet { get; set; }
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public Guid CourseId { get; set; }
public Guid AcademicTermId { get; set; }
public int StudentCount { get; set; }
public int PassedCount { get; set; }
public int ExcellentCount { get; set; }
public decimal HighestScore { get; set; }
public decimal AverageScore { get; set; }
public decimal MedianScore { get; set; }
public decimal LowestScore { get; set; }
public decimal StandardDeviation { get; set; }
public decimal PassRate { get; set; }
public decimal ExcellentRate { get; set; }
public DateTime CalculatedAt { get; set; }
public ICollection<TeachingTaskGradeScoreBand> ScoreBands { get; set; } = [];
}
/// <summary>
/// Flexible score-band rows are kept separately so future band definitions do
/// not require widening the teaching-class summary table.
/// </summary>
public sealed class TeachingTaskGradeScoreBand : EntityBase
{
public Guid TeachingTaskGradeStatisticId { get; set; }
public TeachingTaskGradeStatistic? TeachingTaskGradeStatistic { get; set; }
public required string Label { get; set; }
public decimal LowerBound { get; set; }
public decimal? UpperBound { get; set; }
public int StudentCount { get; set; }
public int SortOrder { get; set; }
}
public enum CourseGradeStatisticScope
{
AdministrativeClass = 1,
@@ -185,6 +185,9 @@ public static class AppCacheKeys
public static string CourseGradeStatistics(Guid gradeSheetId) =>
$"grade-statistics:sheet:{gradeSheetId:N}";
public static string TeachingTaskGradeAnalytics(Guid gradeSheetId) =>
$"grade-analytics:sheet:{gradeSheetId:N}:v1";
}
public static class AppCacheTags
@@ -49,6 +49,8 @@ public sealed class CourseGradeStatisticsRefreshJobProcessor(
x.GradeSheet.TeachingTask!.CourseId == target.CourseId &&
x.GradeSheet.TeachingTask.AcademicTermId == target.AcademicTermId)
.Select(x => new ScoreRow(
x.GradeSheetId,
x.GradeSheet!.TeachingTaskId,
x.TotalScore!.Value,
x.Student!.AdministrativeClassId,
x.Student.AdministrativeClass!.MajorId,
@@ -65,6 +67,16 @@ public sealed class CourseGradeStatisticsRefreshJobProcessor(
scores.GroupBy(x => x.CollegeId), rebuilt, target, now);
AddUniversityStatistic(scores, rebuilt, target, now);
var rebuiltTeachingTasks = scores
.GroupBy(x => new { x.GradeSheetId, x.TeachingTaskId })
.Select(group => CreateTeachingTaskStatistic(
group.Key.GradeSheetId,
group.Key.TeachingTaskId,
group.Select(x => x.Score),
target,
now))
.ToList();
await db.CourseGradeStatistics
.Where(x => x.CourseId == target.CourseId &&
x.AcademicTermId == target.AcademicTermId)
@@ -72,6 +84,24 @@ public sealed class CourseGradeStatisticsRefreshJobProcessor(
if (rebuilt.Count > 0)
db.CourseGradeStatistics.AddRange(rebuilt);
var oldTeachingTaskStatisticIds = await db.TeachingTaskGradeStatistics
.Where(x => x.CourseId == target.CourseId &&
x.AcademicTermId == target.AcademicTermId)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (oldTeachingTaskStatisticIds.Count > 0)
{
await db.TeachingTaskGradeScoreBands
.Where(x => oldTeachingTaskStatisticIds.Contains(
x.TeachingTaskGradeStatisticId))
.ExecuteDeleteAsync(cancellationToken);
await db.TeachingTaskGradeStatistics
.Where(x => oldTeachingTaskStatisticIds.Contains(x.Id))
.ExecuteDeleteAsync(cancellationToken);
}
if (rebuiltTeachingTasks.Count > 0)
db.TeachingTaskGradeStatistics.AddRange(rebuiltTeachingTasks);
job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded;
job.CompletedAt = now;
await db.SaveChangesAsync(cancellationToken);
@@ -141,6 +171,80 @@ public sealed class CourseGradeStatisticsRefreshJobProcessor(
};
}
private sealed record ScoreRow(decimal Score, Guid ClassId, Guid MajorId, Guid CollegeId);
private static TeachingTaskGradeStatistic CreateTeachingTaskStatistic(
Guid gradeSheetId,
Guid teachingTaskId,
IEnumerable<decimal> source,
StatisticsTarget targetInfo,
DateTime calculatedAt)
{
var scores = source.OrderBy(x => x).ToArray();
var passed = scores.Count(x => x >= 60m);
var excellent = scores.Count(x => x >= 90m);
var average = scores.Average();
var middle = scores.Length / 2;
var median = scores.Length % 2 == 0
? (scores[middle - 1] + scores[middle]) / 2m
: scores[middle];
var variance = scores.Average(x =>
(double)((x - average) * (x - average)));
var statistic = new TeachingTaskGradeStatistic
{
GradeSheetId = gradeSheetId,
TeachingTaskId = teachingTaskId,
CourseId = targetInfo.CourseId,
AcademicTermId = targetInfo.AcademicTermId,
StudentCount = scores.Length,
PassedCount = passed,
ExcellentCount = excellent,
HighestScore = scores.Max(),
AverageScore = Math.Round(average, 1),
MedianScore = Math.Round(median, 1),
LowestScore = scores.Min(),
StandardDeviation = Math.Round((decimal)Math.Sqrt(variance), 2),
PassRate = Math.Round((decimal)passed / scores.Length * 100m, 2),
ExcellentRate = Math.Round((decimal)excellent / scores.Length * 100m, 2),
CalculatedAt = calculatedAt
};
statistic.ScoreBands =
[
CreateBand(statistic.Id, "059", 0m, 60m,
scores.Count(x => x < 60m), 0),
CreateBand(statistic.Id, "6069", 60m, 70m,
scores.Count(x => x >= 60m && x < 70m), 1),
CreateBand(statistic.Id, "7079", 70m, 80m,
scores.Count(x => x >= 70m && x < 80m), 2),
CreateBand(statistic.Id, "8089", 80m, 90m,
scores.Count(x => x >= 80m && x < 90m), 3),
CreateBand(statistic.Id, "90100", 90m, null,
scores.Count(x => x >= 90m), 4)
];
return statistic;
}
private static TeachingTaskGradeScoreBand CreateBand(
Guid statisticId,
string label,
decimal lowerBound,
decimal? upperBound,
int count,
int sortOrder) => new()
{
TeachingTaskGradeStatisticId = statisticId,
Label = label,
LowerBound = lowerBound,
UpperBound = upperBound,
StudentCount = count,
SortOrder = sortOrder
};
private sealed record ScoreRow(
Guid GradeSheetId,
Guid TeachingTaskId,
decimal Score,
Guid ClassId,
Guid MajorId,
Guid CollegeId);
private sealed record StatisticsTarget(Guid CourseId, Guid AcademicTermId);
}
@@ -68,6 +68,10 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>();
public DbSet<CourseGradeStatistic> CourseGradeStatistics =>
Set<CourseGradeStatistic>();
public DbSet<TeachingTaskGradeStatistic> TeachingTaskGradeStatistics =>
Set<TeachingTaskGradeStatistic>();
public DbSet<TeachingTaskGradeScoreBand> TeachingTaskGradeScoreBands =>
Set<TeachingTaskGradeScoreBand>();
public DbSet<CourseGradeStatisticsRefreshJob> CourseGradeStatisticsRefreshJobs =>
Set<CourseGradeStatisticsRefreshJob>();
public DbSet<OtherExamBatch> OtherExamBatches => Set<OtherExamBatch>();
@@ -868,6 +872,46 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<TeachingTaskGradeStatistic>(entity =>
{
entity.Property(x => x.HighestScore).HasPrecision(5, 1);
entity.Property(x => x.AverageScore).HasPrecision(5, 1);
entity.Property(x => x.MedianScore).HasPrecision(5, 1);
entity.Property(x => x.LowestScore).HasPrecision(5, 1);
entity.Property(x => x.StandardDeviation).HasPrecision(6, 2);
entity.Property(x => x.PassRate).HasPrecision(5, 2);
entity.Property(x => x.ExcellentRate).HasPrecision(5, 2);
entity.HasIndex(x => x.GradeSheetId).IsUnique();
entity.HasIndex(x => x.TeachingTaskId).IsUnique();
entity.HasIndex(x => new { x.CourseId, x.AcademicTermId });
entity.HasOne(x => x.GradeSheet).WithOne()
.HasForeignKey<TeachingTaskGradeStatistic>(x => x.GradeSheetId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.TeachingTask).WithOne()
.HasForeignKey<TeachingTaskGradeStatistic>(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne<Course>().WithMany().HasForeignKey(x => x.CourseId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne<AcademicTerm>().WithMany().HasForeignKey(x => x.AcademicTermId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<TeachingTaskGradeScoreBand>(entity =>
{
entity.Property(x => x.Label).HasMaxLength(30);
entity.Property(x => x.LowerBound).HasPrecision(5, 1);
entity.Property(x => x.UpperBound).HasPrecision(5, 1);
entity.HasIndex(x => new
{
x.TeachingTaskGradeStatisticId,
x.SortOrder
}).IsUnique();
entity.HasOne(x => x.TeachingTaskGradeStatistic)
.WithMany(x => x.ScoreBands)
.HasForeignKey(x => x.TeachingTaskGradeStatisticId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<CourseGradeStatisticsRefreshJob>(entity =>
{
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
@@ -88,6 +88,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260808_46_course_grade_statistics";
private const string CourseGradeDistributionMigration =
"20260809_47_course_grade_distribution";
private const string TeachingTaskGradeAnalyticsMigration =
"20260809_48_teaching_task_grade_analytics";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -647,9 +649,25 @@ public sealed class DevelopmentSqliteMigrator(
CourseGradeStatisticsMigration,
CourseGradeStatisticsStatements,
cancellationToken);
var courseGradeStatisticColumns = (await db.Database
.SqlQueryRaw<string>(
"""
SELECT name AS "Value"
FROM pragma_table_info('CourseGradeStatistics')
""")
.ToListAsync(cancellationToken))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var missingDistributionStatements = CourseGradeDistributionColumns
.Select((column, index) => new { column, index })
.Where(x => !courseGradeStatisticColumns.Contains(x.column))
.Select(x => CourseGradeDistributionStatements[x.index]);
await ApplyMigrationAsync(
CourseGradeDistributionMigration,
CourseGradeDistributionStatements,
missingDistributionStatements,
cancellationToken);
await ApplyMigrationAsync(
TeachingTaskGradeAnalyticsMigration,
TeachingTaskGradeAnalyticsStatements,
cancellationToken);
}
@@ -2241,6 +2259,26 @@ public sealed class DevelopmentSqliteMigrator(
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "From90To100Count" INTEGER NOT NULL DEFAULT 0;"""
];
private static readonly string[] CourseGradeDistributionColumns =
[
"Below60Count",
"From60To69Count",
"From70To79Count",
"From80To89Count",
"From90To100Count"
];
private static readonly string[] TeachingTaskGradeAnalyticsStatements =
[
"""CREATE TABLE IF NOT EXISTS "TeachingTaskGradeStatistics" ("Id" TEXT NOT NULL CONSTRAINT "PK_TeachingTaskGradeStatistics" PRIMARY KEY, "GradeSheetId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "CourseId" TEXT NOT NULL, "AcademicTermId" TEXT NOT NULL, "StudentCount" INTEGER NOT NULL, "PassedCount" INTEGER NOT NULL, "ExcellentCount" INTEGER NOT NULL, "HighestScore" TEXT NOT NULL, "AverageScore" TEXT NOT NULL, "MedianScore" TEXT NOT NULL, "LowestScore" TEXT NOT NULL, "StandardDeviation" TEXT NOT NULL, "PassRate" TEXT NOT NULL, "ExcellentRate" TEXT NOT NULL, "CalculatedAt" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_TeachingTaskGradeStatistics_GradeSheets" FOREIGN KEY ("GradeSheetId") REFERENCES "GradeSheets" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_TeachingTaskGradeStatistics_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_TeachingTaskGradeStatistics_Courses" FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_TeachingTaskGradeStatistics_AcademicTerms" FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT);""",
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_TeachingTaskGradeStatistics_GradeSheetId" ON "TeachingTaskGradeStatistics" ("GradeSheetId");""",
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_TeachingTaskGradeStatistics_TeachingTaskId" ON "TeachingTaskGradeStatistics" ("TeachingTaskId");""",
"""CREATE INDEX IF NOT EXISTS "IX_TeachingTaskGradeStatistics_CourseId_AcademicTermId" ON "TeachingTaskGradeStatistics" ("CourseId", "AcademicTermId");""",
"""CREATE INDEX IF NOT EXISTS "IX_TeachingTaskGradeStatistics_AcademicTermId" ON "TeachingTaskGradeStatistics" ("AcademicTermId");""",
"""CREATE TABLE IF NOT EXISTS "TeachingTaskGradeScoreBands" ("Id" TEXT NOT NULL CONSTRAINT "PK_TeachingTaskGradeScoreBands" PRIMARY KEY, "TeachingTaskGradeStatisticId" TEXT NOT NULL, "Label" TEXT NOT NULL, "LowerBound" TEXT NOT NULL, "UpperBound" TEXT NULL, "StudentCount" INTEGER NOT NULL, "SortOrder" INTEGER NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_TeachingTaskGradeScoreBands_Statistics" FOREIGN KEY ("TeachingTaskGradeStatisticId") REFERENCES "TeachingTaskGradeStatistics" ("Id") ON DELETE CASCADE);""",
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_TeachingTaskGradeScoreBands_StatisticId_SortOrder" ON "TeachingTaskGradeScoreBands" ("TeachingTaskGradeStatisticId", "SortOrder");"""
];
private static readonly string[] ApprovalTableStatements =
[
"""CREATE TABLE "CourseExemptions" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseExemptions" PRIMARY KEY, "StudentId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "SubmittedAt" TEXT NOT NULL, "ReviewedAt" TEXT NULL, "ReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseExemptions_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseExemptions_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT);""",
@@ -0,0 +1,132 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class TeachingTaskGradeAnalytics : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TeachingTaskGradeStatistics",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
GradeSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
CourseId = table.Column<Guid>(type: "char(36)", nullable: false),
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentCount = table.Column<int>(type: "int", nullable: false),
PassedCount = table.Column<int>(type: "int", nullable: false),
ExcellentCount = table.Column<int>(type: "int", nullable: false),
HighestScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
AverageScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
MedianScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
LowestScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
StandardDeviation = table.Column<decimal>(type: "decimal(6,2)", precision: 6, scale: 2, nullable: false),
PassRate = table.Column<decimal>(type: "decimal(5,2)", precision: 5, scale: 2, nullable: false),
ExcellentRate = table.Column<decimal>(type: "decimal(5,2)", precision: 5, scale: 2, nullable: false),
CalculatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TeachingTaskGradeStatistics", x => x.Id);
table.ForeignKey(
name: "FK_TeachingTaskGradeStatistics_AcademicTerms_AcademicTermId",
column: x => x.AcademicTermId,
principalTable: "AcademicTerms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TeachingTaskGradeStatistics_Courses_CourseId",
column: x => x.CourseId,
principalTable: "Courses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TeachingTaskGradeStatistics_GradeSheets_GradeSheetId",
column: x => x.GradeSheetId,
principalTable: "GradeSheets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_TeachingTaskGradeStatistics_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "TeachingTaskGradeScoreBands",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskGradeStatisticId = table.Column<Guid>(type: "char(36)", nullable: false),
Label = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false),
LowerBound = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
UpperBound = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
StudentCount = table.Column<int>(type: "int", nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TeachingTaskGradeScoreBands", x => x.Id);
table.ForeignKey(
name: "FK_TeachingTaskGradeScoreBands_TeachingTaskGradeStatistics_Teac~",
column: x => x.TeachingTaskGradeStatisticId,
principalTable: "TeachingTaskGradeStatistics",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskGradeScoreBands_TeachingTaskGradeStatisticId_Sor~",
table: "TeachingTaskGradeScoreBands",
columns: new[] { "TeachingTaskGradeStatisticId", "SortOrder" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskGradeStatistics_AcademicTermId",
table: "TeachingTaskGradeStatistics",
column: "AcademicTermId");
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskGradeStatistics_CourseId_AcademicTermId",
table: "TeachingTaskGradeStatistics",
columns: new[] { "CourseId", "AcademicTermId" });
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskGradeStatistics_GradeSheetId",
table: "TeachingTaskGradeStatistics",
column: "GradeSheetId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskGradeStatistics_TeachingTaskId",
table: "TeachingTaskGradeStatistics",
column: "TeachingTaskId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TeachingTaskGradeScoreBands");
migrationBuilder.DropTable(
name: "TeachingTaskGradeStatistics");
}
}
}
@@ -996,6 +996,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<int>("Below60Count")
.HasColumnType("int");
b.Property<DateTime>("CalculatedAt")
.HasColumnType("datetime(6)");
@@ -1005,6 +1008,18 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("From60To69Count")
.HasColumnType("int");
b.Property<int>("From70To79Count")
.HasColumnType("int");
b.Property<int>("From80To89Count")
.HasColumnType("int");
b.Property<int>("From90To100Count")
.HasColumnType("int");
b.Property<decimal>("HighestScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
@@ -1020,21 +1035,6 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<int>("PassedCount")
.HasColumnType("int");
b.Property<int>("Below60Count")
.HasColumnType("int");
b.Property<int>("From60To69Count")
.HasColumnType("int");
b.Property<int>("From70To79Count")
.HasColumnType("int");
b.Property<int>("From80To89Count")
.HasColumnType("int");
b.Property<int>("From90To100Count")
.HasColumnType("int");
b.Property<int>("Scope")
.HasColumnType("int");
@@ -4109,6 +4109,127 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("TeachingTaskClasses");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskGradeScoreBand", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<decimal>("LowerBound")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<int>("StudentCount")
.HasColumnType("int");
b.Property<Guid>("TeachingTaskGradeStatisticId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal?>("UpperBound")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.HasKey("Id");
b.HasIndex("TeachingTaskGradeStatisticId", "SortOrder")
.IsUnique();
b.ToTable("TeachingTaskGradeScoreBands");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskGradeStatistic", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("AcademicTermId")
.HasColumnType("char(36)");
b.Property<decimal>("AverageScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<DateTime>("CalculatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("CourseId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("ExcellentCount")
.HasColumnType("int");
b.Property<decimal>("ExcellentRate")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.Property<Guid>("GradeSheetId")
.HasColumnType("char(36)");
b.Property<decimal>("HighestScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<decimal>("LowestScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<decimal>("MedianScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<decimal>("PassRate")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.Property<int>("PassedCount")
.HasColumnType("int");
b.Property<decimal>("StandardDeviation")
.HasPrecision(6, 2)
.HasColumnType("decimal(6,2)");
b.Property<int>("StudentCount")
.HasColumnType("int");
b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("AcademicTermId");
b.HasIndex("GradeSheetId")
.IsUnique();
b.HasIndex("TeachingTaskId")
.IsUnique();
b.HasIndex("CourseId", "AcademicTermId");
b.ToTable("TeachingTaskGradeStatistics");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b =>
{
b.Property<Guid>("Id")
@@ -6050,6 +6171,48 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskGradeScoreBand", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTaskGradeStatistic", "TeachingTaskGradeStatistic")
.WithMany("ScoreBands")
.HasForeignKey("TeachingTaskGradeStatisticId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("TeachingTaskGradeStatistic");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskGradeStatistic", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", null)
.WithMany()
.HasForeignKey("AcademicTermId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Course", null)
.WithMany()
.HasForeignKey("CourseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet")
.WithOne()
.HasForeignKey("Jiaowu.Api.Domain.Academic.TeachingTaskGradeStatistic", "GradeSheetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
.WithOne()
.HasForeignKey("Jiaowu.Api.Domain.Academic.TeachingTaskGradeStatistic", "TeachingTaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("GradeSheet");
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding")
@@ -6368,6 +6531,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Teachers");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskGradeStatistic", b =>
{
b.Navigation("ScoreBands");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b =>
{
b.Navigation("AllowedClassrooms");
+4
View File
@@ -177,6 +177,10 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student', 'Counselor']),
{ path: '/grades', label: isStudent.value ? '学业成绩' : isTeacher.value ? '成绩录入' : '成绩管理' },
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher']),
{ path: '/grade-analytics', label: isTeacher.value ? '教学班成绩分析' : '成绩分析中心' },
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'Student']),
{ path: '/other-exams', label: '其他考试成绩' },
+8
View File
@@ -255,6 +255,14 @@ const router = createRouter({
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Counselor', 'Student'],
},
},
{
path: 'grade-analytics',
name: 'grade-analytics',
component: () => import('../views/GradeAnalyticsView.vue'),
meta: {
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher'],
},
},
{
path: 'other-exams',
name: 'other-exams',
+462
View File
@@ -0,0 +1,462 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Refresh, Search } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import http, { apiErrorMessage } from '../api/http'
import { academicTermLabel, defaultAcademicTermId } from '../utils/academicTerms'
interface TeachingClassItem {
gradeSheetId: string
teachingTaskId: string
taskNumber: string
taskName: string
courseCode: string
courseName: string
termName: string
academicTermId: string
teacherNames: string[]
classNames: string[]
studentCount: number
averageScore: number
passRate: number
excellentRate: number
calculatedAt: string
}
const terms = ref<any[]>([])
const classes = ref<TeachingClassItem[]>([])
const selected = ref<TeachingClassItem>()
const report = ref<any>()
const termId = ref<string>()
const keyword = ref('')
const page = ref(1)
const total = ref(0)
const pageSize = 12
const loading = ref(false)
const reportLoading = ref(false)
const historyMetric = ref<'average' | 'passRate' | 'excellentRate'>('average')
const distributionElement = ref<HTMLElement>()
const peerAverageElement = ref<HTMLElement>()
const peerPassElement = ref<HTMLElement>()
const benchmarkElement = ref<HTMLElement>()
const historyElement = ref<HTMLElement>()
const charts: echarts.ECharts[] = []
const summary = computed(() => report.value?.summary)
const delta = computed(() => report.value?.universityDelta)
const historyMetricMeta = computed(() => ({
average: { label: '平均分', course: '同课程全校平均', instructor: '当前任课教师历年', suffix: ' 分' },
passRate: { label: '合格率', course: '同课程全校合格率', instructor: '当前任课教师历年', suffix: '%' },
excellentRate: { label: '优秀率', course: '同课程全校优秀率', instructor: '当前任课教师历年', suffix: '%' },
}[historyMetric.value]))
function number(value: unknown, digits = 1) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed.toFixed(digits) : '—'
}
function signed(value: unknown, suffix = '') {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return '—'
return `${parsed > 0 ? '+' : ''}${parsed.toFixed(1)}${suffix}`
}
function disposeCharts() {
charts.splice(0).forEach(chart => chart.dispose())
}
function createChart(element: HTMLElement | undefined) {
if (!element) return undefined
const chart = echarts.init(element)
charts.push(chart)
return chart
}
async function loadClasses(reset = false) {
if (reset) page.value = 1
loading.value = true
try {
const data = (await http.get('/grade-analytics/teaching-classes', {
params: {
academicTermId: termId.value,
keyword: keyword.value.trim() || undefined,
page: page.value,
pageSize,
},
})).data
classes.value = data.items
total.value = data.total
const retained = classes.value.find(item => item.gradeSheetId === selected.value?.gradeSheetId)
selected.value = retained ?? classes.value[0]
if (selected.value) await loadReport(selected.value)
else {
report.value = undefined
disposeCharts()
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function loadReport(item = selected.value) {
if (!item) return
selected.value = item
reportLoading.value = true
try {
report.value = (await http.get(`/grade-analytics/teaching-classes/${item.gradeSheetId}`)).data
await nextTick()
drawCharts()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
reportLoading.value = false
}
}
async function refreshStatistics() {
if (!selected.value) {
await loadClasses()
return
}
reportLoading.value = true
try {
await http.post(`/grade-analytics/teaching-classes/${selected.value.gradeSheetId}/refresh`)
ElMessage.success('已提交统计刷新任务。')
window.setTimeout(() => loadClasses(), 900)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
reportLoading.value = false
}
}
function baseGrid(left = 48, right = 24) {
return { left, right, top: 42, bottom: 44, containLabel: true }
}
function drawCharts() {
disposeCharts()
if (!summary.value) return
const distribution = createChart(distributionElement.value)
distribution?.setOption({
aria: { enabled: true, decal: { show: true } },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: baseGrid(),
xAxis: { type: 'category', data: summary.value.scoreBands.map((x: any) => x.label), axisTick: { show: false } },
yAxis: { type: 'value', minInterval: 1, name: '人数', splitLine: { lineStyle: { color: '#edf1f6' } } },
series: [{
name: '人数', type: 'bar', barMaxWidth: 54,
data: summary.value.scoreBands.map((x: any, index: number) => ({
value: x.studentCount,
itemStyle: { color: ['#b9c6d8', '#95a9c5', '#6f8db5', '#496f9f', '#204f87'][index] },
})),
label: { show: true, position: 'top', formatter: (p: any) => `${p.value}` },
itemStyle: { borderRadius: [4, 4, 0, 0] },
}],
})
const peers = report.value.peerTeachingClasses ?? []
const peerNames = peers.map((x: any) => `${x.taskNumber} · ${x.teacherNames || '未标注教师'}`)
const peerAverage = createChart(peerAverageElement.value)
peerAverage?.setOption({
aria: { enabled: true, decal: { show: true } },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, valueFormatter: (v: any) => `${number(v)}` },
grid: baseGrid(20, 36),
xAxis: { type: 'value', min: 0, max: 100, name: '平均分', splitLine: { lineStyle: { color: '#edf1f6' } } },
yAxis: { type: 'category', inverse: true, data: peerNames, axisTick: { show: false }, axisLabel: { width: 150, overflow: 'truncate' } },
series: [{
type: 'bar', barMaxWidth: 24,
data: peers.map((x: any) => ({ value: x.averageScore, itemStyle: { color: x.isSelected ? '#204f87' : '#aebdd1' } })),
label: { show: true, position: 'right', formatter: (p: any) => number(p.value) },
itemStyle: { borderRadius: [0, 4, 4, 0] },
}],
})
const peerPass = createChart(peerPassElement.value)
peerPass?.setOption({
aria: { enabled: true, decal: { show: true } },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, valueFormatter: (v: any) => `${number(v)}%` },
grid: baseGrid(20, 42),
xAxis: { type: 'value', min: 0, max: 100, name: '合格率', axisLabel: { formatter: '{value}%' }, splitLine: { lineStyle: { color: '#edf1f6' } } },
yAxis: { type: 'category', inverse: true, data: peerNames, axisTick: { show: false }, axisLabel: { width: 150, overflow: 'truncate' } },
series: [{
type: 'bar', barMaxWidth: 18,
data: peers.map((x: any) => ({ value: x.passRate, itemStyle: { color: x.isSelected ? '#b8732d' : '#dcc3a5' } })),
label: { show: true, position: 'right', formatter: (p: any) => `${number(p.value)}%` },
itemStyle: { borderRadius: [0, 4, 4, 0] },
}],
})
const benchmarks = report.value.scopeBenchmarks ?? []
const benchmark = createChart(benchmarkElement.value)
benchmark?.setOption({
aria: { enabled: true, decal: { show: true } },
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const item = benchmarks[params[0]?.dataIndex ?? 0]
return `<b>${item.scope} · ${item.name}</b><br/>样本 ${item.studentCount} 人<br/>最高分 ${number(item.highestScore)}<br/>平均分 ${number(item.averageScore)}<br/>最低分 ${number(item.lowestScore)}<br/>合格率 ${number(item.passRate)}%`
},
},
legend: { top: 0, data: ['分数区间', '平均分', '合格率'] },
grid: { left: 30, right: 54, top: 58, bottom: 52, containLabel: true },
xAxis: { type: 'category', data: benchmarks.map((x: any) => `${x.scope}\n${x.name}`), axisTick: { show: false } },
yAxis: [
{ type: 'value', min: 0, max: 100, name: '分数', splitLine: { lineStyle: { color: '#edf1f6' } } },
{ type: 'value', min: 0, max: 100, name: '合格率', axisLabel: { formatter: '{value}%' }, splitLine: { show: false } },
],
series: [
{ name: '区间起点', type: 'bar', stack: 'range', silent: true, itemStyle: { color: 'transparent' }, data: benchmarks.map((x: any) => x.lowestScore) },
{ name: '分数区间', type: 'bar', stack: 'range', barWidth: 20, itemStyle: { color: '#a7b8cf', borderRadius: 6 }, data: benchmarks.map((x: any) => x.highestScore - x.lowestScore) },
{ name: '平均分', type: 'line', symbolSize: 9, lineStyle: { color: '#204f87', width: 3 }, itemStyle: { color: '#204f87' }, data: benchmarks.map((x: any) => x.averageScore) },
{ name: '合格率', type: 'line', yAxisIndex: 1, symbol: 'diamond', symbolSize: 9, lineStyle: { color: '#b8732d', type: 'dashed', width: 2 }, itemStyle: { color: '#b8732d' }, data: benchmarks.map((x: any) => x.passRate) },
],
})
drawHistoryChart()
}
function drawHistoryChart() {
const old = charts.find(chart => chart.getDom() === historyElement.value)
if (old) {
old.dispose()
charts.splice(charts.indexOf(old), 1)
}
const history = report.value?.history ?? []
const chart = createChart(historyElement.value)
if (!chart) return
const field = historyMetric.value === 'average' ? 'AverageScore' : historyMetric.value === 'passRate' ? 'PassRate' : 'ExcellentRate'
const courseField = `course${field}`
const instructorField = field[0].toLowerCase() + field.slice(1)
const seriesType = history.length >= 4 ? 'line' : 'bar'
chart.setOption({
aria: { enabled: true, decal: { show: true } },
tooltip: { trigger: 'axis', valueFormatter: (v: any) => `${number(v)}${historyMetricMeta.value.suffix}` },
legend: { top: 0, data: [historyMetricMeta.value.course, historyMetricMeta.value.instructor] },
grid: { left: 46, right: 28, top: 58, bottom: 54, containLabel: true },
xAxis: { type: 'category', data: history.map((x: any) => x.termName), axisLabel: { rotate: history.length > 5 ? 24 : 0 } },
yAxis: { type: 'value', min: 0, max: 100, name: historyMetricMeta.value.label, axisLabel: { formatter: historyMetric.value === 'average' ? '{value}' : '{value}%' }, splitLine: { lineStyle: { color: '#edf1f6' } } },
series: [
{
name: historyMetricMeta.value.course, type: seriesType, smooth: seriesType === 'line', symbol: 'circle', symbolSize: 8,
barMaxWidth: 34, itemStyle: { color: '#9eafc5' }, lineStyle: { color: '#9eafc5', width: 2 },
data: history.map((x: any) => x[courseField]),
},
{
name: historyMetricMeta.value.instructor, type: seriesType, smooth: seriesType === 'line', symbol: 'diamond', symbolSize: 9,
barMaxWidth: 34, itemStyle: { color: '#204f87' }, lineStyle: { color: '#204f87', width: 3 },
data: history.map((x: any) => x.instructor?.[instructorField] ?? null),
},
],
})
}
function resizeCharts() {
charts.forEach(chart => chart.resize())
}
watch(historyMetric, async () => {
await nextTick()
drawHistoryChart()
})
onMounted(async () => {
try {
terms.value = (await http.get('/base-data/terms')).data
termId.value = defaultAcademicTermId(terms.value)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
await loadClasses(true)
window.addEventListener('resize', resizeCharts)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeCharts)
disposeCharts()
})
</script>
<template>
<div class="page-stack grade-analytics">
<section class="page-intro analytics-intro">
<div>
<span class="section-kicker">TEACHING CLASS ANALYTICS</span>
<h2>教学班成绩分析</h2>
<p>对比当前教学班同课程教学班学生来源范围与历年成绩统计仅使用已正式发布成绩</p>
</div>
<el-button :icon="Refresh" @click="refreshStatistics">重新计算当前教学班</el-button>
</section>
<section class="filter-bar">
<el-select v-model="termId" clearable placeholder="全部学期" style="width: 240px" @change="loadClasses(true)">
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" />
</el-select>
<el-input v-model="keyword" clearable placeholder="课程、教学班名称或编号" :prefix-icon="Search" @keyup.enter="loadClasses(true)" />
<el-button type="primary" @click="loadClasses(true)">查询</el-button>
</section>
<section class="analytics-shell">
<aside class="class-rail" v-loading="loading">
<header><strong>可查看教学班</strong><small>{{ total }} 个已生成统计</small></header>
<button
v-for="item in classes"
:key="item.gradeSheetId"
type="button"
:class="['class-option', { active: selected?.gradeSheetId === item.gradeSheetId }]"
@click="loadReport(item)"
>
<span class="course-code">{{ item.courseCode }} · {{ item.taskNumber }}</span>
<strong>{{ item.courseName }}</strong>
<small>{{ item.teacherNames.join('、') || '未标注教师' }} · {{ item.studentCount }} </small>
<span class="option-metrics"><b>{{ number(item.averageScore) }}</b> 平均分 · <b>{{ number(item.passRate) }}%</b> 合格</span>
</button>
<el-empty v-if="!loading && !classes.length" :image-size="72" description="当前范围暂无已发布成绩统计" />
<el-pagination
v-if="total > pageSize"
small layout="prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page"
@current-change="loadClasses()"
/>
</aside>
<main class="analysis-canvas" v-loading="reportLoading">
<el-empty v-if="!selected" description="请选择一个教学班查看分析" />
<el-alert v-else-if="report?.isRefreshing" type="info" :closable="false" title="该教学班统计正在生成,请稍后刷新。" />
<template v-else-if="summary">
<header class="report-heading">
<div>
<span>{{ report.courseCode }} · {{ report.taskNumber }} · {{ report.termName }}</span>
<h3>{{ report.courseName }}</h3>
<p>{{ report.taskName }} · 数据更新时间 {{ new Date(summary.calculatedAt).toLocaleString() }}</p>
</div>
<div class="sample-stamp"><b>{{ summary.studentCount }}</b><span>有效成绩</span></div>
</header>
<section class="kpi-strip">
<article><span>平均分</span><strong>{{ number(summary.averageScore) }}</strong><small>全体有效成绩</small></article>
<article><span>中位数</span><strong>{{ number(summary.medianScore) }}</strong><small>降低极端值影响</small></article>
<article><span>标准差</span><strong>{{ number(summary.standardDeviation, 2) }}</strong><small>越大表示分数越分散</small></article>
<article><span>最高 / 最低</span><strong>{{ number(summary.highestScore) }} / {{ number(summary.lowestScore) }}</strong><small>成绩跨度 {{ number(summary.highestScore - summary.lowestScore) }}</small></article>
<article><span>合格率</span><strong>{{ number(summary.passRate) }}%</strong><small>{{ summary.passedCount }} / {{ summary.studentCount }} </small></article>
<article><span>优秀率</span><strong>{{ number(summary.excellentRate) }}%</strong><small>{{ summary.excellentCount }} 人达到 90 </small></article>
</section>
<section v-if="delta" class="benchmark-note">
<span>相对本学期全校同课程</span>
<strong :class="{ negative: delta.averageScoreDifference < 0 }">平均分 {{ signed(delta.averageScoreDifference, ' 分') }}</strong>
<strong :class="{ negative: delta.passRateDifference < 0 }">合格率 {{ signed(delta.passRateDifference, '%') }}</strong>
<small>全校基准平均 {{ number(delta.universityAverageScore) }}合格率 {{ number(delta.universityPassRate) }}%</small>
</section>
<section class="chart-grid">
<article class="chart-card">
<header><div><span>DISTRIBUTION</span><h4>本教学班分数段分布</h4></div><p>{{ summary.studentCount }} 按五个固定区间统计</p></header>
<div ref="distributionElement" class="chart" />
</article>
<article class="chart-card">
<header><div><span>PEER AVERAGE</span><h4>同课程教学班平均分</h4></div><p>当前教学班使用深色标识</p></header>
<div ref="peerAverageElement" class="chart" />
</article>
<article class="chart-card">
<header><div><span>PEER PASS RATE</span><h4>同课程教学班合格率</h4></div><p>同一课程同一学期横向比较</p></header>
<div ref="peerPassElement" class="chart" />
</article>
<article class="chart-card chart-card-wide">
<header><div><span>SCOPE BENCHMARK</span><h4>行政班专业学院与全校基准</h4></div><p>对照本教学班学生来源范围显示分数区间平均分和合格率</p></header>
<div ref="benchmarkElement" class="chart chart-large" />
</article>
<article class="chart-card chart-card-wide">
<header class="history-heading">
<div><span>HISTORICAL COMPARISON</span><h4>当前任课教师与全校同课程历年对比</h4></div>
<el-radio-group v-model="historyMetric" size="small">
<el-radio-button value="average">平均分</el-radio-button>
<el-radio-button value="passRate">合格率</el-radio-button>
<el-radio-button value="excellentRate">优秀率</el-radio-button>
</el-radio-group>
</header>
<p class="chart-caption">教师序列按当前教学班任课教师在各学期所带同课程教学班加权汇总全校序列为该课程当期全部已发布成绩</p>
<div ref="historyElement" class="chart chart-large" />
</article>
</section>
</template>
</main>
</section>
</div>
</template>
<style scoped>
.grade-analytics { --ink: #18324f; --blue: #204f87; --line: #dce3ec; }
.analytics-intro { align-items: flex-end; }
.filter-bar { display: grid; grid-template-columns: 240px minmax(260px, 560px) auto; gap: 12px; align-items: center; padding: 16px 18px; background: #f7f9fc; border: 1px solid var(--line); }
.analytics-shell { display: grid; grid-template-columns: 292px minmax(0, 1fr); gap: 18px; align-items: start; }
.class-rail { position: sticky; top: 18px; max-height: calc(100vh - 36px); overflow: auto; background: #f6f8fb; border: 1px solid var(--line); }
.class-rail > header { position: sticky; top: 0; z-index: 2; display: flex; justify-content: space-between; gap: 8px; padding: 15px 16px; background: #eef2f7; border-bottom: 1px solid var(--line); }
.class-rail > header small { color: #667085; }
.class-option { width: 100%; padding: 15px 16px; border: 0; border-bottom: 1px solid #e1e6ed; background: transparent; color: #475467; text-align: left; cursor: pointer; transition: background .18s ease, box-shadow .18s ease; }
.class-option:hover { background: #fff; }
.class-option.active { background: #fff; box-shadow: inset 4px 0 var(--blue); }
.class-option:focus-visible { outline: 2px solid var(--blue); outline-offset: -3px; }
.class-option > * { display: block; }
.class-option strong { margin: 5px 0; color: var(--ink); font-size: 14px; }
.class-option small { overflow: hidden; color: #667085; text-overflow: ellipsis; white-space: nowrap; }
.course-code { color: #7b8797; font: 11px Consolas, monospace; }
.option-metrics { margin-top: 9px; color: #667085; font-size: 11px; }
.option-metrics b { color: var(--blue); font: 700 13px Consolas, monospace; }
.class-rail :deep(.el-pagination) { justify-content: center; padding: 14px 4px; }
.analysis-canvas { min-width: 0; }
.report-heading { display: flex; justify-content: space-between; gap: 20px; padding: 22px 24px; color: #fff; background: var(--ink); }
.report-heading span { color: #b9c9da; font: 11px Consolas, monospace; }
.report-heading h3 { margin: 7px 0 5px; font-size: 24px; }
.report-heading p { margin: 0; color: #cbd5e1; font-size: 12px; }
.sample-stamp { display: flex; flex-direction: column; min-width: 94px; justify-content: center; padding-left: 22px; border-left: 1px solid rgb(255 255 255 / 20%); }
.sample-stamp b { font: 700 34px/1 Consolas, monospace; }
.sample-stamp span { margin-top: 5px; color: #b9c9da; font-size: 11px; }
.kpi-strip { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); background: #fff; border: 1px solid var(--line); border-top: 0; }
.kpi-strip article { min-height: 112px; padding: 17px 20px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.kpi-strip article:nth-child(3n) { border-right: 0; }
.kpi-strip span, .kpi-strip small { display: block; color: #667085; }
.kpi-strip span { font-size: 11px; font-weight: 700; }
.kpi-strip strong { display: block; margin: 10px 0 7px; overflow-wrap: anywhere; color: var(--ink); font: 700 24px/1 Consolas, monospace; }
.kpi-strip small { font-size: 10px; }
.benchmark-note { display: flex; align-items: center; flex-wrap: wrap; gap: 12px 20px; padding: 14px 18px; background: #edf3fa; border: 1px solid #ccd9e9; border-top: 0; }
.benchmark-note > span { color: var(--ink); font-weight: 700; }
.benchmark-note strong { color: var(--blue); font: 700 13px Consolas, monospace; }
.benchmark-note strong.negative { color: #9a5b28; }
.benchmark-note small { margin-left: auto; color: #667085; }
.chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; margin-top: 18px; }
.chart-card { min-width: 0; padding: 20px 22px; background: #fff; border: 1px solid var(--line); box-shadow: 0 10px 28px rgb(24 50 79 / 5%); }
.chart-card-wide { grid-column: 1 / -1; }
.chart-card header { display: flex; min-width: 0; align-items: end; justify-content: space-between; gap: 18px; }
.chart-card header span { color: #7b8797; font: 10px Consolas, monospace; letter-spacing: .08em; }
.chart-card h4 { margin: 5px 0 0; color: var(--ink); font-size: 16px; }
.chart-card header p, .chart-caption { min-width: 0; margin: 0; color: #667085; font-size: 11px; text-align: right; }
.chart-caption { margin-top: 9px; text-align: left; }
.chart { height: 310px; margin-top: 10px; }
.chart-large { height: 360px; }
@media (max-width: 1180px) {
.analytics-shell { grid-template-columns: 240px minmax(0, 1fr); }
.chart-grid { grid-template-columns: 1fr; }
.chart-card-wide { grid-column: auto; }
}
@media (max-width: 820px) {
.filter-bar { grid-template-columns: 1fr; }
.filter-bar :deep(.el-select) { width: 100% !important; }
.analytics-shell { grid-template-columns: 1fr; }
.class-rail { position: static; max-height: 390px; }
.kpi-strip { grid-template-columns: repeat(2, 1fr); }
.kpi-strip article:nth-child(3n) { border-right: 1px solid var(--line); }
.kpi-strip article:nth-child(2n) { border-right: 0; }
.history-heading, .chart-card header { align-items: flex-start; flex-direction: column; }
.chart-card header p { text-align: left; }
}
@media (max-width: 520px) {
.report-heading { flex-direction: column; }
.sample-stamp { padding: 14px 0 0; border: 0; border-top: 1px solid rgb(255 255 255 / 20%); }
.kpi-strip { grid-template-columns: 1fr; }
.kpi-strip article { border-right: 0 !important; }
.chart-card { padding: 17px 14px; }
}
</style>