From 5e4c76a9e498b0f8352662b5b2fe5d8061eb8220 Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 25 Jul 2026 16:51:12 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=84=E7=B1=BB=E7=94=B3=E8=AF=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/ApprovalsController.cs | 395 ++++++++++++++++++ .../Controllers/AttendanceController.cs | 29 +- .../Domain/Academic/ApprovalEntities.cs | 83 ++++ .../Persistence/AppDbContext.cs | 50 +++ .../Persistence/DevelopmentSqliteMigrator.cs | 28 ++ .../MySql/20260725180000_ApprovalRequests.cs | 117 ++++++ web/src/layouts/AdminLayout.vue | 4 + web/src/router/index.ts | 8 + web/src/views/ApprovalCenterView.vue | 183 ++++++++ web/src/views/TeacherAttendanceView.vue | 8 +- 10 files changed, 901 insertions(+), 4 deletions(-) create mode 100644 src/Jiaowu.Api/Controllers/ApprovalsController.cs create mode 100644 src/Jiaowu.Api/Domain/Academic/ApprovalEntities.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725180000_ApprovalRequests.cs create mode 100644 web/src/views/ApprovalCenterView.vue diff --git a/src/Jiaowu.Api/Controllers/ApprovalsController.cs b/src/Jiaowu.Api/Controllers/ApprovalsController.cs new file mode 100644 index 0000000..a33e2b7 --- /dev/null +++ b/src/Jiaowu.Api/Controllers/ApprovalsController.cs @@ -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 GetPending(CancellationToken ct) + { + var items = new List(); + + items.AddRange(await Scoped(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(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(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(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(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(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(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(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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 Scoped(System.Linq.Expressions.Expression> predicate) where T : class + { + var q = db.Set().AsNoTracking().Where(predicate); + if (scope.Current.Scope == DataScope.All) return q; + var cid = scope.Current.RestrictedCollegeId; + if (typeof(T) == typeof(CourseExemption)) return (IQueryable)((IQueryable)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid); + if (typeof(T) == typeof(DeferredExam)) return (IQueryable)((IQueryable)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid); + if (typeof(T) == typeof(GradeModification)) return (IQueryable)((IQueryable)(object)q).Where(x => x.GradeRecord!.GradeSheet!.TeachingTask!.Course!.CollegeId == cid); + if (typeof(T) == typeof(CourseSubstitution)) return (IQueryable)((IQueryable)(object)q).Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == cid); + if (typeof(T) == typeof(StudentStatusChange)) return (IQueryable)((IQueryable)(object)q).Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == cid); + if (typeof(T) == typeof(CourseAdjustment)) return (IQueryable)((IQueryable)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid); + if (typeof(T) == typeof(GradeSheet)) return (IQueryable)((IQueryable)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid); + if (typeof(T) == typeof(AttendanceRecord)) return (IQueryable)((IQueryable)(object)q).Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == cid); + return q; + } + + private async Task 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); diff --git a/src/Jiaowu.Api/Controllers/AttendanceController.cs b/src/Jiaowu.Api/Controllers/AttendanceController.cs index a7b667e..69d4c33 100644 --- a/src/Jiaowu.Api/Controllers/AttendanceController.cs +++ b/src/Jiaowu.Api/Controllers/AttendanceController.cs @@ -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); diff --git a/src/Jiaowu.Api/Domain/Academic/ApprovalEntities.cs b/src/Jiaowu.Api/Domain/Academic/ApprovalEntities.cs new file mode 100644 index 0000000..3467f52 --- /dev/null +++ b/src/Jiaowu.Api/Domain/Academic/ApprovalEntities.cs @@ -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 +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index 9395641..4f3ccc7 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -56,6 +56,10 @@ public sealed class AppDbContext(DbContextOptions options) public DbSet ExamSessionInvigilators => Set(); public DbSet CourseAdjustments => Set(); + public DbSet CourseExemptions => Set(); + public DbSet DeferredExams => Set(); + public DbSet GradeModifications => Set(); + public DbSet CourseSubstitutions => Set(); public DbSet Notifications => Set(); public DbSet StudentStatusChanges => Set(); @@ -673,6 +677,52 @@ public sealed class AppDbContext(DbContextOptions options) .HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict); }); + builder.Entity(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(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(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(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(entity => { entity.Property(x => x.Reason).HasMaxLength(500); diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index 6eeb40d..a5edddc 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -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("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");""" + ]; } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725180000_ApprovalRequests.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725180000_ApprovalRequests.cs new file mode 100644 index 0000000..752d2c7 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725180000_ApprovalRequests.cs @@ -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(type: "char(36)", nullable: false), + StudentId = table.Column(type: "char(36)", nullable: false), + TeachingTaskId = table.Column(type: "char(36)", nullable: false), + Reason = table.Column(type: "varchar(500)", maxLength: 500, nullable: false), + Status = table.Column(type: "int", nullable: false), + ReviewComment = table.Column(type: "varchar(500)", maxLength: 500, nullable: true), + SubmittedAt = table.Column(type: "datetime(6)", nullable: false), + ReviewedAt = table.Column(type: "datetime(6)", nullable: true), + ReviewedByUserId = table.Column(type: "char(36)", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(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(type: "char(36)", nullable: false), + StudentId = table.Column(type: "char(36)", nullable: false), + TeachingTaskId = table.Column(type: "char(36)", nullable: false), + Reason = table.Column(type: "varchar(500)", maxLength: 500, nullable: false), + Status = table.Column(type: "int", nullable: false), + ReviewComment = table.Column(type: "varchar(500)", maxLength: 500, nullable: true), + SubmittedAt = table.Column(type: "datetime(6)", nullable: false), + ReviewedAt = table.Column(type: "datetime(6)", nullable: true), + ReviewedByUserId = table.Column(type: "char(36)", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(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(type: "char(36)", nullable: false), + GradeRecordId = table.Column(type: "char(36)", nullable: false), + CurrentScore = table.Column(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false), + RequestedScore = table.Column(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false), + Reason = table.Column(type: "varchar(500)", maxLength: 500, nullable: false), + Status = table.Column(type: "int", nullable: false), + ReviewComment = table.Column(type: "varchar(500)", maxLength: 500, nullable: true), + ApplicantUserId = table.Column(type: "char(36)", nullable: false), + SubmittedAt = table.Column(type: "datetime(6)", nullable: false), + CollegeReviewedAt = table.Column(type: "datetime(6)", nullable: true), + CollegeReviewedByUserId = table.Column(type: "char(36)", nullable: true), + FinalReviewedAt = table.Column(type: "datetime(6)", nullable: true), + FinalReviewedByUserId = table.Column(type: "char(36)", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(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(type: "char(36)", nullable: false), + StudentId = table.Column(type: "char(36)", nullable: false), + OriginalCourseId = table.Column(type: "char(36)", nullable: false), + SubstituteCourseId = table.Column(type: "char(36)", nullable: false), + Reason = table.Column(type: "varchar(500)", maxLength: 500, nullable: false), + Status = table.Column(type: "int", nullable: false), + ReviewComment = table.Column(type: "varchar(500)", maxLength: 500, nullable: true), + SubmittedAt = table.Column(type: "datetime(6)", nullable: false), + ReviewedAt = table.Column(type: "datetime(6)", nullable: true), + ReviewedByUserId = table.Column(type: "char(36)", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(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"); + } + } +} diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index 268d07a..e3c028a 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -148,6 +148,10 @@ const navigationGroups = computed(() => [ label: isTeacher.value ? '我的调停课' : '调停课审核', }, ), + ...whenVisible( + hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Teacher', 'Student']), + { path: '/approvals', label: '审批中心' }, + ), ], }, { diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 89101ad..fcd9f92 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -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', diff --git a/web/src/views/ApprovalCenterView.vue b/web/src/views/ApprovalCenterView.vue new file mode 100644 index 0000000..47efbd2 --- /dev/null +++ b/web/src/views/ApprovalCenterView.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/web/src/views/TeacherAttendanceView.vue b/web/src/views/TeacherAttendanceView.vue index 3788947..1b33b8c 100644 --- a/web/src/views/TeacherAttendanceView.vue +++ b/web/src/views/TeacherAttendanceView.vue @@ -289,7 +289,13 @@ onMounted(async () => { - + + +