成绩统计图
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
using Jiaowu.Api.Infrastructure.Grades;
|
using Jiaowu.Api.Infrastructure.Grades;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
@@ -250,6 +251,13 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
// Auto-apply: update grade record
|
// Auto-apply: update grade record
|
||||||
gm.GradeRecord!.TotalScore = gm.RequestedScore;
|
gm.GradeRecord!.TotalScore = gm.RequestedScore;
|
||||||
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
|
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
|
||||||
|
var statisticsJob = new CourseGradeStatisticsRefreshJob
|
||||||
|
{
|
||||||
|
GradeSheetId = gm.GradeRecord.GradeSheetId
|
||||||
|
};
|
||||||
|
db.CourseGradeStatisticsRefreshJobs.Add(statisticsJob);
|
||||||
|
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
||||||
|
BackgroundJobKind.CourseGradeStatisticsRefresh, statisticsJob.Id));
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
await NotificationService.SendAsync(
|
await NotificationService.SendAsync(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ using System.Globalization;
|
|||||||
using Jiaowu.Api.Contracts;
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Excel;
|
using Jiaowu.Api.Infrastructure.Excel;
|
||||||
using Jiaowu.Api.Infrastructure.Grades;
|
using Jiaowu.Api.Infrastructure.Grades;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
@@ -19,7 +21,8 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Route("api/grades")]
|
[Route("api/grades")]
|
||||||
public sealed class GradesController(
|
public sealed class GradesController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
ICurrentUserDataScope currentUserDataScope,
|
||||||
|
IAppCache? cache = null) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string SheetUsers =
|
private const string SheetUsers =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -37,6 +40,9 @@ public sealed class GradesController(
|
|||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
SystemRoles.AcademicAdmin;
|
SystemRoles.AcademicAdmin;
|
||||||
|
|
||||||
|
private const string StatisticsUsers =
|
||||||
|
SheetUsers + "," + SystemRoles.Leader + "," + SystemRoles.Student;
|
||||||
|
|
||||||
[HttpGet("sheets")]
|
[HttpGet("sheets")]
|
||||||
[Authorize(Roles = SheetUsers)]
|
[Authorize(Roles = SheetUsers)]
|
||||||
public async Task<ActionResult> GetSheets(
|
public async Task<ActionResult> GetSheets(
|
||||||
@@ -502,6 +508,7 @@ public sealed class GradesController(
|
|||||||
}
|
}
|
||||||
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
|
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
|
||||||
targetItem.SourceSnapshotAt = DateTime.UtcNow;
|
targetItem.SourceSnapshotAt = DateTime.UtcNow;
|
||||||
|
QueueCourseStatisticsRefresh(sheet.Id);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -661,6 +668,7 @@ public sealed class GradesController(
|
|||||||
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
||||||
sheet.Status = GradeSheetStatus.Published;
|
sheet.Status = GradeSheetStatus.Published;
|
||||||
sheet.PublishedAt = DateTime.UtcNow;
|
sheet.PublishedAt = DateTime.UtcNow;
|
||||||
|
QueueCourseStatisticsRefresh(sheet.Id);
|
||||||
|
|
||||||
// The grade sheet roster is authoritative at publication time. This also
|
// The grade sheet roster is authoritative at publication time. This also
|
||||||
// covers students added through approved roster corrections.
|
// covers students added through approved roster corrections.
|
||||||
@@ -910,6 +918,7 @@ public sealed class GradesController(
|
|||||||
if (errors.Count > 0)
|
if (errors.Count > 0)
|
||||||
return ImportValidationProblem(errors);
|
return ImportValidationProblem(errors);
|
||||||
|
|
||||||
|
QueueCourseStatisticsRefresh(sheet.Id);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return Ok(new { updated, total = rows.Count });
|
return Ok(new { updated, total = rows.Count });
|
||||||
}
|
}
|
||||||
@@ -948,6 +957,7 @@ public sealed class GradesController(
|
|||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
|
GradeSheetId = x.GradeSheetId,
|
||||||
AcademicTermId = x.GradeSheet!.TeachingTask!.AcademicTermId,
|
AcademicTermId = x.GradeSheet!.TeachingTask!.AcademicTermId,
|
||||||
TermName = x.GradeSheet.TeachingTask.AcademicTerm!.Name,
|
TermName = x.GradeSheet.TeachingTask.AcademicTerm!.Name,
|
||||||
x.GradeSheet.TeachingTaskId,
|
x.GradeSheet.TeachingTaskId,
|
||||||
@@ -964,6 +974,121 @@ public sealed class GradesController(
|
|||||||
return Ok(new { Student = student, Records = records });
|
return Ok(new { Student = student, Records = records });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("sheets/{id:guid}/statistics")]
|
||||||
|
[Authorize(Roles = StatisticsUsers)]
|
||||||
|
public async Task<ActionResult> GetCourseStatistics(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
var sheet = await db.GradeSheets.AsNoTracking()
|
||||||
|
.Where(x => x.Id == id)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.Status,
|
||||||
|
x.TeachingTask!.CourseId,
|
||||||
|
x.TeachingTask.AcademicTermId,
|
||||||
|
CourseName = x.TeachingTask.Course!.Name,
|
||||||
|
CourseCode = x.TeachingTask.Course.Code,
|
||||||
|
TermName = x.TeachingTask.AcademicTerm!.Name
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (sheet is null) return NotFound();
|
||||||
|
|
||||||
|
Guid? classId = null;
|
||||||
|
Guid? majorId = null;
|
||||||
|
Guid? collegeId = null;
|
||||||
|
if (scope.IsInRole(SystemRoles.Student))
|
||||||
|
{
|
||||||
|
if (sheet.Status != GradeSheetStatus.Published)
|
||||||
|
return NotFound();
|
||||||
|
var student = await db.GradeRecords.AsNoTracking()
|
||||||
|
.Where(x => x.GradeSheetId == id && x.Student!.UserId == scope.UserId)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Student!.AdministrativeClassId,
|
||||||
|
MajorId = x.Student.AdministrativeClass!.MajorId,
|
||||||
|
CollegeId = x.Student.AdministrativeClass.Major!.CollegeId
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (student is null) return Forbid();
|
||||||
|
classId = student.AdministrativeClassId;
|
||||||
|
majorId = student.MajorId;
|
||||||
|
collegeId = student.CollegeId;
|
||||||
|
}
|
||||||
|
else if (scope.Scope == DataScope.College)
|
||||||
|
{
|
||||||
|
collegeId = scope.RestrictedCollegeId;
|
||||||
|
if (collegeId == Guid.Empty || !scope.CanAccessCollege(
|
||||||
|
await db.Courses.Where(x => x.Id == sheet.CourseId)
|
||||||
|
.Select(x => x.CollegeId).FirstAsync(cancellationToken)))
|
||||||
|
return Forbid();
|
||||||
|
}
|
||||||
|
else if (scope.IsInRole(SystemRoles.Counselor))
|
||||||
|
{
|
||||||
|
var allowedClassIds = await db.AdministrativeClasses.AsNoTracking()
|
||||||
|
.Where(x => x.CounselorUserId == scope.UserId)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (allowedClassIds.Count == 0) return Forbid();
|
||||||
|
// A counselor sees their classes plus the matching major/college
|
||||||
|
// benchmarks, never an unrelated class-level statistic.
|
||||||
|
classId = allowedClassIds.First();
|
||||||
|
majorId = await db.AdministrativeClasses.Where(x => x.Id == classId)
|
||||||
|
.Select(x => x.MajorId).FirstAsync(cancellationToken);
|
||||||
|
collegeId = await db.Majors.Where(x => x.Id == majorId)
|
||||||
|
.Select(x => x.CollegeId).FirstAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var cacheKey = AppCacheKeys.CourseGradeStatistics(id);
|
||||||
|
var statistics = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(cacheKey, async token =>
|
||||||
|
{
|
||||||
|
var source = db.CourseGradeStatistics.AsNoTracking()
|
||||||
|
.Where(x => x.CourseId == sheet.CourseId &&
|
||||||
|
x.AcademicTermId == sheet.AcademicTermId);
|
||||||
|
return await source.Select(x => new
|
||||||
|
{
|
||||||
|
x.Scope, x.ScopeEntityId, x.StudentCount, x.PassedCount,
|
||||||
|
x.Below60Count, x.From60To69Count, x.From70To79Count,
|
||||||
|
x.From80To89Count, x.From90To100Count,
|
||||||
|
x.HighestScore, x.AverageScore, x.LowestScore, x.PassRate,
|
||||||
|
x.CalculatedAt
|
||||||
|
}).ToListAsync(token);
|
||||||
|
}, AppCacheProfile.Analytics,
|
||||||
|
[AppCacheTags.CourseGradeStatistics], cancellationToken);
|
||||||
|
|
||||||
|
object? Find(CourseGradeStatisticScope statisticScope, Guid? entityId)
|
||||||
|
{
|
||||||
|
var item = statistics.FirstOrDefault(x => x.Scope == statisticScope &&
|
||||||
|
x.ScopeEntityId == entityId);
|
||||||
|
return item is null ? null : new
|
||||||
|
{
|
||||||
|
item.Scope, item.ScopeEntityId, item.StudentCount, item.PassedCount,
|
||||||
|
item.HighestScore, item.AverageScore, item.LowestScore, item.PassRate,
|
||||||
|
item.CalculatedAt,
|
||||||
|
Distribution = new[]
|
||||||
|
{
|
||||||
|
new { Range = "0–59", Count = item.Below60Count },
|
||||||
|
new { Range = "60–69", Count = item.From60To69Count },
|
||||||
|
new { Range = "70–79", Count = item.From70To79Count },
|
||||||
|
new { Range = "80–89", Count = item.From80To89Count },
|
||||||
|
new { Range = "90–100", Count = item.From90To100Count }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
sheet.CourseName, sheet.CourseCode, sheet.TermName,
|
||||||
|
IsRefreshing = !statistics.Any(),
|
||||||
|
Class = classId.HasValue ? Find(CourseGradeStatisticScope.AdministrativeClass, classId) : null,
|
||||||
|
Major = majorId.HasValue ? Find(CourseGradeStatisticScope.Major, majorId) : null,
|
||||||
|
College = collegeId.HasValue ? Find(CourseGradeStatisticScope.College, collegeId) : null,
|
||||||
|
University = scope.Scope == DataScope.All || scope.IsInRole(SystemRoles.Student)
|
||||||
|
? Find(CourseGradeStatisticScope.University, null) : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private IQueryable<TeachingTask> AccessibleTasks()
|
private IQueryable<TeachingTask> AccessibleTasks()
|
||||||
{
|
{
|
||||||
var source = db.TeachingTasks.AsQueryable();
|
var source = db.TeachingTasks.AsQueryable();
|
||||||
@@ -1036,6 +1161,7 @@ public sealed class GradesController(
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
QueueCourseStatisticsRefresh(id);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return created ? Created(string.Empty, new { id }) : NoContent();
|
return created ? Created(string.Empty, new { id }) : NoContent();
|
||||||
}
|
}
|
||||||
@@ -1045,6 +1171,14 @@ public sealed class GradesController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void QueueCourseStatisticsRefresh(Guid gradeSheetId)
|
||||||
|
{
|
||||||
|
var job = new CourseGradeStatisticsRefreshJob { GradeSheetId = gradeSheetId };
|
||||||
|
db.CourseGradeStatisticsRefreshJobs.Add(job);
|
||||||
|
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
||||||
|
BackgroundJobKind.CourseGradeStatisticsRefresh, job.Id));
|
||||||
|
}
|
||||||
|
|
||||||
private ActionResult ConflictProblem(string detail) =>
|
private ActionResult ConflictProblem(string detail) =>
|
||||||
Conflict(new ProblemDetails
|
Conflict(new ProblemDetails
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,6 +54,57 @@ public sealed class GradeItemScore
|
|||||||
public decimal? Score { get; set; }
|
public decimal? Score { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persisted course-result aggregate. One course/term is materialized at each
|
||||||
|
/// organizational level so the result-analysis page never aggregates raw
|
||||||
|
/// grade records on request.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CourseGradeStatistic : EntityBase
|
||||||
|
{
|
||||||
|
public Guid CourseId { get; set; }
|
||||||
|
public Guid AcademicTermId { get; set; }
|
||||||
|
public CourseGradeStatisticScope Scope { get; set; }
|
||||||
|
public Guid? ScopeEntityId { get; set; }
|
||||||
|
public int StudentCount { get; set; }
|
||||||
|
public int PassedCount { get; set; }
|
||||||
|
public int Below60Count { get; set; }
|
||||||
|
public int From60To69Count { get; set; }
|
||||||
|
public int From70To79Count { get; set; }
|
||||||
|
public int From80To89Count { get; set; }
|
||||||
|
public int From90To100Count { get; set; }
|
||||||
|
public decimal HighestScore { get; set; }
|
||||||
|
public decimal AverageScore { get; set; }
|
||||||
|
public decimal LowestScore { get; set; }
|
||||||
|
public decimal PassRate { get; set; }
|
||||||
|
public DateTime CalculatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CourseGradeStatisticScope
|
||||||
|
{
|
||||||
|
AdministrativeClass = 1,
|
||||||
|
Major = 2,
|
||||||
|
College = 3,
|
||||||
|
University = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CourseGradeStatisticsRefreshJob : EntityBase
|
||||||
|
{
|
||||||
|
public Guid GradeSheetId { get; set; }
|
||||||
|
public CourseGradeStatisticsRefreshJobStatus Status { get; set; } =
|
||||||
|
CourseGradeStatisticsRefreshJobStatus.Queued;
|
||||||
|
public DateTime? StartedAt { get; set; }
|
||||||
|
public DateTime? CompletedAt { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CourseGradeStatisticsRefreshJobStatus
|
||||||
|
{
|
||||||
|
Queued = 1,
|
||||||
|
Running = 2,
|
||||||
|
Succeeded = 3,
|
||||||
|
Failed = 4
|
||||||
|
}
|
||||||
|
|
||||||
public enum GradeSheetStatus
|
public enum GradeSheetStatus
|
||||||
{
|
{
|
||||||
Draft = 1,
|
Draft = 1,
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ public enum BackgroundJobKind
|
|||||||
MakeupExamAuto = 3,
|
MakeupExamAuto = 3,
|
||||||
ExamArrangement = 4,
|
ExamArrangement = 4,
|
||||||
ExamSignInExport = 5,
|
ExamSignInExport = 5,
|
||||||
ExamPublish = 6
|
ExamPublish = 6,
|
||||||
|
CourseGradeStatisticsRefresh = 7
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum BackgroundJobOutboxState
|
public enum BackgroundJobOutboxState
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public sealed class BackgroundJobOptions
|
|||||||
public int ExamArrangementConcurrency { get; set; } = 1;
|
public int ExamArrangementConcurrency { get; set; } = 1;
|
||||||
public int ExamSignInExportConcurrency { get; set; } = 1;
|
public int ExamSignInExportConcurrency { get; set; } = 1;
|
||||||
public int ExamPublishConcurrency { get; set; } = 1;
|
public int ExamPublishConcurrency { get; set; } = 1;
|
||||||
|
public int CourseGradeStatisticsRefreshConcurrency { get; set; } = 1;
|
||||||
public string Exchange { get; set; } = "jiaowu.background-jobs";
|
public string Exchange { get; set; } = "jiaowu.background-jobs";
|
||||||
public string QueuePrefix { get; set; } = "jiaowu.background-jobs";
|
public string QueuePrefix { get; set; } = "jiaowu.background-jobs";
|
||||||
public bool UseQuorumQueues { get; set; } = true;
|
public bool UseQuorumQueues { get; set; } = true;
|
||||||
@@ -35,6 +36,8 @@ public sealed class BackgroundJobOptions
|
|||||||
BackgroundJobKind.ExamArrangement => ExamArrangementConcurrency,
|
BackgroundJobKind.ExamArrangement => ExamArrangementConcurrency,
|
||||||
BackgroundJobKind.ExamSignInExport => ExamSignInExportConcurrency,
|
BackgroundJobKind.ExamSignInExport => ExamSignInExportConcurrency,
|
||||||
BackgroundJobKind.ExamPublish => ExamPublishConcurrency,
|
BackgroundJobKind.ExamPublish => ExamPublishConcurrency,
|
||||||
|
BackgroundJobKind.CourseGradeStatisticsRefresh =>
|
||||||
|
CourseGradeStatisticsRefreshConcurrency,
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,15 @@ public sealed class BackgroundJobOutboxPublisher(
|
|||||||
message.JobId == x.Id))
|
message.JobId == x.Id))
|
||||||
.Select(x => x.Id)
|
.Select(x => x.Id)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
var gradeStatisticsJobs = await db.CourseGradeStatisticsRefreshJobs.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
(x.Status == CourseGradeStatisticsRefreshJobStatus.Queued ||
|
||||||
|
x.Status == CourseGradeStatisticsRefreshJobStatus.Running) &&
|
||||||
|
!db.BackgroundJobOutboxMessages.Any(message =>
|
||||||
|
message.JobKind == BackgroundJobKind.CourseGradeStatisticsRefresh &&
|
||||||
|
message.JobId == x.Id))
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var missingKeys = automaticJobs
|
var missingKeys = automaticJobs
|
||||||
.Select(id => (BackgroundJobKind.AutomaticSchedule, id))
|
.Select(id => (BackgroundJobKind.AutomaticSchedule, id))
|
||||||
@@ -143,6 +152,8 @@ public sealed class BackgroundJobOutboxPublisher(
|
|||||||
(BackgroundJobKind.ExamSignInExport, id)))
|
(BackgroundJobKind.ExamSignInExport, id)))
|
||||||
.Concat(publishJobs2.Select(id =>
|
.Concat(publishJobs2.Select(id =>
|
||||||
(BackgroundJobKind.ExamPublish, id)))
|
(BackgroundJobKind.ExamPublish, id)))
|
||||||
|
.Concat(gradeStatisticsJobs.Select(id =>
|
||||||
|
(BackgroundJobKind.CourseGradeStatisticsRefresh, id)))
|
||||||
.ToList();
|
.ToList();
|
||||||
foreach (var (kind, jobId) in missingKeys)
|
foreach (var (kind, jobId) in missingKeys)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Diagnostics;
|
|||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.System;
|
using Jiaowu.Api.Domain.System;
|
||||||
using Jiaowu.Api.Infrastructure.Exams;
|
using Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
using Jiaowu.Api.Infrastructure.Grades;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -100,6 +101,11 @@ public sealed class BackgroundJobRunner(
|
|||||||
.GetRequiredService<ExamPublishJobProcessor>()
|
.GetRequiredService<ExamPublishJobProcessor>()
|
||||||
.ProcessAsync(message.JobId, cancellationToken);
|
.ProcessAsync(message.JobId, cancellationToken);
|
||||||
break;
|
break;
|
||||||
|
case BackgroundJobKind.CourseGradeStatisticsRefresh:
|
||||||
|
await scope.ServiceProvider
|
||||||
|
.GetRequiredService<CourseGradeStatisticsRefreshJobProcessor>()
|
||||||
|
.ProcessAsync(message.JobId, cancellationToken);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Unsupported background job kind '{message.JobKind}'.");
|
$"Unsupported background job kind '{message.JobKind}'.");
|
||||||
@@ -308,6 +314,19 @@ public sealed class BackgroundJobRunner(
|
|||||||
.SetProperty(x => x.CompletedAt, completedAt),
|
.SetProperty(x => x.CompletedAt, completedAt),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
break;
|
break;
|
||||||
|
case BackgroundJobKind.CourseGradeStatisticsRefresh:
|
||||||
|
await db.CourseGradeStatisticsRefreshJobs
|
||||||
|
.Where(x => x.Id == message.JobId &&
|
||||||
|
x.Status != CourseGradeStatisticsRefreshJobStatus.Succeeded &&
|
||||||
|
x.Status != CourseGradeStatisticsRefreshJobStatus.Failed)
|
||||||
|
.ExecuteUpdateAsync(
|
||||||
|
setters => setters
|
||||||
|
.SetProperty(x => x.Status,
|
||||||
|
CourseGradeStatisticsRefreshJobStatus.Failed)
|
||||||
|
.SetProperty(x => x.ErrorMessage, error)
|
||||||
|
.SetProperty(x => x.CompletedAt, completedAt),
|
||||||
|
cancellationToken);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new ArgumentOutOfRangeException(
|
throw new ArgumentOutOfRangeException(
|
||||||
nameof(message.JobKind),
|
nameof(message.JobKind),
|
||||||
|
|||||||
@@ -319,7 +319,8 @@ internal static class RabbitMqBackgroundJobTopology
|
|||||||
BackgroundJobKind.MakeupExamAuto,
|
BackgroundJobKind.MakeupExamAuto,
|
||||||
BackgroundJobKind.ExamArrangement,
|
BackgroundJobKind.ExamArrangement,
|
||||||
BackgroundJobKind.ExamSignInExport,
|
BackgroundJobKind.ExamSignInExport,
|
||||||
BackgroundJobKind.ExamPublish
|
BackgroundJobKind.ExamPublish,
|
||||||
|
BackgroundJobKind.CourseGradeStatisticsRefresh
|
||||||
];
|
];
|
||||||
|
|
||||||
public static async Task<IConnection> CreateConnectionAsync(
|
public static async Task<IConnection> CreateConnectionAsync(
|
||||||
@@ -418,6 +419,7 @@ internal static class RabbitMqBackgroundJobTopology
|
|||||||
BackgroundJobKind.ExamArrangement => "exam.arrangement",
|
BackgroundJobKind.ExamArrangement => "exam.arrangement",
|
||||||
BackgroundJobKind.ExamSignInExport => "exam.sign-in-export",
|
BackgroundJobKind.ExamSignInExport => "exam.sign-in-export",
|
||||||
BackgroundJobKind.ExamPublish => "exam.publish",
|
BackgroundJobKind.ExamPublish => "exam.publish",
|
||||||
|
BackgroundJobKind.CourseGradeStatisticsRefresh => "grade-statistics.refresh",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -182,12 +182,16 @@ public static class AppCacheKeys
|
|||||||
return $"statistics:v2:{Normalize(area)}:scope:{Normalize(dataScope)}:" +
|
return $"statistics:v2:{Normalize(area)}:scope:{Normalize(dataScope)}:" +
|
||||||
$"college:{effectiveCollegeId?.ToString("N") ?? "all"}:{filterPart}";
|
$"college:{effectiveCollegeId?.ToString("N") ?? "all"}:{filterPart}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static string CourseGradeStatistics(Guid gradeSheetId) =>
|
||||||
|
$"grade-statistics:sheet:{gradeSheetId:N}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class AppCacheTags
|
public static class AppCacheTags
|
||||||
{
|
{
|
||||||
public const string BaseData = "base-data";
|
public const string BaseData = "base-data";
|
||||||
public const string Analytics = "analytics";
|
public const string Analytics = "analytics";
|
||||||
|
public const string CourseGradeStatistics = "grade-statistics";
|
||||||
public const string Timetables = "timetables";
|
public const string Timetables = "timetables";
|
||||||
public const string TimetableOptions = "timetable:options";
|
public const string TimetableOptions = "timetable:options";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Grades;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rebuilds a course/term's denormalized result statistics. The operation is
|
||||||
|
/// intentionally idempotent: duplicate RabbitMQ deliveries are safe.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CourseGradeStatisticsRefreshJobProcessor(
|
||||||
|
AppDbContext db,
|
||||||
|
IAppCache cache,
|
||||||
|
ILogger<CourseGradeStatisticsRefreshJobProcessor> logger)
|
||||||
|
{
|
||||||
|
public async Task ProcessAsync(Guid jobId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await db.CourseGradeStatisticsRefreshJobs
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken);
|
||||||
|
if (job is null || job.Status == CourseGradeStatisticsRefreshJobStatus.Succeeded)
|
||||||
|
return;
|
||||||
|
|
||||||
|
job.Status = CourseGradeStatisticsRefreshJobStatus.Running;
|
||||||
|
job.StartedAt = DateTime.UtcNow;
|
||||||
|
job.ErrorMessage = null;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
var sheetData = await db.GradeSheets.AsNoTracking()
|
||||||
|
.Where(x => x.Id == job.GradeSheetId)
|
||||||
|
.Select(x => new { x.Id, x.TeachingTask!.CourseId, x.TeachingTask.AcademicTermId })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (sheetData is null)
|
||||||
|
{
|
||||||
|
job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded;
|
||||||
|
job.CompletedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var target = new StatisticsTarget(sheetData.CourseId, sheetData.AcademicTermId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Statistics shown to students are based only on formally published
|
||||||
|
// scores. This prevents an unfinished class from exposing data.
|
||||||
|
var scores = await db.GradeRecords.AsNoTracking()
|
||||||
|
.Where(x => x.TotalScore != null &&
|
||||||
|
x.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||||
|
x.GradeSheet.TeachingTask!.CourseId == target.CourseId &&
|
||||||
|
x.GradeSheet.TeachingTask.AcademicTermId == target.AcademicTermId)
|
||||||
|
.Select(x => new ScoreRow(
|
||||||
|
x.TotalScore!.Value,
|
||||||
|
x.Student!.AdministrativeClassId,
|
||||||
|
x.Student.AdministrativeClass!.MajorId,
|
||||||
|
x.Student.AdministrativeClass.Major!.CollegeId))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var rebuilt = new List<CourseGradeStatistic>();
|
||||||
|
AddStatistics(CourseGradeStatisticScope.AdministrativeClass,
|
||||||
|
scores.GroupBy(x => x.ClassId), rebuilt, target, now);
|
||||||
|
AddStatistics(CourseGradeStatisticScope.Major,
|
||||||
|
scores.GroupBy(x => x.MajorId), rebuilt, target, now);
|
||||||
|
AddStatistics(CourseGradeStatisticScope.College,
|
||||||
|
scores.GroupBy(x => x.CollegeId), rebuilt, target, now);
|
||||||
|
AddUniversityStatistic(scores, rebuilt, target, now);
|
||||||
|
|
||||||
|
await db.CourseGradeStatistics
|
||||||
|
.Where(x => x.CourseId == target.CourseId &&
|
||||||
|
x.AcademicTermId == target.AcademicTermId)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
if (rebuilt.Count > 0)
|
||||||
|
db.CourseGradeStatistics.AddRange(rebuilt);
|
||||||
|
|
||||||
|
job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded;
|
||||||
|
job.CompletedAt = now;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await cache.RemoveByTagAsync(AppCacheTags.CourseGradeStatistics,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
job.Status = CourseGradeStatisticsRefreshJobStatus.Failed;
|
||||||
|
job.ErrorMessage = exception.GetBaseException().Message[..Math.Min(2000,
|
||||||
|
exception.GetBaseException().Message.Length)];
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
logger.LogError(exception, "Course grade statistics refresh {JobId} failed.", jobId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddStatistics(
|
||||||
|
CourseGradeStatisticScope scope,
|
||||||
|
IEnumerable<IGrouping<Guid, ScoreRow>> groups,
|
||||||
|
ICollection<CourseGradeStatistic> target,
|
||||||
|
StatisticsTarget targetInfo,
|
||||||
|
DateTime calculatedAt)
|
||||||
|
{
|
||||||
|
foreach (var group in groups)
|
||||||
|
target.Add(Create(scope, group.Key, group.Select(x => x.Score), targetInfo, calculatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddUniversityStatistic(
|
||||||
|
IReadOnlyCollection<ScoreRow> scores,
|
||||||
|
ICollection<CourseGradeStatistic> target,
|
||||||
|
StatisticsTarget targetInfo,
|
||||||
|
DateTime calculatedAt)
|
||||||
|
{
|
||||||
|
if (scores.Count > 0)
|
||||||
|
target.Add(Create(CourseGradeStatisticScope.University, null,
|
||||||
|
scores.Select(x => x.Score), targetInfo, calculatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CourseGradeStatistic Create(
|
||||||
|
CourseGradeStatisticScope scope,
|
||||||
|
Guid? scopeEntityId,
|
||||||
|
IEnumerable<decimal> source,
|
||||||
|
StatisticsTarget targetInfo,
|
||||||
|
DateTime calculatedAt)
|
||||||
|
{
|
||||||
|
var scores = source.ToArray();
|
||||||
|
var passed = scores.Count(x => x >= 60m);
|
||||||
|
return new CourseGradeStatistic
|
||||||
|
{
|
||||||
|
CourseId = targetInfo.CourseId,
|
||||||
|
AcademicTermId = targetInfo.AcademicTermId,
|
||||||
|
Scope = scope,
|
||||||
|
ScopeEntityId = scopeEntityId,
|
||||||
|
StudentCount = scores.Length,
|
||||||
|
PassedCount = passed,
|
||||||
|
Below60Count = scores.Count(x => x < 60m),
|
||||||
|
From60To69Count = scores.Count(x => x >= 60m && x < 70m),
|
||||||
|
From70To79Count = scores.Count(x => x >= 70m && x < 80m),
|
||||||
|
From80To89Count = scores.Count(x => x >= 80m && x < 90m),
|
||||||
|
From90To100Count = scores.Count(x => x >= 90m),
|
||||||
|
HighestScore = scores.Max(),
|
||||||
|
AverageScore = Math.Round(scores.Average(), 1),
|
||||||
|
LowestScore = scores.Min(),
|
||||||
|
PassRate = Math.Round((decimal)passed / scores.Length * 100m, 2),
|
||||||
|
CalculatedAt = calculatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record ScoreRow(decimal Score, Guid ClassId, Guid MajorId, Guid CollegeId);
|
||||||
|
private sealed record StatisticsTarget(Guid CourseId, Guid AcademicTermId);
|
||||||
|
}
|
||||||
@@ -66,6 +66,10 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
public DbSet<GradeRecord> GradeRecords => Set<GradeRecord>();
|
public DbSet<GradeRecord> GradeRecords => Set<GradeRecord>();
|
||||||
public DbSet<GradeItem> GradeItems => Set<GradeItem>();
|
public DbSet<GradeItem> GradeItems => Set<GradeItem>();
|
||||||
public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>();
|
public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>();
|
||||||
|
public DbSet<CourseGradeStatistic> CourseGradeStatistics =>
|
||||||
|
Set<CourseGradeStatistic>();
|
||||||
|
public DbSet<CourseGradeStatisticsRefreshJob> CourseGradeStatisticsRefreshJobs =>
|
||||||
|
Set<CourseGradeStatisticsRefreshJob>();
|
||||||
public DbSet<OtherExamBatch> OtherExamBatches => Set<OtherExamBatch>();
|
public DbSet<OtherExamBatch> OtherExamBatches => Set<OtherExamBatch>();
|
||||||
public DbSet<OtherExamResult> OtherExamResults => Set<OtherExamResult>();
|
public DbSet<OtherExamResult> OtherExamResults => Set<OtherExamResult>();
|
||||||
public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>();
|
public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>();
|
||||||
@@ -847,6 +851,32 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<CourseGradeStatistic>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.HighestScore).HasPrecision(5, 1);
|
||||||
|
entity.Property(x => x.AverageScore).HasPrecision(5, 1);
|
||||||
|
entity.Property(x => x.LowestScore).HasPrecision(5, 1);
|
||||||
|
entity.Property(x => x.PassRate).HasPrecision(5, 2);
|
||||||
|
entity.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.CourseId, x.AcademicTermId, x.Scope, x.ScopeEntityId
|
||||||
|
}).IsUnique().HasDatabaseName("UX_CourseGradeStatistics_Scope");
|
||||||
|
entity.HasIndex(x => new { x.AcademicTermId, x.Scope, x.ScopeEntityId });
|
||||||
|
entity.HasOne<Course>().WithMany().HasForeignKey(x => x.CourseId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne<AcademicTerm>().WithMany().HasForeignKey(x => x.AcademicTermId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<CourseGradeStatisticsRefreshJob>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
|
||||||
|
entity.HasIndex(x => new { x.Status, x.CreatedAt });
|
||||||
|
entity.HasIndex(x => x.GradeSheetId);
|
||||||
|
entity.HasOne<GradeSheet>().WithMany().HasForeignKey(x => x.GradeSheetId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<AttendanceSheet>(entity =>
|
builder.Entity<AttendanceSheet>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.Name).HasMaxLength(120);
|
entity.Property(x => x.Name).HasMaxLength(120);
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"20260803_44_student_personal_profile";
|
"20260803_44_student_personal_profile";
|
||||||
private const string OtherExamResultsMigration =
|
private const string OtherExamResultsMigration =
|
||||||
"20260808_45_other_exam_results";
|
"20260808_45_other_exam_results";
|
||||||
|
private const string CourseGradeStatisticsMigration =
|
||||||
|
"20260808_46_course_grade_statistics";
|
||||||
|
private const string CourseGradeDistributionMigration =
|
||||||
|
"20260809_47_course_grade_distribution";
|
||||||
|
|
||||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -639,6 +643,14 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
? OtherExamResultsStatements.Skip(1)
|
? OtherExamResultsStatements.Skip(1)
|
||||||
: OtherExamResultsStatements,
|
: OtherExamResultsStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
CourseGradeStatisticsMigration,
|
||||||
|
CourseGradeStatisticsStatements,
|
||||||
|
cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
CourseGradeDistributionMigration,
|
||||||
|
CourseGradeDistributionStatements,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyMigrationAsync(
|
private async Task ApplyMigrationAsync(
|
||||||
@@ -2210,6 +2222,25 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"""CREATE INDEX IF NOT EXISTS "IX_OtherExamResults_StudentId_OtherExamBatchId" ON "OtherExamResults" ("StudentId", "OtherExamBatchId");"""
|
"""CREATE INDEX IF NOT EXISTS "IX_OtherExamResults_StudentId_OtherExamBatchId" ON "OtherExamResults" ("StudentId", "OtherExamBatchId");"""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] CourseGradeStatisticsStatements =
|
||||||
|
[
|
||||||
|
"""CREATE TABLE IF NOT EXISTS "CourseGradeStatistics" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseGradeStatistics" PRIMARY KEY, "CourseId" TEXT NOT NULL, "AcademicTermId" TEXT NOT NULL, "Scope" INTEGER NOT NULL, "ScopeEntityId" TEXT NULL, "StudentCount" INTEGER NOT NULL, "PassedCount" INTEGER NOT NULL, "HighestScore" TEXT NOT NULL, "AverageScore" TEXT NOT NULL, "LowestScore" TEXT NOT NULL, "PassRate" TEXT NOT NULL, "CalculatedAt" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseGradeStatistics_Courses_CourseId" FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseGradeStatistics_AcademicTerms_AcademicTermId" FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT);""",
|
||||||
|
"""CREATE UNIQUE INDEX IF NOT EXISTS "UX_CourseGradeStatistics_Scope" ON "CourseGradeStatistics" ("CourseId", "AcademicTermId", "Scope", "ScopeEntityId");""",
|
||||||
|
"""CREATE INDEX IF NOT EXISTS "IX_CourseGradeStatistics_AcademicTermId_Scope_ScopeEntityId" ON "CourseGradeStatistics" ("AcademicTermId", "Scope", "ScopeEntityId");""",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS "CourseGradeStatisticsRefreshJobs" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseGradeStatisticsRefreshJobs" PRIMARY KEY, "GradeSheetId" TEXT NOT NULL, "Status" INTEGER NOT NULL, "StartedAt" TEXT NULL, "CompletedAt" TEXT NULL, "ErrorMessage" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseGradeStatisticsRefreshJobs_GradeSheets_GradeSheetId" FOREIGN KEY ("GradeSheetId") REFERENCES "GradeSheets" ("Id") ON DELETE CASCADE);""",
|
||||||
|
"""CREATE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshJobs_GradeSheetId" ON "CourseGradeStatisticsRefreshJobs" ("GradeSheetId");""",
|
||||||
|
"""CREATE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshJobs_Status_CreatedAt" ON "CourseGradeStatisticsRefreshJobs" ("Status", "CreatedAt");"""
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] CourseGradeDistributionStatements =
|
||||||
|
[
|
||||||
|
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "Below60Count" INTEGER NOT NULL DEFAULT 0;""",
|
||||||
|
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "From60To69Count" INTEGER NOT NULL DEFAULT 0;""",
|
||||||
|
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "From70To79Count" INTEGER NOT NULL DEFAULT 0;""",
|
||||||
|
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "From80To89Count" INTEGER NOT NULL DEFAULT 0;""",
|
||||||
|
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "From90To100Count" INTEGER NOT NULL DEFAULT 0;"""
|
||||||
|
];
|
||||||
|
|
||||||
private static readonly string[] ApprovalTableStatements =
|
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);""",
|
"""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);""",
|
||||||
|
|||||||
+6381
File diff suppressed because it is too large
Load Diff
+113
@@ -0,0 +1,113 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class CourseGradeStatistics : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CourseGradeStatistics",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = 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),
|
||||||
|
Scope = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ScopeEntityId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||||
|
StudentCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
PassedCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Below60Count = table.Column<int>(type: "int", nullable: false),
|
||||||
|
From60To69Count = table.Column<int>(type: "int", nullable: false),
|
||||||
|
From70To79Count = table.Column<int>(type: "int", nullable: false),
|
||||||
|
From80To89Count = table.Column<int>(type: "int", nullable: false),
|
||||||
|
From90To100Count = 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),
|
||||||
|
LowestScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||||
|
PassRate = 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_CourseGradeStatistics", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CourseGradeStatistics_AcademicTerms_AcademicTermId",
|
||||||
|
column: x => x.AcademicTermId,
|
||||||
|
principalTable: "AcademicTerms",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CourseGradeStatistics_Courses_CourseId",
|
||||||
|
column: x => x.CourseId,
|
||||||
|
principalTable: "Courses",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CourseGradeStatisticsRefreshJobs",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
GradeSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
StartedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
ErrorMessage = table.Column<string>(type: "varchar(2000)", maxLength: 2000, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CourseGradeStatisticsRefreshJobs", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CourseGradeStatisticsRefreshJobs_GradeSheets_GradeSheetId",
|
||||||
|
column: x => x.GradeSheetId,
|
||||||
|
principalTable: "GradeSheets",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CourseGradeStatistics_AcademicTermId_Scope_ScopeEntityId",
|
||||||
|
table: "CourseGradeStatistics",
|
||||||
|
columns: new[] { "AcademicTermId", "Scope", "ScopeEntityId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_CourseGradeStatistics_Scope",
|
||||||
|
table: "CourseGradeStatistics",
|
||||||
|
columns: new[] { "CourseId", "AcademicTermId", "Scope", "ScopeEntityId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CourseGradeStatisticsRefreshJobs_GradeSheetId",
|
||||||
|
table: "CourseGradeStatisticsRefreshJobs",
|
||||||
|
column: "GradeSheetId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CourseGradeStatisticsRefreshJobs_Status_CreatedAt",
|
||||||
|
table: "CourseGradeStatisticsRefreshJobs",
|
||||||
|
columns: new[] { "Status", "CreatedAt" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CourseGradeStatistics");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CourseGradeStatisticsRefreshJobs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+136
@@ -983,6 +983,118 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("CourseExemptions");
|
b.ToTable("CourseExemptions");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGradeStatistic", 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<decimal>("HighestScore")
|
||||||
|
.HasPrecision(5, 1)
|
||||||
|
.HasColumnType("decimal(5,1)");
|
||||||
|
|
||||||
|
b.Property<decimal>("LowestScore")
|
||||||
|
.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<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");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ScopeEntityId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int>("StudentCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AcademicTermId", "Scope", "ScopeEntityId");
|
||||||
|
|
||||||
|
b.HasIndex("CourseId", "AcademicTermId", "Scope", "ScopeEntityId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("UX_CourseGradeStatistics_Scope");
|
||||||
|
|
||||||
|
b.ToTable("CourseGradeStatistics");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGradeStatisticsRefreshJob", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CompletedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMessage")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("varchar(2000)");
|
||||||
|
|
||||||
|
b.Property<Guid>("GradeSheetId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("StartedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("GradeSheetId");
|
||||||
|
|
||||||
|
b.HasIndex("Status", "CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("CourseGradeStatisticsRefreshJobs");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -4871,6 +4983,30 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("TeachingTask");
|
b.Navigation("TeachingTask");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGradeStatistic", 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGradeStatisticsRefreshJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("GradeSheetId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
|
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Text;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||||
|
using Jiaowu.Api.Infrastructure.Grades;
|
||||||
using Jiaowu.Api.Infrastructure.Configuration;
|
using Jiaowu.Api.Infrastructure.Configuration;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
using Jiaowu.Api.Infrastructure.Caching;
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
@@ -223,6 +224,7 @@ if (backgroundJobOptions.PollIntervalMilliseconds is < 100 or > 30000 ||
|
|||||||
backgroundJobOptions.ExamArrangementConcurrency is < 1 or > 16 ||
|
backgroundJobOptions.ExamArrangementConcurrency is < 1 or > 16 ||
|
||||||
backgroundJobOptions.ExamSignInExportConcurrency is < 1 or > 16 ||
|
backgroundJobOptions.ExamSignInExportConcurrency is < 1 or > 16 ||
|
||||||
backgroundJobOptions.ExamPublishConcurrency is < 1 or > 16 ||
|
backgroundJobOptions.ExamPublishConcurrency is < 1 or > 16 ||
|
||||||
|
backgroundJobOptions.CourseGradeStatisticsRefreshConcurrency is < 1 or > 16 ||
|
||||||
backgroundJobOptions.ProcessingAttemptLimit is < 1 or > 100 ||
|
backgroundJobOptions.ProcessingAttemptLimit is < 1 or > 100 ||
|
||||||
backgroundJobOptions.MaintenanceIntervalSeconds is < 10 or > 3600 ||
|
backgroundJobOptions.MaintenanceIntervalSeconds is < 10 or > 3600 ||
|
||||||
backgroundJobOptions.CompletedRetentionDays is < 1 or > 3650 ||
|
backgroundJobOptions.CompletedRetentionDays is < 1 or > 3650 ||
|
||||||
@@ -425,6 +427,7 @@ builder.Services.AddScoped<MakeupExamAutoJobProcessor>();
|
|||||||
builder.Services.AddScoped<ExamArrangementJobProcessor>();
|
builder.Services.AddScoped<ExamArrangementJobProcessor>();
|
||||||
builder.Services.AddScoped<ExamSignInExportJobProcessor>();
|
builder.Services.AddScoped<ExamSignInExportJobProcessor>();
|
||||||
builder.Services.AddScoped<ExamPublishJobProcessor>();
|
builder.Services.AddScoped<ExamPublishJobProcessor>();
|
||||||
|
builder.Services.AddScoped<CourseGradeStatisticsRefreshJobProcessor>();
|
||||||
builder.Services.AddSingleton<BackgroundJobTelemetry>();
|
builder.Services.AddSingleton<BackgroundJobTelemetry>();
|
||||||
builder.Services.AddScoped<BackgroundJobMonitoringService>();
|
builder.Services.AddScoped<BackgroundJobMonitoringService>();
|
||||||
builder.Services.AddScoped<OperationalHealthService>();
|
builder.Services.AddScoped<OperationalHealthService>();
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
"SchedulePublishConcurrency": 1,
|
"SchedulePublishConcurrency": 1,
|
||||||
"MakeupExamAutoConcurrency": 1,
|
"MakeupExamAutoConcurrency": 1,
|
||||||
"ExamArrangementConcurrency": 1,
|
"ExamArrangementConcurrency": 1,
|
||||||
|
"CourseGradeStatisticsRefreshConcurrency": 1,
|
||||||
"Exchange": "jiaowu.background-jobs",
|
"Exchange": "jiaowu.background-jobs",
|
||||||
"QueuePrefix": "jiaowu.background-jobs",
|
"QueuePrefix": "jiaowu.background-jobs",
|
||||||
"UseQuorumQueues": true,
|
"UseQuorumQueues": true,
|
||||||
|
|||||||
@@ -247,6 +247,14 @@ const router = createRouter({
|
|||||||
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student', 'Counselor'],
|
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student', 'Counselor'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'grades/:sheetId/statistics',
|
||||||
|
name: 'course-grade-statistics',
|
||||||
|
component: () => import('../views/CourseGradeStatisticsView.vue'),
|
||||||
|
meta: {
|
||||||
|
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Counselor', 'Student'],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'other-exams',
|
path: 'other-exams',
|
||||||
name: 'other-exams',
|
name: 'other-exams',
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(false)
|
||||||
|
const report = ref<any | null>(null)
|
||||||
|
const activeScope = ref('class')
|
||||||
|
const chartElement = ref<HTMLElement>()
|
||||||
|
const comparisonChartElement = ref<HTMLElement>()
|
||||||
|
let chart: echarts.ECharts | undefined
|
||||||
|
let comparisonChart: echarts.ECharts | undefined
|
||||||
|
|
||||||
|
const rows = computed(() => [
|
||||||
|
{ key: 'class', label: '本班', caption: '同班同学', value: report.value?.class },
|
||||||
|
{ key: 'major', label: '本专业', caption: '专业学生', value: report.value?.major },
|
||||||
|
{ key: 'college', label: '本学院', caption: '学院学生', value: report.value?.college },
|
||||||
|
{ key: 'university', label: '本校', caption: '全校学生', value: report.value?.university },
|
||||||
|
].filter(item => item.value))
|
||||||
|
|
||||||
|
const selectedRow = computed(() => rows.value.find(item => item.key === activeScope.value) ?? rows.value[0])
|
||||||
|
const selectedDistribution = computed(() => selectedRow.value?.value.distribution ?? [])
|
||||||
|
|
||||||
|
function score(value: unknown) {
|
||||||
|
return Number(value).toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
report.value = (await http.get(`/grades/sheets/${route.params.sheetId}/statistics`)).data
|
||||||
|
if (!rows.value.some(item => item.key === activeScope.value)) activeScope.value = rows.value[0]?.key ?? 'class'
|
||||||
|
await nextTick()
|
||||||
|
drawChart()
|
||||||
|
drawComparisonChart()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawChart() {
|
||||||
|
if (!chartElement.value || !selectedRow.value) return
|
||||||
|
chart?.dispose()
|
||||||
|
chart = echarts.init(chartElement.value)
|
||||||
|
const distribution = selectedDistribution.value
|
||||||
|
const total = selectedRow.value.value.studentCount || 1
|
||||||
|
const palette = ['#a7b4c6', '#8198b6', '#5f7fa8', '#3d6797', '#173f78']
|
||||||
|
chart.setOption({
|
||||||
|
animationDuration: 520,
|
||||||
|
aria: { enabled: true, decal: { show: true } },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const item = distribution[params[0]?.dataIndex ?? 0]
|
||||||
|
const rate = item ? Number(item.count) / total * 100 : 0
|
||||||
|
return `<b>${item?.range ?? ''} 分</b><br/>人数 ${item?.count ?? 0} 人<br/>占比 ${rate.toFixed(1)}%`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: { left: 42, right: 22, top: 44, bottom: 38 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
name: '分数段',
|
||||||
|
data: distribution.map((item: any) => item.range),
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLine: { lineStyle: { color: '#cbd4e1' } },
|
||||||
|
axisLabel: { color: '#475467', fontWeight: 600 },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value', min: 0, minInterval: 1, name: '人数',
|
||||||
|
splitLine: { lineStyle: { color: '#edf1f6' } },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: `${selectedRow.value.label}人数`, type: 'bar', barMaxWidth: 62,
|
||||||
|
data: distribution.map((item: any, index: number) => ({ value: item.count, itemStyle: { color: palette[index] } })),
|
||||||
|
itemStyle: { borderRadius: [5, 5, 0, 0] },
|
||||||
|
label: {
|
||||||
|
show: true, position: 'top', color: '#344054', fontWeight: 700,
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const count = Number(params.value)
|
||||||
|
return `${count} 人\n${(count / total * 100).toFixed(1)}%`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawComparisonChart() {
|
||||||
|
if (!comparisonChartElement.value || !rows.value.length) return
|
||||||
|
comparisonChart?.dispose()
|
||||||
|
comparisonChart = echarts.init(comparisonChartElement.value)
|
||||||
|
const selectedIndex = rows.value.findIndex(item => item.key === activeScope.value)
|
||||||
|
comparisonChart.setOption({
|
||||||
|
animationDuration: 520,
|
||||||
|
aria: { enabled: true, decal: { show: true } },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const item = rows.value[params[0]?.dataIndex ?? 0]
|
||||||
|
return `<b>${item.label}</b><br/>最高分 ${score(item.value.highestScore)}<br/>平均分 ${score(item.value.averageScore)}<br/>最低分 ${score(item.value.lowestScore)}<br/>合格率 ${score(item.value.passRate)}%`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: { top: 2, data: ['分数区间', '平均分', '合格率'] },
|
||||||
|
grid: { left: 42, right: 52, top: 56, bottom: 36 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: rows.value.map(item => item.label),
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLine: { lineStyle: { color: '#cbd4e1' } },
|
||||||
|
axisLabel: {
|
||||||
|
color: (value: string) => value === selectedRow.value?.label ? '#173f78' : '#667085',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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: 'score-range', silent: true,
|
||||||
|
itemStyle: { color: 'transparent' }, emphasis: { disabled: true },
|
||||||
|
data: rows.value.map(item => item.value.lowestScore),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '分数区间', type: 'bar', stack: 'score-range', barWidth: 22,
|
||||||
|
itemStyle: { borderRadius: 8 },
|
||||||
|
data: rows.value.map((item, index) => ({
|
||||||
|
value: item.value.highestScore - item.value.lowestScore,
|
||||||
|
itemStyle: { color: index === selectedIndex ? '#315a9b' : '#b8c7dc' },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '平均分', type: 'line', smooth: true, symbol: 'circle', symbolSize: 10,
|
||||||
|
lineStyle: { width: 3, color: '#d58b32' },
|
||||||
|
itemStyle: { color: '#d58b32', borderColor: '#fff', borderWidth: 2 },
|
||||||
|
data: rows.value.map(item => item.value.averageScore),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '合格率', type: 'line', yAxisIndex: 1, smooth: true, symbol: 'diamond', symbolSize: 9,
|
||||||
|
lineStyle: { width: 2, type: 'dashed', color: '#25847a' },
|
||||||
|
itemStyle: { color: '#25847a' },
|
||||||
|
data: rows.value.map(item => item.value.passRate),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(activeScope, async () => {
|
||||||
|
await nextTick()
|
||||||
|
drawChart()
|
||||||
|
drawComparisonChart()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
chart?.dispose()
|
||||||
|
comparisonChart?.dispose()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-stack course-statistics" v-loading="loading">
|
||||||
|
<section class="page-intro">
|
||||||
|
<div>
|
||||||
|
<span class="section-kicker">COURSE INSIGHT</span>
|
||||||
|
<h2>{{ report?.courseName ?? '课程成绩分析' }}</h2>
|
||||||
|
<p>{{ report?.courseCode }} · {{ report?.termName }} · 本页数据来自已正式发布成绩</p>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<el-button :icon="ArrowLeft" @click="router.back()">返回</el-button>
|
||||||
|
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-alert v-if="report?.isRefreshing" type="info" :closable="false"
|
||||||
|
title="统计数据正在生成,请稍后刷新。" />
|
||||||
|
|
||||||
|
<section v-if="rows.length && selectedRow" class="scope-panel">
|
||||||
|
<nav class="scope-tabs" aria-label="统计范围">
|
||||||
|
<button
|
||||||
|
v-for="item in rows"
|
||||||
|
:key="item.key"
|
||||||
|
type="button"
|
||||||
|
:class="{ active: activeScope === item.key }"
|
||||||
|
@click="activeScope = item.key"
|
||||||
|
>
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<small>{{ item.value.studentCount }} 人</small>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
<article class="scope-summary">
|
||||||
|
<div class="average-score">
|
||||||
|
<span>{{ selectedRow.label }}平均分</span>
|
||||||
|
<strong>{{ score(selectedRow.value.averageScore) }}</strong>
|
||||||
|
<small>统计对象:{{ selectedRow.caption }} · {{ selectedRow.value.studentCount }} 人</small>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div><dt>最高分</dt><dd>{{ score(selectedRow.value.highestScore) }}</dd></div>
|
||||||
|
<div><dt>最低分</dt><dd>{{ score(selectedRow.value.lowestScore) }}</dd></div>
|
||||||
|
<div><dt>合格人数</dt><dd>{{ selectedRow.value.passedCount }} / {{ selectedRow.value.studentCount }}</dd></div>
|
||||||
|
<div><dt>合格率</dt><dd>{{ score(selectedRow.value.passRate) }}%</dd></div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
<el-empty v-else-if="!loading" description="暂无可展示的课程统计" />
|
||||||
|
|
||||||
|
<section v-if="rows.length" class="chart-panel">
|
||||||
|
<div class="chart-heading">
|
||||||
|
<div><span class="section-kicker">SCORE DISTRIBUTION</span><h3>{{ selectedRow?.label }}分数段分布</h3></div>
|
||||||
|
<p>按当前标签页单独统计,共 {{ selectedRow?.value.studentCount }} 人;柱顶显示人数和占比。</p>
|
||||||
|
</div>
|
||||||
|
<div ref="chartElement" class="score-chart" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="rows.length" class="chart-panel comparison-panel">
|
||||||
|
<div class="chart-heading">
|
||||||
|
<div><span class="section-kicker">LEVEL COMPARISON</span><h3>各层级整体分析</h3></div>
|
||||||
|
<p>同时比较四个层级的最低—最高分区间、平均分和合格率;当前标签对应层级高亮。</p>
|
||||||
|
</div>
|
||||||
|
<div ref="comparisonChartElement" class="score-chart" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.actions { display: flex; gap: 10px; }
|
||||||
|
.scope-panel, .chart-panel { background: var(--el-bg-color); border: 1px solid var(--el-border-color-lighter); box-shadow: 0 10px 30px rgb(21 47 82 / 5%); }
|
||||||
|
.scope-tabs { display: grid; grid-template-columns: repeat(4, 1fr); border-bottom: 1px solid #dfe5ee; background: #f6f8fb; }
|
||||||
|
.scope-tabs button { min-height: 58px; padding: 10px 18px; border: 0; border-right: 1px solid #e2e7ef; color: #667085; background: transparent; cursor: pointer; text-align: left; transition: color .2s ease, background .2s ease, box-shadow .2s ease; }
|
||||||
|
.scope-tabs button:last-child { border-right: 0; }
|
||||||
|
.scope-tabs button span, .scope-tabs button small { display: block; }
|
||||||
|
.scope-tabs button span { font-weight: 700; font-size: 14px; }
|
||||||
|
.scope-tabs button small { margin-top: 3px; color: #98a2b3; font: 11px Consolas, monospace; }
|
||||||
|
.scope-tabs button.active { color: #173f78; background: #fff; box-shadow: inset 0 3px #315a9b; }
|
||||||
|
.scope-tabs button:focus-visible { outline: 2px solid #315a9b; outline-offset: -3px; }
|
||||||
|
.scope-summary { display: grid; grid-template-columns: minmax(220px, .8fr) 1.6fr; gap: 36px; padding: 28px 30px; }
|
||||||
|
.average-score { padding-right: 30px; border-right: 1px solid #e3e8ef; }
|
||||||
|
.average-score span, .average-score small { display: block; color: #667085; }
|
||||||
|
.average-score span { font-size: 12px; font-weight: 700; }
|
||||||
|
.average-score strong { display: block; margin: 9px 0 7px; color: #173f78; font: 700 48px/1 Consolas, monospace; }
|
||||||
|
.average-score small { font-size: 11px; }
|
||||||
|
.scope-summary dl { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 0; align-items: stretch; }
|
||||||
|
.scope-summary dl div { padding: 14px 16px; background: #f7f9fc; border-left: 2px solid #d5deeb; }
|
||||||
|
.scope-summary dt { color: #667085; font-size: 11px; }
|
||||||
|
.scope-summary dd { margin: 8px 0 0; color: #243b5a; font: 700 20px/1.1 Consolas, monospace; }
|
||||||
|
.chart-panel { margin-top: 18px; padding: 22px 26px; }
|
||||||
|
.chart-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; }
|
||||||
|
.chart-heading h3 { margin: 4px 0 0; }
|
||||||
|
.chart-heading p { max-width: 420px; margin: 0; color: #667085; font-size: 12px; text-align: right; }
|
||||||
|
.score-chart { height: 350px; margin-top: 10px; }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.page-intro { align-items: flex-start; }
|
||||||
|
.actions { flex-wrap: wrap; }
|
||||||
|
.scope-tabs { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.scope-tabs button:nth-child(2) { border-right: 0; }
|
||||||
|
.scope-tabs button:nth-child(-n + 2) { border-bottom: 1px solid #e2e7ef; }
|
||||||
|
.scope-summary { grid-template-columns: 1fr; gap: 20px; padding: 22px; }
|
||||||
|
.average-score { padding: 0 0 20px; border: 0; border-bottom: 1px solid #e3e8ef; }
|
||||||
|
.scope-summary dl { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.chart-heading { align-items: flex-start; flex-direction: column; }
|
||||||
|
.chart-heading p { text-align: left; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
|
ArrowRight,
|
||||||
Delete,
|
Delete,
|
||||||
DocumentChecked,
|
DocumentChecked,
|
||||||
Download,
|
Download,
|
||||||
@@ -15,9 +16,11 @@ import {
|
|||||||
import http, { apiErrorMessage } from '../api/http'
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
import { downloadApiFile, importExcel } from '../api/excel'
|
import { downloadApiFile, importExcel } from '../api/excel'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
const isStudent = computed(() => auth.user?.roles.includes('Student') &&
|
const isStudent = computed(() => auth.user?.roles.includes('Student') &&
|
||||||
!auth.user.roles.some((role) =>
|
!auth.user.roles.some((role) =>
|
||||||
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher'].includes(role)))
|
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher'].includes(role)))
|
||||||
@@ -127,6 +130,10 @@ function removeItem(index: number) {
|
|||||||
createForm.items.splice(index, 1)
|
createForm.items.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openStatistics(record: any) {
|
||||||
|
router.push({ name: 'course-grade-statistics', params: { sheetId: record.gradeSheetId } })
|
||||||
|
}
|
||||||
|
|
||||||
async function load(resetPage = false) {
|
async function load(resetPage = false) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -463,7 +470,17 @@ onMounted(async () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="transcript-list" v-loading="loading">
|
<section class="transcript-list" v-loading="loading">
|
||||||
<article v-for="record in transcript.records" :key="record.id">
|
<article
|
||||||
|
v-for="record in transcript.records"
|
||||||
|
:key="record.id"
|
||||||
|
class="transcript-course-card"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
:aria-label="`查看${record.courseName}成绩分析`"
|
||||||
|
@click="openStatistics(record)"
|
||||||
|
@keydown.enter="openStatistics(record)"
|
||||||
|
@keydown.space.prevent="openStatistics(record)"
|
||||||
|
>
|
||||||
<div class="transcript-course">
|
<div class="transcript-course">
|
||||||
<span>{{ record.termName }}</span>
|
<span>{{ record.termName }}</span>
|
||||||
<h3>{{ record.courseName }}</h3>
|
<h3>{{ record.courseName }}</h3>
|
||||||
@@ -482,6 +499,7 @@ onMounted(async () => {
|
|||||||
<b v-if="record.totalScore != null">{{ record.totalScore }}</b>
|
<b v-if="record.totalScore != null">{{ record.totalScore }}</b>
|
||||||
<b v-else>—</b>
|
<b v-else>—</b>
|
||||||
<small>绩点 {{ record.gradePoint ?? '—' }}</small>
|
<small>绩点 {{ record.gradePoint ?? '—' }}</small>
|
||||||
|
<span class="card-entry">查看成绩分析 <el-icon><ArrowRight /></el-icon></span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<el-empty v-if="!transcript.records.length" description="当前范围内暂无已发布成绩" />
|
<el-empty v-if="!transcript.records.length" description="当前范围内暂无已发布成绩" />
|
||||||
@@ -835,6 +853,10 @@ onMounted(async () => {
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
.transcript-result.is-failed b { color: #b42318; }
|
.transcript-result.is-failed b { color: #b42318; }
|
||||||
|
.transcript-course-card { cursor: pointer; transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease; }
|
||||||
|
.transcript-course-card:hover { transform: translateY(-2px); border-color: #91a8c8; box-shadow: 0 10px 24px rgb(31 66 112 / 9%); }
|
||||||
|
.transcript-course-card:focus-visible { outline: 2px solid #315a9b; outline-offset: 3px; }
|
||||||
|
.card-entry { display: inline-flex !important; align-items: center; justify-content: flex-end; gap: 4px; margin-top: 8px; color: #315a9b !important; font-size: 10px !important; font-weight: 700; }
|
||||||
|
|
||||||
/* Preview total score */
|
/* Preview total score */
|
||||||
.total-score.preview {
|
.total-score.preview {
|
||||||
|
|||||||
Reference in New Issue
Block a user