成绩通知:仅在某门课程成绩正式发布后自动发送,按成绩单学生名单通知,不展示具体分数;发布状态与通知同一次提交。

手动发信:移除“成绩通知”选项,只能发送普通通知,原有角色与范围权限保持不变。
选课通知:管理员关闭一轮选课时,自动向每位参与学生发送一条汇总通知,包含最终选中课程及候补未成功课程;候补失效、轮次关闭和通知生成保持原子性。
关闭选课界面会明确提示发送通知,并显示实际通知人数。
This commit is contained in:
2026-07-26 19:12:42 +08:00 Unverified
parent ecc6546f3a
commit b0679a253d
20 changed files with 6804 additions and 210 deletions
@@ -206,7 +206,9 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
.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);
$"{gmInfo.StudentName} — 《{gmInfo.CourseName}》{gmInfo.CurrentScore}→{gmInfo.RequestedScore}",
cancellationToken: ct,
category: NotificationCategory.Grade);
return Created("", new { gm.Id });
}
@@ -219,7 +221,14 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
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);
await NotificationService.SendToRoleAsync(
db,
SystemRoles.AcademicAdmin,
"成绩修改待校级审核",
$"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》",
linkUrl: "/approvals",
cancellationToken: ct,
category: NotificationCategory.Grade);
return NoContent();
}
@@ -227,7 +236,14 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
[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);
var gm = await db.GradeModifications
.Include(x => x.GradeRecord)
.ThenInclude(x => x!.Student)
.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.CollegeApproved) return ConflictProblem("需先通过学院审核。");
gm.Status = GradeModificationStatus.Approved; gm.FinalReviewedAt = DateTime.UtcNow; gm.FinalReviewedByUserId = scope.Current.UserId;
@@ -235,7 +251,27 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
gm.GradeRecord!.TotalScore = gm.RequestedScore;
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
await db.SaveChangesAsync(ct);
await NotificationService.SendAsync(db, gm.ApplicantUserId, "成绩修改已通过", "您的成绩修改申请已通过三级审批并生效。", cancellationToken: ct);
await NotificationService.SendAsync(
db,
gm.ApplicantUserId,
"成绩修改已通过",
"您的成绩修改申请已通过三级审批并生效。",
"/approvals",
ct,
NotificationCategory.Grade);
var studentUserId = gm.GradeRecord.Student?.UserId;
if (studentUserId.HasValue &&
studentUserId.Value != gm.ApplicantUserId)
{
await NotificationService.SendAsync(
db,
studentUserId.Value,
"成绩已更新",
$"《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》成绩已由 {gm.CurrentScore} 调整为 {gm.RequestedScore}。",
"/grades",
ct,
NotificationCategory.Grade);
}
return NoContent();
}
@@ -249,7 +285,14 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
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);
await NotificationService.SendAsync(
db,
gm.ApplicantUserId,
"成绩修改已驳回",
gm.ReviewComment ?? "审核未通过。",
"/approvals",
ct,
NotificationCategory.Grade);
return NoContent();
}
@@ -513,10 +556,10 @@ 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); 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 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 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); }
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 => "代课", _ => "调停课" };
@@ -793,7 +793,8 @@ public sealed class AttendanceController(
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
"考勤申诉待处理",
$"学生 {studentName} 对《{courseName}》考勤记录提出申诉。",
"/teacher-attendance", cancellationToken);
"/teacher-attendance", cancellationToken,
NotificationCategory.Attendance);
}
return NoContent();
@@ -905,7 +906,7 @@ public sealed class AttendanceController(
request.Comment is not null
? $"您的考勤申诉{result}。意见:{request.Comment}"
: $"您的考勤申诉{result}。",
null, cancellationToken);
null, cancellationToken, NotificationCategory.Attendance);
}
return NoContent();
@@ -138,7 +138,8 @@ public sealed class CourseAdjustmentsController(
SystemRoles.CollegeAdmin,
$"新的{tl}申请",
$"《{taskInfo.Name}》提交了{tl}申请,请及时审核。",
taskInfo.CollegeId, "/course-adjustments", cancellationToken);
taskInfo.CollegeId, "/course-adjustments", cancellationToken,
NotificationCategory.Schedule);
}
}
@@ -171,12 +172,14 @@ public sealed class CourseAdjustmentsController(
$"新的{TypeLabel(adj.Type)}申请",
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请,请及时审核。",
adj.TeachingTask.Course.CollegeId,
"/course-adjustments", cancellationToken);
"/course-adjustments", cancellationToken,
NotificationCategory.Schedule);
await NotificationService.SendToRoleAsync(db,
SystemRoles.AcademicAdmin,
$"新的{TypeLabel(adj.Type)}申请",
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请。",
null, "/course-adjustments", cancellationToken);
null, "/course-adjustments", cancellationToken,
NotificationCategory.Schedule);
return NoContent();
}
@@ -213,7 +216,8 @@ public sealed class CourseAdjustmentsController(
await NotificationService.SendAsync(db, adj.ApplicantUserId,
"调停课申请已通过",
$"您的{TypeLabel(adj.Type)}申请({adj.TeachingTask!.Course!.Name})已通过审核。",
"/course-adjustments", cancellationToken);
"/course-adjustments", cancellationToken,
NotificationCategory.Schedule);
// Notify affected students
var studentUserIds = await db.CourseEnrollments
@@ -230,7 +234,8 @@ public sealed class CourseAdjustmentsController(
await NotificationService.SendToUserIdsAsync(db, studentUserIds,
"课程变动通知",
$"《{adj.TeachingTask.Course.Name}》有{TypeLabel(adj.Type)}变动,请查看课表。",
"/my-timetable", cancellationToken);
"/my-timetable", cancellationToken,
NotificationCategory.Schedule);
}
return NoContent();
@@ -383,7 +388,8 @@ public sealed class CourseAdjustmentsController(
: $"您的{TypeLabel(adj.Type)}申请已退回。";
await NotificationService.SendAsync(db, adj.ApplicantUserId,
$"{TypeLabel(adj.Type)}申请已退回", msg,
"/course-adjustments", cancellationToken);
"/course-adjustments", cancellationToken,
NotificationCategory.Schedule);
return NoContent();
}
@@ -218,38 +218,67 @@ public sealed class CourseSelectionsController(
if (round.Status != CourseSelectionRoundStatus.Open)
return ConflictProblem("只有开放中的选课批次可以关闭。");
var waitlisted = await db.CourseEnrollments
var enrollments = await db.CourseEnrollments
.Include(x => x.Student)
.Include(x => x.CourseSelectionOffering)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Course)
.Where(x =>
x.CourseSelectionOffering!.CourseSelectionRoundId == id &&
x.Status == CourseEnrollmentStatus.Waitlisted)
x.CourseSelectionOffering!.CourseSelectionRoundId == id)
.ToListAsync(cancellationToken);
var waitlisted = enrollments
.Where(x => x.Status == CourseEnrollmentStatus.Waitlisted)
.ToList();
var now = DateTime.UtcNow;
foreach (var enrollment in waitlisted)
{
enrollment.Status = CourseEnrollmentStatus.Expired;
enrollment.WithdrawnAt = now;
if (enrollment.Student!.UserId is Guid userId)
}
var participantGroups = enrollments
.Where(x => x.Student!.UserId.HasValue)
.GroupBy(x => new
{
db.Notifications.Add(new Notification
{
UserId = userId,
Title = "课程候补已结束",
Content =
$"“{enrollment.CourseSelectionOffering!.TeachingTask!.Course!.Name}”" +
"选课批次已关闭,本次候补未获得名额。",
LinkUrl = "/course-selections"
});
}
x.StudentId,
UserId = x.Student!.UserId!.Value
})
.ToList();
foreach (var participant in participantGroups)
{
var selectedCourses = participant
.Where(x => x.Status == CourseEnrollmentStatus.Enrolled)
.Select(x => x.CourseSelectionOffering!.TeachingTask!.Course!.Name)
.Distinct()
.OrderBy(x => x)
.ToArray();
var unsuccessfulCourses = participant
.Where(x => x.Status == CourseEnrollmentStatus.Expired)
.Select(x => x.CourseSelectionOffering!.TeachingTask!.Course!.Name)
.Distinct()
.OrderBy(x => x)
.ToArray();
db.Notifications.Add(new Notification
{
UserId = participant.Key.UserId,
Title = $"{round.Name}结果",
Content = BuildRoundResultContent(
round.Name,
selectedCourses,
unsuccessfulCourses),
Category = NotificationCategory.CourseSelection,
LinkUrl = "/course-selections"
});
}
round.Status = CourseSelectionRoundStatus.Closed;
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Ok(new { ExpiredWaitlistCount = waitlisted.Count });
return Ok(new
{
ExpiredWaitlistCount = waitlisted.Count,
NotifiedStudentCount = participantGroups.Count
});
},
cancellationToken,
IsolationLevel.Serializable);
@@ -940,6 +969,7 @@ public sealed class CourseSelectionsController(
Content =
$"管理员已将你移出“{enrollment.CourseSelectionOffering!.TeachingTask!.Course!.Name}”" +
"候补队列。",
Category = NotificationCategory.CourseSelection,
LinkUrl = "/course-selections"
});
}
@@ -1835,6 +1865,7 @@ public sealed class CourseSelectionsController(
Content =
$"“{offering.TeachingTask!.Course!.Name}”候补未能递补:" +
eligibility.Error,
Category = NotificationCategory.CourseSelection,
LinkUrl = "/course-selections"
});
}
@@ -1869,6 +1900,7 @@ public sealed class CourseSelectionsController(
Content =
$"“{offering.TeachingTask.Course!.Name}”已释放名额," +
"你已自动进入正式选课名单。",
Category = NotificationCategory.CourseSelection,
LinkUrl = "/course-selections"
});
}
@@ -1918,6 +1950,39 @@ public sealed class CourseSelectionsController(
}
}
private static string BuildRoundResultContent(
string roundName,
IReadOnlyCollection<string> selectedCourses,
IReadOnlyCollection<string> unsuccessfulCourses)
{
var parts = new List<string> { $"{roundName}已结束。" };
parts.Add(selectedCourses.Count == 0
? "本轮未选中课程。"
: $"最终选中 {selectedCourses.Count} 门:{FormatCourseNames(selectedCourses)}。");
if (unsuccessfulCourses.Count > 0)
{
parts.Add(
$"候补未成功 {unsuccessfulCourses.Count} 门:" +
$"{FormatCourseNames(unsuccessfulCourses)}。");
}
return string.Concat(parts);
}
private static string FormatCourseNames(IReadOnlyCollection<string> courses)
{
const int visibleCount = 8;
const int visibleNameLength = 40;
var names = courses.Take(visibleCount)
.Select(name => name.Length <= visibleNameLength
? $"《{name}》"
: $"《{name[..visibleNameLength]}…》");
var result = string.Join("、", names);
return courses.Count > visibleCount
? $"{result}等 {courses.Count} 门课程"
: result;
}
private ActionResult ProfileNotFound() =>
ConflictProblem("当前账号未关联有效学生档案,请联系教务管理员。");
+14 -12
View File
@@ -411,7 +411,8 @@ public sealed class GradesController(
$"《{courseName}》成绩已提交,请及时审核。",
collegeId,
"/grades",
cancellationToken);
cancellationToken,
NotificationCategory.Grade);
return NoContent();
}
@@ -452,7 +453,7 @@ public sealed class GradesController(
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
"成绩审核通过",
$"《{sheet.TeachingTask!.Course!.Name}》成绩已通过学院审核,等待校级发布。",
"/grades", cancellationToken);
"/grades", cancellationToken, NotificationCategory.Grade);
return NoContent();
}
@@ -497,7 +498,7 @@ public sealed class GradesController(
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
"成绩被退回",
$"《{sheet.TeachingTask!.Course!.Name}》成绩被退回修改:{sheet.ReviewComment}",
"/grades", cancellationToken);
"/grades", cancellationToken, NotificationCategory.Grade);
return NoContent();
}
@@ -505,7 +506,10 @@ public sealed class GradesController(
[Authorize(Roles = Publishers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
{
var sheet = await AccessibleSheets().FirstOrDefaultAsync(
var sheet = await AccessibleSheets()
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.FirstOrDefaultAsync(
x => x.Id == id,
cancellationToken);
if (sheet is null) return NotFound();
@@ -514,13 +518,11 @@ public sealed class GradesController(
return ConflictProblem("只有审核通过的成绩单可以发布。");
sheet.Status = GradeSheetStatus.Published;
sheet.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
// Notify enrolled students
var studentUserIds = await db.CourseEnrollments
.Where(x =>
x.CourseSelectionOffering!.TeachingTaskId == sheet.TeachingTaskId &&
x.Status == CourseEnrollmentStatus.Enrolled)
// The grade sheet roster is authoritative at publication time. This also
// covers students added through approved roster corrections.
var studentUserIds = await db.GradeRecords
.Where(x => x.GradeSheetId == sheet.Id)
.Select(x => x.Student!.UserId)
.Where(id => id != null)
.Select(id => id!.Value)
@@ -528,8 +530,8 @@ public sealed class GradesController(
.ToListAsync(cancellationToken);
await NotificationService.SendToUserIdsAsync(db, studentUserIds,
"成绩已发布",
$"《{sheet.TeachingTask!.Course!.Name}》成绩已发布,请查看。",
"/grades", cancellationToken);
$"《{sheet.TeachingTask!.Course!.Name}》成绩已正式发布,请前往成绩单查看。",
"/grades", cancellationToken, NotificationCategory.Grade);
return NoContent();
}
@@ -1,4 +1,4 @@
using System.Security.Claims;
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
@@ -16,18 +16,38 @@ public sealed class NotificationsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string Senders =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Counselor + "," +
SystemRoles.Teacher;
[HttpGet]
public async Task<ActionResult> GetNotifications(
bool? unreadOnly,
NotificationCategory? category,
string? keyword,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100);
var userId = currentUserDataScope.Current.UserId;
var source = db.Notifications.AsNoTracking()
.Where(x => x.UserId == userId);
if (unreadOnly == true)
source = source.Where(x => !x.IsRead);
if (category.HasValue)
source = source.Where(x => x.Category == category);
if (!string.IsNullOrWhiteSpace(keyword))
{
var value = keyword.Trim();
source = source.Where(x =>
x.Title.Contains(value) ||
x.Content.Contains(value));
}
var total = await source.CountAsync(cancellationToken);
var items = await source
@@ -36,14 +56,38 @@ public sealed class NotificationsController(
.Take(pageSize)
.Select(x => new
{
x.Id, x.Title, x.Content, x.IsRead, x.LinkUrl, x.CreatedAt
x.Id,
x.Title,
x.Content,
x.Category,
x.IsRead,
x.LinkUrl,
SenderName = x.MessageDispatch == null
? "教务系统"
: x.MessageDispatch.SenderName,
AudienceName = x.MessageDispatch == null
? null
: x.MessageDispatch.AudienceName,
IsManual = x.MessageDispatchId != null,
x.CreatedAt
})
.ToListAsync(cancellationToken);
var unreadCount = await db.Notifications
.CountAsync(x => x.UserId == userId && !x.IsRead, cancellationToken);
var unreadByCategory = await db.Notifications.AsNoTracking()
.Where(x => x.UserId == userId && !x.IsRead)
.GroupBy(x => x.Category)
.Select(x => new { Category = x.Key, Count = x.Count() })
.ToListAsync(cancellationToken);
return Ok(new { Items = items, Total = total, UnreadCount = unreadCount });
return Ok(new
{
Items = items,
Total = total,
UnreadCount = unreadCount,
UnreadByCategory = unreadByCategory
});
}
[HttpGet("unread-count")]
@@ -55,30 +99,362 @@ public sealed class NotificationsController(
return Ok(new { Count = count });
}
[HttpGet("composer")]
[Authorize(Roles = Senders)]
public async Task<ActionResult> GetComposer(CancellationToken cancellationToken)
{
var scope = currentUserDataScope.Current;
if (IsSchoolAdministrator(scope))
{
var recipientCount = await db.Users.AsNoTracking()
.CountAsync(
x => x.IsEnabled && x.Id != scope.UserId,
cancellationToken);
return Ok(new
{
AudienceType = MessageAudienceType.School,
AudienceName = "全校已启用账号",
RecipientCount = recipientCount,
TeachingTasks = Array.Empty<object>()
});
}
if (scope.IsInRole(SystemRoles.CollegeAdmin) ||
scope.IsInRole(SystemRoles.Counselor))
{
var collegeId = await ResolveSenderCollegeIdAsync(
scope,
cancellationToken);
if (!collegeId.HasValue)
{
return ConflictProblem(
"当前账号未关联学院,暂时无法确定发信范围。");
}
var college = await db.Colleges.AsNoTracking()
.Where(x => x.Id == collegeId.Value)
.Select(x => x.Name)
.FirstOrDefaultAsync(cancellationToken);
if (college is null)
return ConflictProblem("当前账号关联的学院不存在。");
var recipientCount = await db.Users.AsNoTracking()
.CountAsync(
x => x.IsEnabled &&
x.Id != scope.UserId &&
x.CollegeId == collegeId.Value,
cancellationToken);
return Ok(new
{
AudienceType = MessageAudienceType.College,
AudienceName = $"{college}全院成员",
RecipientCount = recipientCount,
TeachingTasks = Array.Empty<object>()
});
}
if (scope.IsInRole(SystemRoles.Teacher))
{
var tasks = await db.TeachingTasks.AsNoTracking()
.Where(task =>
task.Status != TeachingTaskStatus.Draft &&
task.Teachers.Any(item =>
item.Teacher!.UserId == scope.UserId))
.OrderByDescending(task => task.AcademicTerm!.IsCurrent)
.ThenByDescending(task => task.AcademicTerm!.StartDate)
.ThenBy(task => task.TaskNumber)
.Select(task => new
{
task.Id,
task.TaskNumber,
CourseName = task.Course!.Name,
TermName = task.AcademicTerm!.Name,
RecipientCount = db.Students.Count(student =>
student.Status == StudentStatus.Active &&
student.UserId != null &&
(task.Classes.Any(item =>
item.AdministrativeClassId ==
student.AdministrativeClassId) ||
db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == student.Id &&
enrollment.Status ==
CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!
.TeachingTaskId == task.Id)))
})
.ToListAsync(cancellationToken);
return Ok(new
{
AudienceType = MessageAudienceType.TeachingTask,
AudienceName = "所带教学班",
RecipientCount = 0,
TeachingTasks = tasks
});
}
return Forbid();
}
[HttpPost("send")]
[Authorize(Roles = Senders)]
public async Task<ActionResult> Send(
SendMessageRequest request,
CancellationToken cancellationToken)
{
var title = request.Title.Trim();
var content = request.Content.Trim();
if (title.Length == 0)
return ValidationProblem("请填写消息标题。");
if (content.Length == 0)
return ValidationProblem("请填写消息正文。");
var scope = currentUserDataScope.Current;
MessageAudienceType audienceType;
Guid? audienceId = null;
string audienceName;
List<Guid> recipientIds;
if (IsSchoolAdministrator(scope))
{
audienceType = MessageAudienceType.School;
audienceName = "全校已启用账号";
recipientIds = await db.Users.AsNoTracking()
.Where(x => x.IsEnabled && x.Id != scope.UserId)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
}
else if (scope.IsInRole(SystemRoles.CollegeAdmin) ||
scope.IsInRole(SystemRoles.Counselor))
{
var collegeId = await ResolveSenderCollegeIdAsync(
scope,
cancellationToken);
if (!collegeId.HasValue)
{
return ConflictProblem(
"当前账号未关联学院,暂时无法确定发信范围。");
}
var collegeName = await db.Colleges.AsNoTracking()
.Where(x => x.Id == collegeId.Value)
.Select(x => x.Name)
.FirstOrDefaultAsync(cancellationToken);
if (collegeName is null)
return ConflictProblem("当前账号关联的学院不存在。");
audienceType = MessageAudienceType.College;
audienceId = collegeId.Value;
audienceName = $"{collegeName}全院成员";
recipientIds = await db.Users.AsNoTracking()
.Where(x =>
x.IsEnabled &&
x.Id != scope.UserId &&
x.CollegeId == collegeId.Value)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
}
else if (scope.IsInRole(SystemRoles.Teacher))
{
if (!request.TeachingTaskId.HasValue)
return ValidationProblem("请选择接收消息的教学班。");
var task = await db.TeachingTasks.AsNoTracking()
.Where(x =>
x.Id == request.TeachingTaskId.Value &&
x.Status != TeachingTaskStatus.Draft &&
x.Teachers.Any(item =>
item.Teacher!.UserId == scope.UserId))
.Select(x => new
{
x.Id,
x.TaskNumber,
CourseName = x.Course!.Name
})
.FirstOrDefaultAsync(cancellationToken);
if (task is null)
{
return ConflictProblem(
"只能向自己所带且已发布的教学班发信。");
}
audienceType = MessageAudienceType.TeachingTask;
audienceId = task.Id;
audienceName = $"{task.TaskNumber} · {task.CourseName}";
recipientIds = await GetTeachingTaskRecipientIdsAsync(
task.Id,
cancellationToken);
}
else
{
return Forbid();
}
recipientIds = recipientIds.Distinct().ToList();
if (recipientIds.Count == 0)
return ConflictProblem("当前发信范围内没有可接收消息的已启用账号。");
var dispatch = new MessageDispatch
{
SenderUserId = scope.UserId,
SenderName = scope.DisplayName ?? "教务用户",
Title = title,
Content = content,
Category = NotificationCategory.General,
AudienceType = audienceType,
AudienceId = audienceId,
AudienceName = audienceName,
RecipientCount = recipientIds.Count,
LinkUrl = null
};
dispatch.Notifications = recipientIds.Select(userId => new Notification
{
UserId = userId,
Title = title,
Content = content,
Category = NotificationCategory.General,
LinkUrl = null
}).ToList();
db.MessageDispatches.Add(dispatch);
await db.SaveChangesAsync(cancellationToken);
return Ok(new
{
dispatch.Id,
dispatch.AudienceName,
dispatch.RecipientCount,
dispatch.CreatedAt
});
}
[HttpGet("sent")]
[Authorize(Roles = Senders)]
public async Task<ActionResult> GetSent(
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100);
var userId = currentUserDataScope.Current.UserId;
var source = db.MessageDispatches.AsNoTracking()
.Where(x => x.SenderUserId == userId);
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.Category,
x.AudienceType,
x.AudienceName,
x.RecipientCount,
x.CreatedAt
})
.ToListAsync(cancellationToken);
return Ok(new { Items = items, Total = total });
}
[HttpPost("{id:guid}/read")]
public async Task<ActionResult> MarkRead(Guid id, CancellationToken cancellationToken)
public async Task<ActionResult> 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;
var notification = await db.Notifications
.FirstOrDefaultAsync(
x => x.Id == id && x.UserId == userId,
cancellationToken);
if (notification is null) return NotFound();
notification.IsRead = true;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("read-all")]
public async Task<ActionResult> MarkAllRead(CancellationToken cancellationToken)
public async Task<ActionResult> 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),
.ExecuteUpdateAsync(
updates => updates.SetProperty(x => x.IsRead, true),
cancellationToken);
return NoContent();
}
private async Task<List<Guid>> GetTeachingTaskRecipientIdsAsync(
Guid teachingTaskId,
CancellationToken cancellationToken)
{
return await db.Students.AsNoTracking()
.Where(student =>
student.Status == StudentStatus.Active &&
student.UserId != null &&
db.Users.Any(user =>
user.Id == student.UserId &&
user.IsEnabled) &&
(db.TeachingTaskClasses.Any(item =>
item.TeachingTaskId == teachingTaskId &&
item.AdministrativeClassId ==
student.AdministrativeClassId) ||
db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == student.Id &&
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId ==
teachingTaskId)))
.Select(x => x.UserId!.Value)
.Distinct()
.ToListAsync(cancellationToken);
}
private async Task<Guid?> ResolveSenderCollegeIdAsync(
CurrentUserScope scope,
CancellationToken cancellationToken)
{
if (scope.CollegeId.HasValue)
return scope.CollegeId;
if (!scope.IsInRole(SystemRoles.Counselor))
return null;
var collegeIds = await db.AdministrativeClasses.AsNoTracking()
.Where(x => x.CounselorUserId == scope.UserId)
.Select(x => x.Major!.CollegeId)
.Distinct()
.Take(2)
.ToListAsync(cancellationToken);
return collegeIds.Count == 1 ? collegeIds[0] : null;
}
private static bool IsSchoolAdministrator(CurrentUserScope scope) =>
scope.IsInRole(SystemRoles.SuperAdmin) ||
scope.IsInRole(SystemRoles.AcademicAdmin);
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "无法发送消息",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
private ActionResult ValidationProblem(string detail) =>
BadRequest(new ProblemDetails
{
Title = "消息内容不完整",
Detail = detail,
Status = StatusCodes.Status400BadRequest
});
}
public sealed record SendMessageRequest(
[property: Required, StringLength(200)] string Title,
[property: Required, StringLength(1000)] string Content,
Guid? TeachingTaskId = null);
/// <summary>
/// Centralized helper to send notifications across the app.
/// </summary>
@@ -90,13 +466,15 @@ public static class NotificationService
string title,
string content,
string? linkUrl = null,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
NotificationCategory category = NotificationCategory.General)
{
db.Notifications.Add(new Notification
{
UserId = userId,
Title = title,
Content = content,
Category = category,
LinkUrl = linkUrl
});
await db.SaveChangesAsync(cancellationToken);
@@ -109,22 +487,46 @@ public static class NotificationService
string content,
Guid? collegeId = null,
string? linkUrl = null,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
NotificationCategory category = NotificationCategory.General)
{
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! });
.Join(
db.UserRoles,
user => user.Id,
userRole => userRole.UserId,
(user, userRole) => new
{
user.Id,
user.IsEnabled,
user.CollegeId,
userRole.RoleId
})
.Join(
db.Roles,
item => item.RoleId,
role => role.Id,
(item, role) => new
{
item.Id,
item.IsEnabled,
item.CollegeId,
RoleName = role.Name!
});
var userIds = query.Where(x => x.RoleName == roleName);
var userIds = query.Where(x =>
x.RoleName == roleName &&
x.IsEnabled);
if (collegeId.HasValue && roleName == SystemRoles.CollegeAdmin)
if (collegeId.HasValue)
{
userIds = userIds.Where(x =>
db.Teachers.Any(t =>
t.UserId == x.Id && t.CollegeId == collegeId.Value));
userIds = userIds.Where(x => x.CollegeId == collegeId.Value);
}
var ids = await userIds.Select(x => x.Id).Distinct().ToListAsync(cancellationToken);
var ids = await userIds
.Select(x => x.Id)
.Distinct()
.ToListAsync(cancellationToken);
foreach (var id in ids)
{
db.Notifications.Add(new Notification
@@ -132,6 +534,7 @@ public static class NotificationService
UserId = id,
Title = title,
Content = content,
Category = category,
LinkUrl = linkUrl
});
}
@@ -144,7 +547,8 @@ public static class NotificationService
string title,
string content,
string? linkUrl = null,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
NotificationCategory category = NotificationCategory.General)
{
foreach (var userId in userIds.Distinct())
{
@@ -153,6 +557,7 @@ public static class NotificationService
UserId = userId,
Title = title,
Content = content,
Category = category,
LinkUrl = linkUrl
});
}
@@ -92,12 +92,12 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
var si = studentInfos.First(s => s.Id == w.StudentId);
var r = rules.First(r => r.Type == w.Type);
if (r.NotifyStudent && si.UserId.HasValue)
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", cancellationToken: ct);
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", ct, NotificationCategory.Warning);
if (r.NotifyCounselor)
{
var counselorId = await db.AdministrativeClasses.Where(c => c.Id == si.ClassId && c.CounselorUserId != null).Select(c => c.CounselorUserId!.Value).FirstOrDefaultAsync(ct);
if (counselorId != default)
await NotificationService.SendAsync(db, counselorId, "学生学业预警", $"{si.Name}{w.Detail}", "/warnings", cancellationToken: ct);
await NotificationService.SendAsync(db, counselorId, "学生学业预警", $"{si.Name}{w.Detail}", "/warnings", ct, NotificationCategory.Warning);
}
}
}
@@ -7,6 +7,42 @@ public sealed class Notification : EntityBase
public Guid UserId { get; set; }
public required string Title { get; set; }
public required string Content { get; set; }
public NotificationCategory Category { get; set; } = NotificationCategory.General;
public bool IsRead { get; set; }
public string? LinkUrl { get; set; }
public Guid? MessageDispatchId { get; set; }
public MessageDispatch? MessageDispatch { get; set; }
}
public sealed class MessageDispatch : EntityBase
{
public Guid SenderUserId { get; set; }
public required string SenderName { get; set; }
public required string Title { get; set; }
public required string Content { get; set; }
public NotificationCategory Category { get; set; } = NotificationCategory.General;
public MessageAudienceType AudienceType { get; set; }
public Guid? AudienceId { get; set; }
public required string AudienceName { get; set; }
public int RecipientCount { get; set; }
public string? LinkUrl { get; set; }
public ICollection<Notification> Notifications { get; set; } = [];
}
public enum NotificationCategory
{
General = 1,
Approval = 2,
Schedule = 3,
Grade = 4,
Attendance = 5,
CourseSelection = 6,
Warning = 7
}
public enum MessageAudienceType
{
School = 1,
College = 2,
TeachingTask = 3
}
@@ -77,6 +77,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<EvaluationRecord> EvaluationRecords => Set<EvaluationRecord>();
public DbSet<EvaluationScore> EvaluationScores => Set<EvaluationScore>();
public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<MessageDispatch> MessageDispatches => Set<MessageDispatch>();
public DbSet<StudentStatusChange> StudentStatusChanges =>
Set<StudentStatusChange>();
public DbSet<GraduationAuditBatch> GraduationAuditBatches =>
@@ -919,7 +920,23 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.Property(x => x.Content).HasMaxLength(1000);
entity.Property(x => x.LinkUrl).HasMaxLength(300);
entity.HasIndex(x => new { x.UserId, x.IsRead });
entity.HasIndex(x => new { x.UserId, x.Category, x.CreatedAt });
entity.HasIndex(x => x.MessageDispatchId);
entity.HasIndex(x => x.CreatedAt);
entity.HasOne(x => x.MessageDispatch)
.WithMany(x => x.Notifications)
.HasForeignKey(x => x.MessageDispatchId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<MessageDispatch>(entity =>
{
entity.Property(x => x.SenderName).HasMaxLength(100);
entity.Property(x => x.Title).HasMaxLength(200);
entity.Property(x => x.Content).HasMaxLength(1000);
entity.Property(x => x.AudienceName).HasMaxLength(200);
entity.Property(x => x.LinkUrl).HasMaxLength(300);
entity.HasIndex(x => new { x.SenderUserId, x.CreatedAt });
});
builder.Entity<AuditLog>(entity =>
@@ -54,6 +54,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260726_29_personal_calendar_subscription";
private const string OfficialDocumentsMigration =
"20260726_30_official_documents";
private const string UnifiedMessageCenterMigration =
"20260726_31_unified_message_center";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -391,6 +393,19 @@ public sealed class DevelopmentSqliteMigrator(
OfficialDocumentsMigration,
officialDocumentsExist ? [] : OfficialDocumentStatements,
cancellationToken);
var messageDispatchesExist = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM sqlite_master
WHERE type = 'table' AND name = 'MessageDispatches'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
UnifiedMessageCenterMigration,
messageDispatchesExist ? [] : UnifiedMessageCenterStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -1876,4 +1891,30 @@ public sealed class DevelopmentSqliteMigrator(
"""CREATE INDEX "IX_MakeupExamAutoJobs_MakeupExamPlanId_CreatedAt" ON "MakeupExamAutoJobs" ("MakeupExamPlanId", "CreatedAt");""",
"""CREATE INDEX "IX_MakeupExamAutoJobs_Status_CreatedAt" ON "MakeupExamAutoJobs" ("Status", "CreatedAt");""",
];
private static readonly string[] UnifiedMessageCenterStatements =
[
"""
CREATE TABLE "MessageDispatches" (
"Id" TEXT NOT NULL CONSTRAINT "PK_MessageDispatches" PRIMARY KEY,
"SenderUserId" TEXT NOT NULL,
"SenderName" TEXT NOT NULL,
"Title" TEXT NOT NULL,
"Content" TEXT NOT NULL,
"Category" INTEGER NOT NULL,
"AudienceType" INTEGER NOT NULL,
"AudienceId" TEXT NULL,
"AudienceName" TEXT NOT NULL,
"RecipientCount" INTEGER NOT NULL,
"LinkUrl" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
""",
"""CREATE INDEX "IX_MessageDispatches_SenderUserId_CreatedAt" ON "MessageDispatches" ("SenderUserId", "CreatedAt");""",
"""ALTER TABLE "Notifications" ADD COLUMN "Category" INTEGER NOT NULL DEFAULT 1;""",
"""ALTER TABLE "Notifications" ADD COLUMN "MessageDispatchId" TEXT NULL REFERENCES "MessageDispatches" ("Id") ON DELETE CASCADE;""",
"""CREATE INDEX "IX_Notifications_MessageDispatchId" ON "Notifications" ("MessageDispatchId");""",
"""CREATE INDEX "IX_Notifications_UserId_Category_CreatedAt" ON "Notifications" ("UserId", "Category", "CreatedAt");"""
];
}
@@ -0,0 +1,102 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class UnifiedMessageCenter : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Category",
table: "Notifications",
type: "int",
nullable: false,
defaultValue: 1);
migrationBuilder.AddColumn<Guid>(
name: "MessageDispatchId",
table: "Notifications",
type: "char(36)",
nullable: true);
migrationBuilder.CreateTable(
name: "MessageDispatches",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
SenderUserId = table.Column<Guid>(type: "char(36)", nullable: false),
SenderName = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
Title = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false),
Content = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: false),
Category = table.Column<int>(type: "int", nullable: false),
AudienceType = table.Column<int>(type: "int", nullable: false),
AudienceId = table.Column<Guid>(type: "char(36)", nullable: true),
AudienceName = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false),
RecipientCount = table.Column<int>(type: "int", nullable: false),
LinkUrl = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MessageDispatches", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Notifications_MessageDispatchId",
table: "Notifications",
column: "MessageDispatchId");
migrationBuilder.CreateIndex(
name: "IX_Notifications_UserId_Category_CreatedAt",
table: "Notifications",
columns: new[] { "UserId", "Category", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_MessageDispatches_SenderUserId_CreatedAt",
table: "MessageDispatches",
columns: new[] { "SenderUserId", "CreatedAt" });
migrationBuilder.AddForeignKey(
name: "FK_Notifications_MessageDispatches_MessageDispatchId",
table: "Notifications",
column: "MessageDispatchId",
principalTable: "MessageDispatches",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Notifications_MessageDispatches_MessageDispatchId",
table: "Notifications");
migrationBuilder.DropTable(
name: "MessageDispatches");
migrationBuilder.DropIndex(
name: "IX_Notifications_MessageDispatchId",
table: "Notifications");
migrationBuilder.DropIndex(
name: "IX_Notifications_UserId_Category_CreatedAt",
table: "Notifications");
migrationBuilder.DropColumn(
name: "Category",
table: "Notifications");
migrationBuilder.DropColumn(
name: "MessageDispatchId",
table: "Notifications");
}
}
}
@@ -2210,12 +2210,73 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("MakeupExamSessionInvigilators");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MessageDispatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid?>("AudienceId")
.HasColumnType("char(36)");
b.Property<string>("AudienceName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<int>("AudienceType")
.HasColumnType("int");
b.Property<int>("Category")
.HasColumnType("int");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("LinkUrl")
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<int>("RecipientCount")
.HasColumnType("int");
b.Property<string>("SenderName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<Guid>("SenderUserId")
.HasColumnType("char(36)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("SenderUserId", "CreatedAt");
b.ToTable("MessageDispatches");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Notification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<int>("Category")
.HasColumnType("int");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(1000)
@@ -2231,6 +2292,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<Guid?>("MessageDispatchId")
.HasColumnType("char(36)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
@@ -2246,8 +2310,12 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("CreatedAt");
b.HasIndex("MessageDispatchId");
b.HasIndex("UserId", "IsRead");
b.HasIndex("UserId", "Category", "CreatedAt");
b.ToTable("Notifications");
});
@@ -4076,6 +4144,16 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Teacher");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Notification", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.MessageDispatch", "MessageDispatch")
.WithMany("Notifications")
.HasForeignKey("MessageDispatchId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("MessageDispatch");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b =>
{
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "InvalidatedByUser")
@@ -4549,6 +4627,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Invigilators");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MessageDispatch", b =>
{
b.Navigation("Notifications");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b =>
{
b.Navigation("Downloads");
@@ -75,11 +75,11 @@ public sealed class WarningCheckWorker(IServiceScopeFactory scopeFactory, ILogge
{
var si = await db.Students.Where(x => x.Id == w.StudentId).Select(x => new { x.UserId, x.Name, x.AdministrativeClassId }).FirstAsync(ct);
if (rule.NotifyStudent && si.UserId.HasValue)
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", cancellationToken: ct);
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", ct, NotificationCategory.Warning);
if (rule.NotifyCounselor)
{
var cid = await db.AdministrativeClasses.Where(c => c.Id == si.AdministrativeClassId && c.CounselorUserId != null).Select(c => c.CounselorUserId!.Value).FirstOrDefaultAsync(ct);
if (cid != default) await NotificationService.SendAsync(db, cid, "学生学业预警", $"{si.Name}{w.Detail}", "/warnings", cancellationToken: ct);
if (cid != default) await NotificationService.SendAsync(db, cid, "学生学业预警", $"{si.Name}{w.Detail}", "/warnings", ct, NotificationCategory.Warning);
}
}
}
@@ -95,18 +95,31 @@ public sealed class CourseSelectionsControllerTests
var adminController = new CourseSelectionsController(
db,
new AdminDataScope());
Assert.IsType<OkObjectResult>(await adminController.CloseRound(
var closeResult = Assert.IsType<OkObjectResult>(await adminController.CloseRound(
data.RoundId,
CancellationToken.None));
Assert.Equal(2, ReadIntProperty(closeResult.Value, "NotifiedStudentCount"));
db.ChangeTracker.Clear();
var waitlist = await db.CourseEnrollments.SingleAsync(
x => x.StudentId == data.FirstWaiterStudentId);
Assert.Equal(CourseEnrollmentStatus.Expired, waitlist.Status);
Assert.NotNull(waitlist.WithdrawnAt);
Assert.True(await db.Notifications.AnyAsync(x =>
x.UserId == data.FirstWaiterUserId &&
x.Title == "课程候补已结束"));
var resultNotifications = await db.Notifications
.Where(x => x.Title == "第一轮选课结果")
.OrderBy(x => x.UserId)
.ToListAsync();
Assert.Equal(2, resultNotifications.Count);
var selectedNotification = Assert.Single(resultNotifications
.Where(x => x.UserId == data.EnrolledUserId));
Assert.Contains("最终选中 1 门", selectedNotification.Content);
Assert.Contains("《程序设计基础》", selectedNotification.Content);
var waitlistNotification = Assert.Single(resultNotifications
.Where(x => x.UserId == data.FirstWaiterUserId));
Assert.Contains("本轮未选中课程", waitlistNotification.Content);
Assert.Contains("候补未成功 1 门", waitlistNotification.Content);
Assert.Equal(NotificationCategory.CourseSelection, waitlistNotification.Category);
Assert.Equal("/course-selections", waitlistNotification.LinkUrl);
}
[Fact]
@@ -337,6 +350,14 @@ public sealed class CourseSelectionsControllerTests
DisplayName = displayName
};
private static int ReadIntProperty(object? value, string propertyName)
{
Assert.NotNull(value);
var property = value.GetType().GetProperty(propertyName);
Assert.NotNull(property);
return Assert.IsType<int>(property.GetValue(value));
}
private static Student CreateStudent(
string number,
string name,
@@ -0,0 +1,381 @@
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class NotificationsControllerTests
{
[Fact]
public async Task College_administrator_can_only_send_to_enabled_users_in_own_college()
{
await using var fixture = await NotificationFixture.CreateAsync();
var controller = new NotificationsController(
fixture.Db,
new TestDataScope(
fixture.Sender.Id,
fixture.FirstCollege.Id,
SystemRoles.CollegeAdmin));
var result = await controller.Send(
new SendMessageRequest(
"学院通知",
"请按时完成教学材料提交。"),
CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
var notifications = await fixture.Db.Notifications
.Include(x => x.MessageDispatch)
.ToListAsync();
Assert.Equal(
new[]
{
fixture.ClassStudentUser.Id,
fixture.CollegeRecipient.Id,
fixture.TeacherUser.Id
}.Order(),
notifications.Select(x => x.UserId).Order());
var dispatch = Assert.Single(
notifications.Select(x => x.MessageDispatch).Distinct());
Assert.Equal("第一学院全院成员", dispatch!.AudienceName);
Assert.Equal(3, dispatch.RecipientCount);
}
[Fact]
public async Task Teacher_can_send_notice_only_to_own_teaching_class()
{
await using var fixture = await NotificationFixture.CreateAsync();
var controller = new NotificationsController(
fixture.Db,
new TestDataScope(
fixture.TeacherUser.Id,
fixture.FirstCollege.Id,
SystemRoles.Teacher));
var result = await controller.Send(
new SendMessageRequest(
"课程资料提醒",
"请在规定时间内完成课程资料提交。",
fixture.OwnTask.Id),
CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
var notification = Assert.Single(await fixture.Db.Notifications
.Include(x => x.MessageDispatch)
.ToListAsync());
Assert.Equal(fixture.ClassStudentUser.Id, notification.UserId);
Assert.Equal(NotificationCategory.General, notification.Category);
Assert.Null(notification.LinkUrl);
Assert.Equal(
MessageAudienceType.TeachingTask,
notification.MessageDispatch!.AudienceType);
fixture.Db.Notifications.Remove(notification);
fixture.Db.MessageDispatches.Remove(notification.MessageDispatch);
await fixture.Db.SaveChangesAsync();
var forbiddenTask = await controller.Send(
new SendMessageRequest(
"越权消息",
"不应发送。",
fixture.OtherTask.Id),
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(forbiddenTask);
Assert.Empty(await fixture.Db.Notifications.ToListAsync());
}
[Fact]
public async Task School_administrator_reaches_all_enabled_accounts_except_self()
{
await using var fixture = await NotificationFixture.CreateAsync();
var controller = new NotificationsController(
fixture.Db,
new TestDataScope(
fixture.Sender.Id,
fixture.FirstCollege.Id,
SystemRoles.AcademicAdmin));
var result = await controller.Send(
new SendMessageRequest(
"全校通知",
"本周五进行系统维护。"),
CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
var recipientIds = await fixture.Db.Notifications
.Select(x => x.UserId)
.ToListAsync();
Assert.DoesNotContain(fixture.Sender.Id, recipientIds);
Assert.DoesNotContain(fixture.DisabledRecipient.Id, recipientIds);
Assert.Contains(fixture.CollegeRecipient.Id, recipientIds);
Assert.Contains(fixture.OutsideRecipient.Id, recipientIds);
Assert.Contains(fixture.TeacherUser.Id, recipientIds);
Assert.Contains(fixture.ClassStudentUser.Id, recipientIds);
}
[Fact]
public async Task Publishing_course_grades_automatically_notifies_roster_students()
{
await using var fixture = await NotificationFixture.CreateAsync();
var studentId = await fixture.Db.Students
.Where(x => x.UserId == fixture.ClassStudentUser.Id)
.Select(x => x.Id)
.SingleAsync();
var sheet = new GradeSheet
{
TeachingTaskId = fixture.OwnTask.Id,
Status = GradeSheetStatus.Approved,
Records =
[
new GradeRecord
{
StudentId = studentId,
RegularScore = 88,
FinalScore = 92,
TotalScore = 90.8m,
GradePoint = 4
}
]
};
fixture.Db.GradeSheets.Add(sheet);
await fixture.Db.SaveChangesAsync();
fixture.Db.ChangeTracker.Clear();
var controller = new GradesController(
fixture.Db,
new TestDataScope(
fixture.Sender.Id,
fixture.FirstCollege.Id,
SystemRoles.AcademicAdmin));
Assert.IsType<NoContentResult>(await controller.Publish(
sheet.Id,
CancellationToken.None));
var notification = Assert.Single(await fixture.Db.Notifications
.Where(x => x.UserId == fixture.ClassStudentUser.Id)
.ToListAsync());
Assert.Equal("成绩已发布", notification.Title);
Assert.Contains("《测试课程》", notification.Content);
Assert.DoesNotContain("90.8", notification.Content);
Assert.Equal(NotificationCategory.Grade, notification.Category);
Assert.Equal("/grades", notification.LinkUrl);
}
private sealed class TestDataScope(
Guid userId,
Guid? collegeId,
params string[] roles) : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
userId,
"测试发送人",
collegeId,
EffectiveDataScopeResolver.Resolve(roles),
roles.ToHashSet(StringComparer.OrdinalIgnoreCase));
}
private sealed class NotificationFixture : IAsyncDisposable
{
private readonly SqliteConnection connection;
private NotificationFixture(
SqliteConnection connection,
AppDbContext db,
College firstCollege,
ApplicationUser sender,
ApplicationUser collegeRecipient,
ApplicationUser outsideRecipient,
ApplicationUser disabledRecipient,
ApplicationUser teacherUser,
ApplicationUser classStudentUser,
TeachingTask ownTask,
TeachingTask otherTask)
{
this.connection = connection;
Db = db;
FirstCollege = firstCollege;
Sender = sender;
CollegeRecipient = collegeRecipient;
OutsideRecipient = outsideRecipient;
DisabledRecipient = disabledRecipient;
TeacherUser = teacherUser;
ClassStudentUser = classStudentUser;
OwnTask = ownTask;
OtherTask = otherTask;
}
public AppDbContext Db { get; }
public College FirstCollege { get; }
public ApplicationUser Sender { get; }
public ApplicationUser CollegeRecipient { get; }
public ApplicationUser OutsideRecipient { get; }
public ApplicationUser DisabledRecipient { get; }
public ApplicationUser TeacherUser { get; }
public ApplicationUser ClassStudentUser { get; }
public TeachingTask OwnTask { get; }
public TeachingTask OtherTask { get; }
public static async Task<NotificationFixture> CreateAsync()
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var firstCollege = new College
{
Code = "C01",
Name = "第一学院"
};
var secondCollege = new College
{
Code = "C02",
Name = "第二学院"
};
var users = Enumerable.Range(1, 6)
.Select(index => new ApplicationUser
{
Id = Guid.NewGuid(),
UserName = $"user{index}",
NormalizedUserName = $"USER{index}",
DisplayName = $"用户{index}",
CollegeId = index == 3 ? secondCollege.Id : firstCollege.Id,
IsEnabled = index != 4
})
.ToArray();
var sender = users[0];
var collegeRecipient = users[1];
var outsideRecipient = users[2];
var disabledRecipient = users[3];
var teacherUser = users[4];
var classStudentUser = users[5];
var major = new Major
{
Code = "M01",
Name = "测试专业",
CollegeId = firstCollege.Id,
DegreeType = "本科",
SchoolingYears = 4
};
var administrativeClass = new AdministrativeClass
{
Code = "CL01",
Name = "测试行政班",
MajorId = major.Id,
Grade = 2025
};
var student = new Student
{
StudentNumber = "20250001",
Name = "测试学生",
AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2025,
EnrollmentDate = new DateOnly(2025, 9, 1),
UserId = classStudentUser.Id
};
var teacher = new Teacher
{
TeacherNumber = "T001",
Name = "测试教师",
CollegeId = firstCollege.Id,
UserId = teacherUser.Id
};
var term = new AcademicTerm
{
Code = "2025-A",
Name = "2025 秋季学期",
AcademicYear = "2025-2026",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2025, 9, 1),
EndDate = new DateOnly(2026, 1, 20),
IsCurrent = true
};
var course = new Course
{
Code = "COURSE-1",
Name = "测试课程",
CollegeId = firstCollege.Id,
Credits = 2,
TotalHours = 32,
LectureHours = 32,
PracticeHours = 0,
Nature = CourseNature.MajorRequired,
AssessmentMethod = AssessmentMethod.Examination
};
var ownTask = new TeachingTask
{
TaskNumber = "TASK-1",
Name = "测试课程教学班",
AcademicTermId = term.Id,
CourseId = course.Id,
Capacity = 30,
Status = TeachingTaskStatus.Published
};
ownTask.Teachers.Add(new TeachingTaskTeacher
{
TeachingTaskId = ownTask.Id,
TeacherId = teacher.Id,
IsPrimary = true
});
ownTask.Classes.Add(new TeachingTaskClass
{
TeachingTaskId = ownTask.Id,
AdministrativeClassId = administrativeClass.Id
});
var otherTask = new TeachingTask
{
TaskNumber = "TASK-2",
Name = "其他教学班",
AcademicTermId = term.Id,
CourseId = course.Id,
Capacity = 30,
Status = TeachingTaskStatus.Published
};
db.AddRange(firstCollege, secondCollege, term);
db.Users.AddRange(users);
await db.SaveChangesAsync();
db.Add(major);
await db.SaveChangesAsync();
db.Add(course);
await db.SaveChangesAsync();
db.Add(teacher);
await db.SaveChangesAsync();
db.Add(administrativeClass);
await db.SaveChangesAsync();
db.AddRange(student, ownTask, otherTask);
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
return new NotificationFixture(
connection,
db,
firstCollege,
sender,
collegeRecipient,
outsideRecipient,
disabledRecipient,
teacherUser,
classStudentUser,
ownTask,
otherTask);
}
public async ValueTask DisposeAsync()
{
await Db.DisposeAsync();
await connection.DisposeAsync();
}
}
}
+1 -1
View File
@@ -282,7 +282,7 @@ onMounted(() => {
<div class="user-block">
<el-badge :value="unreadNotifCount" :hidden="!unreadNotifCount" :max="99">
<el-button :icon="Bell" circle text @click="router.push('/notifications')" title="消息通知" />
<el-button :icon="Bell" circle text @click="router.push('/notifications')" title="消息中心" />
</el-badge>
<div class="avatar">{{ auth.user?.displayName?.slice(0, 1) ?? '管' }}</div>
<div class="user-copy">
+1 -56
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Bell, Check, Plus, Refresh } from '@element-plus/icons-vue'
import { Check, Plus, Refresh } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
@@ -12,8 +12,6 @@ const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isMana
const adjustments = ref<any[]>([])
const pendingReviews = ref<any[]>([])
const notifications = ref<any[]>([])
const unreadCount = ref(0)
const loading = ref(false)
const reviewLoading = ref(false)
const dialog = ref(false)
@@ -61,19 +59,10 @@ async function load() {
params: { academicTermId: termId.value },
})).data
}
await loadNotifications()
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
finally { loading.value = false }
}
async function loadNotifications() {
try {
const { data } = await http.get('/notifications', { params: { pageSize: 50 } })
notifications.value = data.items
unreadCount.value = data.unreadCount
} catch (_) { /* notifications are optional */ }
}
function openCreate() {
Object.assign(form, {
teachingTaskId: undefined,
@@ -146,23 +135,6 @@ async function doReject() {
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
async function markRead(n: any) {
if (n.isRead) return
try {
await http.post(`/notifications/${n.id}/read`)
n.isRead = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
} catch (_) {}
}
async function markAllRead() {
try {
await http.post('/notifications/read-all')
notifications.value.forEach(n => n.isRead = true)
unreadCount.value = 0
} catch (_) {}
}
onMounted(async () => {
try {
const [termRes, taskRes] = await Promise.all([
@@ -195,9 +167,6 @@ function showCancel(type: string) { return type === 'Cancel' }
<p>教师提交调课停课补课代课申请学院审核后生效并通知相关人员</p>
</div>
<div style="display:flex;gap:8px;align-items:center">
<el-badge :value="unreadCount" :hidden="!unreadCount">
<el-button :icon="Bell" @click="tab = 'notifications'">消息</el-button>
</el-badge>
<el-button v-if="isTeacher" type="primary" :icon="Plus" @click="openCreate">提交申请</el-button>
<el-button :icon="Refresh" @click="load">刷新</el-button>
</div>
@@ -210,7 +179,6 @@ function showCancel(type: string) { return type === 'Cancel' }
<el-segmented v-model="tab" :options="[
...(isTeacher ? [{ label: '我的申请', value: 'mine' }] : []),
...(isManager ? [{ label: '待审核', value: 'reviews' }] : []),
{ label: '消息通知', value: 'notifications' },
]" />
</section>
@@ -284,22 +252,6 @@ function showCancel(type: string) { return type === 'Cancel' }
<el-empty v-if="!pendingReviews.length" description="暂无待审核申请" />
</section>
<!-- Notifications -->
<section v-if="tab === 'notifications'" class="adj-list">
<div v-if="unreadCount" class="notif-actions">
<el-button size="small" text @click="markAllRead">全部标为已读</el-button>
</div>
<article v-for="n in notifications" :key="n.id" class="notif-card" :class="{ unread: !n.isRead }" @click="markRead(n)">
<div class="notif-dot" v-if="!n.isRead" />
<div>
<b>{{ n.title }}</b>
<p>{{ n.content }}</p>
<small>{{ new Date(n.createdAt).toLocaleString('zh-CN') }}</small>
</div>
</article>
<el-empty v-if="!notifications.length" description="暂无消息通知" />
</section>
<!-- Create dialog -->
<el-dialog v-model="dialog" title="提交调停课申请" width="680px" top="5vh">
<el-form label-position="top">
@@ -410,11 +362,4 @@ function showCancel(type: string) { return type === 'Cancel' }
.adj-card.approved { border-left: 4px solid #67c23a; }
.adj-card.rejected { border-left: 4px solid #f56c6c; }
.notif-card { display: flex; align-items: flex-start; gap: 12px; padding: 14px 18px; background: #fff; border: 1px solid #e4e7ed; border-radius: 8px; cursor: pointer; }
.notif-card.unread { background: #ecf5ff; border-color: #b3d8ff; }
.notif-dot { width: 8px; height: 8px; border-radius: 50%; background: #409eff; flex-shrink: 0; margin-top: 6px; }
.notif-card b { font-size: 14px; display: block; margin-bottom: 4px; }
.notif-card p { font-size: 13px; color: #606266; margin: 0; }
.notif-card small { font-size: 11px; color: var(--muted); }
.notif-actions { display: flex; justify-content: flex-end; margin-bottom: 8px; }
</style>
+3 -2
View File
@@ -476,11 +476,12 @@ async function openSelection(round: any) {
async function closeSelection(round: any) {
try {
await ElMessageBox.confirm(
'关闭后学生将不能继续选课或退课,现有教学班名单会保留。',
'关闭后学生将不能继续选课或退课,系统会向本轮参与学生发送最终选课结果。',
'关闭选课',
{ type: 'warning', confirmButtonText: '确认关闭', cancelButtonText: '取消' },
)
await http.post(`/course-selections/rounds/${round.id}/close`)
const { data } = await http.post(`/course-selections/rounds/${round.id}/close`)
ElMessage.success(`选课批次已关闭,已向 ${data.notifiedStudentCount} 名学生发送结果通知`)
await loadRounds()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
+861 -78
View File
@@ -1,127 +1,910 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Bell, Check, Refresh } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref } from 'vue'
import {
Bell,
Check,
EditPen,
Message,
Promotion,
Refresh,
Search,
} from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const auth = useAuthStore()
const senderRoles = ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Teacher']
const canSend = computed(() =>
auth.user?.roles.some(role => senderRoles.includes(role)) ?? false)
const activeTab = ref<'inbox' | 'compose' | 'sent'>('inbox')
const notifications = ref<any[]>([])
const sentMessages = ref<any[]>([])
const unreadCount = ref(0)
const total = ref(0)
const sentTotal = ref(0)
const loading = ref(false)
const sending = ref(false)
const composerLoading = ref(false)
const page = ref(1)
const sentPage = ref(1)
const pageSize = 20
const unreadOnly = ref(false)
const category = ref<string>()
const keyword = ref('')
const composer = ref<any>(null)
const messageForm = reactive({
teachingTaskId: undefined as string | undefined,
title: '',
content: '',
})
const categories: Record<string, { label: string; className: string }> = {
General: { label: '一般通知', className: 'general' },
Approval: { label: '审核待办', className: 'approval' },
Schedule: { label: '课程变动', className: 'schedule' },
Grade: { label: '成绩通知', className: 'grade' },
Attendance: { label: '考勤消息', className: 'attendance' },
CourseSelection: { label: '选课消息', className: 'selection' },
Warning: { label: '学业预警', className: 'warning' },
}
const selectedTask = computed(() =>
composer.value?.teachingTasks?.find(
(task: any) => task.id === messageForm.teachingTaskId))
const recipientCount = computed(() =>
composer.value?.audienceType === 'TeachingTask'
? selectedTask.value?.recipientCount ?? 0
: composer.value?.recipientCount ?? 0)
const audienceName = computed(() =>
composer.value?.audienceType === 'TeachingTask'
? selectedTask.value
? `${selectedTask.value.taskNumber} · ${selectedTask.value.courseName}`
: '请选择教学班'
: composer.value?.audienceName ?? '正在读取发信范围')
function categoryLabel(value: string) {
return categories[value]?.label ?? '系统消息'
}
function categoryClass(value: string) {
return categories[value]?.className ?? 'general'
}
async function load(reset = false) {
if (reset) page.value = 1
loading.value = true
try {
const { data } = await http.get('/notifications', {
params: { page: page.value, pageSize, unreadOnly: unreadOnly.value || undefined },
params: {
page: page.value,
pageSize,
unreadOnly: unreadOnly.value || undefined,
category: category.value || undefined,
keyword: keyword.value.trim() || undefined,
},
})
notifications.value = data.items
total.value = data.total
unreadCount.value = data.unreadCount
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
finally { loading.value = false }
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function markRead(n: any) {
if (n.isRead) return
async function loadSent(reset = false) {
if (reset) sentPage.value = 1
loading.value = true
try {
await http.post(`/notifications/${n.id}/read`)
n.isRead = true
const { data } = await http.get('/notifications/sent', {
params: { page: sentPage.value, pageSize },
})
sentMessages.value = data.items
sentTotal.value = data.total
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function loadComposer() {
if (!canSend.value || composer.value) return
composerLoading.value = true
try {
composer.value = (await http.get('/notifications/composer')).data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
composerLoading.value = false
}
}
async function changeTab(tab: 'inbox' | 'compose' | 'sent') {
activeTab.value = tab
if (tab === 'compose') await loadComposer()
if (tab === 'sent') await loadSent(true)
}
async function markRead(notification: any) {
if (notification.isRead) return
try {
await http.post(`/notifications/${notification.id}/read`)
notification.isRead = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
} catch (_) {}
} catch {
// Keep navigation available even when the read receipt cannot be saved.
}
}
async function openNotification(notification: any) {
await markRead(notification)
if (notification.linkUrl) await router.push(notification.linkUrl)
}
async function markAllRead() {
try {
await http.post('/notifications/read-all')
notifications.value.forEach(n => n.isRead = true)
notifications.value.forEach(notification => {
notification.isRead = true
})
unreadCount.value = 0
ElMessage.success('全部标为已读')
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
ElMessage.success('全部消息已标为已读')
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function goLink(link: string | null) {
if (!link) return
const router = (window as any).__router__
if (router) router.push(link)
async function sendMessage() {
if (!messageForm.title.trim()) {
ElMessage.warning('请填写消息标题')
return
}
if (!messageForm.content.trim()) {
ElMessage.warning('请填写消息正文')
return
}
if (composer.value?.audienceType === 'TeachingTask' &&
!messageForm.teachingTaskId) {
ElMessage.warning('请选择接收消息的教学班')
return
}
if (!recipientCount.value) {
ElMessage.warning('当前范围内没有可接收消息的账号')
return
}
try {
await ElMessageBox.confirm(
`消息将发送给“${audienceName.value}”的 ${recipientCount.value} 个账号,发送后不可撤回。`,
'确认发送消息',
{
type: 'warning',
confirmButtonText: '确认发送',
cancelButtonText: '继续编辑',
},
)
sending.value = true
const { data } = await http.post('/notifications/send', {
title: messageForm.title,
content: messageForm.content,
teachingTaskId: messageForm.teachingTaskId || null,
})
ElMessage.success(`已发送给 ${data.recipientCount} 个账号`)
messageForm.title = ''
messageForm.content = ''
await changeTab('sent')
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') {
ElMessage.error(apiErrorMessage(error))
}
} finally {
sending.value = false
}
}
onMounted(() => load())
</script>
<template>
<div class="page-stack notif-page">
<section class="page-intro">
<div class="page-stack message-center">
<section class="page-intro message-intro">
<div>
<span class="section-kicker">NOTIFICATION CENTER</span>
<h2>消息通知</h2>
<p>审核提示调课通知成绩发布等所有系统消息统一汇总于此</p>
<span class="section-kicker">MESSAGE CENTER</span>
<h2>消息中心</h2>
<p>系统通知审核待办课程变动与成绩消息统一汇总重要信息不再散落在业务页面</p>
</div>
<div style="display:flex;gap:8px;align-items:center">
<el-badge :value="unreadCount" :hidden="!unreadCount">
<el-button :icon="Bell">未读 {{ unreadCount }}</el-button>
</el-badge>
<el-button v-if="unreadCount" :icon="Check" @click="markAllRead">全部已读</el-button>
<el-button :icon="Refresh" @click="load(true)">刷新</el-button>
</div>
</section>
<section class="notif-toolbar">
<el-checkbox v-model="unreadOnly" @change="load(true)">仅显示未读</el-checkbox>
</section>
<section v-loading="loading" class="notif-list">
<article
v-for="n in notifications"
:key="n.id"
class="notif-card"
:class="{ unread: !n.isRead }"
@click="goLink(n.linkUrl); markRead(n)"
:style="{ cursor: n.linkUrl ? 'pointer' : 'default' }"
>
<div class="notif-dot" v-if="!n.isRead" />
<div class="notif-body">
<div class="notif-head">
<b>{{ n.title }}</b>
<el-tag v-if="!n.isRead" size="small" type="primary">未读</el-tag>
</div>
<p>{{ n.content }}</p>
<small>{{ new Date(n.createdAt).toLocaleString('zh-CN') }}</small>
<div class="intro-actions">
<div class="unread-summary" :class="{ quiet: !unreadCount }">
<span>未读消息</span>
<b>{{ unreadCount }}</b>
</div>
</article>
<el-empty v-if="!notifications.length" description="暂无消息通知" />
<el-button
v-if="canSend"
type="primary"
:icon="EditPen"
@click="changeTab('compose')"
>
发消息
</el-button>
<el-button :icon="Refresh" @click="activeTab === 'sent' ? loadSent() : load()">
刷新
</el-button>
</div>
</section>
<div class="notif-pager" v-if="total > pageSize">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
@current-change="load()"
layout="prev, pager, next, total"
/>
</div>
<section class="center-switcher" aria-label="消息中心功能">
<button
type="button"
:class="{ active: activeTab === 'inbox' }"
@click="changeTab('inbox')"
>
<el-icon><Bell /></el-icon>
收件箱
<span v-if="unreadCount">{{ unreadCount }}</span>
</button>
<button
v-if="canSend"
type="button"
:class="{ active: activeTab === 'compose' }"
@click="changeTab('compose')"
>
<el-icon><Promotion /></el-icon>
发消息
</button>
<button
v-if="canSend"
type="button"
:class="{ active: activeTab === 'sent' }"
@click="changeTab('sent')"
>
<el-icon><Message /></el-icon>
已发送
</button>
</section>
<template v-if="activeTab === 'inbox'">
<section class="message-toolbar">
<el-select
v-model="category"
clearable
placeholder="全部类型"
@change="load(true)"
>
<el-option
v-for="(meta, value) in categories"
:key="value"
:label="meta.label"
:value="value"
/>
</el-select>
<el-input
v-model="keyword"
clearable
:prefix-icon="Search"
placeholder="搜索标题或正文"
@keyup.enter="load(true)"
@clear="load(true)"
/>
<el-checkbox v-model="unreadOnly" @change="load(true)">仅看未读</el-checkbox>
<el-button v-if="unreadCount" :icon="Check" text @click="markAllRead">
全部已读
</el-button>
</section>
<section v-loading="loading" class="message-list">
<article
v-for="notification in notifications"
:key="notification.id"
class="message-card"
:class="[
categoryClass(notification.category),
{ unread: !notification.isRead, linked: notification.linkUrl },
]"
@click="openNotification(notification)"
>
<div class="category-rail" />
<div class="message-main">
<header>
<div class="message-labels">
<span class="category-label">{{ categoryLabel(notification.category) }}</span>
<span v-if="notification.isManual" class="manual-label">人工发送</span>
<span v-if="!notification.isRead" class="unread-label">未读</span>
</div>
<time>{{ new Date(notification.createdAt).toLocaleString('zh-CN') }}</time>
</header>
<h3>{{ notification.title }}</h3>
<p>{{ notification.content }}</p>
<footer>
<span>来自 {{ notification.senderName }}</span>
<span v-if="notification.audienceName">
接收范围{{ notification.audienceName }}
</span>
<span v-if="notification.linkUrl" class="open-link">查看详情 </span>
</footer>
</div>
</article>
<el-empty
v-if="!loading && !notifications.length"
:description="unreadOnly ? '当前筛选范围内没有未读消息' : '当前筛选范围内没有消息'"
/>
</section>
<div v-if="total > pageSize" class="message-pager">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="prev, pager, next, total"
@current-change="load()"
/>
</div>
</template>
<section
v-else-if="activeTab === 'compose'"
v-loading="composerLoading"
class="compose-workspace"
>
<aside class="audience-panel">
<span class="panel-kicker">AUTHORIZED AUDIENCE</span>
<h3>本次可发送范围</h3>
<div class="audience-mark">
<b>{{ recipientCount }}</b>
<span>个接收账号</span>
</div>
<strong>{{ audienceName }}</strong>
<p v-if="composer?.audienceType === 'School'">
校级管理员可向全校所有已启用账号发送消息
</p>
<p v-else-if="composer?.audienceType === 'College'">
消息范围由当前账号所属学院确定不能跨学院发送
</p>
<p v-else>
任课教师只能选择本人任教的教学班接收人来自教学班与有效选课名单
</p>
</aside>
<el-form class="compose-form" label-position="top">
<div class="compose-heading">
<div>
<span class="panel-kicker">NEW MESSAGE</span>
<h3>编写消息</h3>
</div>
<span>发送后不可撤回</span>
</div>
<el-form-item
v-if="composer?.audienceType === 'TeachingTask'"
label="接收教学班"
required
>
<el-select
v-model="messageForm.teachingTaskId"
filterable
placeholder="选择本人任教的教学班"
>
<el-option
v-for="task in composer.teachingTasks"
:key="task.id"
:value="task.id"
:label="`${task.taskNumber} · ${task.courseName}${task.recipientCount} 人)`"
>
<div class="task-option">
<span>{{ task.taskNumber }} · {{ task.courseName }}</span>
<small>{{ task.termName }} · {{ task.recipientCount }} </small>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="消息标题" required>
<el-input
v-model="messageForm.title"
maxlength="200"
show-word-limit
placeholder="用一句话说明需要关注的事项"
/>
</el-form-item>
<el-form-item label="消息正文" required>
<el-input
v-model="messageForm.content"
type="textarea"
:rows="7"
maxlength="1000"
show-word-limit
placeholder="写清事项、时间和需要接收人完成的动作"
/>
</el-form-item>
<div class="compose-actions">
<span>将发送给 {{ recipientCount }} 个账号</span>
<el-button
type="primary"
:icon="Promotion"
:loading="sending"
@click="sendMessage"
>
确认发送
</el-button>
</div>
</el-form>
</section>
<template v-else>
<section v-loading="loading" class="sent-list">
<article v-for="item in sentMessages" :key="item.id" class="sent-card">
<header>
<span :class="['category-pill', categoryClass(item.category)]">
{{ categoryLabel(item.category) }}
</span>
<time>{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</time>
</header>
<h3>{{ item.title }}</h3>
<p>{{ item.content }}</p>
<footer>
<span>{{ item.audienceName }}</span>
<b>{{ item.recipientCount }} 个接收账号</b>
</footer>
</article>
<el-empty v-if="!loading && !sentMessages.length" description="还没有发送记录" />
</section>
<div v-if="sentTotal > pageSize" class="message-pager">
<el-pagination
v-model:current-page="sentPage"
:page-size="pageSize"
:total="sentTotal"
layout="prev, pager, next, total"
@current-change="loadSent()"
/>
</div>
</template>
</div>
</template>
<style scoped>
.notif-toolbar { margin-bottom: 16px; }
.notif-list { display: grid; gap: 8px; }
.notif-card {
display: flex; align-items: flex-start; gap: 14px;
padding: 16px 20px; background: #fff; border: 1px solid #e4e7ed;
border-radius: 10px; transition: box-shadow .15s;
.message-center {
--message-blue: #2563eb;
--message-ink: #172033;
--message-line: #dfe5ef;
}
.message-intro {
align-items: center;
}
.intro-actions {
display: flex;
align-items: center;
gap: 10px;
}
.unread-summary {
display: grid;
grid-template-columns: auto auto;
align-items: baseline;
gap: 8px;
min-width: 108px;
padding: 8px 12px;
color: #1d4ed8;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
}
.unread-summary span {
font-size: 12px;
}
.unread-summary b {
font-size: 24px;
line-height: 1;
}
.unread-summary.quiet {
color: #64748b;
background: #f8fafc;
border-color: #e2e8f0;
}
.center-switcher {
display: flex;
gap: 4px;
padding: 4px;
width: fit-content;
background: #eef2f7;
border-radius: 10px;
}
.center-switcher button {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 36px;
padding: 0 15px;
color: #64748b;
background: transparent;
border: 0;
border-radius: 7px;
cursor: pointer;
}
.center-switcher button.active {
color: var(--message-ink);
background: #fff;
box-shadow: 0 1px 3px rgb(15 23 42 / 10%);
font-weight: 600;
}
.center-switcher button > span {
min-width: 18px;
padding: 1px 5px;
color: #fff;
background: var(--message-blue);
border-radius: 10px;
font-size: 11px;
line-height: 16px;
}
.message-toolbar {
display: grid;
grid-template-columns: 160px minmax(220px, 420px) auto auto;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: #fff;
border: 1px solid var(--message-line);
border-radius: 10px;
}
.message-list,
.sent-list {
display: grid;
gap: 10px;
}
.message-card {
position: relative;
display: flex;
min-height: 138px;
overflow: hidden;
background: #fff;
border: 1px solid var(--message-line);
border-radius: 10px;
transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease;
}
.message-card.linked {
cursor: pointer;
}
.message-card:hover {
border-color: #c7d2e2;
box-shadow: 0 8px 22px rgb(15 23 42 / 7%);
transform: translateY(-1px);
}
.message-card.unread {
background: linear-gradient(90deg, #f7faff 0, #fff 42%);
border-color: #bfdbfe;
}
.category-rail {
width: 5px;
flex: 0 0 5px;
background: #64748b;
}
.message-card.approval .category-rail { background: #d97706; }
.message-card.schedule .category-rail { background: #7c3aed; }
.message-card.grade .category-rail { background: #059669; }
.message-card.attendance .category-rail { background: #db2777; }
.message-card.selection .category-rail { background: #0891b2; }
.message-card.warning .category-rail { background: #dc2626; }
.message-main {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
padding: 17px 20px 15px;
}
.message-main header,
.sent-card header,
.sent-card footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.message-labels {
display: flex;
align-items: center;
gap: 7px;
}
.category-label,
.manual-label,
.unread-label,
.category-pill {
padding: 2px 7px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.category-label {
color: #334155;
background: #eef2f7;
}
.manual-label {
color: #075985;
background: #e0f2fe;
}
.unread-label {
color: #1d4ed8;
background: #dbeafe;
}
.message-main time,
.sent-card time {
color: #94a3b8;
font-size: 12px;
}
.message-main h3,
.sent-card h3,
.compose-workspace h3 {
margin: 11px 0 6px;
color: var(--message-ink);
font-size: 16px;
}
.message-main p,
.sent-card p {
margin: 0;
color: #526078;
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
}
.message-main footer {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
margin-top: auto;
padding-top: 12px;
color: #94a3b8;
font-size: 12px;
}
.open-link {
margin-left: auto;
color: var(--message-blue);
font-weight: 600;
}
.message-pager {
display: flex;
justify-content: center;
padding: 8px 0;
}
.compose-workspace {
display: grid;
grid-template-columns: minmax(220px, 290px) minmax(0, 1fr);
overflow: hidden;
background: #fff;
border: 1px solid var(--message-line);
border-radius: 12px;
}
.audience-panel {
padding: 28px 24px;
color: #dbeafe;
background:
linear-gradient(155deg, rgb(37 99 235 / 96%), rgb(30 64 175 / 98%)),
#1d4ed8;
}
.panel-kicker {
font-size: 10px;
font-weight: 700;
letter-spacing: .14em;
}
.audience-panel h3 {
margin-top: 7px;
color: #fff;
}
.audience-mark {
display: flex;
align-items: baseline;
gap: 8px;
margin: 34px 0 8px;
}
.audience-mark b {
color: #fff;
font-size: 48px;
font-weight: 700;
line-height: 1;
}
.audience-panel strong {
display: block;
margin-top: 18px;
color: #fff;
font-size: 15px;
}
.audience-panel p {
margin: 12px 0 0;
font-size: 12px;
line-height: 1.75;
}
.compose-form {
padding: 28px 32px 30px;
}
.compose-heading {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 22px;
}
.compose-heading h3 {
margin-top: 5px;
font-size: 20px;
}
.compose-heading > span {
color: #94a3b8;
font-size: 12px;
}
.field-help {
display: block;
width: 100%;
margin-top: 6px;
color: #94a3b8;
line-height: 1.5;
}
.task-option {
display: flex;
justify-content: space-between;
gap: 20px;
}
.task-option small {
color: #94a3b8;
}
.compose-actions {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 17px;
border-top: 1px solid #e9edf3;
}
.compose-actions span {
color: #64748b;
font-size: 12px;
}
.sent-card {
padding: 18px 20px;
background: #fff;
border: 1px solid var(--message-line);
border-radius: 10px;
}
.sent-card footer {
margin-top: 15px;
padding-top: 12px;
color: #64748b;
border-top: 1px solid #eef2f6;
font-size: 12px;
}
.sent-card footer b {
color: #334155;
}
.category-pill {
color: #334155;
background: #eef2f7;
}
.category-pill.grade {
color: #047857;
background: #d1fae5;
}
@media (max-width: 760px) {
.message-intro,
.intro-actions,
.compose-actions {
align-items: stretch;
}
.intro-actions {
flex-wrap: wrap;
}
.unread-summary {
flex: 1;
}
.center-switcher {
width: 100%;
}
.center-switcher button {
flex: 1;
justify-content: center;
padding: 0 8px;
}
.message-toolbar {
grid-template-columns: 1fr;
}
.message-card {
min-height: 0;
}
.message-main header,
.sent-card header {
align-items: flex-start;
flex-direction: column;
}
.open-link {
width: 100%;
margin-left: 0;
}
.compose-workspace {
grid-template-columns: 1fr;
}
.audience-panel {
padding: 22px;
}
.audience-mark {
margin-top: 20px;
}
.compose-form {
padding: 22px 18px 24px;
}
.compose-actions {
flex-direction: column;
gap: 12px;
}
.compose-actions .el-button {
width: 100%;
}
}
.notif-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,.06); }
.notif-card.unread { background: #ecf5ff; border-color: #c6e2ff; }
.notif-dot { width: 10px; height: 10px; border-radius: 50%; background: #409eff; flex-shrink: 0; margin-top: 5px; }
.notif-body { flex: 1; min-width: 0; }
.notif-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 6px; }
.notif-head b { font-size: 14px; }
.notif-body p { font-size: 13px; color: #606266; margin: 0 0 6px; line-height: 1.5; }
.notif-body small { font-size: 11px; color: var(--muted); }
.notif-pager { display: flex; justify-content: center; margin-top: 20px; }
</style>