学业预警自动化
This commit is contained in:
@@ -21,7 +21,7 @@ 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 })
|
.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 })
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
|
||||||
[HttpPut("rules")]
|
[HttpPut("rules")]
|
||||||
@@ -37,7 +37,11 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
|||||||
AcademicTermId = academicTermId, Type = r.Type, Name = r.Name.Trim(),
|
AcademicTermId = academicTermId, Type = r.Type, Name = r.Name.Trim(),
|
||||||
Threshold = r.Threshold, IsEnabled = r.IsEnabled,
|
Threshold = r.Threshold, IsEnabled = r.IsEnabled,
|
||||||
NotifyStudent = r.NotifyStudent, NotifyCounselor = r.NotifyCounselor,
|
NotifyStudent = r.NotifyStudent, NotifyCounselor = r.NotifyCounselor,
|
||||||
Description = r.Description?.Trim()
|
Description = r.Description?.Trim(),
|
||||||
|
AutoCheckEnabled = r.AutoCheckEnabled,
|
||||||
|
CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek,
|
||||||
|
CheckHour = r.CheckHour,
|
||||||
|
CheckMinute = r.CheckMinute
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
@@ -115,26 +119,6 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
|||||||
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, x.Type, x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt }).ToListAsync(ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════ Schedule ═══════════
|
|
||||||
[HttpGet("schedule")]
|
|
||||||
[Authorize(Roles = Managers)]
|
|
||||||
public async Task<ActionResult> GetSchedule(Guid academicTermId, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var s = await db.WarningSchedules.AsNoTracking().FirstOrDefaultAsync(x => x.AcademicTermId == academicTermId, ct);
|
|
||||||
return Ok(s is null ? new { isEnabled = false, dayOfWeek = 1, hour = 8, minute = 0, lastRunAt = (DateTime?)null } : new { s.IsEnabled, s.DayOfWeek, s.Hour, s.Minute, s.LastRunAt });
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPut("schedule")]
|
|
||||||
[Authorize(Roles = Managers)]
|
|
||||||
public async Task<ActionResult> SaveSchedule(Guid academicTermId, [FromBody] ScheduleDto dto, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var s = await db.WarningSchedules.FirstOrDefaultAsync(x => x.AcademicTermId == academicTermId, ct);
|
|
||||||
if (s is null) { s = new WarningSchedule { AcademicTermId = academicTermId }; db.WarningSchedules.Add(s); }
|
|
||||||
s.IsEnabled = dto.IsEnabled; s.DayOfWeek = dto.DayOfWeek; s.Hour = dto.Hour; s.Minute = dto.Minute;
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════ Student ═══════════
|
// ═══════════ Student ═══════════
|
||||||
[HttpGet("my-warnings")]
|
[HttpGet("my-warnings")]
|
||||||
[Authorize(Roles = SystemRoles.Student)]
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
@@ -232,6 +216,5 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
|||||||
}
|
}
|
||||||
|
|
||||||
public sealed record StudentInfo(Guid Id, Guid? UserId, string Name, Guid ClassId);
|
public sealed record StudentInfo(Guid Id, Guid? UserId, string Name, Guid ClassId);
|
||||||
public sealed record WarningRuleDto(WarningType Type, [MaxLength(100)] string Name, decimal Threshold, bool IsEnabled, bool NotifyStudent, bool NotifyCounselor, [MaxLength(300)] string? Description);
|
public sealed record WarningRuleDto(WarningType Type, [MaxLength(100)] string Name, decimal Threshold, bool IsEnabled, bool NotifyStudent, bool NotifyCounselor, [MaxLength(300)] string? Description, bool AutoCheckEnabled, int? CheckDayOfWeek, int CheckHour, int CheckMinute);
|
||||||
public sealed record ScheduleDto(bool IsEnabled, int DayOfWeek, int Hour, int Minute);
|
|
||||||
public sealed record AckBody([MaxLength(300)] string? Comment);
|
public sealed record AckBody([MaxLength(300)] string? Comment);
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ public sealed class WarningRule : EntityBase
|
|||||||
public Guid AcademicTermId { get; set; }
|
public Guid AcademicTermId { get; set; }
|
||||||
public AcademicTerm? AcademicTerm { get; set; }
|
public AcademicTerm? AcademicTerm { get; set; }
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
|
// Auto-check schedule (per-type, independent)
|
||||||
|
public bool AutoCheckEnabled { get; set; }
|
||||||
|
public int? CheckDayOfWeek { get; set; } // null = daily
|
||||||
|
public int CheckHour { get; set; } = 8;
|
||||||
|
public int CheckMinute { get; set; }
|
||||||
|
public DateTime? LastCheckAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class WarningRecord : EntityBase
|
public sealed class WarningRecord : EntityBase
|
||||||
@@ -36,17 +42,6 @@ public enum WarningType
|
|||||||
GraduationDelay = 4
|
GraduationDelay = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class WarningSchedule : EntityBase
|
|
||||||
{
|
|
||||||
public Guid AcademicTermId { get; set; }
|
|
||||||
public AcademicTerm? AcademicTerm { get; set; }
|
|
||||||
public bool IsEnabled { get; set; }
|
|
||||||
public int DayOfWeek { get; set; } = 1;
|
|
||||||
public int Hour { get; set; } = 8;
|
|
||||||
public int Minute { get; set; }
|
|
||||||
public DateTime? LastRunAt { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum WarningStatus
|
public enum WarningStatus
|
||||||
{
|
{
|
||||||
Active = 1,
|
Active = 1,
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
public DbSet<CourseSubstitution> CourseSubstitutions => Set<CourseSubstitution>();
|
public DbSet<CourseSubstitution> CourseSubstitutions => Set<CourseSubstitution>();
|
||||||
public DbSet<WarningRule> WarningRules => Set<WarningRule>();
|
public DbSet<WarningRule> WarningRules => Set<WarningRule>();
|
||||||
public DbSet<WarningRecord> WarningRecords => Set<WarningRecord>();
|
public DbSet<WarningRecord> WarningRecords => Set<WarningRecord>();
|
||||||
public DbSet<WarningSchedule> WarningSchedules => Set<WarningSchedule>();
|
|
||||||
public DbSet<Notification> Notifications => Set<Notification>();
|
public DbSet<Notification> Notifications => Set<Notification>();
|
||||||
public DbSet<StudentStatusChange> StudentStatusChanges =>
|
public DbSet<StudentStatusChange> StudentStatusChanges =>
|
||||||
Set<StudentStatusChange>();
|
Set<StudentStatusChange>();
|
||||||
@@ -731,12 +730,6 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.HasOne(x => x.Student).WithMany()
|
entity.HasOne(x => x.Student).WithMany()
|
||||||
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
builder.Entity<WarningSchedule>(entity =>
|
|
||||||
{
|
|
||||||
entity.HasIndex(x => x.AcademicTermId).IsUnique();
|
|
||||||
entity.HasOne(x => x.AcademicTerm).WithMany()
|
|
||||||
.HasForeignKey(x => x.AcademicTermId).OnDelete(DeleteBehavior.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Entity<CourseSubstitution>(entity =>
|
builder.Entity<CourseSubstitution>(entity =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1624,7 +1624,10 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"""CREATE TABLE "WarningRecords" ("Id" TEXT NOT NULL CONSTRAINT "PK_WarningRecords" PRIMARY KEY, "StudentId" TEXT NOT NULL, "Type" INTEGER NOT NULL, "Status" INTEGER NOT NULL, "TriggerValue" TEXT NOT NULL, "Detail" TEXT NOT NULL, "AcknowledgedAt" TEXT NULL, "AcknowledgeComment" TEXT NULL, "AcademicTermId" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_WarningRecords_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT);""",
|
"""CREATE TABLE "WarningRecords" ("Id" TEXT NOT NULL CONSTRAINT "PK_WarningRecords" PRIMARY KEY, "StudentId" TEXT NOT NULL, "Type" INTEGER NOT NULL, "Status" INTEGER NOT NULL, "TriggerValue" TEXT NOT NULL, "Detail" TEXT NOT NULL, "AcknowledgedAt" TEXT NULL, "AcknowledgeComment" TEXT NULL, "AcademicTermId" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_WarningRecords_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT);""",
|
||||||
"""CREATE UNIQUE INDEX "IX_WarningRecords_StudentId_AcademicTermId_Type" ON "WarningRecords" ("StudentId", "AcademicTermId", "Type");""",
|
"""CREATE UNIQUE INDEX "IX_WarningRecords_StudentId_AcademicTermId_Type" ON "WarningRecords" ("StudentId", "AcademicTermId", "Type");""",
|
||||||
"""CREATE INDEX "IX_WarningRecords_Status" ON "WarningRecords" ("Status");""",
|
"""CREATE INDEX "IX_WarningRecords_Status" ON "WarningRecords" ("Status");""",
|
||||||
"""CREATE TABLE "WarningSchedules" ("Id" TEXT NOT NULL CONSTRAINT "PK_WarningSchedules" PRIMARY KEY, "AcademicTermId" TEXT NOT NULL, "IsEnabled" INTEGER NOT NULL, "DayOfWeek" INTEGER NOT NULL, "Hour" INTEGER NOT NULL, "Minute" INTEGER NOT NULL, "LastRunAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_WarningSchedules_AcademicTerms" FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE CASCADE);""",
|
"""ALTER TABLE "WarningRules" ADD COLUMN "AutoCheckEnabled" INTEGER NOT NULL DEFAULT 0;""",
|
||||||
"""CREATE UNIQUE INDEX "IX_WarningSchedules_AcademicTermId" ON "WarningSchedules" ("AcademicTermId");"""
|
"""ALTER TABLE "WarningRules" ADD COLUMN "CheckDayOfWeek" INTEGER NULL;""",
|
||||||
|
"""ALTER TABLE "WarningRules" ADD COLUMN "CheckHour" INTEGER NOT NULL DEFAULT 8;""",
|
||||||
|
"""ALTER TABLE "WarningRules" ADD COLUMN "CheckMinute" INTEGER NOT NULL DEFAULT 0;""",
|
||||||
|
"""ALTER TABLE "WarningRules" ADD COLUMN "LastCheckAt" TEXT NULL;"""
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
public partial class WarningAutoCheck : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable("WarningSchedules");
|
||||||
|
migrationBuilder.AddColumn<bool>("AutoCheckEnabled", "WarningRules", type: "tinyint(1)", nullable: false, defaultValue: false);
|
||||||
|
migrationBuilder.AddColumn<int?>("CheckDayOfWeek", "WarningRules", type: "int", nullable: true);
|
||||||
|
migrationBuilder.AddColumn<int>("CheckHour", "WarningRules", type: "int", nullable: false, defaultValue: 8);
|
||||||
|
migrationBuilder.AddColumn<int>("CheckMinute", "WarningRules", type: "int", nullable: false, defaultValue: 0);
|
||||||
|
migrationBuilder.AddColumn<DateTime?>("LastCheckAt", "WarningRules", type: "datetime(6)", nullable: true);
|
||||||
|
}
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn("LastCheckAt", "WarningRules");
|
||||||
|
migrationBuilder.DropColumn("CheckMinute", "WarningRules");
|
||||||
|
migrationBuilder.DropColumn("CheckHour", "WarningRules");
|
||||||
|
migrationBuilder.DropColumn("CheckDayOfWeek", "WarningRules");
|
||||||
|
migrationBuilder.DropColumn("AutoCheckEnabled", "WarningRules");
|
||||||
|
// WarningSchedules table recreation omitted for brevity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,117 +5,99 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace Jiaowu.Api.Infrastructure.Scheduling;
|
namespace Jiaowu.Api.Infrastructure.Scheduling;
|
||||||
|
|
||||||
public sealed class WarningCheckWorker(
|
public sealed class WarningCheckWorker(IServiceScopeFactory scopeFactory, ILogger<WarningCheckWorker> logger) : BackgroundService
|
||||||
IServiceScopeFactory scopeFactory,
|
|
||||||
ILogger<WarningCheckWorker> logger) : BackgroundService
|
|
||||||
{
|
{
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!ct.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
await Task.Delay(TimeSpan.FromMinutes(1), ct);
|
||||||
await using var scope = scopeFactory.CreateAsyncScope();
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
await RunScheduledChecksAsync(db, stoppingToken);
|
await RunAsync(db, ct);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { break; }
|
catch (OperationCanceledException) { break; }
|
||||||
catch (Exception ex) { logger.LogError(ex, "Warning check worker error"); }
|
catch (Exception ex) { logger.LogError(ex, "Warning check error"); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task RunScheduledChecksAsync(AppDbContext db, CancellationToken ct)
|
private static async Task RunAsync(AppDbContext db, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
var dow = (int)now.DayOfWeek == 0 ? 7 : (int)now.DayOfWeek;
|
||||||
var currentMinute = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc);
|
var currentMinute = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
var schedules = await db.Set<WarningSchedule>()
|
|
||||||
.Where(s => s.IsEnabled &&
|
|
||||||
s.DayOfWeek == ((int)now.DayOfWeek == 0 ? 7 : (int)now.DayOfWeek) &&
|
|
||||||
s.Hour == now.Hour &&
|
|
||||||
s.Minute == now.Minute)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
foreach (var schedule in schedules)
|
|
||||||
{
|
|
||||||
if (schedule.LastRunAt.HasValue && schedule.LastRunAt.Value >= currentMinute)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
schedule.LastRunAt = now;
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
var rules = await db.WarningRules
|
var rules = await db.WarningRules
|
||||||
.Where(x => x.AcademicTermId == schedule.AcademicTermId && x.IsEnabled)
|
.Where(r => r.IsEnabled && r.AutoCheckEnabled &&
|
||||||
.ToListAsync(ct);
|
r.CheckHour == now.Hour && r.CheckMinute == now.Minute &&
|
||||||
if (rules.Count == 0) continue;
|
(r.CheckDayOfWeek == null || r.CheckDayOfWeek == dow))
|
||||||
|
|
||||||
var studentIds = await db.Students
|
|
||||||
.Where(x => x.Status == StudentStatus.Active)
|
|
||||||
.Select(x => x.Id)
|
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
var generated = new List<WarningRecord>();
|
|
||||||
foreach (var rule in rules)
|
foreach (var rule in rules)
|
||||||
{
|
{
|
||||||
|
if (rule.LastCheckAt.HasValue && rule.LastCheckAt.Value >= currentMinute)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
rule.LastCheckAt = now;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
var studentIds = await db.Students.Where(x => x.Status == StudentStatus.Active).Select(x => x.Id).ToListAsync(ct);
|
||||||
|
var generated = new List<WarningRecord>();
|
||||||
|
|
||||||
foreach (var sid in studentIds)
|
foreach (var sid in studentIds)
|
||||||
{
|
{
|
||||||
if (await db.WarningRecords.AnyAsync(x => x.StudentId == sid && x.AcademicTermId == schedule.AcademicTermId && x.Type == rule.Type, ct))
|
if (await db.WarningRecords.AnyAsync(x => x.StudentId == sid && x.AcademicTermId == rule.AcademicTermId && x.Type == rule.Type, ct))
|
||||||
continue;
|
continue;
|
||||||
WarningRecord? w = rule.Type switch
|
var w = rule.Type switch
|
||||||
{
|
{
|
||||||
WarningType.FailedCredits => await CheckFailedCredits(db, rule, schedule.AcademicTermId, sid, ct),
|
WarningType.FailedCredits => await CountWarning(db, rule, sid, rule.AcademicTermId,
|
||||||
WarningType.LowGPA => await CheckLowGPA(db, rule, schedule.AcademicTermId, sid, ct),
|
sid => db.GradeRecords.CountAsync(x => x.StudentId == sid && x.GradeSheet!.TeachingTask!.AcademicTermId == rule.AcademicTermId && x.GradeSheet.Status == GradeSheetStatus.Published && x.TotalScore < 60, ct),
|
||||||
WarningType.Absenteeism => await CheckAbsenteeism(db, rule, schedule.AcademicTermId, sid, ct),
|
n => $"不及格课程 {n} 门", ct),
|
||||||
WarningType.GraduationDelay => await CheckDelay(db, rule, schedule.AcademicTermId, sid, ct),
|
WarningType.LowGPA => await GPACheck(db, rule, sid, rule.AcademicTermId, ct),
|
||||||
|
WarningType.Absenteeism => await CountWarning(db, rule, sid, rule.AcademicTermId,
|
||||||
|
sid => db.AttendanceRecords.CountAsync(x => x.StudentId == sid && x.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted && x.AttendanceSheet.TeachingTask!.AcademicTermId == rule.AcademicTermId && (x.Status == AttendanceStatus.Absent || x.Status == AttendanceStatus.Late), ct),
|
||||||
|
n => $"缺勤/迟到 {n} 次", ct),
|
||||||
|
WarningType.GraduationDelay => await CountWarning(db, rule, sid, rule.AcademicTermId,
|
||||||
|
sid => db.GradeRecords.CountAsync(x => x.StudentId == sid && x.GradeSheet!.Status == GradeSheetStatus.Published && x.TotalScore < 60, ct),
|
||||||
|
n => $"累计不及格 {n} 门", ct),
|
||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
if (w is not null) generated.Add(w);
|
if (w is not null) generated.Add(w);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (generated.Count > 0)
|
if (generated.Count > 0)
|
||||||
{
|
{
|
||||||
db.WarningRecords.AddRange(generated);
|
db.WarningRecords.AddRange(generated);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
foreach (var w in generated)
|
foreach (var w in generated)
|
||||||
{
|
{
|
||||||
var si = await db.Students.Where(x => x.Id == w.StudentId).Select(x => new { x.UserId, x.Name, x.AdministrativeClassId }).FirstAsync(ct);
|
var si = await db.Students.Where(x => x.Id == w.StudentId).Select(x => new { x.UserId, x.Name, x.AdministrativeClassId }).FirstAsync(ct);
|
||||||
var r = rules.First(x => x.Type == w.Type);
|
if (rule.NotifyStudent && si.UserId.HasValue)
|
||||||
if (r.NotifyStudent && si.UserId.HasValue)
|
|
||||||
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", cancellationToken: ct);
|
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", cancellationToken: ct);
|
||||||
if (r.NotifyCounselor)
|
if (rule.NotifyCounselor)
|
||||||
{
|
{
|
||||||
var cid = await db.AdministrativeClasses.Where(c => c.Id == si.AdministrativeClassId && c.CounselorUserId != null).Select(c => c.CounselorUserId!.Value).FirstOrDefaultAsync(ct);
|
var cid = await db.AdministrativeClasses.Where(c => c.Id == si.AdministrativeClassId && c.CounselorUserId != null).Select(c => c.CounselorUserId!.Value).FirstOrDefaultAsync(ct);
|
||||||
if (cid != default)
|
if (cid != default) await NotificationService.SendAsync(db, cid, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", cancellationToken: ct);
|
||||||
await NotificationService.SendAsync(db, cid, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", cancellationToken: ct);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<WarningRecord?> CheckFailedCredits(AppDbContext db, WarningRule rule, Guid termId, Guid sid, CancellationToken ct)
|
private static async Task<WarningRecord?> CountWarning(AppDbContext db, WarningRule rule, Guid sid, Guid termId,
|
||||||
|
Func<Guid, Task<int>> counter, Func<int, string> msg, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var n = await db.GradeRecords.CountAsync(x => x.StudentId == sid && x.GradeSheet!.TeachingTask!.AcademicTermId == termId && x.GradeSheet.Status == GradeSheetStatus.Published && x.TotalScore < 60, ct);
|
var n = await counter(sid);
|
||||||
return n >= rule.Threshold ? new WarningRecord { StudentId = sid, Type = WarningType.FailedCredits, TriggerValue = n, Detail = $"不及格课程 {n} 门,达到预警阈值 {rule.Threshold} 门。", AcademicTermId = termId } : null;
|
return n >= rule.Threshold ? new WarningRecord { StudentId = sid, Type = rule.Type, TriggerValue = n, Detail = $"{msg(n)},达到预警阈值 {rule.Threshold}。", AcademicTermId = termId } : null;
|
||||||
}
|
}
|
||||||
private static async Task<WarningRecord?> CheckLowGPA(AppDbContext db, WarningRule rule, Guid termId, Guid sid, CancellationToken ct)
|
|
||||||
|
private static async Task<WarningRecord?> GPACheck(AppDbContext db, WarningRule rule, Guid sid, Guid termId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var grades = await db.GradeRecords.Where(x => x.StudentId == sid && x.GradeSheet!.TeachingTask!.AcademicTermId == termId && x.GradeSheet.Status == GradeSheetStatus.Published && x.GradePoint != null).ToListAsync(ct);
|
var grades = await db.GradeRecords.Where(x => x.StudentId == sid && x.GradeSheet!.TeachingTask!.AcademicTermId == termId && x.GradeSheet.Status == GradeSheetStatus.Published && x.GradePoint != null).ToListAsync(ct);
|
||||||
if (grades.Count == 0) return null;
|
if (grades.Count == 0) return null;
|
||||||
var gpa = grades.Average(x => x.GradePoint!.Value);
|
var gpa = grades.Average(x => x.GradePoint!.Value);
|
||||||
return gpa < rule.Threshold ? new WarningRecord { StudentId = sid, Type = WarningType.LowGPA, TriggerValue = Math.Round(gpa, 2), Detail = $"平均绩点 {gpa:F2},低于预警阈值 {rule.Threshold}。", AcademicTermId = termId } : null;
|
return gpa < rule.Threshold ? new WarningRecord { StudentId = sid, Type = WarningType.LowGPA, TriggerValue = Math.Round(gpa, 2), Detail = $"平均绩点 {gpa:F2},低于预警阈值 {rule.Threshold}。", AcademicTermId = termId } : null;
|
||||||
}
|
}
|
||||||
private static async Task<WarningRecord?> CheckAbsenteeism(AppDbContext db, WarningRule rule, Guid termId, Guid sid, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var n = await db.AttendanceRecords.CountAsync(x => x.StudentId == sid && x.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted && x.AttendanceSheet.TeachingTask!.AcademicTermId == termId && (x.Status == AttendanceStatus.Absent || x.Status == AttendanceStatus.Late), ct);
|
|
||||||
return n >= rule.Threshold ? new WarningRecord { StudentId = sid, Type = WarningType.Absenteeism, TriggerValue = n, Detail = $"缺勤/迟到 {n} 次,达到预警阈值 {rule.Threshold} 次。", AcademicTermId = termId } : null;
|
|
||||||
}
|
|
||||||
private static async Task<WarningRecord?> CheckDelay(AppDbContext db, WarningRule rule, Guid termId, Guid sid, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var n = await db.GradeRecords.CountAsync(x => x.StudentId == sid && x.GradeSheet!.Status == GradeSheetStatus.Published && x.TotalScore < 60, ct);
|
|
||||||
return n >= rule.Threshold ? new WarningRecord { StudentId = sid, Type = WarningType.GraduationDelay, TriggerValue = n, Detail = $"累计不及格 {n} 门,达到延毕预警阈值 {rule.Threshold} 门。", AcademicTermId = termId } : null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ const myWarnings = ref<any[]>([])
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const detecting = ref(false)
|
const detecting = ref(false)
|
||||||
const filterType = ref('')
|
const filterType = ref('')
|
||||||
const schedule = reactive({ isEnabled: false, dayOfWeek: 1, hour: 8, minute: 0, lastRunAt: null as string | null })
|
|
||||||
|
|
||||||
const typeLabels: Record<number, string> = { 1: '不及格学分', 2: '低绩点', 3: '缺勤', 4: '延毕风险' }
|
const typeLabels: Record<number, string> = { 1: '不及格学分', 2: '低绩点', 3: '缺勤', 4: '延毕风险' }
|
||||||
const statusLabels: Record<number, string> = { 1: '生效中', 2: '已确认', 3: '已处理', 4: '已忽略' }
|
const statusLabels: Record<number, string> = { 1: '生效中', 2: '已确认', 3: '已处理', 4: '已忽略' }
|
||||||
@@ -31,12 +30,13 @@ function thresholdUnit(type: number): string {
|
|||||||
return '门 累计不及格'
|
return '门 累计不及格'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reactive rule objects — one per type, bound directly to v-model
|
const weekOptions = [{ value: 0, label: '每天' }, { value: 1, label: '周一' }, { value: 2, label: '周二' }, { value: 3, label: '周三' }, { value: 4, label: '周四' }, { value: 5, label: '周五' }, { value: 6, label: '周六' }, { value: 7, label: '周日' }]
|
||||||
|
|
||||||
const ruleRows = reactive([
|
const ruleRows = reactive([
|
||||||
{ type: 1 as number, name: '不及格学分', threshold: 2, isEnabled: true, notifyStudent: true, notifyCounselor: true, description: '' },
|
{ type: 1 as number, name: '不及格学分', threshold: 2, isEnabled: true, notifyStudent: true, notifyCounselor: true, description: '', autoCheckEnabled: false, checkDayOfWeek: 0, checkHour: 8, checkMinute: 0, lastCheckAt: null as string | null },
|
||||||
{ type: 2, name: '低绩点', threshold: 2, isEnabled: true, notifyStudent: true, notifyCounselor: true, description: '' },
|
{ type: 2, name: '低绩点', threshold: 2, isEnabled: true, notifyStudent: true, notifyCounselor: true, description: '', autoCheckEnabled: false, checkDayOfWeek: 0, checkHour: 8, checkMinute: 0, lastCheckAt: null as string | null },
|
||||||
{ type: 3, name: '缺勤', threshold: 3, isEnabled: true, notifyStudent: true, notifyCounselor: true, description: '' },
|
{ type: 3, name: '缺勤', threshold: 3, isEnabled: true, notifyStudent: true, notifyCounselor: true, description: '', autoCheckEnabled: false, checkDayOfWeek: 0, checkHour: 8, checkMinute: 0, lastCheckAt: null as string | null },
|
||||||
{ type: 4, name: '延毕风险', threshold: 5, isEnabled: true, notifyStudent: true, notifyCounselor: true, description: '' },
|
{ type: 4, name: '延毕风险', threshold: 5, isEnabled: true, notifyStudent: true, notifyCounselor: true, description: '', autoCheckEnabled: false, checkDayOfWeek: 0, checkHour: 8, checkMinute: 0, lastCheckAt: null as string | null },
|
||||||
])
|
])
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -53,9 +53,13 @@ async function load() {
|
|||||||
row.notifyStudent = sr.notifyStudent
|
row.notifyStudent = sr.notifyStudent
|
||||||
row.notifyCounselor = sr.notifyCounselor
|
row.notifyCounselor = sr.notifyCounselor
|
||||||
row.description = sr.description || ''
|
row.description = sr.description || ''
|
||||||
|
row.autoCheckEnabled = sr.autoCheckEnabled
|
||||||
|
row.checkDayOfWeek = sr.checkDayOfWeek
|
||||||
|
row.checkHour = sr.checkHour
|
||||||
|
row.checkMinute = sr.checkMinute
|
||||||
|
row.lastCheckAt = sr.lastCheckAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await loadSchedule()
|
|
||||||
}
|
}
|
||||||
if (isSuperAdmin.value || isCounselor.value)
|
if (isSuperAdmin.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
|
||||||
@@ -63,19 +67,9 @@ async function load() {
|
|||||||
} catch (e) { ElMessage.error(apiErrorMessage(e)) } finally { loading.value = false }
|
} catch (e) { ElMessage.error(apiErrorMessage(e)) } finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSchedule() {
|
|
||||||
if (!termId.value) return
|
|
||||||
try { const { data } = await http.get('/warnings/schedule', { params: { academicTermId: termId.value } }); Object.assign(schedule, data) }
|
|
||||||
catch (_) {}
|
|
||||||
}
|
|
||||||
async function saveSchedule() {
|
|
||||||
try { await http.put('/warnings/schedule', { isEnabled: schedule.isEnabled, dayOfWeek: schedule.dayOfWeek, hour: schedule.hour, minute: schedule.minute }, { params: { academicTermId: termId.value } }); ElMessage.success('已保存') }
|
|
||||||
catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveRules() {
|
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 }))
|
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 } })
|
||||||
ElMessage.success('预警规则已保存')
|
ElMessage.success('预警规则已保存')
|
||||||
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
||||||
@@ -114,26 +108,6 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Admin: Schedule config -->
|
|
||||||
<section v-if="isSuperAdmin && termId" class="warn-schedule">
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
|
||||||
<h3 style="margin:0">定时检测</h3>
|
|
||||||
<el-button type="primary" size="small" @click="saveSchedule">保存定时</el-button>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;align-items:center;gap:12px;margin-top:10px;flex-wrap:wrap">
|
|
||||||
<el-switch v-model="schedule.isEnabled" active-text="启用自动检测" />
|
|
||||||
<span>每</span>
|
|
||||||
<el-select v-model="schedule.dayOfWeek" size="small" style="width:100px" :disabled="!schedule.isEnabled">
|
|
||||||
<el-option v-for="d in 7" :key="d" :label="['','周一','周二','周三','周四','周五','周六','周日'][d]" :value="d" />
|
|
||||||
</el-select>
|
|
||||||
<el-input-number v-model="schedule.hour" :min="0" :max="23" size="small" style="width:80px" :disabled="!schedule.isEnabled" />
|
|
||||||
<span>时</span>
|
|
||||||
<el-input-number v-model="schedule.minute" :min="0" :max="59" size="small" style="width:80px" :disabled="!schedule.isEnabled" />
|
|
||||||
<span>分</span>
|
|
||||||
<span v-if="schedule.lastRunAt" style="font-size:11px;color:var(--muted);margin-left:auto">上次执行:{{ new Date(schedule.lastRunAt).toLocaleString('zh-CN') }}</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Admin: Rule config -->
|
<!-- Admin: Rule config -->
|
||||||
<section v-if="isSuperAdmin && termId" class="warn-rules">
|
<section v-if="isSuperAdmin && 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">
|
||||||
@@ -156,10 +130,28 @@ onMounted(async () => {
|
|||||||
<el-switch v-model="row.isEnabled" size="small" active-text="启用" style="margin-right:12px" />
|
<el-switch v-model="row.isEnabled" size="small" active-text="启用" style="margin-right:12px" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="通知" width="200">
|
<el-table-column label="通知" width="180">
|
||||||
<template #default="{row}">
|
<template #default="{row}">
|
||||||
<el-checkbox v-model="row.notifyStudent" :disabled="!row.isEnabled">学生</el-checkbox>
|
<el-checkbox v-model="row.notifyStudent" :disabled="!row.isEnabled">学生</el-checkbox>
|
||||||
<el-checkbox v-model="row.notifyCounselor" :disabled="!row.isEnabled" style="margin-left:12px">辅导员</el-checkbox>
|
<el-checkbox v-model="row.notifyCounselor" :disabled="!row.isEnabled" style="margin-left:8px">辅导员</el-checkbox>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="自动检测" min-width="290">
|
||||||
|
<template #default="{row}">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
|
||||||
|
<el-switch v-model="row.autoCheckEnabled" size="small" :disabled="!row.isEnabled" />
|
||||||
|
<template v-if="row.autoCheckEnabled">
|
||||||
|
<span style="font-size:11px">每</span>
|
||||||
|
<el-select v-model="row.checkDayOfWeek" size="small" style="width:75px">
|
||||||
|
<el-option v-for="o in weekOptions" :key="String(o.value)" :label="o.label" :value="o.value" />
|
||||||
|
</el-select>
|
||||||
|
<el-input-number v-model="row.checkHour" :min="0" :max="23" size="small" :controls="false" style="width:56px" />
|
||||||
|
<span style="font-size:11px">时</span>
|
||||||
|
<el-input-number v-model="row.checkMinute" :min="0" :max="59" size="small" :controls="false" style="width:56px" />
|
||||||
|
<span style="font-size:11px">分</span>
|
||||||
|
<span v-if="row.lastCheckAt" style="font-size:10px;color:var(--muted)">上次: {{ new Date(row.lastCheckAt).toLocaleString('zh-CN') }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="说明"><template #default="{row}"><el-input v-model="row.description" size="small" placeholder="可选" maxlength="300" /></template></el-table-column>
|
<el-table-column label="说明"><template #default="{row}"><el-input v-model="row.description" size="small" placeholder="可选" maxlength="300" /></template></el-table-column>
|
||||||
@@ -198,7 +190,6 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.warn-schedule { background:#f0f9eb; border:1px solid #c6e2ff; border-radius:8px; padding:14px 20px; margin-bottom:16px; }
|
|
||||||
.warn-rules { background:#fff; border:1px solid #e4e7ed; border-radius:8px; padding:16px 20px; margin-bottom:20px; }
|
.warn-rules { background:#fff; border:1px solid #e4e7ed; border-radius:8px; padding:16px 20px; margin-bottom:20px; }
|
||||||
.warn-records { background:#fff; border:1px solid #e4e7ed; border-radius:8px; padding:16px 20px; }
|
.warn-records { background:#fff; border:1px solid #e4e7ed; border-radius:8px; padding:16px 20px; }
|
||||||
.warn-mine { display:grid; gap:10px; }
|
.warn-mine { display:grid; gap:10px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user