From 4c95edb678c563d929c9af44b53fae026277c623 Mon Sep 17 00:00:00 2001 From: biss Date: Mon, 17 Aug 2026 20:50:56 +0800 Subject: [PATCH] Add deep links for pending approval notifications --- .../Controllers/ApprovalsController.cs | 51 +++++++++++++++++-- .../CourseAdjustmentsController.cs | 13 +++-- .../Controllers/ExperimentGradesController.cs | 8 ++- .../Controllers/GradesController.cs | 8 ++- .../Jiaowu.Api.Tests/GradesPaginationTests.cs | 37 +++++++++++++- web/src/views/ApprovalCenterView.vue | 28 +++++++--- web/src/views/CourseAdjustmentsView.vue | 22 +++++++- web/src/views/ExperimentGradesView.vue | 32 ++++++++++-- web/src/views/GradesView.vue | 26 +++++++--- 9 files changed, 191 insertions(+), 34 deletions(-) diff --git a/src/Jiaowu.Api/Controllers/ApprovalsController.cs b/src/Jiaowu.Api/Controllers/ApprovalsController.cs index 00d33cf..8a83cd3 100644 --- a/src/Jiaowu.Api/Controllers/ApprovalsController.cs +++ b/src/Jiaowu.Api/Controllers/ApprovalsController.cs @@ -93,6 +93,46 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s return Ok(new PagedResult(pageItems, total, page, pageSize)); } + [HttpGet("pending/{type}/{id:guid}")] + [Authorize(Roles = Managers)] + public async Task> GetPendingItem( + string type, + Guid id, + CancellationToken ct) + { + ApprovalItem? item = type switch + { + "CourseExemption" => await Scoped(x => + x.Id == id && 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)) + .FirstOrDefaultAsync(ct), + "DeferredExam" => await Scoped(x => + x.Id == id && 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)) + .FirstOrDefaultAsync(ct), + "CourseSubstitution" => await Scoped(x => + x.Id == id && 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)) + .FirstOrDefaultAsync(ct), + "GradeModification" => await Scoped(x => + x.Id == id && (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)) + .FirstOrDefaultAsync(ct), + _ => null + }; + return item is null ? NotFound() : Ok(item); + } + // ═══════════════ Course Exemption ═══════════════ [HttpGet("exemptions/mine")] [Authorize(Roles = SystemRoles.Student)] @@ -124,7 +164,7 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s 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); + await NotifyManagers("CourseExemption", ex.Id, "免修申请", $"{info.StudentName} 申请免修《{info.CourseName}》", ct); return Created("", new { ex.Id }); } @@ -187,7 +227,7 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s 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); + await NotifyManagers("DeferredExam", d.Id, "缓考申请", $"{info.StudentName} 申请《{info.CourseName}》缓考", ct); return Created("", new { d.Id }); } @@ -239,6 +279,7 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s .FirstAsync(ct); await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核", $"{gmInfo.StudentName} — 《{gmInfo.CourseName}》{gmInfo.CurrentScore}→{gmInfo.RequestedScore}", + linkUrl: $"/approvals?tab=pending&type=GradeModification&id={gm.Id}", cancellationToken: ct, category: NotificationCategory.Grade); return Created("", new { gm.Id }); @@ -258,7 +299,7 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s SystemRoles.AcademicAdmin, "成绩修改待校级审核", $"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》", - linkUrl: "/approvals", + linkUrl: $"/approvals?tab=pending&type=GradeModification&id={gm.Id}", cancellationToken: ct, category: NotificationCategory.Grade); return NoContent(); @@ -406,7 +447,7 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s 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); + await NotifyManagers("CourseSubstitution", cs.Id, "课程替代申请", $"{info.StudentName} 申请用《{info.SubName}》替代《{info.OrigName}》", ct); return Created("", new { cs.Id }); } @@ -588,7 +629,7 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s 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, category: NotificationCategory.Approval); await NotificationService.SendToRoleAsync(db, SystemRoles.AcademicAdmin, title, content, cancellationToken: ct, category: NotificationCategory.Approval); } + private async Task NotifyManagers(string type, Guid id, string title, string content, CancellationToken ct) { var linkUrl = $"/approvals?tab=pending&type={type}&id={id}"; await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, title, content, linkUrl: linkUrl, cancellationToken: ct, category: NotificationCategory.Approval); await NotificationService.SendToRoleAsync(db, SystemRoles.AcademicAdmin, title, content, linkUrl: linkUrl, cancellationToken: ct, category: NotificationCategory.Approval); } 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, "/approvals", ct, NotificationCategory.Approval); } 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, category: NotificationCategory.Grade); } private async Task NotifyRole(string role, string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, role, title, content, cancellationToken: ct, category: NotificationCategory.Approval); } diff --git a/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs b/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs index 13a0b94..f89f9b4 100644 --- a/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs +++ b/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs @@ -212,7 +212,8 @@ public sealed class CourseAdjustmentsController( Guid? academicTermId, int page = 1, int pageSize = 20, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Guid? adjustmentId = null) { if (page < 1 || pageSize is < 1 or > 100) return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。"); @@ -223,7 +224,9 @@ public sealed class CourseAdjustmentsController( if (scope.Scope == DataScope.College) source = source.Where(x => x.TeachingTask!.Course!.CollegeId == scope.RestrictedCollegeId); - if (academicTermId.HasValue) + if (adjustmentId.HasValue) + source = source.Where(x => x.Id == adjustmentId.Value); + else if (academicTermId.HasValue) source = source.Where(x => x.TeachingTask!.AcademicTermId == academicTermId); @@ -324,7 +327,7 @@ public sealed class CourseAdjustmentsController( SystemRoles.CollegeAdmin, $"新的{tl}申请", $"《{taskInfo.Name}》提交了{tl}申请,请及时审核。", - taskInfo.CollegeId, "/course-adjustments", cancellationToken, + taskInfo.CollegeId, $"/course-adjustments?adjustmentId={adj.Id}", cancellationToken, NotificationCategory.Schedule); } } @@ -377,13 +380,13 @@ public sealed class CourseAdjustmentsController( $"新的{TypeLabel(adj.Type)}申请", $"《{courseName}》提交了{TypeLabel(adj.Type)}申请,请及时审核。", adj.TeachingTask.Course.CollegeId, - "/course-adjustments", cancellationToken, + $"/course-adjustments?adjustmentId={adj.Id}", cancellationToken, NotificationCategory.Schedule); await NotificationService.SendToRoleAsync(db, SystemRoles.AcademicAdmin, $"新的{TypeLabel(adj.Type)}申请", $"《{courseName}》提交了{TypeLabel(adj.Type)}申请。", - null, "/course-adjustments", cancellationToken, + null, $"/course-adjustments?adjustmentId={adj.Id}", cancellationToken, NotificationCategory.Schedule); return NoContent(); } diff --git a/src/Jiaowu.Api/Controllers/ExperimentGradesController.cs b/src/Jiaowu.Api/Controllers/ExperimentGradesController.cs index 487c360..55a0d02 100644 --- a/src/Jiaowu.Api/Controllers/ExperimentGradesController.cs +++ b/src/Jiaowu.Api/Controllers/ExperimentGradesController.cs @@ -46,7 +46,8 @@ public sealed class ExperimentGradesController( string? keyword = null, int page = 1, int pageSize = 20, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Guid? sheetId = null) { page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 10, 50); @@ -62,6 +63,9 @@ public sealed class ExperimentGradesController( source = source.Where(x => x.GradeSheet != null && x.GradeSheet.Status == status.Value); + if (sheetId.HasValue) + source = source.Where(x => + x.GradeSheet != null && x.GradeSheet.Id == sheetId.Value); if (collegeId.HasValue) source = source.Where(x => x.TeachingTask!.Course!.CollegeId == collegeId.Value); @@ -947,7 +951,7 @@ public sealed class ExperimentGradesController( "实验成绩待审核", $"“{sheet.ExperimentProject!.Name}”实验成绩已提交,请及时审核。", sheet.ExperimentProject.TeachingTask!.Course!.CollegeId, - "/experiment-grades", + $"/experiment-grades?sheetId={sheet.Id}", cancellationToken, NotificationCategory.Grade); return NoContent(); diff --git a/src/Jiaowu.Api/Controllers/GradesController.cs b/src/Jiaowu.Api/Controllers/GradesController.cs index 6a962df..4e4da0b 100644 --- a/src/Jiaowu.Api/Controllers/GradesController.cs +++ b/src/Jiaowu.Api/Controllers/GradesController.cs @@ -54,7 +54,8 @@ public sealed class GradesController( CancellationToken cancellationToken = default, Guid? collegeId = null, string? teacherKeyword = null, - string? classKeyword = null) + string? classKeyword = null, + Guid? sheetId = null) { page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 10, 50); @@ -69,6 +70,9 @@ public sealed class GradesController( if (status.HasValue) source = source.Where(x => db.GradeSheets.Any(sheet => sheet.TeachingTaskId == x.Id && sheet.Status == status.Value)); + if (sheetId.HasValue) + source = source.Where(x => db.GradeSheets.Any(sheet => + sheet.TeachingTaskId == x.Id && sheet.Id == sheetId.Value)); if (!string.IsNullOrWhiteSpace(keyword)) { keyword = keyword.Trim(); @@ -582,7 +586,7 @@ public sealed class GradesController( "成绩单待审核", $"《{courseName}》成绩已提交,请及时审核。", collegeId, - "/grades", + $"/grades?sheetId={sheet.Id}", cancellationToken, NotificationCategory.Grade); return NoContent(); diff --git a/tests/Jiaowu.Api.Tests/GradesPaginationTests.cs b/tests/Jiaowu.Api.Tests/GradesPaginationTests.cs index 2eba4a7..57fd0fd 100644 --- a/tests/Jiaowu.Api.Tests/GradesPaginationTests.cs +++ b/tests/Jiaowu.Api.Tests/GradesPaginationTests.cs @@ -8,6 +8,7 @@ using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -163,6 +164,20 @@ public sealed class GradesPaginationTests await db.Database.EnsureCreatedAsync(); var college = new College { Code = "CS", Name = "计算机学院" }; + var collegeAdminRole = new ApplicationRole + { + Id = Guid.NewGuid(), + Name = SystemRoles.CollegeAdmin, + NormalizedName = SystemRoles.CollegeAdmin.ToUpperInvariant() + }; + var collegeAdmin = new ApplicationUser + { + Id = Guid.NewGuid(), + UserName = "college-admin", + NormalizedUserName = "COLLEGE-ADMIN", + DisplayName = "学院管理员", + CollegeId = college.Id + }; var major = new Major { Code = "080901", @@ -230,7 +245,22 @@ public sealed class GradesPaginationTests } ] }; - db.AddRange(college, major, administrativeClass, term, course, task, student, sheet); + db.AddRange( + college, + collegeAdminRole, + collegeAdmin, + new IdentityUserRole + { + UserId = collegeAdmin.Id, + RoleId = collegeAdminRole.Id + }, + major, + administrativeClass, + term, + course, + task, + student, + sheet); await db.SaveChangesAsync(); db.ChangeTracker.Clear(); @@ -244,6 +274,11 @@ public sealed class GradesPaginationTests await db.GradeSheets.Where(x => x.Id == sheet.Id) .Select(x => x.Status) .SingleAsync()); + Assert.Equal( + $"/grades?sheetId={sheet.Id}", + await db.Notifications.Where(x => x.Title == "成绩单待审核") + .Select(x => x.LinkUrl) + .SingleAsync()); var approvedSheet = await db.GradeSheets.SingleAsync(x => x.Id == sheet.Id); approvedSheet.Status = GradeSheetStatus.Approved; diff --git a/web/src/views/ApprovalCenterView.vue b/web/src/views/ApprovalCenterView.vue index 450793c..6ffc42e 100644 --- a/web/src/views/ApprovalCenterView.vue +++ b/web/src/views/ApprovalCenterView.vue @@ -59,6 +59,14 @@ const pending = ref([]) const pendingPage = ref(1) const pendingTotal = ref(0) const pendingPageSize = 20 +const targetApprovalType = computed(() => { + const value = route.query.type + return typeof value === 'string' ? value : undefined +}) +const targetApprovalId = computed(() => { + const value = route.query.id + return typeof value === 'string' ? value : undefined +}) const loading = ref(false) const submitting = ref(false) const tab = ref(isManager.value ? 'pending' : 'mine') @@ -176,11 +184,19 @@ async function load() { loading.value = true try { if (isManager.value) { - const { data } = await http.get('/approvals/pending', { - params: { page: pendingPage.value, pageSize: pendingPageSize }, - }) - pending.value = data.items - pendingTotal.value = data.total + if (targetApprovalType.value && targetApprovalId.value) { + const { data } = await http.get( + `/approvals/pending/${targetApprovalType.value}/${targetApprovalId.value}`, + ) + pending.value = [data] + pendingTotal.value = 1 + } else { + const { data } = await http.get('/approvals/pending', { + params: { page: pendingPage.value, pageSize: pendingPageSize }, + }) + pending.value = data.items + pendingTotal.value = data.total + } if (!pending.value.length && pendingPage.value > 1) { pendingPage.value-- return await load() @@ -336,7 +352,7 @@ function courseScheduleText(course: CourseOption) { } onMounted(() => { - if (isManager.value && route.query.tab === 'pending') tab.value = 'pending' + if (isManager.value && (route.query.tab === 'pending' || targetApprovalId.value)) tab.value = 'pending' return load() }) diff --git a/web/src/views/CourseAdjustmentsView.vue b/web/src/views/CourseAdjustmentsView.vue index fcaa719..64d5bfd 100644 --- a/web/src/views/CourseAdjustmentsView.vue +++ b/web/src/views/CourseAdjustmentsView.vue @@ -1,5 +1,5 @@