各类申请

This commit is contained in:
2026-07-25 16:51:12 +08:00 Unverified
parent d02b0c56a1
commit 5e4c76a9e4
10 changed files with 901 additions and 4 deletions
@@ -0,0 +1,395 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/approvals")]
public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope scope) : ControllerBase
{
private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin;
private const string Teachers = Managers + "," + SystemRoles.Teacher;
private const string Students = Managers + "," + SystemRoles.Student;
// ═══════════════ Aggregated pending ═══════════════
[HttpGet("pending")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetPending(CancellationToken ct)
{
var items = new List<ApprovalItem>();
items.AddRange(await Scoped<CourseExemption>(x => x.Status == ApprovalStatus.Submitted)
.Select(x => new ApprovalItem(x.Id, "CourseExemption", "免修", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name))
.ToListAsync(ct));
items.AddRange(await Scoped<DeferredExam>(x => x.Status == ApprovalStatus.Submitted)
.Select(x => new ApprovalItem(x.Id, "DeferredExam", "缓考", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name))
.ToListAsync(ct));
items.AddRange(await Scoped<GradeModification>(x => x.Status == GradeModificationStatus.TeacherSubmitted || x.Status == GradeModificationStatus.CollegeApproved)
.Select(x => new ApprovalItem(x.Id, "GradeModification", "成绩修改", $"{x.GradeRecord!.Student!.Name} — 《{x.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》", $"{x.CurrentScore} → {x.RequestedScore}{x.Reason}", x.SubmittedAt, x.GradeRecord.GradeSheet.TeachingTask.Course!.College!.Name))
.ToListAsync(ct));
items.AddRange(await Scoped<CourseSubstitution>(x => x.Status == ApprovalStatus.Submitted)
.Select(x => new ApprovalItem(x.Id, "CourseSubstitution", "课程替代", $"{x.Student!.Name}{x.SubstituteCourse!.Name} → {x.OriginalCourse!.Name}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name))
.ToListAsync(ct));
items.AddRange(await Scoped<StudentStatusChange>(x => x.State != StudentStatusChangeState.Approved && x.State != StudentStatusChangeState.Rejected && x.State != StudentStatusChangeState.Cancelled)
.Select(x => new ApprovalItem(x.Id, "StudentStatusChange", "学籍异动", $"{x.Student!.Name} — {SSCLabel(x.Type)}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name))
.ToListAsync(ct));
items.AddRange(await Scoped<CourseAdjustment>(x => x.Status == CourseAdjustmentStatus.Submitted)
.Select(x => new ApprovalItem(x.Id, "CourseAdjustment", CALabel(x.Type), $"《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name))
.ToListAsync(ct));
items.AddRange(await Scoped<GradeSheet>(x => x.Status == GradeSheetStatus.Submitted)
.Select(x => new ApprovalItem(x.Id, "GradeSheet", "成绩审核", $"《{x.TeachingTask!.Course!.Name}》— {x.Records.Count}人", "教师已提交成绩", x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name))
.ToListAsync(ct));
items.AddRange(await Scoped<AttendanceRecord>(x => x.AppealStatus == AttendanceAppealStatus.Pending)
.Select(x => new ApprovalItem(x.StudentId, "AttendanceAppeal", "考勤申诉", $"{x.Student!.Name} — 《{x.AttendanceSheet!.TeachingTask!.Course!.Name}》", x.AppealReason ?? "", x.AppealSubmittedAt!.Value, x.Student.AdministrativeClass!.Major!.College!.Name))
.ToListAsync(ct));
return Ok(items.OrderByDescending(x => x.Time).ToList());
}
// ═══════════════ Course Exemption ═══════════════
[HttpGet("exemptions/mine")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyExemptions(CancellationToken ct)
{
var studentId = await GetStudentIdAsync(ct);
if (studentId is null) return StudentNotFound();
return Ok(await db.CourseExemptions.AsNoTracking()
.Where(x => x.StudentId == studentId).OrderByDescending(x => x.CreatedAt)
.Select(x => new { x.Id, x.Status, x.Reason, x.ReviewComment, x.SubmittedAt, CourseName = x.TeachingTask!.Course!.Name })
.ToListAsync(ct));
}
[HttpPost("exemptions")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> ApplyExemption(ExemptionRequest req, CancellationToken ct)
{
var studentId = await GetStudentIdAsync(ct);
if (studentId is null) return StudentNotFound();
if (await db.CourseExemptions.AnyAsync(x => x.StudentId == studentId && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
return ConflictProblem("该课程已有免修申请在审核中。");
var ex = new CourseExemption { StudentId = studentId.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
db.CourseExemptions.Add(ex);
await db.SaveChangesAsync(ct);
// Reload to get nav properties for notification
var info = await db.CourseExemptions.Where(x => x.Id == ex.Id)
.Select(x => new { StudentName = x.Student!.Name, CourseName = x.TeachingTask!.Course!.Name })
.FirstAsync(ct);
await NotifyManagers("免修申请", $"{info.StudentName} 申请免修《{info.CourseName}》", ct);
return Created("", new { ex.Id });
}
[HttpPost("exemptions/{id:guid}/approve")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> ApproveExemption(Guid id, CancellationToken ct)
{
var ex = await db.CourseExemptions.Include(x => x.TeachingTask).ThenInclude(x => x!.Course).FirstOrDefaultAsync(x => x.Id == id, ct);
if (ex is null) return NotFound();
ex.Status = ApprovalStatus.Approved; ex.ReviewedAt = DateTime.UtcNow; ex.ReviewedByUserId = scope.Current.UserId;
// Auto-apply: mark grade record as exempt
var sheet = await db.GradeSheets.FirstOrDefaultAsync(x => x.TeachingTaskId == ex.TeachingTaskId, ct);
if (sheet is not null)
{
var record = await db.GradeRecords.FirstOrDefaultAsync(x => x.GradeSheetId == sheet.Id && x.StudentId == ex.StudentId, ct);
if (record is not null) { record.ExamStatus = GradeExamStatus.Exempt; record.RegularScore = null; record.FinalScore = null; record.TotalScore = null; record.GradePoint = null; }
}
await db.SaveChangesAsync(ct);
await NotifyStudent(ex.StudentId, "免修申请已通过", $"《{ex.TeachingTask!.Course!.Name}》免修已批准。", ct);
return NoContent();
}
[HttpPost("exemptions/{id:guid}/reject")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> RejectExemption(Guid id, [FromBody] ReviewBody body, CancellationToken ct)
{
var ex = await db.CourseExemptions.FindAsync([id], ct);
if (ex is null) return NotFound();
ex.Status = ApprovalStatus.Rejected; ex.ReviewedAt = DateTime.UtcNow; ex.ReviewComment = body.Comment?.Trim(); ex.ReviewedByUserId = scope.Current.UserId;
await db.SaveChangesAsync(ct);
await NotifyStudent(ex.StudentId, "免修申请已驳回", ex.ReviewComment ?? "审核未通过。", ct);
return NoContent();
}
// ═══════════════ Deferred Exam ═══════════════
[HttpGet("deferred/mine")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyDeferred(CancellationToken ct)
{
var sid = await GetStudentIdAsync(ct);
if (sid is null) return StudentNotFound();
return Ok(await db.DeferredExams.AsNoTracking().Where(x => x.StudentId == sid).OrderByDescending(x => x.CreatedAt)
.Select(x => new { x.Id, x.Status, x.Reason, x.ReviewComment, x.SubmittedAt, CourseName = x.TeachingTask!.Course!.Name }).ToListAsync(ct));
}
[HttpPost("deferred")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> ApplyDeferred(ExemptionRequest req, CancellationToken ct)
{
var sid = await GetStudentIdAsync(ct);
if (sid is null) return StudentNotFound();
if (await db.DeferredExams.AnyAsync(x => x.StudentId == sid && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
return ConflictProblem("该课程已有缓考申请在审核中。");
var d = new DeferredExam { StudentId = sid.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
db.DeferredExams.Add(d);
await db.SaveChangesAsync(ct);
var info = await db.DeferredExams.Where(x => x.Id == d.Id)
.Select(x => new { StudentName = x.Student!.Name, CourseName = x.TeachingTask!.Course!.Name })
.FirstAsync(ct);
await NotifyManagers("缓考申请", $"{info.StudentName} 申请《{info.CourseName}》缓考", ct);
return Created("", new { d.Id });
}
[HttpPost("deferred/{id:guid}/approve")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> ApproveDeferred(Guid id, CancellationToken ct)
{
var d = await db.DeferredExams.Include(x => x.TeachingTask).ThenInclude(x => x!.Course).FirstOrDefaultAsync(x => x.Id == id, ct);
if (d is null) return NotFound();
d.Status = ApprovalStatus.Approved; d.ReviewedAt = DateTime.UtcNow; d.ReviewedByUserId = scope.Current.UserId;
// Auto-apply: set grade record to deferred
var sheet = await db.GradeSheets.FirstOrDefaultAsync(x => x.TeachingTaskId == d.TeachingTaskId, ct);
if (sheet is not null)
{
var record = await db.GradeRecords.FirstOrDefaultAsync(x => x.GradeSheetId == sheet.Id && x.StudentId == d.StudentId, ct);
if (record is not null) record.ExamStatus = GradeExamStatus.Deferred;
}
await db.SaveChangesAsync(ct);
await NotifyStudent(d.StudentId, "缓考申请已通过", $"《{d.TeachingTask!.Course!.Name}》缓考已批准。", ct);
return NoContent();
}
[HttpPost("deferred/{id:guid}/reject")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> RejectDeferred(Guid id, [FromBody] ReviewBody body, CancellationToken ct)
{
var d = await db.DeferredExams.FindAsync([id], ct);
if (d is null) return NotFound();
d.Status = ApprovalStatus.Rejected; d.ReviewedAt = DateTime.UtcNow; d.ReviewComment = body.Comment?.Trim(); d.ReviewedByUserId = scope.Current.UserId;
await db.SaveChangesAsync(ct);
await NotifyStudent(d.StudentId, "缓考申请已驳回", d.ReviewComment ?? "审核未通过。", ct);
return NoContent();
}
// ═══════════════ Grade Modification (3-level) ═══════════════
[HttpPost("grade-modifications")]
[Authorize(Roles = Teachers)]
public async Task<ActionResult> ApplyGradeMod(GradeModRequest req, CancellationToken ct)
{
var record = await db.GradeRecords.Include(x => x.GradeSheet).FirstOrDefaultAsync(x => x.Id == req.GradeRecordId, ct);
if (record is null) return NotFound();
if (record.GradeSheet!.Status != GradeSheetStatus.Published)
return ConflictProblem("只能修改已发布的成绩。");
var gm = new GradeModification { GradeRecordId = req.GradeRecordId, CurrentScore = record.TotalScore ?? 0, RequestedScore = req.RequestedScore, Reason = req.Reason.Trim(), ApplicantUserId = scope.Current.UserId };
db.GradeModifications.Add(gm);
await db.SaveChangesAsync(ct);
var gmInfo = await db.GradeModifications.Where(x => x.Id == gm.Id)
.Select(x => new { StudentName = x.GradeRecord!.Student!.Name, CourseName = x.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name, x.CurrentScore, x.RequestedScore })
.FirstAsync(ct);
await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核",
$"{gmInfo.StudentName} — 《{gmInfo.CourseName}》{gmInfo.CurrentScore}→{gmInfo.RequestedScore}", cancellationToken: ct);
return Created("", new { gm.Id });
}
[HttpPost("grade-modifications/{id:guid}/college-approve")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CollegeApproveGradeMod(Guid id, CancellationToken ct)
{
var gm = await db.GradeModifications.Include(x => x.GradeRecord).ThenInclude(x => x!.GradeSheet).ThenInclude(x => x!.TeachingTask).ThenInclude(x => x!.Course).FirstOrDefaultAsync(x => x.Id == id, ct);
if (gm is null) return NotFound();
if (gm.Status != GradeModificationStatus.TeacherSubmitted) return ConflictProblem("状态不正确。");
gm.Status = GradeModificationStatus.CollegeApproved; gm.CollegeReviewedAt = DateTime.UtcNow; gm.CollegeReviewedByUserId = scope.Current.UserId;
await db.SaveChangesAsync(ct);
await NotifyRole(SystemRoles.AcademicAdmin, "成绩修改待校级审核", $"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》", ct);
return NoContent();
}
[HttpPost("grade-modifications/{id:guid}/approve")]
[Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)]
public async Task<ActionResult> FinalApproveGradeMod(Guid id, CancellationToken ct)
{
var gm = await db.GradeModifications.Include(x => x.GradeRecord).FirstOrDefaultAsync(x => x.Id == id, ct);
if (gm is null) return NotFound();
if (gm.Status != GradeModificationStatus.CollegeApproved) return ConflictProblem("需先通过学院审核。");
gm.Status = GradeModificationStatus.Approved; gm.FinalReviewedAt = DateTime.UtcNow; gm.FinalReviewedByUserId = scope.Current.UserId;
// Auto-apply: update grade record
gm.GradeRecord!.TotalScore = gm.RequestedScore;
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
await db.SaveChangesAsync(ct);
await NotificationService.SendAsync(db, gm.ApplicantUserId, "成绩修改已通过", "您的成绩修改申请已通过三级审批并生效。", cancellationToken: ct);
return NoContent();
}
[HttpPost("grade-modifications/{id:guid}/reject")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> RejectGradeMod(Guid id, [FromBody] ReviewBody body, CancellationToken ct)
{
var gm = await db.GradeModifications.FindAsync([id], ct);
if (gm is null) return NotFound();
gm.Status = GradeModificationStatus.Rejected; gm.ReviewComment = body.Comment?.Trim();
if (gm.Status == GradeModificationStatus.TeacherSubmitted) { gm.CollegeReviewedAt = DateTime.UtcNow; gm.CollegeReviewedByUserId = scope.Current.UserId; }
else { gm.FinalReviewedAt = DateTime.UtcNow; gm.FinalReviewedByUserId = scope.Current.UserId; }
await db.SaveChangesAsync(ct);
await NotificationService.SendAsync(db, gm.ApplicantUserId, "成绩修改已驳回", gm.ReviewComment ?? "审核未通过。", cancellationToken: ct);
return NoContent();
}
// ═══════════════ Course Substitution ═══════════════
[HttpPost("substitutions")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> ApplySubstitution(SubstitutionRequest req, CancellationToken ct)
{
var sid = await GetStudentIdAsync(ct);
if (sid is null) return StudentNotFound();
if (await db.CourseSubstitutions.AnyAsync(x => x.StudentId == sid && x.OriginalCourseId == req.OriginalCourseId && x.Status == ApprovalStatus.Submitted, ct))
return ConflictProblem("该课程已有替代申请在审核中。");
var cs = new CourseSubstitution { StudentId = sid.Value, OriginalCourseId = req.OriginalCourseId, SubstituteCourseId = req.SubstituteCourseId, Reason = req.Reason.Trim() };
db.CourseSubstitutions.Add(cs);
await db.SaveChangesAsync(ct);
var info = await db.CourseSubstitutions.Where(x => x.Id == cs.Id)
.Select(x => new { StudentName = x.Student!.Name, SubName = x.SubstituteCourse!.Name, OrigName = x.OriginalCourse!.Name })
.FirstAsync(ct);
await NotifyManagers("课程替代申请", $"{info.StudentName} 申请用《{info.SubName}》替代《{info.OrigName}》", ct);
return Created("", new { cs.Id });
}
[HttpPost("substitutions/{id:guid}/approve")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> ApproveSubstitution(Guid id, CancellationToken ct)
{
var cs = await db.CourseSubstitutions.Include(x => x.Student).Include(x => x.OriginalCourse).Include(x => x.SubstituteCourse).FirstOrDefaultAsync(x => x.Id == id, ct);
if (cs is null) return NotFound();
cs.Status = ApprovalStatus.Approved; cs.ReviewedAt = DateTime.UtcNow; cs.ReviewedByUserId = scope.Current.UserId;
// Auto-apply: copy passed grade to original course
var currentTerm = await db.AcademicTerms.FirstOrDefaultAsync(x => x.IsCurrent, ct);
if (currentTerm is not null)
{
var passedRecord = await db.GradeRecords
.Where(x => x.StudentId == cs.StudentId && x.GradeSheet!.TeachingTask!.CourseId == cs.SubstituteCourseId && x.TotalScore >= 60)
.OrderByDescending(x => x.GradeSheet!.TeachingTask!.AcademicTerm!.StartDate)
.FirstOrDefaultAsync(ct);
if (passedRecord is not null)
{
// Find or create grade record for original course
var origTask = await db.TeachingTasks.FirstOrDefaultAsync(x => x.CourseId == cs.OriginalCourseId && x.AcademicTermId == currentTerm.Id, ct);
if (origTask is not null)
{
var origSheet = await db.GradeSheets.FirstOrDefaultAsync(x => x.TeachingTaskId == origTask.Id, ct);
if (origSheet is not null)
{
var origRecord = await db.GradeRecords.FirstOrDefaultAsync(x => x.GradeSheetId == origSheet.Id && x.StudentId == cs.StudentId, ct);
if (origRecord is not null)
{
origRecord.TotalScore = passedRecord.TotalScore;
origRecord.GradePoint = passedRecord.GradePoint;
origRecord.Notes = $"课程替代:{cs.SubstituteCourse.Code} {cs.SubstituteCourse.Name}";
}
}
}
}
}
await db.SaveChangesAsync(ct);
await NotifyStudent(cs.StudentId, "课程替代申请已通过", $"《{cs.SubstituteCourse!.Name}》替代《{cs.OriginalCourse!.Name}》已生效。", ct);
return NoContent();
}
[HttpPost("substitutions/{id:guid}/reject")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> RejectSubstitution(Guid id, [FromBody] ReviewBody body, CancellationToken ct)
{
var cs = await db.CourseSubstitutions.FindAsync([id], ct);
if (cs is null) return NotFound();
cs.Status = ApprovalStatus.Rejected; cs.ReviewedAt = DateTime.UtcNow; cs.ReviewComment = body.Comment?.Trim(); cs.ReviewedByUserId = scope.Current.UserId;
await db.SaveChangesAsync(ct);
await NotifyStudent(cs.StudentId, "课程替代申请已驳回", cs.ReviewComment ?? "审核未通过。", ct);
return NoContent();
}
// ═══════════════ Student enrolled courses ═══════════════
[HttpGet("my-courses")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyEnrolledCourses(CancellationToken ct)
{
var sid = await GetStudentIdAsync(ct);
if (sid is null) return StudentNotFound();
var courses = await db.CourseEnrollments.AsNoTracking()
.Where(x => x.StudentId == sid && x.Status == CourseEnrollmentStatus.Enrolled)
.Select(x => new { x.CourseSelectionOffering!.TeachingTaskId, x.CourseSelectionOffering.TeachingTask!.TaskNumber, CourseCode = x.CourseSelectionOffering.TeachingTask.Course!.Code, CourseName = x.CourseSelectionOffering.TeachingTask.Course.Name })
.ToListAsync(ct);
return Ok(courses);
}
[HttpGet("my-grades")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyGrades(CancellationToken ct)
{
var sid = await GetStudentIdAsync(ct);
if (sid is null) return StudentNotFound();
var grades = await db.GradeRecords.AsNoTracking()
.Where(x => x.StudentId == sid && x.GradeSheet!.Status == GradeSheetStatus.Published)
.Select(x => new { CourseId = x.GradeSheet!.TeachingTask!.CourseId, CourseCode = x.GradeSheet.TeachingTask.Course!.Code, CourseName = x.GradeSheet.TeachingTask.Course.Name, x.TotalScore, x.GradePoint, x.ExamStatus })
.OrderBy(x => x.CourseCode)
.ToListAsync(ct);
return Ok(grades);
}
[HttpGet("grade-records")]
[Authorize(Roles = Teachers)]
public async Task<ActionResult> GetGradeRecordsForMod(CancellationToken ct)
{
var userId = scope.Current.UserId;
var records = await db.GradeRecords.AsNoTracking()
.Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published && x.GradeSheet.TeachingTask!.Teachers.Any(t => t.Teacher!.UserId == userId))
.Select(x => new { x.Id, StudentName = x.Student!.Name, StudentNumber = x.Student.StudentNumber, CourseName = x.GradeSheet!.TeachingTask!.Course!.Name, x.TotalScore })
.ToListAsync(ct);
return Ok(records);
}
// ═══════════════ Helpers ═══════════════
private IQueryable<T> Scoped<T>(System.Linq.Expressions.Expression<Func<T, bool>> predicate) where T : class
{
var q = db.Set<T>().AsNoTracking().Where(predicate);
if (scope.Current.Scope == DataScope.All) return q;
var cid = scope.Current.RestrictedCollegeId;
if (typeof(T) == typeof(CourseExemption)) return (IQueryable<T>)((IQueryable<CourseExemption>)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid);
if (typeof(T) == typeof(DeferredExam)) return (IQueryable<T>)((IQueryable<DeferredExam>)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid);
if (typeof(T) == typeof(GradeModification)) return (IQueryable<T>)((IQueryable<GradeModification>)(object)q).Where(x => x.GradeRecord!.GradeSheet!.TeachingTask!.Course!.CollegeId == cid);
if (typeof(T) == typeof(CourseSubstitution)) return (IQueryable<T>)((IQueryable<CourseSubstitution>)(object)q).Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == cid);
if (typeof(T) == typeof(StudentStatusChange)) return (IQueryable<T>)((IQueryable<StudentStatusChange>)(object)q).Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == cid);
if (typeof(T) == typeof(CourseAdjustment)) return (IQueryable<T>)((IQueryable<CourseAdjustment>)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid);
if (typeof(T) == typeof(GradeSheet)) return (IQueryable<T>)((IQueryable<GradeSheet>)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid);
if (typeof(T) == typeof(AttendanceRecord)) return (IQueryable<T>)((IQueryable<AttendanceRecord>)(object)q).Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == cid);
return q;
}
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 = "未关联学生档案", Detail = "当前账号未关联有效学生档案。", Status = 409 });
private async Task NotifyManagers(string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, title, content, cancellationToken: ct); await NotificationService.SendToRoleAsync(db, SystemRoles.AcademicAdmin, title, content, cancellationToken: ct); }
private async Task NotifyStudent(Guid sid, string title, string content, CancellationToken ct) { var uid = await db.Students.Where(s => s.Id == sid).Select(s => s.UserId).FirstOrDefaultAsync(ct); if (uid.HasValue) await NotificationService.SendAsync(db, uid.Value, title, content, cancellationToken: ct); }
private async Task NotifyCollege(GradeModification gm, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核", $"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》{gm.CurrentScore}→{gm.RequestedScore}", cancellationToken: ct); }
private async Task NotifyRole(string role, string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, role, title, content, cancellationToken: ct); }
private static string SSCLabel(StudentStatusChangeType t) => t switch { StudentStatusChangeType.Suspension => "休学", StudentStatusChangeType.Resumption => "复学", StudentStatusChangeType.Withdrawal => "退学", _ => "异动" };
private static string CALabel(CourseAdjustmentType t) => t switch { CourseAdjustmentType.Reschedule => "调课", CourseAdjustmentType.Cancel => "停课", CourseAdjustmentType.Makeup => "补课", CourseAdjustmentType.Substitute => "代课", _ => "调停课" };
private ActionResult ConflictProblem(string d) => Conflict(new ProblemDetails { Title = "无法完成", Detail = d, Status = 409 });
}
public sealed record ApprovalItem(Guid Id, string Type, string Label, string Title, string Desc, DateTime Time, string College);
public sealed record ExemptionRequest(Guid TeachingTaskId, [Required, MaxLength(500)] string Reason);
public sealed record GradeModRequest(Guid GradeRecordId, decimal RequestedScore, [Required, MaxLength(500)] string Reason);
public sealed record SubstitutionRequest(Guid OriginalCourseId, Guid SubstituteCourseId, [Required, MaxLength(500)] string Reason);
public sealed record ReviewBody([MaxLength(500)] string? Comment);
@@ -155,8 +155,32 @@ public sealed class AttendanceController(
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
// Load exemption/deferred in separate query to avoid SQL APPLY (unsupported on SQLite)
var exemptStudentIds = await db.CourseExemptions
.Where(e => e.TeachingTaskId == sheet.TeachingTaskId && e.Status == ApprovalStatus.Approved)
.Select(e => e.StudentId).ToHashSetAsync(cancellationToken);
var deferredStudentIds = await db.DeferredExams
.Where(e => e.TeachingTaskId == sheet.TeachingTaskId && e.Status == ApprovalStatus.Approved)
.Select(e => e.StudentId).ToHashSetAsync(cancellationToken);
var canEdit = sheet.Status == AttendanceSheetStatus.Draft;
return Ok(new { Sheet = sheet, CanEdit = canEdit });
return Ok(new
{
Sheet = new
{
sheet.Id, sheet.TeachingTaskId, sheet.Name, sheet.AttendanceDate,
sheet.Status, sheet.Notes, sheet.SubmittedAt,
sheet.TaskNumber, sheet.TaskName, sheet.CourseCode, sheet.CourseName,
Records = sheet.Records.Select(r => new
{
r.StudentId, r.StudentNumber, r.Name, r.ClassName,
r.Status, r.Notes,
IsExempt = exemptStudentIds.Contains(r.StudentId),
IsDeferred = deferredStudentIds.Contains(r.StudentId)
})
},
CanEdit = canEdit
});
}
[HttpPut("sheets/{id:guid}/records")]
@@ -430,8 +454,7 @@ public sealed class AttendanceController(
var source = db.AttendanceRecords.AsNoTracking()
.Where(r =>
r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted &&
r.Student!.AdministrativeClassId != null &&
targetClassIds.Contains(r.Student.AdministrativeClassId));
targetClassIds.Contains(r.Student!.AdministrativeClassId));
if (academicTermId.HasValue)
source = source.Where(r =>
r.AttendanceSheet!.TeachingTask!.AcademicTermId == academicTermId);
@@ -0,0 +1,83 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
// ═══════════════ 免修 ═══════════════
public sealed class CourseExemption : EntityBase
{
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public required string Reason { get; set; }
public ApprovalStatus Status { get; set; } = ApprovalStatus.Submitted;
public string? ReviewComment { get; set; }
public DateTime SubmittedAt { get; set; } = DateTime.UtcNow;
public DateTime? ReviewedAt { get; set; }
public Guid? ReviewedByUserId { get; set; }
}
// ═══════════════ 缓考 ═══════════════
public sealed class DeferredExam : EntityBase
{
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public required string Reason { get; set; }
public ApprovalStatus Status { get; set; } = ApprovalStatus.Submitted;
public string? ReviewComment { get; set; }
public DateTime SubmittedAt { get; set; } = DateTime.UtcNow;
public DateTime? ReviewedAt { get; set; }
public Guid? ReviewedByUserId { get; set; }
}
// ═══════════════ 成绩修改(三级审批) ═══════════════
public sealed class GradeModification : EntityBase
{
public Guid GradeRecordId { get; set; }
public GradeRecord? GradeRecord { get; set; }
public decimal CurrentScore { get; set; }
public decimal RequestedScore { get; set; }
public required string Reason { get; set; }
public GradeModificationStatus Status { get; set; } = GradeModificationStatus.TeacherSubmitted;
public string? ReviewComment { get; set; }
public Guid ApplicantUserId { get; set; }
public DateTime SubmittedAt { get; set; } = DateTime.UtcNow;
public DateTime? CollegeReviewedAt { get; set; }
public Guid? CollegeReviewedByUserId { get; set; }
public DateTime? FinalReviewedAt { get; set; }
public Guid? FinalReviewedByUserId { get; set; }
}
// ═══════════════ 课程替代 ═══════════════
public sealed class CourseSubstitution : EntityBase
{
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public Guid OriginalCourseId { get; set; }
public Course? OriginalCourse { get; set; }
public Guid SubstituteCourseId { get; set; }
public Course? SubstituteCourse { get; set; }
public required string Reason { get; set; }
public ApprovalStatus Status { get; set; } = ApprovalStatus.Submitted;
public string? ReviewComment { get; set; }
public DateTime SubmittedAt { get; set; } = DateTime.UtcNow;
public DateTime? ReviewedAt { get; set; }
public Guid? ReviewedByUserId { get; set; }
}
public enum ApprovalStatus
{
Submitted = 1,
Approved = 2,
Rejected = 3
}
public enum GradeModificationStatus
{
TeacherSubmitted = 1,
CollegeApproved = 2,
Approved = 3,
Rejected = 4
}
@@ -56,6 +56,10 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
Set<ExamSessionInvigilator>();
public DbSet<CourseAdjustment> CourseAdjustments => Set<CourseAdjustment>();
public DbSet<CourseExemption> CourseExemptions => Set<CourseExemption>();
public DbSet<DeferredExam> DeferredExams => Set<DeferredExam>();
public DbSet<GradeModification> GradeModifications => Set<GradeModification>();
public DbSet<CourseSubstitution> CourseSubstitutions => Set<CourseSubstitution>();
public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<StudentStatusChange> StudentStatusChanges =>
Set<StudentStatusChange>();
@@ -673,6 +677,52 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CourseExemption>(entity =>
{
entity.Property(x => x.Reason).HasMaxLength(500);
entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasIndex(x => new { x.StudentId, x.TeachingTaskId }).IsUnique();
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.TeachingTask).WithMany()
.HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<DeferredExam>(entity =>
{
entity.Property(x => x.Reason).HasMaxLength(500);
entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasIndex(x => new { x.StudentId, x.TeachingTaskId }).IsUnique();
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.TeachingTask).WithMany()
.HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<GradeModification>(entity =>
{
entity.Property(x => x.CurrentScore).HasPrecision(5, 1);
entity.Property(x => x.RequestedScore).HasPrecision(5, 1);
entity.Property(x => x.Reason).HasMaxLength(500);
entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasOne(x => x.GradeRecord).WithMany()
.HasForeignKey(x => x.GradeRecordId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CourseSubstitution>(entity =>
{
entity.Property(x => x.Reason).HasMaxLength(500);
entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasIndex(x => new { x.StudentId, x.OriginalCourseId }).IsUnique();
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.OriginalCourse).WithMany()
.HasForeignKey(x => x.OriginalCourseId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.SubstituteCourse).WithMany()
.HasForeignKey(x => x.SubstituteCourseId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CourseAdjustment>(entity =>
{
entity.Property(x => x.Reason).HasMaxLength(500);
@@ -40,6 +40,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260725_22_course_adjustments";
private const string AttendanceAppealMigration =
"20260725_23_attendance_appeal";
private const string ApprovalTablesMigration =
"20260725_24_approval_tables";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -270,6 +272,14 @@ public sealed class DevelopmentSqliteMigrator(
AttendanceAppealMigration,
attendanceAppealExists ? [] : AttendanceAppealStatements,
cancellationToken);
var approvalTablesExist = await db.Database
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CourseExemptions'")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
ApprovalTablesMigration,
approvalTablesExist ? [] : ApprovalTableStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -1578,4 +1588,22 @@ public sealed class DevelopmentSqliteMigrator(
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "AppealReviewedAt" TEXT NULL;""",
"""CREATE INDEX IF NOT EXISTS "IX_AttendanceRecords_AppealStatus" ON "AttendanceRecords" ("AppealStatus");"""
];
private static readonly string[] ApprovalTableStatements =
[
"""CREATE TABLE "CourseExemptions" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseExemptions" PRIMARY KEY, "StudentId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "SubmittedAt" TEXT NOT NULL, "ReviewedAt" TEXT NULL, "ReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseExemptions_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseExemptions_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT);""",
"""CREATE UNIQUE INDEX "IX_CourseExemptions_StudentId_TeachingTaskId" ON "CourseExemptions" ("StudentId", "TeachingTaskId");""",
"""CREATE INDEX "IX_CourseExemptions_Status_CreatedAt" ON "CourseExemptions" ("Status", "CreatedAt");""",
"""CREATE TABLE "DeferredExams" ("Id" TEXT NOT NULL CONSTRAINT "PK_DeferredExams" PRIMARY KEY, "StudentId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "SubmittedAt" TEXT NOT NULL, "ReviewedAt" TEXT NULL, "ReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_DeferredExams_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_DeferredExams_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT);""",
"""CREATE UNIQUE INDEX "IX_DeferredExams_StudentId_TeachingTaskId" ON "DeferredExams" ("StudentId", "TeachingTaskId");""",
"""CREATE INDEX "IX_DeferredExams_Status_CreatedAt" ON "DeferredExams" ("Status", "CreatedAt");""",
"""CREATE TABLE "GradeModifications" ("Id" TEXT NOT NULL CONSTRAINT "PK_GradeModifications" PRIMARY KEY, "GradeRecordId" TEXT NOT NULL, "CurrentScore" TEXT NOT NULL, "RequestedScore" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "ApplicantUserId" TEXT NOT NULL, "SubmittedAt" TEXT NOT NULL, "CollegeReviewedAt" TEXT NULL, "CollegeReviewedByUserId" TEXT NULL, "FinalReviewedAt" TEXT NULL, "FinalReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_GradeModifications_GradeRecords" FOREIGN KEY ("GradeRecordId") REFERENCES "GradeRecords" ("Id") ON DELETE RESTRICT);""",
"""CREATE INDEX "IX_GradeModifications_Status_CreatedAt" ON "GradeModifications" ("Status", "CreatedAt");""",
"""CREATE TABLE "CourseSubstitutions" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseSubstitutions" PRIMARY KEY, "StudentId" TEXT NOT NULL, "OriginalCourseId" TEXT NOT NULL, "SubstituteCourseId" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "SubmittedAt" TEXT NOT NULL, "ReviewedAt" TEXT NULL, "ReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseSubstitutions_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseSubstitutions_OriginalCourse" FOREIGN KEY ("OriginalCourseId") REFERENCES "Courses" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseSubstitutions_SubstituteCourse" FOREIGN KEY ("SubstituteCourseId") REFERENCES "Courses" ("Id") ON DELETE RESTRICT);""",
"""CREATE UNIQUE INDEX "IX_CourseSubstitutions_StudentId_OriginalCourseId" ON "CourseSubstitutions" ("StudentId", "OriginalCourseId");""",
"""CREATE INDEX "IX_CourseSubstitutions_Status_CreatedAt" ON "CourseSubstitutions" ("Status", "CreatedAt");"""
];
}
@@ -0,0 +1,117 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
public partial class ApprovalRequests : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// CourseExemption
migrationBuilder.CreateTable("CourseExemptions", table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
Reason = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ReviewedByUserId = table.Column<Guid>(type: "char(36)", 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_CourseExemptions", x => x.Id);
table.ForeignKey("FK_CourseExemptions_Students", x => x.StudentId, "Students", "Id", onDelete: ReferentialAction.Restrict);
table.ForeignKey("FK_CourseExemptions_TeachingTasks", x => x.TeachingTaskId, "TeachingTasks", "Id", onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex("IX_CourseExemptions_Status_CreatedAt", "CourseExemptions", new[] { "Status", "CreatedAt" });
migrationBuilder.CreateIndex("IX_CourseExemptions_StudentId_TeachingTaskId", "CourseExemptions", new[] { "StudentId", "TeachingTaskId" }, unique: true);
// DeferredExam
migrationBuilder.CreateTable("DeferredExams", table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
Reason = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ReviewedByUserId = table.Column<Guid>(type: "char(36)", 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_DeferredExams", x => x.Id);
table.ForeignKey("FK_DeferredExams_Students", x => x.StudentId, "Students", "Id", onDelete: ReferentialAction.Restrict);
table.ForeignKey("FK_DeferredExams_TeachingTasks", x => x.TeachingTaskId, "TeachingTasks", "Id", onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex("IX_DeferredExams_Status_CreatedAt", "DeferredExams", new[] { "Status", "CreatedAt" });
migrationBuilder.CreateIndex("IX_DeferredExams_StudentId_TeachingTaskId", "DeferredExams", new[] { "StudentId", "TeachingTaskId" }, unique: true);
// GradeModification
migrationBuilder.CreateTable("GradeModifications", table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
GradeRecordId = table.Column<Guid>(type: "char(36)", nullable: false),
CurrentScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
RequestedScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
Reason = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
ApplicantUserId = table.Column<Guid>(type: "char(36)", nullable: false),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CollegeReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CollegeReviewedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
FinalReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
FinalReviewedByUserId = table.Column<Guid>(type: "char(36)", 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_GradeModifications", x => x.Id);
table.ForeignKey("FK_GradeModifications_GradeRecords", x => x.GradeRecordId, "GradeRecords", "Id", onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex("IX_GradeModifications_Status_CreatedAt", "GradeModifications", new[] { "Status", "CreatedAt" });
// CourseSubstitution
migrationBuilder.CreateTable("CourseSubstitutions", table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
OriginalCourseId = table.Column<Guid>(type: "char(36)", nullable: false),
SubstituteCourseId = table.Column<Guid>(type: "char(36)", nullable: false),
Reason = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ReviewedByUserId = table.Column<Guid>(type: "char(36)", 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_CourseSubstitutions", x => x.Id);
table.ForeignKey("FK_CourseSubstitutions_Students", x => x.StudentId, "Students", "Id", onDelete: ReferentialAction.Restrict);
table.ForeignKey("FK_CourseSubstitutions_OriginalCourse", x => x.OriginalCourseId, "Courses", "Id", onDelete: ReferentialAction.Restrict);
table.ForeignKey("FK_CourseSubstitutions_SubstituteCourse", x => x.SubstituteCourseId, "Courses", "Id", onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex("IX_CourseSubstitutions_Status_CreatedAt", "CourseSubstitutions", new[] { "Status", "CreatedAt" });
migrationBuilder.CreateIndex("IX_CourseSubstitutions_StudentId_OriginalCourseId", "CourseSubstitutions", new[] { "StudentId", "OriginalCourseId" }, unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable("CourseSubstitutions");
migrationBuilder.DropTable("GradeModifications");
migrationBuilder.DropTable("DeferredExams");
migrationBuilder.DropTable("CourseExemptions");
}
}
}
+4
View File
@@ -148,6 +148,10 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
label: isTeacher.value ? '我的调停课' : '调停课审核',
},
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Teacher', 'Student']),
{ path: '/approvals', label: '审批中心' },
),
],
},
{
+8
View File
@@ -208,6 +208,14 @@ const router = createRouter({
name: 'notifications',
component: () => import('../views/NotificationCenterView.vue'),
},
{
path: 'approvals',
name: 'approvals',
component: () => import('../views/ApprovalCenterView.vue'),
meta: {
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Teacher', 'Student'],
},
},
{
path: 'student-status-changes',
name: 'student-status-changes',
+183
View File
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Check, Close, Plus, Refresh } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const isManager = computed(() => auth.user?.roles.some(r => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(r)) ?? false)
const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isManager.value)
const isStudent = computed(() => auth.user?.roles.includes('Student') && !isManager.value)
const pending = ref<any[]>([])
const loading = ref(false)
const tab = ref(isManager.value ? 'pending' : 'mine')
const dialog = ref(false)
const dialogType = ref('')
const rejectDialog = ref(false)
const rejectItem = ref<any>(null)
const rejectComment = ref('')
const enrolledCourses = ref<any[]>([])
const gradeRecords = ref<any[]>([])
const myGrades = ref<any[]>([])
const myExemptions = ref<any[]>([])
const myDeferred = ref<any[]>([])
const form = reactive({ teachingTaskId: '', reason: '', originalCourseId: '', substituteCourseId: '', gradeRecordId: '', requestedScore: 0 })
async function load() {
loading.value = true
try {
if (isManager.value) pending.value = (await http.get('/approvals/pending')).data
if (isStudent.value) {
const [courses, grades, ex, df] = await Promise.all([
http.get('/approvals/my-courses'), http.get('/approvals/my-grades'),
http.get('/approvals/exemptions/mine'), http.get('/approvals/deferred/mine'),
])
enrolledCourses.value = courses.data; myGrades.value = grades.data
myExemptions.value = ex.data; myDeferred.value = df.data
}
if (isTeacher.value) gradeRecords.value = (await http.get('/approvals/grade-records')).data
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
finally { loading.value = false }
}
function openDialog(type: string) {
dialogType.value = type
Object.assign(form, { teachingTaskId: '', reason: '', originalCourseId: '', substituteCourseId: '', gradeRecordId: '', requestedScore: 0 })
dialog.value = true
}
async function submitForm() {
if (!form.reason.trim()) { ElMessage.warning('请填写原因'); return }
try {
if (dialogType.value === 'exemption') {
if (!form.teachingTaskId) { ElMessage.warning('请选择课程'); return }
await http.post('/approvals/exemptions', { teachingTaskId: form.teachingTaskId, reason: form.reason })
} else if (dialogType.value === 'deferred') {
if (!form.teachingTaskId) { ElMessage.warning('请选择课程'); return }
await http.post('/approvals/deferred', { teachingTaskId: form.teachingTaskId, reason: form.reason })
} else if (dialogType.value === 'gradeMod') {
if (!form.gradeRecordId) { ElMessage.warning('请选择成绩记录'); return }
await http.post('/approvals/grade-modifications', { gradeRecordId: form.gradeRecordId, requestedScore: form.requestedScore, reason: form.reason })
} else if (dialogType.value === 'substitution') {
if (!form.originalCourseId || !form.substituteCourseId) { ElMessage.warning('请选择两门课程'); return }
await http.post('/approvals/substitutions', { originalCourseId: form.originalCourseId, substituteCourseId: form.substituteCourseId, reason: form.reason })
}
dialog.value = false; ElMessage.success('申请已提交'); await load()
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
async function approveItem(item: any) {
try {
const ep = `/approvals/${item.type === 'CourseExemption' ? 'exemptions' : item.type === 'DeferredExam' ? 'deferred' : item.type === 'CourseSubstitution' ? 'substitutions' : ''}`;
if (ep) { await http.post(`${ep}/${item.id}/approve`); ElMessage.success('已通过'); await load() }
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
function openReject(item: any) { rejectItem.value = item; rejectComment.value = ''; rejectDialog.value = true }
async function rejectSubmit() {
if (!rejectItem.value) return
try {
const ep = `/approvals/${rejectItem.value.type === 'CourseExemption' ? 'exemptions' : rejectItem.value.type === 'DeferredExam' ? 'deferred' : rejectItem.value.type === 'CourseSubstitution' ? 'substitutions' : ''}`;
if (ep) { await http.post(`${ep}/${rejectItem.value.id}/reject`, { comment: rejectComment.value }); rejectDialog.value = false; ElMessage.success('已驳回'); await load() }
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
onMounted(load)
</script>
<template>
<div class="page-stack appr-page">
<section class="page-intro">
<div><span class="section-kicker">APPROVAL CENTER</span><h2>审批中心</h2><p>学籍异动调停课成绩审核考勤申诉免修缓考成绩修改课程替代</p></div>
<div style="display:flex;gap:8px">
<el-button v-if="isStudent" :icon="Plus" @click="openDialog('exemption')">免修</el-button>
<el-button v-if="isStudent" :icon="Plus" @click="openDialog('deferred')">缓考</el-button>
<el-button v-if="isStudent" :icon="Plus" @click="openDialog('substitution')">课程替代</el-button>
<el-button v-if="isTeacher" :icon="Plus" type="primary" @click="openDialog('gradeMod')">成绩修改</el-button>
<el-button :icon="Refresh" @click="load">刷新</el-button>
</div>
</section>
<el-segmented v-model="tab" :options="[
...(isManager ? [{ label: `待审批 (${pending.length})`, value: 'pending' }] : []),
{ label: '我的记录', value: 'mine' },
]" style="margin-bottom:16px" />
<!-- Pending -->
<section v-if="tab === 'pending'" v-loading="loading" class="appr-list">
<article v-for="item in pending" :key="`${item.type}-${item.id}`" class="appr-card">
<el-tag size="small" :type="item.label === '考勤申诉' ? 'warning' : 'primary'">{{ item.label }}</el-tag>
<div class="appr-body"><b>{{ item.title }}</b><p>{{ item.desc }}</p><small>{{ item.college }} · {{ new Date(item.time).toLocaleString('zh-CN') }}</small></div>
<div class="appr-actions" v-if="['CourseExemption','DeferredExam','CourseSubstitution'].includes(item.type)">
<el-button size="small" type="danger" plain :icon="Close" @click="openReject(item)">驳回</el-button>
<el-button size="small" type="success" :icon="Check" @click="approveItem(item)">通过</el-button>
</div>
<div class="appr-actions" v-else><el-button size="small" text type="primary" @click="$router.push(item.type==='StudentStatusChange'?'/student-status-changes':item.type==='CourseAdjustment'?'/course-adjustments':item.type==='GradeSheet'?'/grades':'/teacher-attendance')">查看 </el-button></div>
</article>
<el-empty v-if="!pending.length" description="暂无待审批" />
</section>
<!-- My records -->
<section v-if="tab === 'mine'" v-loading="loading">
<template v-if="isStudent">
<h4 style="margin-bottom:8px">免修申请</h4>
<div class="appr-list" style="margin-bottom:16px">
<article v-for="e in myExemptions" :key="e.id" class="appr-card"><el-tag size="small" :type="e.status === 'Approved' ? 'success' : e.status === 'Rejected' ? 'danger' : 'warning'">{{ e.status === 'Submitted' ? '待审核' : e.status === 'Approved' ? '已通过' : '已驳回' }}</el-tag><div class="appr-body"><b>{{ e.courseName }}</b><p>{{ e.reason }}</p><small v-if="e.reviewComment">{{ e.reviewComment }}</small></div></article>
<el-empty v-if="!myExemptions.length" description="无" />
</div>
<h4 style="margin-bottom:8px">缓考申请</h4>
<div class="appr-list" style="margin-bottom:16px">
<article v-for="d in myDeferred" :key="d.id" class="appr-card"><el-tag size="small" :type="d.status === 'Approved' ? 'success' : d.status === 'Rejected' ? 'danger' : 'warning'">{{ d.status === 'Submitted' ? '待审核' : d.status === 'Approved' ? '已通过' : '已驳回' }}</el-tag><div class="appr-body"><b>{{ d.courseName }}</b><p>{{ d.reason }}</p></div></article>
<el-empty v-if="!myDeferred.length" description="无" />
</div>
</template>
</section>
<!-- Dialog -->
<el-dialog v-model="dialog" :title="dialogType === 'exemption' ? '免修申请' : dialogType === 'deferred' ? '缓考申请' : dialogType === 'gradeMod' ? '成绩修改申请' : '课程替代申请'" width="560px">
<el-form label-position="top">
<template v-if="dialogType === 'exemption' || dialogType === 'deferred'">
<el-form-item label="选择课程" required>
<el-select v-model="form.teachingTaskId" filterable><el-option v-for="c in enrolledCourses" :key="c.teachingTaskId" :label="`${c.courseCode} ${c.courseName}`" :value="c.teachingTaskId" /></el-select>
</el-form-item>
</template>
<template v-if="dialogType === 'substitution'">
<el-form-item label="未通过课程(被替代)" required>
<el-select v-model="form.originalCourseId" filterable><el-option v-for="g in myGrades.filter((x:any) => Number(x.totalScore) < 60)" :key="g.courseId" :label="`${g.courseCode} ${g.courseName} (${g.totalScore}分)`" :value="g.courseId" /></el-select>
</el-form-item>
<el-form-item label="已通过课程(替代课程)" required>
<el-select v-model="form.substituteCourseId" filterable><el-option v-for="g in myGrades.filter((x:any) => Number(x.totalScore) >= 60)" :key="g.courseId" :label="`${g.courseCode} ${g.courseName} (${g.totalScore}分)`" :value="g.courseId" /></el-select>
</el-form-item>
</template>
<template v-if="dialogType === 'gradeMod'">
<el-form-item label="成绩记录" required>
<el-select v-model="form.gradeRecordId" filterable><el-option v-for="r in gradeRecords" :key="r.id" :label="`${r.studentName}(${r.studentNumber}) — ${r.courseName} — ${r.totalScore}分`" :value="r.id" /></el-select>
</el-form-item>
<el-form-item label="修改为" required><el-input-number v-model="form.requestedScore" :min="0" :max="100" /></el-form-item>
</template>
<el-form-item label="原因" required><el-input v-model="form.reason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
</el-form>
<template #footer><el-button @click="dialog = false">取消</el-button><el-button type="primary" @click="submitForm">提交申请</el-button></template>
</el-dialog>
<!-- Reject -->
<el-dialog v-model="rejectDialog" title="驳回" width="460px">
<el-input v-model="rejectComment" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="驳回原因" />
<template #footer><el-button @click="rejectDialog = false">取消</el-button><el-button type="danger" @click="rejectSubmit">确认驳回</el-button></template>
</el-dialog>
</div>
</template>
<style scoped>
.appr-list { display: grid; gap: 8px; }
.appr-card { display: flex; align-items: center; gap: 14px; padding: 14px 18px; background: #fff; border: 1px solid #e4e7ed; border-radius: 8px; }
.appr-body { flex: 1; min-width: 0; }
.appr-body b { font-size: 14px; display: block; margin-bottom: 4px; }
.appr-body p { font-size: 13px; color: #606266; margin: 0 0 4px; }
.appr-body small { font-size: 11px; color: var(--muted); }
.appr-actions { display: flex; gap: 6px; flex-shrink: 0; }
</style>
+7 -1
View File
@@ -289,7 +289,13 @@ onMounted(async () => {
<el-table-column label="学号" width="120">
<template #default="{ row }"><span class="registry-number">{{ row.studentNumber }}</span></template>
</el-table-column>
<el-table-column prop="name" label="姓名" min-width="80" />
<el-table-column label="姓名" min-width="110">
<template #default="{ row }">
<span>{{ row.name }}</span>
<el-tag v-if="row.isExempt" size="small" type="success" style="margin-left:4px">免修</el-tag>
<el-tag v-if="row.isDeferred" size="small" type="warning" style="margin-left:4px">缓考</el-tag>
</template>
</el-table-column>
<el-table-column label="考勤" width="110">
<template #default="{ row }">
<el-select