“教学班成绩分析”页面新增“定时刷新设置”,支持启停、刷新间隔、单次处理上限、查看上次/下次扫描时间。
配置保存在独立数据库表 CourseGradeStatisticsRefreshSettings,不是修改 appsettings.json。 后台每 10 秒读取配置,仅到期扫描;成绩录入、导入、审批时不再立即创建统计任务。 扫描发现过期数据后,仍通过持久任务、Outbox 和 RabbitMQ 执行;Redis统计缓存由处理任务统一刷新。
This commit is contained in:
@@ -18,6 +18,7 @@ BackgroundJobs__AutomaticScheduleConcurrency=1
|
|||||||
BackgroundJobs__SchedulePublishConcurrency=1
|
BackgroundJobs__SchedulePublishConcurrency=1
|
||||||
BackgroundJobs__MakeupExamAutoConcurrency=1
|
BackgroundJobs__MakeupExamAutoConcurrency=1
|
||||||
BackgroundJobs__ExamArrangementConcurrency=1
|
BackgroundJobs__ExamArrangementConcurrency=1
|
||||||
|
BackgroundJobs__CourseGradeStatisticsRefreshConcurrency=1
|
||||||
# RabbitMq__HostName=rabbitmq.example.edu.cn
|
# RabbitMq__HostName=rabbitmq.example.edu.cn
|
||||||
# RabbitMq__Port=5671
|
# RabbitMq__Port=5671
|
||||||
# RabbitMq__UserName=jiaowu
|
# RabbitMq__UserName=jiaowu
|
||||||
|
|||||||
@@ -251,13 +251,6 @@ 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,
|
||||||
|
|||||||
@@ -25,6 +25,69 @@ public sealed class GradeAnalyticsController(
|
|||||||
SystemRoles.CollegeAdmin + "," +
|
SystemRoles.CollegeAdmin + "," +
|
||||||
SystemRoles.Leader + "," +
|
SystemRoles.Leader + "," +
|
||||||
SystemRoles.Teacher;
|
SystemRoles.Teacher;
|
||||||
|
private const string ScheduleManagers =
|
||||||
|
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||||
|
|
||||||
|
[HttpGet("refresh-schedule")]
|
||||||
|
[Authorize(Roles = ScheduleManagers)]
|
||||||
|
public async Task<ActionResult> GetRefreshSchedule(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var setting = await db.CourseGradeStatisticsRefreshSettings.AsNoTracking()
|
||||||
|
.SingleOrDefaultAsync(
|
||||||
|
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
|
||||||
|
cancellationToken);
|
||||||
|
var defaults = new CourseGradeStatisticsRefreshSetting();
|
||||||
|
var enabled = setting?.IsEnabled ?? defaults.IsEnabled;
|
||||||
|
var intervalSeconds = setting?.IntervalSeconds ?? defaults.IntervalSeconds;
|
||||||
|
var batchSize = setting?.BatchSize ?? defaults.BatchSize;
|
||||||
|
var lastRunAt = setting?.LastRunAt;
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
IsEnabled = enabled,
|
||||||
|
IntervalSeconds = intervalSeconds,
|
||||||
|
BatchSize = batchSize,
|
||||||
|
LastRunAt = lastRunAt,
|
||||||
|
NextRunAt = enabled && lastRunAt.HasValue
|
||||||
|
? lastRunAt.Value.AddSeconds(intervalSeconds)
|
||||||
|
: null as DateTime?
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("refresh-schedule")]
|
||||||
|
[Authorize(Roles = ScheduleManagers)]
|
||||||
|
public async Task<ActionResult> SaveRefreshSchedule(
|
||||||
|
SaveGradeStatisticsRefreshScheduleRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (request.IntervalSeconds is < 10 or > 86400)
|
||||||
|
return BadRequest(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "刷新间隔应在 10 秒到 24 小时之间。",
|
||||||
|
Status = StatusCodes.Status400BadRequest
|
||||||
|
});
|
||||||
|
if (request.BatchSize is < 1 or > 5000)
|
||||||
|
return BadRequest(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "单次刷新批量应在 1 到 5000 之间。",
|
||||||
|
Status = StatusCodes.Status400BadRequest
|
||||||
|
});
|
||||||
|
|
||||||
|
var setting = await db.CourseGradeStatisticsRefreshSettings
|
||||||
|
.SingleOrDefaultAsync(
|
||||||
|
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
|
||||||
|
cancellationToken);
|
||||||
|
if (setting is null)
|
||||||
|
{
|
||||||
|
setting = new CourseGradeStatisticsRefreshSetting();
|
||||||
|
db.CourseGradeStatisticsRefreshSettings.Add(setting);
|
||||||
|
}
|
||||||
|
|
||||||
|
setting.IsEnabled = request.IsEnabled;
|
||||||
|
setting.IntervalSeconds = request.IntervalSeconds;
|
||||||
|
setting.BatchSize = request.BatchSize;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("teaching-classes")]
|
[HttpGet("teaching-classes")]
|
||||||
public async Task<ActionResult> GetTeachingClasses(
|
public async Task<ActionResult> GetTeachingClasses(
|
||||||
@@ -526,3 +589,8 @@ public sealed class GradeAnalyticsController(
|
|||||||
IReadOnlyList<HistoricalComparison> History,
|
IReadOnlyList<HistoricalComparison> History,
|
||||||
ComparisonDelta? UniversityDelta);
|
ComparisonDelta? UniversityDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record SaveGradeStatisticsRefreshScheduleRequest(
|
||||||
|
bool IsEnabled,
|
||||||
|
int IntervalSeconds,
|
||||||
|
int BatchSize);
|
||||||
|
|||||||
@@ -508,7 +508,6 @@ 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
|
||||||
{
|
{
|
||||||
@@ -668,7 +667,6 @@ 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.
|
||||||
@@ -918,7 +916,6 @@ 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 });
|
||||||
}
|
}
|
||||||
@@ -1161,7 +1158,6 @@ 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();
|
||||||
}
|
}
|
||||||
@@ -1171,14 +1167,6 @@ 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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,32 +21,45 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
|||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> GetRules(Guid academicTermId, CancellationToken ct) =>
|
public async Task<ActionResult> GetRules(Guid academicTermId, CancellationToken ct) =>
|
||||||
Ok(await db.WarningRules.AsNoTracking().Where(x => x.AcademicTermId == academicTermId)
|
Ok(await db.WarningRules.AsNoTracking().Where(x => x.AcademicTermId == academicTermId)
|
||||||
.OrderBy(x => x.Type).Select(x => new { x.Id, x.Type, x.Name, x.Threshold, x.IsEnabled, x.NotifyStudent, x.NotifyCounselor, x.Description, x.AutoCheckEnabled, CheckDayOfWeek = x.CheckDayOfWeek ?? 0, x.CheckHour, x.CheckMinute, x.LastCheckAt })
|
.OrderBy(x => x.Type).Select(x => new { x.Id, Type = (int)x.Type, x.Name, x.Threshold, x.IsEnabled, x.NotifyStudent, x.NotifyCounselor, x.Description, x.AutoCheckEnabled, CheckDayOfWeek = x.CheckDayOfWeek ?? 0, x.CheckHour, x.CheckMinute, x.LastCheckAt })
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
|
||||||
[HttpPut("rules")]
|
[HttpPut("rules")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> SaveRules(Guid academicTermId, List<WarningRuleDto> rules, CancellationToken ct)
|
public async Task<ActionResult> SaveRules(Guid academicTermId, List<WarningRuleDto> rules, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
if (rules.GroupBy(x => x.Type).Any(group => group.Count() > 1))
|
||||||
|
return BadRequest(new ProblemDetails { Title = "预警类型不能重复。", Status = StatusCodes.Status400BadRequest });
|
||||||
|
if (rules.Any(x => x.CheckDayOfWeek is < 0 or > 7 || x.CheckHour is < 0 or > 23 || x.CheckMinute is < 0 or > 59))
|
||||||
|
return BadRequest(new ProblemDetails { Title = "自动检测时间无效。", Status = StatusCodes.Status400BadRequest });
|
||||||
|
|
||||||
var existing = await db.WarningRules.Where(x => x.AcademicTermId == academicTermId).ToListAsync(ct);
|
var existing = await db.WarningRules.Where(x => x.AcademicTermId == academicTermId).ToListAsync(ct);
|
||||||
db.WarningRules.RemoveRange(existing);
|
var incomingTypes = rules.Select(x => x.Type).ToHashSet();
|
||||||
|
db.WarningRules.RemoveRange(existing.Where(x => !incomingTypes.Contains(x.Type)));
|
||||||
foreach (var r in rules)
|
foreach (var r in rules)
|
||||||
{
|
{
|
||||||
db.WarningRules.Add(new WarningRule
|
var entity = existing.FirstOrDefault(x => x.Type == r.Type);
|
||||||
|
if (entity is null)
|
||||||
|
{
|
||||||
|
entity = new WarningRule
|
||||||
{
|
{
|
||||||
AcademicTermId = academicTermId,
|
AcademicTermId = academicTermId,
|
||||||
Type = r.Type,
|
Type = r.Type,
|
||||||
Name = r.Name.Trim(),
|
Name = r.Name.Trim()
|
||||||
Threshold = r.Threshold,
|
};
|
||||||
IsEnabled = r.IsEnabled,
|
db.WarningRules.Add(entity);
|
||||||
NotifyStudent = r.NotifyStudent,
|
}
|
||||||
NotifyCounselor = r.NotifyCounselor,
|
|
||||||
Description = r.Description?.Trim(),
|
entity.Name = r.Name.Trim();
|
||||||
AutoCheckEnabled = r.AutoCheckEnabled,
|
entity.Threshold = r.Threshold;
|
||||||
CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek,
|
entity.IsEnabled = r.IsEnabled;
|
||||||
CheckHour = r.CheckHour,
|
entity.NotifyStudent = r.NotifyStudent;
|
||||||
CheckMinute = r.CheckMinute
|
entity.NotifyCounselor = r.NotifyCounselor;
|
||||||
});
|
entity.Description = r.Description?.Trim();
|
||||||
|
entity.AutoCheckEnabled = r.AutoCheckEnabled;
|
||||||
|
entity.CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek;
|
||||||
|
entity.CheckHour = r.CheckHour;
|
||||||
|
entity.CheckMinute = r.CheckMinute;
|
||||||
}
|
}
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
@@ -120,7 +133,7 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
|||||||
}
|
}
|
||||||
if (academicTermId.HasValue) q = q.Where(x => x.AcademicTermId == academicTermId);
|
if (academicTermId.HasValue) q = q.Where(x => x.AcademicTermId == academicTermId);
|
||||||
if (type.HasValue) q = q.Where(x => x.Type == type);
|
if (type.HasValue) q = q.Where(x => x.Type == type);
|
||||||
return Ok(await q.OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.StudentId, StudentName = x.Student!.Name, StudentNumber = x.Student.StudentNumber, ClassName = x.Student.AdministrativeClass!.Name, x.Type, x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt }).ToListAsync(ct));
|
return Ok(await q.OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.StudentId, StudentName = x.Student!.Name, StudentNumber = x.Student.StudentNumber, ClassName = x.Student.AdministrativeClass!.Name, Type = (int)x.Type, Status = (int)x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt }).ToListAsync(ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════ Student ═══════════
|
// ═══════════ Student ═══════════
|
||||||
@@ -131,7 +144,7 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
|||||||
var sid = await GetStudentIdAsync(ct);
|
var sid = await GetStudentIdAsync(ct);
|
||||||
if (sid is null) return StudentNotFound();
|
if (sid is null) return StudentNotFound();
|
||||||
return Ok(await db.WarningRecords.AsNoTracking().Where(x => x.StudentId == sid).OrderByDescending(x => x.CreatedAt)
|
return Ok(await db.WarningRecords.AsNoTracking().Where(x => x.StudentId == sid).OrderByDescending(x => x.CreatedAt)
|
||||||
.Select(x => new { x.Id, x.Type, x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt, TermName = x.AcademicTermId })
|
.Select(x => new { x.Id, Type = (int)x.Type, Status = (int)x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt, TermName = x.AcademicTermId })
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using Jiaowu.Api.Domain.Common;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Domain.Academic;
|
||||||
|
|
||||||
|
public sealed class CourseGradeStatisticsRefreshSetting : EntityBase
|
||||||
|
{
|
||||||
|
public const string DefaultKey = "default";
|
||||||
|
|
||||||
|
public string Key { get; set; } = DefaultKey;
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
public int IntervalSeconds { get; set; } = 300;
|
||||||
|
public int BatchSize { get; set; } = 100;
|
||||||
|
public DateTime? LastRunAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
|
using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Grades;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repairs missing or stale materialized grade statistics on a database-
|
||||||
|
/// configured fixed interval. Grade writes do not enqueue refresh jobs; this
|
||||||
|
/// worker batches changes made during bulk imports.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CourseGradeStatisticsRefreshWorker(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
ILogger<CourseGradeStatisticsRefreshWorker> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Database-configured course grade statistics scheduler started.");
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10), timeProvider);
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
|
var scheduler = scope.ServiceProvider
|
||||||
|
.GetRequiredService<CourseGradeStatisticsRefreshScheduler>();
|
||||||
|
var queued = await scheduler.EnqueueDueAsync(
|
||||||
|
timeProvider.GetUtcNow().UtcDateTime,
|
||||||
|
stoppingToken);
|
||||||
|
if (queued > 0)
|
||||||
|
logger.LogInformation(
|
||||||
|
"Scheduled course grade statistics scan queued {Count} refresh jobs.",
|
||||||
|
queued);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "Scheduled course grade statistics scan failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await timer.WaitForNextTickAsync(stoppingToken)) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CourseGradeStatisticsRefreshScheduler(
|
||||||
|
AppDbContext db,
|
||||||
|
ILogger<CourseGradeStatisticsRefreshScheduler> logger)
|
||||||
|
{
|
||||||
|
public async Task<int> EnqueueDueAsync(
|
||||||
|
DateTime utcNow,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await ExecuteWithLeaseAsync(async () =>
|
||||||
|
{
|
||||||
|
var setting = await db.CourseGradeStatisticsRefreshSettings
|
||||||
|
.SingleOrDefaultAsync(
|
||||||
|
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
|
||||||
|
cancellationToken);
|
||||||
|
if (setting is null)
|
||||||
|
{
|
||||||
|
setting = new CourseGradeStatisticsRefreshSetting();
|
||||||
|
db.CourseGradeStatisticsRefreshSettings.Add(setting);
|
||||||
|
}
|
||||||
|
|
||||||
|
var interval = TimeSpan.FromSeconds(
|
||||||
|
Math.Clamp(setting.IntervalSeconds, 10, 86400));
|
||||||
|
if (!setting.IsEnabled ||
|
||||||
|
setting.LastRunAt.HasValue && utcNow < setting.LastRunAt.Value + interval)
|
||||||
|
{
|
||||||
|
if (db.Entry(setting).State == EntityState.Added)
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
setting.LastRunAt = utcNow;
|
||||||
|
var queued = await EnqueueStaleCoreAsync(setting.BatchSize, cancellationToken);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return queued;
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<int> EnqueueStaleAsync(
|
||||||
|
int batchSize,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await ExecuteWithLeaseAsync(async () =>
|
||||||
|
{
|
||||||
|
var queued = await EnqueueStaleCoreAsync(batchSize, cancellationToken);
|
||||||
|
if (queued > 0) await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return queued;
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
private async Task<int> ExecuteWithLeaseAsync(
|
||||||
|
Func<Task<int>> action,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var usesMySqlLease = db.Database.ProviderName?.Contains(
|
||||||
|
"MySql",
|
||||||
|
StringComparison.OrdinalIgnoreCase) == true;
|
||||||
|
if (usesMySqlLease && !await TryAcquireMySqlLeaseAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
await db.Database.CloseConnectionAsync();
|
||||||
|
logger.LogDebug("Another instance owns the grade statistics refresh lease.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await action();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (usesMySqlLease)
|
||||||
|
await ReleaseMySqlLeaseAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> EnqueueStaleCoreAsync(
|
||||||
|
int batchSize,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
batchSize = Math.Clamp(batchSize, 1, 5000);
|
||||||
|
var activeTargets = await (
|
||||||
|
from job in db.CourseGradeStatisticsRefreshJobs.AsNoTracking()
|
||||||
|
join sheet in db.GradeSheets.AsNoTracking()
|
||||||
|
on job.GradeSheetId equals sheet.Id
|
||||||
|
where job.Status == CourseGradeStatisticsRefreshJobStatus.Queued ||
|
||||||
|
job.Status == CourseGradeStatisticsRefreshJobStatus.Running
|
||||||
|
select new CourseTermTarget(
|
||||||
|
sheet.TeachingTask!.CourseId,
|
||||||
|
sheet.TeachingTask.AcademicTermId))
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var active = activeTargets.ToHashSet();
|
||||||
|
|
||||||
|
var rows = await db.GradeSheets.AsNoTracking()
|
||||||
|
.Where(sheet =>
|
||||||
|
sheet.Status == GradeSheetStatus.Published &&
|
||||||
|
sheet.Records.Any(record => record.TotalScore != null))
|
||||||
|
.Select(sheet => new RefreshCandidate(
|
||||||
|
sheet.Id,
|
||||||
|
sheet.TeachingTask!.CourseId,
|
||||||
|
sheet.TeachingTask.AcademicTermId,
|
||||||
|
sheet.UpdatedAt,
|
||||||
|
sheet.Records
|
||||||
|
.Where(record => record.TotalScore != null)
|
||||||
|
.Max(record => record.UpdatedAt),
|
||||||
|
db.TeachingTaskGradeStatistics
|
||||||
|
.Where(statistic => statistic.GradeSheetId == sheet.Id)
|
||||||
|
.Select(statistic => (DateTime?)statistic.CalculatedAt)
|
||||||
|
.FirstOrDefault()))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var stale = rows
|
||||||
|
.Where(row =>
|
||||||
|
row.CalculatedAt is null ||
|
||||||
|
row.SheetUpdatedAt > row.CalculatedAt ||
|
||||||
|
row.RecordsUpdatedAt > row.CalculatedAt)
|
||||||
|
.GroupBy(row => new CourseTermTarget(row.CourseId, row.AcademicTermId))
|
||||||
|
.Where(group => !active.Contains(group.Key))
|
||||||
|
.Select(group => group
|
||||||
|
.OrderByDescending(row => row.RecordsUpdatedAt)
|
||||||
|
.ThenByDescending(row => row.SheetUpdatedAt)
|
||||||
|
.First())
|
||||||
|
.Take(batchSize)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var candidate in stale)
|
||||||
|
{
|
||||||
|
var job = new CourseGradeStatisticsRefreshJob
|
||||||
|
{
|
||||||
|
GradeSheetId = candidate.GradeSheetId
|
||||||
|
};
|
||||||
|
db.CourseGradeStatisticsRefreshJobs.Add(job);
|
||||||
|
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
||||||
|
BackgroundJobKind.CourseGradeStatisticsRefresh,
|
||||||
|
job.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stale.Length == 0) return 0;
|
||||||
|
logger.LogDebug(
|
||||||
|
"Queued {Count} stale course grade statistics targets.",
|
||||||
|
stale.Length);
|
||||||
|
return stale.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> TryAcquireMySqlLeaseAsync(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await db.Database.OpenConnectionAsync(cancellationToken);
|
||||||
|
await using var command = db.Database.GetDbConnection().CreateCommand();
|
||||||
|
command.CommandText = "SELECT GET_LOCK('jiaowu:grade-statistics-refresh', 0);";
|
||||||
|
var result = await command.ExecuteScalarAsync(cancellationToken);
|
||||||
|
return Convert.ToInt32(result) == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReleaseMySqlLeaseAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var command = db.Database.GetDbConnection().CreateCommand();
|
||||||
|
command.CommandText = "SELECT RELEASE_LOCK('jiaowu:grade-statistics-refresh');";
|
||||||
|
await command.ExecuteScalarAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(exception, "Failed to release grade statistics refresh lease.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await db.Database.CloseConnectionAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record RefreshCandidate(
|
||||||
|
Guid GradeSheetId,
|
||||||
|
Guid CourseId,
|
||||||
|
Guid AcademicTermId,
|
||||||
|
DateTime SheetUpdatedAt,
|
||||||
|
DateTime RecordsUpdatedAt,
|
||||||
|
DateTime? CalculatedAt);
|
||||||
|
|
||||||
|
private sealed record CourseTermTarget(Guid CourseId, Guid AcademicTermId);
|
||||||
|
}
|
||||||
@@ -144,6 +144,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
Set<AppUpdateRelease>();
|
Set<AppUpdateRelease>();
|
||||||
public DbSet<SystemFeatureSetting> SystemFeatureSettings =>
|
public DbSet<SystemFeatureSetting> SystemFeatureSettings =>
|
||||||
Set<SystemFeatureSetting>();
|
Set<SystemFeatureSetting>();
|
||||||
|
public DbSet<CourseGradeStatisticsRefreshSetting> CourseGradeStatisticsRefreshSettings =>
|
||||||
|
Set<CourseGradeStatisticsRefreshSetting>();
|
||||||
public DbSet<RefreshSession> RefreshSessions => Set<RefreshSession>();
|
public DbSet<RefreshSession> RefreshSessions => Set<RefreshSession>();
|
||||||
|
|
||||||
protected override void ConfigureConventions(
|
protected override void ConfigureConventions(
|
||||||
@@ -1536,6 +1538,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.HasIndex(x => x.Key).IsUnique();
|
entity.HasIndex(x => x.Key).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<CourseGradeStatisticsRefreshSetting>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.Key).HasMaxLength(50);
|
||||||
|
entity.HasIndex(x => x.Key).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<OfficialDocument>(entity =>
|
builder.Entity<OfficialDocument>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.DocumentNumber).HasMaxLength(50);
|
entity.Property(x => x.DocumentNumber).HasMaxLength(50);
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"20260809_51_separate_experiment_classroom_scope";
|
"20260809_51_separate_experiment_classroom_scope";
|
||||||
private const string ReusableCourseGroupsMigration =
|
private const string ReusableCourseGroupsMigration =
|
||||||
"20260809_52_reusable_course_groups";
|
"20260809_52_reusable_course_groups";
|
||||||
|
private const string CourseGradeStatisticsRefreshSettingsMigration =
|
||||||
|
"20260809_53_course_grade_statistics_refresh_settings";
|
||||||
|
|
||||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -677,6 +679,14 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
TeachingTaskGradeAnalyticsMigration,
|
TeachingTaskGradeAnalyticsMigration,
|
||||||
TeachingTaskGradeAnalyticsStatements,
|
TeachingTaskGradeAnalyticsStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
var gradeRefreshSettingsExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CourseGradeStatisticsRefreshSettings'")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
CourseGradeStatisticsRefreshSettingsMigration,
|
||||||
|
gradeRefreshSettingsExist ? [] : CourseGradeStatisticsRefreshSettingsStatements,
|
||||||
|
cancellationToken);
|
||||||
var swaggerSettingsExist = await db.Database
|
var swaggerSettingsExist = await db.Database
|
||||||
.SqlQueryRaw<int>(
|
.SqlQueryRaw<int>(
|
||||||
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'SystemFeatureSettings'")
|
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'SystemFeatureSettings'")
|
||||||
@@ -2973,6 +2983,12 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"""
|
"""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] CourseGradeStatisticsRefreshSettingsStatements =
|
||||||
|
[
|
||||||
|
"""CREATE TABLE IF NOT EXISTS "CourseGradeStatisticsRefreshSettings" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseGradeStatisticsRefreshSettings" PRIMARY KEY, "Key" TEXT NOT NULL, "IsEnabled" INTEGER NOT NULL, "IntervalSeconds" INTEGER NOT NULL, "BatchSize" INTEGER NOT NULL, "LastRunAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL);""",
|
||||||
|
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshSettings_Key" ON "CourseGradeStatisticsRefreshSettings" ("Key");"""
|
||||||
|
];
|
||||||
|
|
||||||
private static readonly string[] ExperimentClassroomConstraintStatements =
|
private static readonly string[] ExperimentClassroomConstraintStatements =
|
||||||
[
|
[
|
||||||
"""
|
"""
|
||||||
|
|||||||
+6867
File diff suppressed because it is too large
Load Diff
+48
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class CourseGradeStatisticsRefreshSettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CourseGradeStatisticsRefreshSettings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
Key = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false),
|
||||||
|
IsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
IntervalSeconds = table.Column<int>(type: "int", nullable: false),
|
||||||
|
BatchSize = table.Column<int>(type: "int", nullable: false),
|
||||||
|
LastRunAt = table.Column<DateTime>(type: "datetime(6)", 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_CourseGradeStatisticsRefreshSettings", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CourseGradeStatisticsRefreshSettings_Key",
|
||||||
|
table: "CourseGradeStatisticsRefreshSettings",
|
||||||
|
column: "Key",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CourseGradeStatisticsRefreshSettings");
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -1098,6 +1098,43 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("CourseGradeStatisticsRefreshJobs");
|
b.ToTable("CourseGradeStatisticsRefreshJobs");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGradeStatisticsRefreshSetting", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int>("BatchSize")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("IntervalSeconds")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("IsEnabled")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("varchar(50)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastRunAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("CourseGradeStatisticsRefreshSettings");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGroup", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGroup", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|||||||
@@ -438,6 +438,9 @@ 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.AddScoped<CourseGradeStatisticsRefreshJobProcessor>();
|
||||||
|
builder.Services.AddScoped<CourseGradeStatisticsRefreshScheduler>();
|
||||||
|
builder.Services.AddSingleton(TimeProvider.System);
|
||||||
|
builder.Services.AddHostedService<CourseGradeStatisticsRefreshWorker>();
|
||||||
builder.Services.AddSingleton<BackgroundJobTelemetry>();
|
builder.Services.AddSingleton<BackgroundJobTelemetry>();
|
||||||
builder.Services.AddScoped<BackgroundJobMonitoringService>();
|
builder.Services.AddScoped<BackgroundJobMonitoringService>();
|
||||||
builder.Services.AddScoped<OperationalHealthService>();
|
builder.Services.AddScoped<OperationalHealthService>();
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
|
using Jiaowu.Api.Infrastructure.Grades;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class CourseGradeStatisticsRefreshSchedulerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task EnqueueDueAsync_UsesPersistentScheduleAndHonorsInterval()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
await using var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
var scheduler = new CourseGradeStatisticsRefreshScheduler(
|
||||||
|
db,
|
||||||
|
NullLogger<CourseGradeStatisticsRefreshScheduler>.Instance);
|
||||||
|
var now = new DateTime(2026, 8, 9, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
await scheduler.EnqueueDueAsync(now, CancellationToken.None);
|
||||||
|
var setting = Assert.Single(await db.CourseGradeStatisticsRefreshSettings.ToListAsync());
|
||||||
|
Assert.True(setting.IsEnabled);
|
||||||
|
Assert.Equal(now, setting.LastRunAt);
|
||||||
|
|
||||||
|
await scheduler.EnqueueDueAsync(now.AddMinutes(1), CancellationToken.None);
|
||||||
|
Assert.Equal(now, setting.LastRunAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnqueueStaleAsync_GroupsByCourseTerm_AndSkipsActiveTarget()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
await using var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var college = new College { Code = "SCHEDULE", Name = "定时任务学院" };
|
||||||
|
var major = new Major
|
||||||
|
{
|
||||||
|
Code = "SCHEDULE-M",
|
||||||
|
Name = "定时任务专业",
|
||||||
|
College = college,
|
||||||
|
DegreeType = "本科"
|
||||||
|
};
|
||||||
|
var administrativeClass = new AdministrativeClass
|
||||||
|
{
|
||||||
|
Code = "SCHEDULE-C",
|
||||||
|
Name = "定时任务一班",
|
||||||
|
Major = major,
|
||||||
|
Grade = 2026
|
||||||
|
};
|
||||||
|
var student = new Student
|
||||||
|
{
|
||||||
|
StudentNumber = "SCHEDULE-001",
|
||||||
|
Name = "定时任务学生",
|
||||||
|
AdministrativeClass = administrativeClass,
|
||||||
|
EnrollmentYear = 2026,
|
||||||
|
EnrollmentDate = new DateOnly(2026, 9, 1)
|
||||||
|
};
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
Code = "SCHEDULE-COURSE",
|
||||||
|
Name = "定时任务课程",
|
||||||
|
College = college,
|
||||||
|
Credits = 2,
|
||||||
|
TotalHours = 32,
|
||||||
|
LectureHours = 32
|
||||||
|
};
|
||||||
|
var term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2026-A",
|
||||||
|
Name = "2026-2027 学年第一学期",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2026, 9, 1),
|
||||||
|
EndDate = new DateOnly(2027, 1, 15)
|
||||||
|
};
|
||||||
|
var firstTask = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "SCHEDULE-T1",
|
||||||
|
Name = "定时任务教学班一",
|
||||||
|
Course = course,
|
||||||
|
AcademicTerm = term,
|
||||||
|
Capacity = 30
|
||||||
|
};
|
||||||
|
var secondTask = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "SCHEDULE-T2",
|
||||||
|
Name = "定时任务教学班二",
|
||||||
|
Course = course,
|
||||||
|
AcademicTerm = term,
|
||||||
|
Capacity = 30
|
||||||
|
};
|
||||||
|
var firstSheet = PublishedSheet(firstTask, student, 80m);
|
||||||
|
var secondSheet = PublishedSheet(secondTask, student, 90m);
|
||||||
|
db.GradeSheets.AddRange(firstSheet, secondSheet);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var scheduler = new CourseGradeStatisticsRefreshScheduler(
|
||||||
|
db,
|
||||||
|
NullLogger<CourseGradeStatisticsRefreshScheduler>.Instance);
|
||||||
|
|
||||||
|
var queued = await scheduler.EnqueueStaleAsync(100, CancellationToken.None);
|
||||||
|
var queuedAgain = await scheduler.EnqueueStaleAsync(100, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, queued);
|
||||||
|
Assert.Equal(0, queuedAgain);
|
||||||
|
var job = Assert.Single(await db.CourseGradeStatisticsRefreshJobs.ToListAsync());
|
||||||
|
var outbox = Assert.Single(await db.BackgroundJobOutboxMessages.ToListAsync());
|
||||||
|
Assert.Equal(BackgroundJobKind.CourseGradeStatisticsRefresh, outbox.JobKind);
|
||||||
|
|
||||||
|
job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded;
|
||||||
|
db.TeachingTaskGradeStatistics.AddRange(
|
||||||
|
FreshStatistic(firstSheet, course, term),
|
||||||
|
FreshStatistic(secondSheet, course, term));
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var queuedAfterFreshStatistics = await scheduler.EnqueueStaleAsync(
|
||||||
|
100,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, queuedAfterFreshStatistics);
|
||||||
|
Assert.Single(await db.CourseGradeStatisticsRefreshJobs.ToListAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GradeSheet PublishedSheet(
|
||||||
|
TeachingTask task,
|
||||||
|
Student student,
|
||||||
|
decimal score)
|
||||||
|
{
|
||||||
|
var sheet = new GradeSheet
|
||||||
|
{
|
||||||
|
TeachingTask = task,
|
||||||
|
Status = GradeSheetStatus.Published,
|
||||||
|
PublishedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
sheet.Records.Add(new GradeRecord
|
||||||
|
{
|
||||||
|
GradeSheet = sheet,
|
||||||
|
Student = student,
|
||||||
|
TotalScore = score,
|
||||||
|
GradePoint = 3m
|
||||||
|
});
|
||||||
|
return sheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TeachingTaskGradeStatistic FreshStatistic(
|
||||||
|
GradeSheet sheet,
|
||||||
|
Course course,
|
||||||
|
AcademicTerm term) => new()
|
||||||
|
{
|
||||||
|
GradeSheetId = sheet.Id,
|
||||||
|
TeachingTaskId = sheet.TeachingTaskId,
|
||||||
|
CourseId = course.Id,
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
StudentCount = 1,
|
||||||
|
PassedCount = 1,
|
||||||
|
HighestScore = 80m,
|
||||||
|
AverageScore = 80m,
|
||||||
|
MedianScore = 80m,
|
||||||
|
LowestScore = 80m,
|
||||||
|
PassRate = 100m,
|
||||||
|
CalculatedAt = DateTime.UtcNow.AddMinutes(1)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using Jiaowu.Api.Controllers;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class WarningRulePersistenceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveRules_PersistsAutoCheck_AndReturnsNumericType()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
await using var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
var term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "WARN-TERM",
|
||||||
|
Name = "预警测试学期",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2026, 9, 1),
|
||||||
|
EndDate = new DateOnly(2027, 1, 15)
|
||||||
|
};
|
||||||
|
db.AcademicTerms.Add(term);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var controller = new WarningsController(db, new AllDataScope());
|
||||||
|
|
||||||
|
var saveResult = await controller.SaveRules(term.Id,
|
||||||
|
[
|
||||||
|
new WarningRuleDto(
|
||||||
|
WarningType.FailedCredits,
|
||||||
|
"不及格学分",
|
||||||
|
2m,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
1,
|
||||||
|
9,
|
||||||
|
30)
|
||||||
|
], CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<NoContentResult>(saveResult);
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
var persisted = Assert.Single(await db.WarningRules.AsNoTracking().ToListAsync());
|
||||||
|
Assert.True(persisted.AutoCheckEnabled);
|
||||||
|
Assert.Equal(1, persisted.CheckDayOfWeek);
|
||||||
|
Assert.Equal(9, persisted.CheckHour);
|
||||||
|
Assert.Equal(30, persisted.CheckMinute);
|
||||||
|
|
||||||
|
var getResult = await controller.GetRules(term.Id, CancellationToken.None);
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(getResult);
|
||||||
|
var row = Assert.Single(Assert.IsAssignableFrom<IEnumerable>(ok.Value).Cast<object>());
|
||||||
|
var type = row.GetType().GetProperty("Type")?.GetValue(row);
|
||||||
|
Assert.Equal(1, Assert.IsType<int>(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AllDataScope : ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = new(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"测试管理员",
|
||||||
|
null,
|
||||||
|
DataScope.All,
|
||||||
|
new HashSet<string>([SystemRoles.SuperAdmin]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { Download, Refresh, Search } from '@element-plus/icons-vue'
|
import { Download, Refresh, Search, Setting } from '@element-plus/icons-vue'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import http, { apiErrorMessage } from '../api/http'
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
import { downloadApiFile } from '../api/excel'
|
import { downloadApiFile } from '../api/excel'
|
||||||
import { academicTermLabel, defaultAcademicTermId } from '../utils/academicTerms'
|
import { academicTermLabel, defaultAcademicTermId } from '../utils/academicTerms'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
interface TeachingClassItem {
|
interface TeachingClassItem {
|
||||||
gradeSheetId: string
|
gradeSheetId: string
|
||||||
@@ -25,6 +26,8 @@ interface TeachingClassItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const terms = ref<any[]>([])
|
const terms = ref<any[]>([])
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const canManageSchedule = computed(() => auth.user?.roles.some(role => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false)
|
||||||
const classes = ref<TeachingClassItem[]>([])
|
const classes = ref<TeachingClassItem[]>([])
|
||||||
const selected = ref<TeachingClassItem>()
|
const selected = ref<TeachingClassItem>()
|
||||||
const report = ref<any>()
|
const report = ref<any>()
|
||||||
@@ -37,6 +40,16 @@ const loading = ref(false)
|
|||||||
const reportLoading = ref(false)
|
const reportLoading = ref(false)
|
||||||
const exporting = ref(false)
|
const exporting = ref(false)
|
||||||
const historyMetric = ref<'average' | 'passRate' | 'excellentRate'>('average')
|
const historyMetric = ref<'average' | 'passRate' | 'excellentRate'>('average')
|
||||||
|
const scheduleDialogVisible = ref(false)
|
||||||
|
const scheduleLoading = ref(false)
|
||||||
|
const scheduleSaving = ref(false)
|
||||||
|
const schedule = reactive({
|
||||||
|
isEnabled: true,
|
||||||
|
intervalMinutes: 5,
|
||||||
|
batchSize: 100,
|
||||||
|
lastRunAt: null as string | null,
|
||||||
|
nextRunAt: null as string | null,
|
||||||
|
})
|
||||||
|
|
||||||
const distributionElement = ref<HTMLElement>()
|
const distributionElement = ref<HTMLElement>()
|
||||||
const peerAverageElement = ref<HTMLElement>()
|
const peerAverageElement = ref<HTMLElement>()
|
||||||
@@ -134,6 +147,40 @@ async function refreshStatistics() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openScheduleSettings() {
|
||||||
|
scheduleDialogVisible.value = true
|
||||||
|
scheduleLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = (await http.get('/grade-analytics/refresh-schedule')).data
|
||||||
|
schedule.isEnabled = data.isEnabled
|
||||||
|
schedule.intervalMinutes = Math.max(1, Math.round(data.intervalSeconds / 60))
|
||||||
|
schedule.batchSize = data.batchSize
|
||||||
|
schedule.lastRunAt = data.lastRunAt
|
||||||
|
schedule.nextRunAt = data.nextRunAt
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
scheduleLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveScheduleSettings() {
|
||||||
|
scheduleSaving.value = true
|
||||||
|
try {
|
||||||
|
await http.put('/grade-analytics/refresh-schedule', {
|
||||||
|
isEnabled: schedule.isEnabled,
|
||||||
|
intervalSeconds: schedule.intervalMinutes * 60,
|
||||||
|
batchSize: schedule.batchSize,
|
||||||
|
})
|
||||||
|
ElMessage.success('成绩统计定时刷新设置已保存')
|
||||||
|
scheduleDialogVisible.value = false
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
scheduleSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function exportWordReport() {
|
async function exportWordReport() {
|
||||||
if (!selected.value) return
|
if (!selected.value) return
|
||||||
exporting.value = true
|
exporting.value = true
|
||||||
@@ -307,6 +354,7 @@ onBeforeUnmount(() => {
|
|||||||
<p>对比当前教学班、同课程教学班、学生来源范围与历年成绩;统计仅使用已正式发布成绩。</p>
|
<p>对比当前教学班、同课程教学班、学生来源范围与历年成绩;统计仅使用已正式发布成绩。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="intro-actions">
|
<div class="intro-actions">
|
||||||
|
<el-button v-if="canManageSchedule" :icon="Setting" @click="openScheduleSettings">定时刷新设置</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
:icon="Download"
|
:icon="Download"
|
||||||
@@ -318,6 +366,32 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<el-dialog v-model="scheduleDialogVisible" title="成绩统计定时刷新" width="520px">
|
||||||
|
<el-form v-loading="scheduleLoading" label-width="130px">
|
||||||
|
<el-form-item label="启用定时刷新">
|
||||||
|
<el-switch v-model="schedule.isEnabled" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="刷新间隔">
|
||||||
|
<el-input-number v-model="schedule.intervalMinutes" :min="1" :max="1440" :disabled="!schedule.isEnabled" />
|
||||||
|
<span class="schedule-unit">分钟</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="单次处理上限">
|
||||||
|
<el-input-number v-model="schedule.batchSize" :min="1" :max="5000" :step="50" :disabled="!schedule.isEnabled" />
|
||||||
|
<span class="schedule-unit">个课程学期</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="运行状态">
|
||||||
|
<div class="schedule-status">
|
||||||
|
<span>上次扫描:{{ schedule.lastRunAt ? new Date(schedule.lastRunAt).toLocaleString('zh-CN') : '尚未运行' }}</span>
|
||||||
|
<span v-if="schedule.isEnabled">下次扫描:{{ schedule.nextRunAt ? new Date(schedule.nextRunAt).toLocaleString('zh-CN') : '启用后将尽快运行' }}</span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="scheduleDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="scheduleSaving" @click="saveScheduleSettings">保存设置</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<section class="filter-bar">
|
<section class="filter-bar">
|
||||||
<el-select v-model="termId" clearable placeholder="全部学期" style="width: 240px" @change="loadClasses(true)">
|
<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-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" />
|
||||||
@@ -461,6 +535,8 @@ onBeforeUnmount(() => {
|
|||||||
.chart-card h4 { margin: 5px 0 0; color: var(--ink); font-size: 16px; }
|
.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-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-caption { margin-top: 9px; text-align: left; }
|
||||||
|
.schedule-unit { margin-left: 10px; color: #667085; font-size: 12px; }
|
||||||
|
.schedule-status { display: grid; gap: 3px; color: #667085; font-size: 12px; }
|
||||||
.chart { height: 310px; margin-top: 10px; }
|
.chart { height: 310px; margin-top: 10px; }
|
||||||
.chart-large { height: 360px; }
|
.chart-large { height: 360px; }
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useAuthStore } from '../stores/auth'
|
|||||||
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const isSuperAdmin = computed(() => auth.user?.roles.includes('SuperAdmin'))
|
const isWarningManager = computed(() => auth.user?.roles.some(r => ['SuperAdmin', 'AcademicAdmin'].includes(r)) ?? false)
|
||||||
const isCounselor = computed(() => auth.user?.roles.includes('Counselor') && !auth.user?.roles.some(r => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(r)))
|
const isCounselor = computed(() => auth.user?.roles.includes('Counselor') && !auth.user?.roles.some(r => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(r)))
|
||||||
const isStudent = computed(() => auth.user?.roles.includes('Student') && !auth.user?.roles.some(r => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(r)))
|
const isStudent = computed(() => auth.user?.roles.includes('Student') && !auth.user?.roles.some(r => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(r)))
|
||||||
|
|
||||||
@@ -43,11 +43,11 @@ const ruleRows = reactive([
|
|||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
if (isSuperAdmin.value && termId.value) {
|
if (isWarningManager.value && termId.value) {
|
||||||
const serverRules = (await http.get('/warnings/rules', { params: { academicTermId: termId.value } })).data
|
const serverRules = (await http.get('/warnings/rules', { params: { academicTermId: termId.value } })).data
|
||||||
// Merge server values into reactive rows
|
// Merge server values into reactive rows
|
||||||
for (const row of ruleRows) {
|
for (const row of ruleRows) {
|
||||||
const sr = serverRules.find((r: any) => r.type === row.type)
|
const sr = serverRules.find((r: any) => Number(r.type) === row.type)
|
||||||
if (sr) {
|
if (sr) {
|
||||||
row.threshold = sr.threshold
|
row.threshold = sr.threshold
|
||||||
row.isEnabled = sr.isEnabled
|
row.isEnabled = sr.isEnabled
|
||||||
@@ -62,7 +62,7 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isSuperAdmin.value || isCounselor.value)
|
if (isWarningManager.value || isCounselor.value)
|
||||||
records.value = (await http.get('/warnings/records', { params: { academicTermId: termId.value || undefined, type: filterType.value || undefined } })).data
|
records.value = (await http.get('/warnings/records', { params: { academicTermId: termId.value || undefined, type: filterType.value || undefined } })).data
|
||||||
if (isStudent.value) myWarnings.value = (await http.get('/warnings/my-warnings')).data
|
if (isStudent.value) myWarnings.value = (await http.get('/warnings/my-warnings')).data
|
||||||
} catch (e) { ElMessage.error(apiErrorMessage(e)) } finally { loading.value = false }
|
} catch (e) { ElMessage.error(apiErrorMessage(e)) } finally { loading.value = false }
|
||||||
@@ -72,6 +72,7 @@ async function saveRules() {
|
|||||||
try {
|
try {
|
||||||
const payload = ruleRows.map(r => ({ type: r.type, name: r.name, threshold: r.threshold, isEnabled: r.isEnabled, notifyStudent: r.notifyStudent, notifyCounselor: r.notifyCounselor, description: r.description, autoCheckEnabled: r.autoCheckEnabled, checkDayOfWeek: r.checkDayOfWeek, checkHour: r.checkHour, checkMinute: r.checkMinute }))
|
const payload = ruleRows.map(r => ({ type: r.type, name: r.name, threshold: r.threshold, isEnabled: r.isEnabled, notifyStudent: r.notifyStudent, notifyCounselor: r.notifyCounselor, description: r.description, autoCheckEnabled: r.autoCheckEnabled, checkDayOfWeek: r.checkDayOfWeek, checkHour: r.checkHour, checkMinute: r.checkMinute }))
|
||||||
await http.put('/warnings/rules', payload, { params: { academicTermId: termId.value } })
|
await http.put('/warnings/rules', payload, { params: { academicTermId: termId.value } })
|
||||||
|
await load()
|
||||||
ElMessage.success('预警规则已保存')
|
ElMessage.success('预警规则已保存')
|
||||||
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
||||||
}
|
}
|
||||||
@@ -102,7 +103,7 @@ onMounted(async () => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-stack warn-page">
|
<div class="page-stack warn-page">
|
||||||
<section class="page-intro">
|
<section class="page-intro">
|
||||||
<div><span class="section-kicker">ACADEMIC WARNING</span><h2>{{ isStudent ? '我的预警' : '学业预警' }}</h2><p>{{ isSuperAdmin ? '配置预警规则,执行检测,查看预警记录。' : isCounselor ? '查看所管学生的学业预警情况。' : '查看并确认您的学业预警通知。' }}</p></div>
|
<div><span class="section-kicker">ACADEMIC WARNING</span><h2>{{ isStudent ? '我的预警' : '学业预警' }}</h2><p>{{ isWarningManager ? '配置预警规则,执行检测,查看预警记录。' : isCounselor ? '查看所管学生的学业预警情况。' : '查看并确认您的学业预警通知。' }}</p></div>
|
||||||
<div style="display:flex;gap:8px;align-items:center">
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
<el-select v-model="termId" clearable @change="load" style="width:240px"><el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" /></el-select>
|
<el-select v-model="termId" clearable @change="load" style="width:240px"><el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" /></el-select>
|
||||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||||
@@ -110,7 +111,7 @@ onMounted(async () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Admin: Rule config -->
|
<!-- Admin: Rule config -->
|
||||||
<section v-if="isSuperAdmin && termId" class="warn-rules">
|
<section v-if="isWarningManager && termId" class="warn-rules">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||||||
<h3 style="margin:0">预警规则配置</h3>
|
<h3 style="margin:0">预警规则配置</h3>
|
||||||
<div>
|
<div>
|
||||||
@@ -160,7 +161,7 @@ onMounted(async () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Records -->
|
<!-- Records -->
|
||||||
<section v-if="isSuperAdmin || isCounselor" v-loading="loading" class="warn-records">
|
<section v-if="isWarningManager || isCounselor" v-loading="loading" class="warn-records">
|
||||||
<div style="display:flex;gap:12px;align-items:center;margin-bottom:12px">
|
<div style="display:flex;gap:12px;align-items:center;margin-bottom:12px">
|
||||||
<h3 style="margin:0">预警记录</h3>
|
<h3 style="margin:0">预警记录</h3>
|
||||||
<el-select v-model="filterType" clearable placeholder="全部类型" @change="load" style="width:140px"><el-option v-for="(v,k) in typeLabels" :key="k" :label="v" :value="Number(k)" /></el-select>
|
<el-select v-model="filterType" clearable placeholder="全部类型" @change="load" style="width:140px"><el-option v-for="(v,k) in typeLabels" :key="k" :label="v" :value="Number(k)" /></el-select>
|
||||||
|
|||||||
Reference in New Issue
Block a user