学业预警
This commit is contained in:
@@ -0,0 +1,216 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/warnings")]
|
||||||
|
public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope scope) : ControllerBase
|
||||||
|
{
|
||||||
|
private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||||
|
|
||||||
|
// ═══════════ Rules (admin) ═══════════
|
||||||
|
[HttpGet("rules")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> GetRules(Guid academicTermId, CancellationToken ct) =>
|
||||||
|
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 })
|
||||||
|
.ToListAsync(ct));
|
||||||
|
|
||||||
|
[HttpPut("rules")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> SaveRules(Guid academicTermId, List<WarningRuleDto> rules, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var existing = await db.WarningRules.Where(x => x.AcademicTermId == academicTermId).ToListAsync(ct);
|
||||||
|
db.WarningRules.RemoveRange(existing);
|
||||||
|
foreach (var r in rules)
|
||||||
|
{
|
||||||
|
db.WarningRules.Add(new WarningRule
|
||||||
|
{
|
||||||
|
AcademicTermId = academicTermId, Type = r.Type, Name = r.Name.Trim(),
|
||||||
|
Threshold = r.Threshold, IsEnabled = r.IsEnabled,
|
||||||
|
NotifyStudent = r.NotifyStudent, NotifyCounselor = r.NotifyCounselor,
|
||||||
|
Description = r.Description?.Trim()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════ Detection + Records ═══════════
|
||||||
|
[HttpPost("detect")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> DetectWarnings(Guid academicTermId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var rules = await db.WarningRules.Where(x => x.AcademicTermId == academicTermId && x.IsEnabled).ToListAsync(ct);
|
||||||
|
if (rules.Count == 0) return Ok(new { Generated = 0, Message = "当前学期无启用的预警规则。" });
|
||||||
|
|
||||||
|
var studentInfos = await db.Students.Where(x => x.Status == StudentStatus.Active).Select(x => new StudentInfo(x.Id, x.UserId, x.Name, x.AdministrativeClassId)).ToListAsync(ct);
|
||||||
|
var generated = new List<WarningRecord>();
|
||||||
|
|
||||||
|
foreach (var rule in rules)
|
||||||
|
{
|
||||||
|
switch (rule.Type)
|
||||||
|
{
|
||||||
|
case WarningType.FailedCredits:
|
||||||
|
generated.AddRange(await DetectFailedCredits(rule, academicTermId, studentInfos, ct));
|
||||||
|
break;
|
||||||
|
case WarningType.LowGPA:
|
||||||
|
generated.AddRange(await DetectLowGPA(rule, academicTermId, studentInfos, ct));
|
||||||
|
break;
|
||||||
|
case WarningType.Absenteeism:
|
||||||
|
generated.AddRange(await DetectAbsenteeism(rule, academicTermId, studentInfos, ct));
|
||||||
|
break;
|
||||||
|
case WarningType.GraduationDelay:
|
||||||
|
generated.AddRange(await DetectGraduationDelay(rule, academicTermId, studentInfos, ct));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generated.Count > 0)
|
||||||
|
{
|
||||||
|
db.WarningRecords.AddRange(generated);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
foreach (var w in generated)
|
||||||
|
{
|
||||||
|
var si = studentInfos.First(s => s.Id == w.StudentId);
|
||||||
|
var r = rules.First(r => r.Type == w.Type);
|
||||||
|
if (r.NotifyStudent && si.UserId.HasValue)
|
||||||
|
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", cancellationToken: ct);
|
||||||
|
if (r.NotifyCounselor)
|
||||||
|
{
|
||||||
|
var counselorId = await db.AdministrativeClasses.Where(c => c.Id == si.ClassId && c.CounselorUserId != null).Select(c => c.CounselorUserId!.Value).FirstOrDefaultAsync(ct);
|
||||||
|
if (counselorId != default)
|
||||||
|
await NotificationService.SendAsync(db, counselorId, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", cancellationToken: ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new { Generated = generated.Count });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("records")]
|
||||||
|
[Authorize(Roles = Managers + "," + SystemRoles.Counselor)]
|
||||||
|
public async Task<ActionResult> GetRecords(Guid? academicTermId, WarningType? type, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = db.WarningRecords.AsNoTracking().AsQueryable();
|
||||||
|
if (scope.Current.Scope == DataScope.College || scope.Current.IsInRole(SystemRoles.Counselor))
|
||||||
|
{
|
||||||
|
var classIds = await db.AdministrativeClasses.Where(c => c.CounselorUserId == scope.Current.UserId).Select(c => c.Id).ToListAsync(ct);
|
||||||
|
if (classIds.Count > 0)
|
||||||
|
q = q.Where(x => classIds.Contains(x.Student!.AdministrativeClassId));
|
||||||
|
else if (scope.Current.RestrictedCollegeId.HasValue)
|
||||||
|
q = q.Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == scope.Current.RestrictedCollegeId);
|
||||||
|
}
|
||||||
|
if (academicTermId.HasValue) q = q.Where(x => x.AcademicTermId == academicTermId);
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════ Student ═══════════
|
||||||
|
[HttpGet("my-warnings")]
|
||||||
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
|
public async Task<ActionResult> GetMyWarnings(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sid = await GetStudentIdAsync(ct);
|
||||||
|
if (sid is null) return StudentNotFound();
|
||||||
|
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 })
|
||||||
|
.ToListAsync(ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/acknowledge")]
|
||||||
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
|
public async Task<ActionResult> Acknowledge(Guid id, [FromBody] AckBody body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sid = await GetStudentIdAsync(ct);
|
||||||
|
if (sid is null) return StudentNotFound();
|
||||||
|
var w = await db.WarningRecords.FirstOrDefaultAsync(x => x.Id == id && x.StudentId == sid, ct);
|
||||||
|
if (w is null) return NotFound();
|
||||||
|
if (w.Status != WarningStatus.Active) return ConflictProblem("该预警已处理。");
|
||||||
|
w.Status = WarningStatus.Acknowledged;
|
||||||
|
w.AcknowledgedAt = DateTime.UtcNow;
|
||||||
|
w.AcknowledgeComment = body.Comment?.Trim();
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════ Detection logic ═══════════
|
||||||
|
private async Task<List<WarningRecord>> DetectFailedCredits(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var records = new List<WarningRecord>();
|
||||||
|
foreach (var s in students)
|
||||||
|
{
|
||||||
|
var failed = await db.GradeRecords.CountAsync(x => x.StudentId == s.Id && x.GradeSheet!.TeachingTask!.AcademicTermId == termId && x.GradeSheet.Status == GradeSheetStatus.Published && x.TotalScore < 60, ct);
|
||||||
|
if (failed >= rule.Threshold)
|
||||||
|
{
|
||||||
|
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.FailedCredits, ct)) continue;
|
||||||
|
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = failed, Detail = $"不及格课程 {failed} 门,达到预警阈值 {rule.Threshold} 门。", AcademicTermId = termId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<WarningRecord>> DetectLowGPA(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var records = new List<WarningRecord>();
|
||||||
|
foreach (var s in students)
|
||||||
|
{
|
||||||
|
var grades = await db.GradeRecords.Where(x => x.StudentId == s.Id && x.GradeSheet!.TeachingTask!.AcademicTermId == termId && x.GradeSheet.Status == GradeSheetStatus.Published && x.GradePoint != null).ToListAsync(ct);
|
||||||
|
if (grades.Count == 0) continue;
|
||||||
|
var gpa = grades.Average(x => x.GradePoint!.Value);
|
||||||
|
if (gpa < rule.Threshold)
|
||||||
|
{
|
||||||
|
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.LowGPA, ct)) continue;
|
||||||
|
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = Math.Round(gpa, 2), Detail = $"平均绩点 {gpa:F2},低于预警阈值 {rule.Threshold}。", AcademicTermId = termId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<WarningRecord>> DetectAbsenteeism(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var records = new List<WarningRecord>();
|
||||||
|
foreach (var s in students)
|
||||||
|
{
|
||||||
|
var absent = await db.AttendanceRecords.CountAsync(x => x.StudentId == s.Id && x.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted && x.AttendanceSheet.TeachingTask!.AcademicTermId == termId && (x.Status == AttendanceStatus.Absent || x.Status == AttendanceStatus.Late), ct);
|
||||||
|
if (absent >= rule.Threshold)
|
||||||
|
{
|
||||||
|
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.Absenteeism, ct)) continue;
|
||||||
|
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = absent, Detail = $"缺勤/迟到 {absent} 次,达到预警阈值 {rule.Threshold} 次。", AcademicTermId = termId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<WarningRecord>> DetectGraduationDelay(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var records = new List<WarningRecord>();
|
||||||
|
foreach (var s in students)
|
||||||
|
{
|
||||||
|
var total = await db.GradeRecords.CountAsync(x => x.StudentId == s.Id && x.GradeSheet!.Status == GradeSheetStatus.Published && x.TotalScore < 60, ct);
|
||||||
|
if (total >= rule.Threshold)
|
||||||
|
{
|
||||||
|
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.GraduationDelay, ct)) continue;
|
||||||
|
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = total, Detail = $"累计不及格 {total} 门,达到延毕预警阈值 {rule.Threshold} 门。", AcademicTermId = termId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Guid?> GetStudentIdAsync(CancellationToken ct) => await db.Students.Where(s => s.UserId == scope.Current.UserId).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
|
||||||
|
private ActionResult StudentNotFound() => Conflict(new ProblemDetails { Title = "未关联学生档案", Status = 409 });
|
||||||
|
private ActionResult ConflictProblem(string d) => Conflict(new ProblemDetails { Title = "操作失败", Detail = d, Status = 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 AckBody([MaxLength(300)] string? Comment);
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Jiaowu.Api.Domain.Common;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Domain.Academic;
|
||||||
|
|
||||||
|
public sealed class WarningRule : EntityBase
|
||||||
|
{
|
||||||
|
public WarningType Type { get; set; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public decimal Threshold { get; set; }
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
public bool NotifyStudent { get; set; } = true;
|
||||||
|
public bool NotifyCounselor { get; set; } = true;
|
||||||
|
public Guid AcademicTermId { get; set; }
|
||||||
|
public AcademicTerm? AcademicTerm { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WarningRecord : EntityBase
|
||||||
|
{
|
||||||
|
public Guid StudentId { get; set; }
|
||||||
|
public Student? Student { get; set; }
|
||||||
|
public WarningType Type { get; set; }
|
||||||
|
public WarningStatus Status { get; set; } = WarningStatus.Active;
|
||||||
|
public decimal TriggerValue { get; set; }
|
||||||
|
public required string Detail { get; set; }
|
||||||
|
public DateTime? AcknowledgedAt { get; set; }
|
||||||
|
public string? AcknowledgeComment { get; set; }
|
||||||
|
public Guid AcademicTermId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum WarningType
|
||||||
|
{
|
||||||
|
FailedCredits = 1,
|
||||||
|
LowGPA = 2,
|
||||||
|
Absenteeism = 3,
|
||||||
|
GraduationDelay = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum WarningStatus
|
||||||
|
{
|
||||||
|
Active = 1,
|
||||||
|
Acknowledged = 2,
|
||||||
|
Resolved = 3,
|
||||||
|
Dismissed = 4
|
||||||
|
}
|
||||||
@@ -60,6 +60,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
public DbSet<DeferredExam> DeferredExams => Set<DeferredExam>();
|
public DbSet<DeferredExam> DeferredExams => Set<DeferredExam>();
|
||||||
public DbSet<GradeModification> GradeModifications => Set<GradeModification>();
|
public DbSet<GradeModification> GradeModifications => Set<GradeModification>();
|
||||||
public DbSet<CourseSubstitution> CourseSubstitutions => Set<CourseSubstitution>();
|
public DbSet<CourseSubstitution> CourseSubstitutions => Set<CourseSubstitution>();
|
||||||
|
public DbSet<WarningRule> WarningRules => Set<WarningRule>();
|
||||||
|
public DbSet<WarningRecord> WarningRecords => Set<WarningRecord>();
|
||||||
public DbSet<Notification> Notifications => Set<Notification>();
|
public DbSet<Notification> Notifications => Set<Notification>();
|
||||||
public DbSet<StudentStatusChange> StudentStatusChanges =>
|
public DbSet<StudentStatusChange> StudentStatusChanges =>
|
||||||
Set<StudentStatusChange>();
|
Set<StudentStatusChange>();
|
||||||
@@ -709,6 +711,26 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.HasOne(x => x.GradeRecord).WithMany()
|
entity.HasOne(x => x.GradeRecord).WithMany()
|
||||||
.HasForeignKey(x => x.GradeRecordId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(x => x.GradeRecordId).OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
builder.Entity<WarningRule>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.Name).HasMaxLength(100);
|
||||||
|
entity.Property(x => x.Threshold).HasPrecision(7, 2);
|
||||||
|
entity.Property(x => x.Description).HasMaxLength(300);
|
||||||
|
entity.HasIndex(x => new { x.AcademicTermId, x.Type }).IsUnique();
|
||||||
|
entity.HasOne(x => x.AcademicTerm).WithMany()
|
||||||
|
.HasForeignKey(x => x.AcademicTermId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
builder.Entity<WarningRecord>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.TriggerValue).HasPrecision(7, 2);
|
||||||
|
entity.Property(x => x.Detail).HasMaxLength(1000);
|
||||||
|
entity.Property(x => x.AcknowledgeComment).HasMaxLength(300);
|
||||||
|
entity.HasIndex(x => new { x.StudentId, x.AcademicTermId, x.Type }).IsUnique();
|
||||||
|
entity.HasIndex(x => x.Status);
|
||||||
|
entity.HasOne(x => x.Student).WithMany()
|
||||||
|
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<CourseSubstitution>(entity =>
|
builder.Entity<CourseSubstitution>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.Reason).HasMaxLength(500);
|
entity.Property(x => x.Reason).HasMaxLength(500);
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"20260725_23_attendance_appeal";
|
"20260725_23_attendance_appeal";
|
||||||
private const string ApprovalTablesMigration =
|
private const string ApprovalTablesMigration =
|
||||||
"20260725_24_approval_tables";
|
"20260725_24_approval_tables";
|
||||||
|
private const string AcademicWarningsMigration =
|
||||||
|
"20260725_25_academic_warnings";
|
||||||
|
|
||||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -280,6 +282,14 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
ApprovalTablesMigration,
|
ApprovalTablesMigration,
|
||||||
approvalTablesExist ? [] : ApprovalTableStatements,
|
approvalTablesExist ? [] : ApprovalTableStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
var warningRulesExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'WarningRules'")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
AcademicWarningsMigration,
|
||||||
|
warningRulesExist ? [] : AcademicWarningStatements,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyMigrationAsync(
|
private async Task ApplyMigrationAsync(
|
||||||
@@ -1606,4 +1616,13 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"""CREATE UNIQUE INDEX "IX_CourseSubstitutions_StudentId_OriginalCourseId" ON "CourseSubstitutions" ("StudentId", "OriginalCourseId");""",
|
"""CREATE UNIQUE INDEX "IX_CourseSubstitutions_StudentId_OriginalCourseId" ON "CourseSubstitutions" ("StudentId", "OriginalCourseId");""",
|
||||||
"""CREATE INDEX "IX_CourseSubstitutions_Status_CreatedAt" ON "CourseSubstitutions" ("Status", "CreatedAt");"""
|
"""CREATE INDEX "IX_CourseSubstitutions_Status_CreatedAt" ON "CourseSubstitutions" ("Status", "CreatedAt");"""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] AcademicWarningStatements =
|
||||||
|
[
|
||||||
|
"""CREATE TABLE "WarningRules" ("Id" TEXT NOT NULL CONSTRAINT "PK_WarningRules" PRIMARY KEY, "Type" INTEGER NOT NULL, "Name" TEXT NOT NULL, "Threshold" TEXT NOT NULL, "IsEnabled" INTEGER NOT NULL, "NotifyStudent" INTEGER NOT NULL, "NotifyCounselor" INTEGER NOT NULL, "AcademicTermId" TEXT NOT NULL, "Description" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_WarningRules_AcademicTerms" FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE CASCADE);""",
|
||||||
|
"""CREATE UNIQUE INDEX "IX_WarningRules_AcademicTermId_Type" ON "WarningRules" ("AcademicTermId", "Type");""",
|
||||||
|
"""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 INDEX "IX_WarningRecords_Status" ON "WarningRecords" ("Status");"""
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
public partial class AcademicWarnings : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable("WarningRules", table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
Type = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
|
||||||
|
Threshold = table.Column<decimal>(type: "decimal(7,2)", precision: 7, scale: 2, nullable: false),
|
||||||
|
IsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
NotifyStudent = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
NotifyCounselor = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
}, constraints: t => { t.PrimaryKey("PK_WarningRules", x => x.Id); t.ForeignKey("FK_WarningRules_AcademicTerms", x => x.AcademicTermId, "AcademicTerms", "Id", onDelete: ReferentialAction.Cascade); });
|
||||||
|
migrationBuilder.CreateIndex("IX_WarningRules_AcademicTermId_Type", "WarningRules", new[] { "AcademicTermId", "Type" }, unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable("WarningRecords", table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
Type = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
TriggerValue = table.Column<decimal>(type: "decimal(7,2)", precision: 7, scale: 2, nullable: false),
|
||||||
|
Detail = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: false),
|
||||||
|
AcknowledgedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
AcknowledgeComment = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true),
|
||||||
|
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
}, constraints: t => { t.PrimaryKey("PK_WarningRecords", x => x.Id); t.ForeignKey("FK_WarningRecords_Students", x => x.StudentId, "Students", "Id", onDelete: ReferentialAction.Restrict); });
|
||||||
|
migrationBuilder.CreateIndex("IX_WarningRecords_StudentId_AcademicTermId_Type", "WarningRecords", new[] { "StudentId", "AcademicTermId", "Type" }, unique: true);
|
||||||
|
migrationBuilder.CreateIndex("IX_WarningRecords_Status", "WarningRecords", "Status");
|
||||||
|
}
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable("WarningRecords"); migrationBuilder.DropTable("WarningRules"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -152,6 +152,10 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
|||||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Teacher', 'Student']),
|
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Teacher', 'Student']),
|
||||||
{ path: '/approvals', label: '审批中心' },
|
{ path: '/approvals', label: '审批中心' },
|
||||||
),
|
),
|
||||||
|
...whenVisible(
|
||||||
|
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'Counselor', 'Student']),
|
||||||
|
{ path: '/warnings', label: '学业预警' },
|
||||||
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -216,6 +216,12 @@ const router = createRouter({
|
|||||||
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Teacher', 'Student'],
|
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Teacher', 'Student'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'warnings',
|
||||||
|
name: 'warnings',
|
||||||
|
component: () => import('../views/WarningsView.vue'),
|
||||||
|
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'Counselor', 'Student'] },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'student-status-changes',
|
path: 'student-status-changes',
|
||||||
name: 'student-status-changes',
|
name: 'student-status-changes',
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { Check, Refresh, Search } from '@element-plus/icons-vue'
|
||||||
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const isSuperAdmin = computed(() => auth.user?.roles.includes('SuperAdmin'))
|
||||||
|
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 terms = ref<any[]>([])
|
||||||
|
const termId = ref('')
|
||||||
|
const records = ref<any[]>([])
|
||||||
|
const myWarnings = ref<any[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const detecting = ref(false)
|
||||||
|
const filterType = ref('')
|
||||||
|
|
||||||
|
const typeLabels: Record<number, string> = { 1: '不及格学分', 2: '低绩点', 3: '缺勤', 4: '延毕风险' }
|
||||||
|
const statusLabels: Record<number, string> = { 1: '生效中', 2: '已确认', 3: '已处理', 4: '已忽略' }
|
||||||
|
|
||||||
|
function tagType(type: number): 'danger' | 'warning' | 'info' {
|
||||||
|
return type === 1 ? 'danger' : type === 2 ? 'warning' : 'info'
|
||||||
|
}
|
||||||
|
function thresholdUnit(type: number): string {
|
||||||
|
if (type === 1) return '门 不及格'
|
||||||
|
if (type === 2) return 'GPA 下限'
|
||||||
|
if (type === 3) return '次 缺勤/迟到'
|
||||||
|
return '门 累计不及格'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reactive rule objects — one per type, bound directly to v-model
|
||||||
|
const ruleRows = reactive([
|
||||||
|
{ type: 1 as number, name: '不及格学分', threshold: 2, isEnabled: true, notifyStudent: true, notifyCounselor: true, description: '' },
|
||||||
|
{ type: 2, name: '低绩点', threshold: 2, isEnabled: true, notifyStudent: true, notifyCounselor: true, description: '' },
|
||||||
|
{ type: 3, name: '缺勤', threshold: 3, isEnabled: true, notifyStudent: true, notifyCounselor: true, description: '' },
|
||||||
|
{ type: 4, name: '延毕风险', threshold: 5, isEnabled: true, notifyStudent: true, notifyCounselor: true, description: '' },
|
||||||
|
])
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
if (isSuperAdmin.value && termId.value) {
|
||||||
|
const serverRules = (await http.get('/warnings/rules', { params: { academicTermId: termId.value } })).data
|
||||||
|
// Merge server values into reactive rows
|
||||||
|
for (const row of ruleRows) {
|
||||||
|
const sr = serverRules.find((r: any) => r.type === row.type)
|
||||||
|
if (sr) {
|
||||||
|
row.threshold = sr.threshold
|
||||||
|
row.isEnabled = sr.isEnabled
|
||||||
|
row.notifyStudent = sr.notifyStudent
|
||||||
|
row.notifyCounselor = sr.notifyCounselor
|
||||||
|
row.description = sr.description || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isSuperAdmin.value || isCounselor.value)
|
||||||
|
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
|
||||||
|
} catch (e) { ElMessage.error(apiErrorMessage(e)) } finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRules() {
|
||||||
|
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 }))
|
||||||
|
await http.put('/warnings/rules', payload, { params: { academicTermId: termId.value } })
|
||||||
|
ElMessage.success('预警规则已保存')
|
||||||
|
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectWarnings() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('系统将扫描当前学期全部在籍学生,产生的预警将自动通知学生及辅导员。', '执行学业预警检测', { type: 'warning', confirmButtonText: '开始检测' })
|
||||||
|
detecting.value = true
|
||||||
|
const res = await http.post('/warnings/detect', null, { params: { academicTermId: termId.value } })
|
||||||
|
ElMessage.success(`检测完成,生成 ${res.data.generated} 条预警`)
|
||||||
|
await load()
|
||||||
|
} catch (e: any) { if (e !== 'cancel' && e !== 'close') ElMessage.error(apiErrorMessage(e)) } finally { detecting.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acknowledge(w: any) {
|
||||||
|
try {
|
||||||
|
const { value } = await ElMessageBox.prompt('确认已知晓此预警?(可选填备注)', '确认预警', { confirmButtonText: '确认', inputType: 'textarea' })
|
||||||
|
await http.post(`/warnings/${w.id}/acknowledge`, { comment: value || null }); ElMessage.success('已确认'); await load()
|
||||||
|
} catch (e: any) { if (e !== 'cancel' && e !== 'close') ElMessage.error(apiErrorMessage(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try { terms.value = (await http.get('/base-data/terms')).data; termId.value = terms.value.find(t => t.isCurrent)?.id; await load() }
|
||||||
|
catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-stack warn-page">
|
||||||
|
<section class="page-intro">
|
||||||
|
<div><span class="section-kicker">ACADEMIC WARNING</span><h2>{{ isStudent ? '我的预警' : '学业预警' }}</h2><p>{{ isSuperAdmin ? '配置预警规则,执行检测,查看预警记录。' : isCounselor ? '查看所管学生的学业预警情况。' : '查看并确认您的学业预警通知。' }}</p></div>
|
||||||
|
<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="t.name" :value="t.id" /></el-select>
|
||||||
|
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Admin: Rule config -->
|
||||||
|
<section v-if="isSuperAdmin && termId" class="warn-rules">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||||||
|
<h3 style="margin:0">预警规则配置</h3>
|
||||||
|
<div>
|
||||||
|
<el-button type="primary" @click="saveRules">保存规则</el-button>
|
||||||
|
<el-button :icon="Search" :loading="detecting" type="warning" @click="detectWarnings">执行检测</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-table :data="ruleRows" size="small">
|
||||||
|
<el-table-column label="类型" width="100"><template #default="{row}"><b>{{ row.name }}</b></template></el-table-column>
|
||||||
|
<el-table-column label="阈值" width="160">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-input-number v-model="row.threshold" :min="1" :max="row.type===4?20:row.type===3?50:10" :precision="row.type===2?1:0" size="small" />
|
||||||
|
<span style="margin-left:4px;font-size:12px;color:var(--muted)">{{ thresholdUnit(row.type) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="开关" width="200">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-switch v-model="row.isEnabled" size="small" active-text="启用" style="margin-right:12px" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="通知" width="200">
|
||||||
|
<template #default="{row}">
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</el-table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Records -->
|
||||||
|
<section v-if="isSuperAdmin || isCounselor" v-loading="loading" class="warn-records">
|
||||||
|
<div style="display:flex;gap:12px;align-items:center;margin-bottom:12px">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<el-table :data="records" size="small" v-if="records.length">
|
||||||
|
<el-table-column label="学生" min-width="150"><template #default="{row}"><b>{{ row.studentName }}</b><span style="font-size:11px;color:var(--muted);margin-left:6px">{{ row.studentNumber }}</span></template></el-table-column>
|
||||||
|
<el-table-column label="班级" prop="className" width="120" />
|
||||||
|
<el-table-column label="类型" width="100"><template #default="{row}"><el-tag size="small" :type="tagType(row.type)">{{ typeLabels[row.type] }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column label="阈值/详情" min-width="200"><template #default="{row}"><span>{{ row.triggerValue }}</span><p style="margin:0;font-size:11px;color:var(--muted)">{{ row.detail }}</p></template></el-table-column>
|
||||||
|
<el-table-column label="状态" width="100"><template #default="{row}"><el-tag size="small" :type="row.status===1?'danger':row.status===2?'success':'info'">{{ statusLabels[row.status] }}</el-tag></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-if="!records.length" description="暂无预警记录,请先执行检测。" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Student -->
|
||||||
|
<section v-if="isStudent" v-loading="loading" class="warn-mine">
|
||||||
|
<article v-for="w in myWarnings" :key="w.id" class="warn-card" :class="{ active: w.status===1 }">
|
||||||
|
<div><el-tag size="small" :type="(w.type===1?'danger':w.type===2?'warning':'info') as 'danger'|'warning'|'info'">{{ typeLabels[w.type] }}</el-tag></div>
|
||||||
|
<div class="warn-body"><b>{{ w.detail }}</b><small>{{ new Date(w.createdAt).toLocaleString('zh-CN') }}</small></div>
|
||||||
|
<div class="warn-status">
|
||||||
|
<el-tag size="small" :type="w.status===1?'danger':w.status===2?'success':'info'">{{ statusLabels[w.status] }}</el-tag>
|
||||||
|
<el-button v-if="w.status===1" size="small" type="primary" :icon="Check" @click="acknowledge(w)">确认</el-button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<el-empty v-if="!myWarnings.length" description="暂无学业预警" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.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-mine { display:grid; gap:10px; }
|
||||||
|
.warn-card { display:flex; align-items:center; gap:14px; padding:14px 18px; background:#fff; border:1px solid #e4e7ed; border-radius:8px; }
|
||||||
|
.warn-card.active { border-left:4px solid #f56c6c; }
|
||||||
|
.warn-body { flex:1; }
|
||||||
|
.warn-body b { font-size:14px; display:block; margin-bottom:4px; }
|
||||||
|
.warn-body small { font-size:11px; color:var(--muted); }
|
||||||
|
.warn-status { display:flex; align-items:center; gap:8px; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user