成绩通知:仅在某门课程成绩正式发布后自动发送,按成绩单学生名单通知,不展示具体分数;发布状态与通知同一次提交。
手动发信:移除“成绩通知”选项,只能发送普通通知,原有角色与范围权限保持不变。 选课通知:管理员关闭一轮选课时,自动向每位参与学生发送一条汇总通知,包含最终选中课程及候补未成功课程;候补失效、轮次关闭和通知生成保持原子性。 关闭选课界面会明确提示发送通知,并显示实际通知人数。
This commit is contained in:
@@ -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("当前账号未关联有效学生档案,请联系教务管理员。");
|
||||
|
||||
|
||||
@@ -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");"""
|
||||
];
|
||||
}
|
||||
|
||||
+4662
File diff suppressed because it is too large
Load Diff
+102
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user