diff --git a/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs b/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs index 864bc75..c2448be 100644 --- a/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs +++ b/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs @@ -126,7 +126,21 @@ public sealed class CourseAdjustmentsController( await db.SaveChangesAsync(cancellationToken); if (request.Submit) - await NotifyReviewersAsync(adj, cancellationToken); + { + var taskInfo = await db.TeachingTasks + .Where(x => x.Id == request.TeachingTaskId) + .Select(x => new { x.Course!.Name, x.Course.CollegeId }) + .FirstOrDefaultAsync(cancellationToken); + if (taskInfo is not null) + { + var tl = TypeLabel(request.Type); + await NotificationService.SendToRoleAsync(db, + SystemRoles.CollegeAdmin, + $"新的{tl}申请", + $"《{taskInfo.Name}》提交了{tl}申请,请及时审核。", + taskInfo.CollegeId, "/course-adjustments", cancellationToken); + } + } return Created(string.Empty, new { adj.Id }); } @@ -150,7 +164,19 @@ public sealed class CourseAdjustmentsController( adj.Status = CourseAdjustmentStatus.Submitted; adj.SubmittedAt = DateTime.UtcNow; await db.SaveChangesAsync(cancellationToken); - await NotifyReviewersAsync(adj, cancellationToken); + + var courseName = adj.TeachingTask!.Course!.Name; + await NotificationService.SendToRoleAsync(db, + SystemRoles.CollegeAdmin, + $"新的{TypeLabel(adj.Type)}申请", + $"《{courseName}》提交了{TypeLabel(adj.Type)}申请,请及时审核。", + adj.TeachingTask.Course.CollegeId, + "/course-adjustments", cancellationToken); + await NotificationService.SendToRoleAsync(db, + SystemRoles.AcademicAdmin, + $"新的{TypeLabel(adj.Type)}申请", + $"《{courseName}》提交了{TypeLabel(adj.Type)}申请。", + null, "/course-adjustments", cancellationToken); return NoContent(); } @@ -163,6 +189,7 @@ public sealed class CourseAdjustmentsController( var adj = await db.CourseAdjustments .Include(x => x.TeachingTask) .ThenInclude(x => x!.Course) + .ThenInclude(x => x!.College) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (adj is null) return NotFound(); @@ -176,11 +203,153 @@ public sealed class CourseAdjustmentsController( adj.Status = CourseAdjustmentStatus.Approved; adj.ReviewedAt = DateTime.UtcNow; adj.ReviewedByUserId = currentUserDataScope.Current.UserId; + + // Apply schedule changes + await ApplyScheduleChangesAsync(adj, cancellationToken); + await db.SaveChangesAsync(cancellationToken); - await NotifyApplicantAsync(adj, "已通过", cancellationToken); + + // Notify applicant + await NotificationService.SendAsync(db, adj.ApplicantUserId, + "调停课申请已通过", + $"您的{TypeLabel(adj.Type)}申请({adj.TeachingTask!.Course!.Name})已通过审核。", + "/course-adjustments", cancellationToken); + + // Notify affected students + var studentUserIds = await db.CourseEnrollments + .Where(x => + x.CourseSelectionOffering!.TeachingTaskId == adj.TeachingTaskId && + x.Status == CourseEnrollmentStatus.Enrolled) + .Select(x => x.Student!.UserId) + .Where(uid => uid != null) + .Select(uid => uid!.Value) + .Distinct() + .ToListAsync(cancellationToken); + if (studentUserIds.Count > 0) + { + await NotificationService.SendToUserIdsAsync(db, studentUserIds, + "课程变动通知", + $"《{adj.TeachingTask.Course.Name}》有{TypeLabel(adj.Type)}变动,请查看课表。", + "/my-timetable", cancellationToken); + } + return NoContent(); } + private async Task ApplyScheduleChangesAsync( + CourseAdjustment adj, CancellationToken ct) + { + switch (adj.Type) + { + case CourseAdjustmentType.Reschedule: + // Update existing schedule entries for this teaching task + if (adj.DayOfWeek.HasValue && adj.StartPeriod.HasValue) + { + var entries = await db.ScheduleEntries + .Where(x => x.TeachingTaskId == adj.TeachingTaskId) + .ToListAsync(ct); + foreach (var entry in entries) + { + entry.DayOfWeek = adj.DayOfWeek.Value; + entry.StartPeriod = adj.StartPeriod.Value; + entry.PeriodCount = adj.PeriodCount ?? entry.PeriodCount; + } + if (adj.ClassroomId.HasValue) + { + foreach (var entry in entries) + entry.ClassroomId = adj.ClassroomId.Value; + } + } + break; + + case CourseAdjustmentType.Cancel: + // Cancel: remove schedule entries for the specified week + if (adj.CancelWeek.HasValue) + { + var entries = await db.ScheduleEntries + .Where(x => x.TeachingTaskId == adj.TeachingTaskId && + x.StartWeek <= adj.CancelWeek.Value && + x.EndWeek >= adj.CancelWeek.Value) + .ToListAsync(ct); + foreach (var entry in entries) + { + // Split the entry to exclude the cancelled week + if (entry.StartWeek == adj.CancelWeek.Value && + entry.EndWeek == adj.CancelWeek.Value) + { + db.ScheduleEntries.Remove(entry); + } + else if (entry.StartWeek == adj.CancelWeek.Value) + { + entry.StartWeek = adj.CancelWeek.Value + 1; + } + else if (entry.EndWeek == adj.CancelWeek.Value) + { + entry.EndWeek = adj.CancelWeek.Value - 1; + } + } + } + break; + + case CourseAdjustmentType.Makeup: + // Makeup: add a temp schedule entry for the makeup date + if (adj.TargetDate.HasValue && adj.DayOfWeek.HasValue && + adj.StartPeriod.HasValue) + { + var publishedPlan = await db.SchedulePlans + .Where(x => x.AcademicTermId == adj.TeachingTask!.AcademicTermId && + x.Status == SchedulePlanStatus.Published) + .FirstOrDefaultAsync(ct); + if (publishedPlan is not null) + { + db.ScheduleEntries.Add(new ScheduleEntry + { + SchedulePlanId = publishedPlan.Id, + TeachingTaskId = adj.TeachingTaskId, + ClassroomId = adj.ClassroomId, + DayOfWeek = adj.DayOfWeek.Value, + StartPeriod = adj.StartPeriod.Value, + PeriodCount = adj.PeriodCount ?? 2, + StartWeek = 1, + EndWeek = 1, + WeekPattern = WeekPattern.All, + Notes = $"补课(原申请 {adj.CreatedAt:yyyy-MM-dd})" + }); + } + } + break; + + case CourseAdjustmentType.Substitute: + // Substitute: add substitute teacher to the teaching task + if (adj.SubstituteTeacherId.HasValue) + { + var alreadyExists = await db.TeachingTaskTeachers + .AnyAsync(x => + x.TeachingTaskId == adj.TeachingTaskId && + x.TeacherId == adj.SubstituteTeacherId.Value, ct); + if (!alreadyExists) + { + db.TeachingTaskTeachers.Add(new TeachingTaskTeacher + { + TeachingTaskId = adj.TeachingTaskId, + TeacherId = adj.SubstituteTeacherId.Value, + IsPrimary = false + }); + } + } + break; + } + } + + private static string TypeLabel(CourseAdjustmentType type) => type switch + { + CourseAdjustmentType.Reschedule => "调课", + CourseAdjustmentType.Cancel => "停课", + CourseAdjustmentType.Makeup => "补课", + CourseAdjustmentType.Substitute => "代课", + _ => "调停课" + }; + // ═══════════════ Reject ═══════════════ [HttpPost("{id:guid}/reject")] @@ -208,134 +377,18 @@ public sealed class CourseAdjustmentsController( adj.ReviewedAt = DateTime.UtcNow; adj.ReviewedByUserId = currentUserDataScope.Current.UserId; await db.SaveChangesAsync(cancellationToken); - await NotifyApplicantAsync(adj, - $"已退回" + (adj.ReviewComment is not null ? $":{adj.ReviewComment}" : ""), - cancellationToken); - return NoContent(); - } - // ═══════════════ Notifications ═══════════════ - - [HttpGet("notifications")] - [Authorize] - public async Task GetNotifications( - bool? unreadOnly, - CancellationToken cancellationToken) - { - var userId = currentUserDataScope.Current.UserId; - var source = db.Notifications.AsNoTracking() - .Where(x => x.UserId == userId); - if (unreadOnly == true) - source = source.Where(x => !x.IsRead); - - return Ok(new - { - Items = await source.OrderByDescending(x => x.CreatedAt) - .Take(50) - .Select(x => new - { - x.Id, x.Title, x.Content, x.IsRead, x.LinkUrl, x.CreatedAt - }) - .ToListAsync(cancellationToken), - UnreadCount = await db.Notifications - .CountAsync(x => x.UserId == userId && !x.IsRead, cancellationToken) - }); - } - - [HttpPost("notifications/{id:guid}/read")] - [Authorize] - public async Task MarkRead(Guid id, CancellationToken cancellationToken) - { - var userId = currentUserDataScope.Current.UserId; - var n = await db.Notifications - .FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId, cancellationToken); - if (n is null) return NotFound(); - n.IsRead = true; - await db.SaveChangesAsync(cancellationToken); - return NoContent(); - } - - [HttpPost("notifications/read-all")] - [Authorize] - public async Task MarkAllRead(CancellationToken cancellationToken) - { - var userId = currentUserDataScope.Current.UserId; - await db.Notifications - .Where(x => x.UserId == userId && !x.IsRead) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.IsRead, true), - cancellationToken); + var msg = adj.ReviewComment is not null + ? $"您的{TypeLabel(adj.Type)}申请已退回。审核意见:{adj.ReviewComment}" + : $"您的{TypeLabel(adj.Type)}申请已退回。"; + await NotificationService.SendAsync(db, adj.ApplicantUserId, + $"{TypeLabel(adj.Type)}申请已退回", msg, + "/course-adjustments", cancellationToken); return NoContent(); } // ═══════════════ Helpers ═══════════════ - private async Task NotifyReviewersAsync( - CourseAdjustment adj, CancellationToken ct) - { - var typeLabel = adj.Type switch - { - CourseAdjustmentType.Reschedule => "调课", - CourseAdjustmentType.Cancel => "停课", - CourseAdjustmentType.Makeup => "补课", - CourseAdjustmentType.Substitute => "代课", - _ => "调停课" - }; - var taskInfo = adj.TeachingTask is not null - ? $"{adj.TeachingTask.Course!.Name}({adj.TeachingTask.TaskNumber})" - : ""; - - // Notify CollegeAdmin and AcademicAdmin of the course's college - var collegeId = adj.TeachingTask?.Course?.CollegeId; - if (!collegeId.HasValue) return; - - var reviewerUserIds = await db.Users - .Join(db.UserRoles, u => u.Id, ur => ur.UserId, (u, ur) => new { u.Id, ur.RoleId }) - .Join(db.Roles, x => x.RoleId, r => r.Id, (x, r) => new { x.Id, RoleName = r.Name! }) - .Where(x => - (x.RoleName == SystemRoles.AcademicAdmin) || - (x.RoleName == SystemRoles.CollegeAdmin && - db.Teachers.Any(t => - t.UserId == x.Id && t.CollegeId == collegeId))) - .Select(x => x.Id) - .Distinct() - .ToListAsync(ct); - - foreach (var reviewerId in reviewerUserIds) - { - db.Notifications.Add(new Notification - { - UserId = reviewerId, - Title = $"新的{typeLabel}申请", - Content = $"{taskInfo} 提交了{typeLabel}申请,请及时审核。", - LinkUrl = "/course-adjustments" - }); - } - await db.SaveChangesAsync(ct); - } - - private async Task NotifyApplicantAsync( - CourseAdjustment adj, string result, CancellationToken ct) - { - var typeLabel = adj.Type switch - { - CourseAdjustmentType.Reschedule => "调课", - CourseAdjustmentType.Cancel => "停课", - CourseAdjustmentType.Makeup => "补课", - CourseAdjustmentType.Substitute => "代课", - _ => "调停课" - }; - db.Notifications.Add(new Notification - { - UserId = adj.ApplicantUserId, - Title = $"{typeLabel}申请{result}", - Content = adj.ReviewComment is not null - ? $"您的{typeLabel}申请{result}。审核意见:{adj.ReviewComment}" - : $"您的{typeLabel}申请{result}。", - LinkUrl = "/course-adjustments" - }); - await db.SaveChangesAsync(ct); - } - private async Task ValidateRequestAsync( CourseAdjustmentRequest request, CancellationToken cancellationToken) diff --git a/src/Jiaowu.Api/Controllers/GradesController.cs b/src/Jiaowu.Api/Controllers/GradesController.cs index a6087b0..39837c1 100644 --- a/src/Jiaowu.Api/Controllers/GradesController.cs +++ b/src/Jiaowu.Api/Controllers/GradesController.cs @@ -397,7 +397,19 @@ public sealed class GradesController( sheet.Status = GradeSheetStatus.Submitted; sheet.SubmittedAt = DateTime.UtcNow; sheet.ReviewComment = null; - return await SaveAsync(id, false, cancellationToken); + await db.SaveChangesAsync(cancellationToken); + + // Notify college admins + var courseName = sheet.TeachingTask!.Course!.Name; + var collegeId = sheet.TeachingTask.Course.CollegeId; + await NotificationService.SendToRoleAsync(db, + SystemRoles.CollegeAdmin, + "成绩单待审核", + $"《{courseName}》成绩已提交,请及时审核。", + collegeId, + "/grades", + cancellationToken); + return NoContent(); } [HttpPost("sheets/{id:guid}/approve")] @@ -425,7 +437,20 @@ public sealed class GradesController( sheet.Status = GradeSheetStatus.Approved; sheet.ReviewedAt = DateTime.UtcNow; sheet.ReviewComment = null; - return await SaveAsync(id, false, cancellationToken); + await db.SaveChangesAsync(cancellationToken); + + // Notify teachers + var teacherUserIds = await db.TeachingTaskTeachers + .Where(x => x.TeachingTaskId == sheet.TeachingTaskId) + .Select(x => x.Teacher!.UserId) + .Where(id => id != null) + .Select(id => id!.Value) + .ToListAsync(cancellationToken); + await NotificationService.SendToUserIdsAsync(db, teacherUserIds, + "成绩审核通过", + $"《{sheet.TeachingTask!.Course!.Name}》成绩已通过学院审核,等待校级发布。", + "/grades", cancellationToken); + return NoContent(); } [HttpPost("sheets/{id:guid}/return")] @@ -458,7 +483,19 @@ public sealed class GradesController( sheet.Status = GradeSheetStatus.Returned; sheet.ReviewedAt = DateTime.UtcNow; sheet.ReviewComment = request.Comment.Trim(); - return await SaveAsync(id, false, cancellationToken); + await db.SaveChangesAsync(cancellationToken); + + var teacherUserIds = await db.TeachingTaskTeachers + .Where(x => x.TeachingTaskId == sheet.TeachingTaskId) + .Select(x => x.Teacher!.UserId) + .Where(id => id != null) + .Select(id => id!.Value) + .ToListAsync(cancellationToken); + await NotificationService.SendToUserIdsAsync(db, teacherUserIds, + "成绩被退回", + $"《{sheet.TeachingTask!.Course!.Name}》成绩被退回修改:{sheet.ReviewComment}", + "/grades", cancellationToken); + return NoContent(); } [HttpPost("sheets/{id:guid}/publish")] @@ -474,7 +511,23 @@ public sealed class GradesController( return ConflictProblem("只有审核通过的成绩单可以发布。"); sheet.Status = GradeSheetStatus.Published; sheet.PublishedAt = DateTime.UtcNow; - return await SaveAsync(id, false, cancellationToken); + await db.SaveChangesAsync(cancellationToken); + + // Notify enrolled students + var studentUserIds = await db.CourseEnrollments + .Where(x => + x.CourseSelectionOffering!.TeachingTaskId == sheet.TeachingTaskId && + x.Status == CourseEnrollmentStatus.Enrolled) + .Select(x => x.Student!.UserId) + .Where(id => id != null) + .Select(id => id!.Value) + .Distinct() + .ToListAsync(cancellationToken); + await NotificationService.SendToUserIdsAsync(db, studentUserIds, + "成绩已发布", + $"《{sheet.TeachingTask!.Course!.Name}》成绩已发布,请查看。", + "/grades", cancellationToken); + return NoContent(); } [HttpGet("student/transcript")] diff --git a/src/Jiaowu.Api/Controllers/NotificationsController.cs b/src/Jiaowu.Api/Controllers/NotificationsController.cs new file mode 100644 index 0000000..9c2c076 --- /dev/null +++ b/src/Jiaowu.Api/Controllers/NotificationsController.cs @@ -0,0 +1,161 @@ +using System.Security.Claims; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Controllers; + +[ApiController] +[Authorize] +[Route("api/notifications")] +public sealed class NotificationsController( + AppDbContext db, + ICurrentUserDataScope currentUserDataScope) : ControllerBase +{ + [HttpGet] + public async Task GetNotifications( + bool? unreadOnly, + int page = 1, + int pageSize = 20, + CancellationToken cancellationToken = default) + { + var userId = currentUserDataScope.Current.UserId; + var source = db.Notifications.AsNoTracking() + .Where(x => x.UserId == userId); + if (unreadOnly == true) + source = source.Where(x => !x.IsRead); + + var total = await source.CountAsync(cancellationToken); + var items = await source + .OrderByDescending(x => x.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(x => new + { + x.Id, x.Title, x.Content, x.IsRead, x.LinkUrl, x.CreatedAt + }) + .ToListAsync(cancellationToken); + + var unreadCount = await db.Notifications + .CountAsync(x => x.UserId == userId && !x.IsRead, cancellationToken); + + return Ok(new { Items = items, Total = total, UnreadCount = unreadCount }); + } + + [HttpGet("unread-count")] + public async Task GetUnreadCount(CancellationToken cancellationToken) + { + var userId = currentUserDataScope.Current.UserId; + var count = await db.Notifications + .CountAsync(x => x.UserId == userId && !x.IsRead, cancellationToken); + return Ok(new { Count = count }); + } + + [HttpPost("{id:guid}/read")] + public async Task MarkRead(Guid id, CancellationToken cancellationToken) + { + var userId = currentUserDataScope.Current.UserId; + var n = await db.Notifications + .FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId, cancellationToken); + if (n is null) return NotFound(); + n.IsRead = true; + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [HttpPost("read-all")] + public async Task MarkAllRead(CancellationToken cancellationToken) + { + var userId = currentUserDataScope.Current.UserId; + await db.Notifications + .Where(x => x.UserId == userId && !x.IsRead) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.IsRead, true), + cancellationToken); + return NoContent(); + } +} + +/// +/// Centralized helper to send notifications across the app. +/// +public static class NotificationService +{ + public static async Task SendAsync( + AppDbContext db, + Guid userId, + string title, + string content, + string? linkUrl = null, + CancellationToken cancellationToken = default) + { + db.Notifications.Add(new Notification + { + UserId = userId, + Title = title, + Content = content, + LinkUrl = linkUrl + }); + await db.SaveChangesAsync(cancellationToken); + } + + public static async Task SendToRoleAsync( + AppDbContext db, + string roleName, + string title, + string content, + Guid? collegeId = null, + string? linkUrl = null, + CancellationToken cancellationToken = default) + { + var query = db.Users + .Join(db.UserRoles, u => u.Id, ur => ur.UserId, (u, ur) => new { u.Id, ur.RoleId }) + .Join(db.Roles, x => x.RoleId, r => r.Id, (x, r) => new { x.Id, RoleName = r.Name! }); + + var userIds = query.Where(x => x.RoleName == roleName); + + if (collegeId.HasValue && roleName == SystemRoles.CollegeAdmin) + { + userIds = userIds.Where(x => + db.Teachers.Any(t => + t.UserId == x.Id && t.CollegeId == collegeId.Value)); + } + + var ids = await userIds.Select(x => x.Id).Distinct().ToListAsync(cancellationToken); + foreach (var id in ids) + { + db.Notifications.Add(new Notification + { + UserId = id, + Title = title, + Content = content, + LinkUrl = linkUrl + }); + } + await db.SaveChangesAsync(cancellationToken); + } + + public static async Task SendToUserIdsAsync( + AppDbContext db, + IEnumerable userIds, + string title, + string content, + string? linkUrl = null, + CancellationToken cancellationToken = default) + { + foreach (var userId in userIds.Distinct()) + { + db.Notifications.Add(new Notification + { + UserId = userId, + Title = title, + Content = content, + LinkUrl = linkUrl + }); + } + await db.SaveChangesAsync(cancellationToken); + } +} diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index f1bd1bc..6a18326 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -1,9 +1,19 @@