统一通知中心
This commit is contained in:
@@ -126,7 +126,21 @@ public sealed class CourseAdjustmentsController(
|
|||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
if (request.Submit)
|
if (request.Submit)
|
||||||
await NotifyReviewersAsync(adj, cancellationToken);
|
{
|
||||||
|
var taskInfo = await db.TeachingTasks
|
||||||
|
.Where(x => x.Id == request.TeachingTaskId)
|
||||||
|
.Select(x => new { x.Course!.Name, x.Course.CollegeId })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (taskInfo is not null)
|
||||||
|
{
|
||||||
|
var tl = TypeLabel(request.Type);
|
||||||
|
await NotificationService.SendToRoleAsync(db,
|
||||||
|
SystemRoles.CollegeAdmin,
|
||||||
|
$"新的{tl}申请",
|
||||||
|
$"《{taskInfo.Name}》提交了{tl}申请,请及时审核。",
|
||||||
|
taskInfo.CollegeId, "/course-adjustments", cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Created(string.Empty, new { adj.Id });
|
return Created(string.Empty, new { adj.Id });
|
||||||
}
|
}
|
||||||
@@ -150,7 +164,19 @@ public sealed class CourseAdjustmentsController(
|
|||||||
adj.Status = CourseAdjustmentStatus.Submitted;
|
adj.Status = CourseAdjustmentStatus.Submitted;
|
||||||
adj.SubmittedAt = DateTime.UtcNow;
|
adj.SubmittedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
await NotifyReviewersAsync(adj, cancellationToken);
|
|
||||||
|
var courseName = adj.TeachingTask!.Course!.Name;
|
||||||
|
await NotificationService.SendToRoleAsync(db,
|
||||||
|
SystemRoles.CollegeAdmin,
|
||||||
|
$"新的{TypeLabel(adj.Type)}申请",
|
||||||
|
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请,请及时审核。",
|
||||||
|
adj.TeachingTask.Course.CollegeId,
|
||||||
|
"/course-adjustments", cancellationToken);
|
||||||
|
await NotificationService.SendToRoleAsync(db,
|
||||||
|
SystemRoles.AcademicAdmin,
|
||||||
|
$"新的{TypeLabel(adj.Type)}申请",
|
||||||
|
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请。",
|
||||||
|
null, "/course-adjustments", cancellationToken);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +189,7 @@ public sealed class CourseAdjustmentsController(
|
|||||||
var adj = await db.CourseAdjustments
|
var adj = await db.CourseAdjustments
|
||||||
.Include(x => x.TeachingTask)
|
.Include(x => x.TeachingTask)
|
||||||
.ThenInclude(x => x!.Course)
|
.ThenInclude(x => x!.Course)
|
||||||
|
.ThenInclude(x => x!.College)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
if (adj is null) return NotFound();
|
if (adj is null) return NotFound();
|
||||||
|
|
||||||
@@ -176,11 +203,153 @@ public sealed class CourseAdjustmentsController(
|
|||||||
adj.Status = CourseAdjustmentStatus.Approved;
|
adj.Status = CourseAdjustmentStatus.Approved;
|
||||||
adj.ReviewedAt = DateTime.UtcNow;
|
adj.ReviewedAt = DateTime.UtcNow;
|
||||||
adj.ReviewedByUserId = currentUserDataScope.Current.UserId;
|
adj.ReviewedByUserId = currentUserDataScope.Current.UserId;
|
||||||
|
|
||||||
|
// Apply schedule changes
|
||||||
|
await ApplyScheduleChangesAsync(adj, cancellationToken);
|
||||||
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
await NotifyApplicantAsync(adj, "已通过", cancellationToken);
|
|
||||||
|
// Notify applicant
|
||||||
|
await NotificationService.SendAsync(db, adj.ApplicantUserId,
|
||||||
|
"调停课申请已通过",
|
||||||
|
$"您的{TypeLabel(adj.Type)}申请({adj.TeachingTask!.Course!.Name})已通过审核。",
|
||||||
|
"/course-adjustments", cancellationToken);
|
||||||
|
|
||||||
|
// Notify affected students
|
||||||
|
var studentUserIds = await db.CourseEnrollments
|
||||||
|
.Where(x =>
|
||||||
|
x.CourseSelectionOffering!.TeachingTaskId == adj.TeachingTaskId &&
|
||||||
|
x.Status == CourseEnrollmentStatus.Enrolled)
|
||||||
|
.Select(x => x.Student!.UserId)
|
||||||
|
.Where(uid => uid != null)
|
||||||
|
.Select(uid => uid!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (studentUserIds.Count > 0)
|
||||||
|
{
|
||||||
|
await NotificationService.SendToUserIdsAsync(db, studentUserIds,
|
||||||
|
"课程变动通知",
|
||||||
|
$"《{adj.TeachingTask.Course.Name}》有{TypeLabel(adj.Type)}变动,请查看课表。",
|
||||||
|
"/my-timetable", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ApplyScheduleChangesAsync(
|
||||||
|
CourseAdjustment adj, CancellationToken ct)
|
||||||
|
{
|
||||||
|
switch (adj.Type)
|
||||||
|
{
|
||||||
|
case CourseAdjustmentType.Reschedule:
|
||||||
|
// Update existing schedule entries for this teaching task
|
||||||
|
if (adj.DayOfWeek.HasValue && adj.StartPeriod.HasValue)
|
||||||
|
{
|
||||||
|
var entries = await db.ScheduleEntries
|
||||||
|
.Where(x => x.TeachingTaskId == adj.TeachingTaskId)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
entry.DayOfWeek = adj.DayOfWeek.Value;
|
||||||
|
entry.StartPeriod = adj.StartPeriod.Value;
|
||||||
|
entry.PeriodCount = adj.PeriodCount ?? entry.PeriodCount;
|
||||||
|
}
|
||||||
|
if (adj.ClassroomId.HasValue)
|
||||||
|
{
|
||||||
|
foreach (var entry in entries)
|
||||||
|
entry.ClassroomId = adj.ClassroomId.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CourseAdjustmentType.Cancel:
|
||||||
|
// Cancel: remove schedule entries for the specified week
|
||||||
|
if (adj.CancelWeek.HasValue)
|
||||||
|
{
|
||||||
|
var entries = await db.ScheduleEntries
|
||||||
|
.Where(x => x.TeachingTaskId == adj.TeachingTaskId &&
|
||||||
|
x.StartWeek <= adj.CancelWeek.Value &&
|
||||||
|
x.EndWeek >= adj.CancelWeek.Value)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
// Split the entry to exclude the cancelled week
|
||||||
|
if (entry.StartWeek == adj.CancelWeek.Value &&
|
||||||
|
entry.EndWeek == adj.CancelWeek.Value)
|
||||||
|
{
|
||||||
|
db.ScheduleEntries.Remove(entry);
|
||||||
|
}
|
||||||
|
else if (entry.StartWeek == adj.CancelWeek.Value)
|
||||||
|
{
|
||||||
|
entry.StartWeek = adj.CancelWeek.Value + 1;
|
||||||
|
}
|
||||||
|
else if (entry.EndWeek == adj.CancelWeek.Value)
|
||||||
|
{
|
||||||
|
entry.EndWeek = adj.CancelWeek.Value - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CourseAdjustmentType.Makeup:
|
||||||
|
// Makeup: add a temp schedule entry for the makeup date
|
||||||
|
if (adj.TargetDate.HasValue && adj.DayOfWeek.HasValue &&
|
||||||
|
adj.StartPeriod.HasValue)
|
||||||
|
{
|
||||||
|
var publishedPlan = await db.SchedulePlans
|
||||||
|
.Where(x => x.AcademicTermId == adj.TeachingTask!.AcademicTermId &&
|
||||||
|
x.Status == SchedulePlanStatus.Published)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (publishedPlan is not null)
|
||||||
|
{
|
||||||
|
db.ScheduleEntries.Add(new ScheduleEntry
|
||||||
|
{
|
||||||
|
SchedulePlanId = publishedPlan.Id,
|
||||||
|
TeachingTaskId = adj.TeachingTaskId,
|
||||||
|
ClassroomId = adj.ClassroomId,
|
||||||
|
DayOfWeek = adj.DayOfWeek.Value,
|
||||||
|
StartPeriod = adj.StartPeriod.Value,
|
||||||
|
PeriodCount = adj.PeriodCount ?? 2,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 1,
|
||||||
|
WeekPattern = WeekPattern.All,
|
||||||
|
Notes = $"补课(原申请 {adj.CreatedAt:yyyy-MM-dd})"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CourseAdjustmentType.Substitute:
|
||||||
|
// Substitute: add substitute teacher to the teaching task
|
||||||
|
if (adj.SubstituteTeacherId.HasValue)
|
||||||
|
{
|
||||||
|
var alreadyExists = await db.TeachingTaskTeachers
|
||||||
|
.AnyAsync(x =>
|
||||||
|
x.TeachingTaskId == adj.TeachingTaskId &&
|
||||||
|
x.TeacherId == adj.SubstituteTeacherId.Value, ct);
|
||||||
|
if (!alreadyExists)
|
||||||
|
{
|
||||||
|
db.TeachingTaskTeachers.Add(new TeachingTaskTeacher
|
||||||
|
{
|
||||||
|
TeachingTaskId = adj.TeachingTaskId,
|
||||||
|
TeacherId = adj.SubstituteTeacherId.Value,
|
||||||
|
IsPrimary = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TypeLabel(CourseAdjustmentType type) => type switch
|
||||||
|
{
|
||||||
|
CourseAdjustmentType.Reschedule => "调课",
|
||||||
|
CourseAdjustmentType.Cancel => "停课",
|
||||||
|
CourseAdjustmentType.Makeup => "补课",
|
||||||
|
CourseAdjustmentType.Substitute => "代课",
|
||||||
|
_ => "调停课"
|
||||||
|
};
|
||||||
|
|
||||||
// ═══════════════ Reject ═══════════════
|
// ═══════════════ Reject ═══════════════
|
||||||
|
|
||||||
[HttpPost("{id:guid}/reject")]
|
[HttpPost("{id:guid}/reject")]
|
||||||
@@ -208,134 +377,18 @@ public sealed class CourseAdjustmentsController(
|
|||||||
adj.ReviewedAt = DateTime.UtcNow;
|
adj.ReviewedAt = DateTime.UtcNow;
|
||||||
adj.ReviewedByUserId = currentUserDataScope.Current.UserId;
|
adj.ReviewedByUserId = currentUserDataScope.Current.UserId;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
await NotifyApplicantAsync(adj,
|
|
||||||
$"已退回" + (adj.ReviewComment is not null ? $":{adj.ReviewComment}" : ""),
|
|
||||||
cancellationToken);
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════ Notifications ═══════════════
|
var msg = adj.ReviewComment is not null
|
||||||
|
? $"您的{TypeLabel(adj.Type)}申请已退回。审核意见:{adj.ReviewComment}"
|
||||||
[HttpGet("notifications")]
|
: $"您的{TypeLabel(adj.Type)}申请已退回。";
|
||||||
[Authorize]
|
await NotificationService.SendAsync(db, adj.ApplicantUserId,
|
||||||
public async Task<ActionResult> GetNotifications(
|
$"{TypeLabel(adj.Type)}申请已退回", msg,
|
||||||
bool? unreadOnly,
|
"/course-adjustments", cancellationToken);
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var userId = currentUserDataScope.Current.UserId;
|
|
||||||
var source = db.Notifications.AsNoTracking()
|
|
||||||
.Where(x => x.UserId == userId);
|
|
||||||
if (unreadOnly == true)
|
|
||||||
source = source.Where(x => !x.IsRead);
|
|
||||||
|
|
||||||
return Ok(new
|
|
||||||
{
|
|
||||||
Items = await source.OrderByDescending(x => x.CreatedAt)
|
|
||||||
.Take(50)
|
|
||||||
.Select(x => new
|
|
||||||
{
|
|
||||||
x.Id, x.Title, x.Content, x.IsRead, x.LinkUrl, x.CreatedAt
|
|
||||||
})
|
|
||||||
.ToListAsync(cancellationToken),
|
|
||||||
UnreadCount = await db.Notifications
|
|
||||||
.CountAsync(x => x.UserId == userId && !x.IsRead, cancellationToken)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("notifications/{id:guid}/read")]
|
|
||||||
[Authorize]
|
|
||||||
public async Task<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;
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("notifications/read-all")]
|
|
||||||
[Authorize]
|
|
||||||
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),
|
|
||||||
cancellationToken);
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════ Helpers ═══════════════
|
// ═══════════════ Helpers ═══════════════
|
||||||
|
|
||||||
private async Task NotifyReviewersAsync(
|
|
||||||
CourseAdjustment adj, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var typeLabel = adj.Type switch
|
|
||||||
{
|
|
||||||
CourseAdjustmentType.Reschedule => "调课",
|
|
||||||
CourseAdjustmentType.Cancel => "停课",
|
|
||||||
CourseAdjustmentType.Makeup => "补课",
|
|
||||||
CourseAdjustmentType.Substitute => "代课",
|
|
||||||
_ => "调停课"
|
|
||||||
};
|
|
||||||
var taskInfo = adj.TeachingTask is not null
|
|
||||||
? $"{adj.TeachingTask.Course!.Name}({adj.TeachingTask.TaskNumber})"
|
|
||||||
: "";
|
|
||||||
|
|
||||||
// Notify CollegeAdmin and AcademicAdmin of the course's college
|
|
||||||
var collegeId = adj.TeachingTask?.Course?.CollegeId;
|
|
||||||
if (!collegeId.HasValue) return;
|
|
||||||
|
|
||||||
var reviewerUserIds = await db.Users
|
|
||||||
.Join(db.UserRoles, u => u.Id, ur => ur.UserId, (u, ur) => new { u.Id, ur.RoleId })
|
|
||||||
.Join(db.Roles, x => x.RoleId, r => r.Id, (x, r) => new { x.Id, RoleName = r.Name! })
|
|
||||||
.Where(x =>
|
|
||||||
(x.RoleName == SystemRoles.AcademicAdmin) ||
|
|
||||||
(x.RoleName == SystemRoles.CollegeAdmin &&
|
|
||||||
db.Teachers.Any(t =>
|
|
||||||
t.UserId == x.Id && t.CollegeId == collegeId)))
|
|
||||||
.Select(x => x.Id)
|
|
||||||
.Distinct()
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
foreach (var reviewerId in reviewerUserIds)
|
|
||||||
{
|
|
||||||
db.Notifications.Add(new Notification
|
|
||||||
{
|
|
||||||
UserId = reviewerId,
|
|
||||||
Title = $"新的{typeLabel}申请",
|
|
||||||
Content = $"{taskInfo} 提交了{typeLabel}申请,请及时审核。",
|
|
||||||
LinkUrl = "/course-adjustments"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task NotifyApplicantAsync(
|
|
||||||
CourseAdjustment adj, string result, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var typeLabel = adj.Type switch
|
|
||||||
{
|
|
||||||
CourseAdjustmentType.Reschedule => "调课",
|
|
||||||
CourseAdjustmentType.Cancel => "停课",
|
|
||||||
CourseAdjustmentType.Makeup => "补课",
|
|
||||||
CourseAdjustmentType.Substitute => "代课",
|
|
||||||
_ => "调停课"
|
|
||||||
};
|
|
||||||
db.Notifications.Add(new Notification
|
|
||||||
{
|
|
||||||
UserId = adj.ApplicantUserId,
|
|
||||||
Title = $"{typeLabel}申请{result}",
|
|
||||||
Content = adj.ReviewComment is not null
|
|
||||||
? $"您的{typeLabel}申请{result}。审核意见:{adj.ReviewComment}"
|
|
||||||
: $"您的{typeLabel}申请{result}。",
|
|
||||||
LinkUrl = "/course-adjustments"
|
|
||||||
});
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<ActionResult?> ValidateRequestAsync(
|
private async Task<ActionResult?> ValidateRequestAsync(
|
||||||
CourseAdjustmentRequest request,
|
CourseAdjustmentRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -397,7 +397,19 @@ public sealed class GradesController(
|
|||||||
sheet.Status = GradeSheetStatus.Submitted;
|
sheet.Status = GradeSheetStatus.Submitted;
|
||||||
sheet.SubmittedAt = DateTime.UtcNow;
|
sheet.SubmittedAt = DateTime.UtcNow;
|
||||||
sheet.ReviewComment = null;
|
sheet.ReviewComment = null;
|
||||||
return await SaveAsync(id, false, cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Notify college admins
|
||||||
|
var courseName = sheet.TeachingTask!.Course!.Name;
|
||||||
|
var collegeId = sheet.TeachingTask.Course.CollegeId;
|
||||||
|
await NotificationService.SendToRoleAsync(db,
|
||||||
|
SystemRoles.CollegeAdmin,
|
||||||
|
"成绩单待审核",
|
||||||
|
$"《{courseName}》成绩已提交,请及时审核。",
|
||||||
|
collegeId,
|
||||||
|
"/grades",
|
||||||
|
cancellationToken);
|
||||||
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("sheets/{id:guid}/approve")]
|
[HttpPost("sheets/{id:guid}/approve")]
|
||||||
@@ -425,7 +437,20 @@ public sealed class GradesController(
|
|||||||
sheet.Status = GradeSheetStatus.Approved;
|
sheet.Status = GradeSheetStatus.Approved;
|
||||||
sheet.ReviewedAt = DateTime.UtcNow;
|
sheet.ReviewedAt = DateTime.UtcNow;
|
||||||
sheet.ReviewComment = null;
|
sheet.ReviewComment = null;
|
||||||
return await SaveAsync(id, false, cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Notify teachers
|
||||||
|
var teacherUserIds = await db.TeachingTaskTeachers
|
||||||
|
.Where(x => x.TeachingTaskId == sheet.TeachingTaskId)
|
||||||
|
.Select(x => x.Teacher!.UserId)
|
||||||
|
.Where(id => id != null)
|
||||||
|
.Select(id => id!.Value)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
|
||||||
|
"成绩审核通过",
|
||||||
|
$"《{sheet.TeachingTask!.Course!.Name}》成绩已通过学院审核,等待校级发布。",
|
||||||
|
"/grades", cancellationToken);
|
||||||
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("sheets/{id:guid}/return")]
|
[HttpPost("sheets/{id:guid}/return")]
|
||||||
@@ -458,7 +483,19 @@ public sealed class GradesController(
|
|||||||
sheet.Status = GradeSheetStatus.Returned;
|
sheet.Status = GradeSheetStatus.Returned;
|
||||||
sheet.ReviewedAt = DateTime.UtcNow;
|
sheet.ReviewedAt = DateTime.UtcNow;
|
||||||
sheet.ReviewComment = request.Comment.Trim();
|
sheet.ReviewComment = request.Comment.Trim();
|
||||||
return await SaveAsync(id, false, cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
var teacherUserIds = await db.TeachingTaskTeachers
|
||||||
|
.Where(x => x.TeachingTaskId == sheet.TeachingTaskId)
|
||||||
|
.Select(x => x.Teacher!.UserId)
|
||||||
|
.Where(id => id != null)
|
||||||
|
.Select(id => id!.Value)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
|
||||||
|
"成绩被退回",
|
||||||
|
$"《{sheet.TeachingTask!.Course!.Name}》成绩被退回修改:{sheet.ReviewComment}",
|
||||||
|
"/grades", cancellationToken);
|
||||||
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("sheets/{id:guid}/publish")]
|
[HttpPost("sheets/{id:guid}/publish")]
|
||||||
@@ -474,7 +511,23 @@ public sealed class GradesController(
|
|||||||
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
||||||
sheet.Status = GradeSheetStatus.Published;
|
sheet.Status = GradeSheetStatus.Published;
|
||||||
sheet.PublishedAt = DateTime.UtcNow;
|
sheet.PublishedAt = DateTime.UtcNow;
|
||||||
return await SaveAsync(id, false, cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Notify enrolled students
|
||||||
|
var studentUserIds = await db.CourseEnrollments
|
||||||
|
.Where(x =>
|
||||||
|
x.CourseSelectionOffering!.TeachingTaskId == sheet.TeachingTaskId &&
|
||||||
|
x.Status == CourseEnrollmentStatus.Enrolled)
|
||||||
|
.Select(x => x.Student!.UserId)
|
||||||
|
.Where(id => id != null)
|
||||||
|
.Select(id => id!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
await NotificationService.SendToUserIdsAsync(db, studentUserIds,
|
||||||
|
"成绩已发布",
|
||||||
|
$"《{sheet.TeachingTask!.Course!.Name}》成绩已发布,请查看。",
|
||||||
|
"/grades", cancellationToken);
|
||||||
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("student/transcript")]
|
[HttpGet("student/transcript")]
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/notifications")]
|
||||||
|
public sealed class NotificationsController(
|
||||||
|
AppDbContext db,
|
||||||
|
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult> GetNotifications(
|
||||||
|
bool? unreadOnly,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var userId = currentUserDataScope.Current.UserId;
|
||||||
|
var source = db.Notifications.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == userId);
|
||||||
|
if (unreadOnly == true)
|
||||||
|
source = source.Where(x => !x.IsRead);
|
||||||
|
|
||||||
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var items = await source
|
||||||
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id, x.Title, x.Content, x.IsRead, x.LinkUrl, x.CreatedAt
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var unreadCount = await db.Notifications
|
||||||
|
.CountAsync(x => x.UserId == userId && !x.IsRead, cancellationToken);
|
||||||
|
|
||||||
|
return Ok(new { Items = items, Total = total, UnreadCount = unreadCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("unread-count")]
|
||||||
|
public async Task<ActionResult> GetUnreadCount(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userId = currentUserDataScope.Current.UserId;
|
||||||
|
var count = await db.Notifications
|
||||||
|
.CountAsync(x => x.UserId == userId && !x.IsRead, cancellationToken);
|
||||||
|
return Ok(new { Count = count });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/read")]
|
||||||
|
public async Task<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;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("read-all")]
|
||||||
|
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),
|
||||||
|
cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Centralized helper to send notifications across the app.
|
||||||
|
/// </summary>
|
||||||
|
public static class NotificationService
|
||||||
|
{
|
||||||
|
public static async Task SendAsync(
|
||||||
|
AppDbContext db,
|
||||||
|
Guid userId,
|
||||||
|
string title,
|
||||||
|
string content,
|
||||||
|
string? linkUrl = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
db.Notifications.Add(new Notification
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
Title = title,
|
||||||
|
Content = content,
|
||||||
|
LinkUrl = linkUrl
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task SendToRoleAsync(
|
||||||
|
AppDbContext db,
|
||||||
|
string roleName,
|
||||||
|
string title,
|
||||||
|
string content,
|
||||||
|
Guid? collegeId = null,
|
||||||
|
string? linkUrl = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var query = db.Users
|
||||||
|
.Join(db.UserRoles, u => u.Id, ur => ur.UserId, (u, ur) => new { u.Id, ur.RoleId })
|
||||||
|
.Join(db.Roles, x => x.RoleId, r => r.Id, (x, r) => new { x.Id, RoleName = r.Name! });
|
||||||
|
|
||||||
|
var userIds = query.Where(x => x.RoleName == roleName);
|
||||||
|
|
||||||
|
if (collegeId.HasValue && roleName == SystemRoles.CollegeAdmin)
|
||||||
|
{
|
||||||
|
userIds = userIds.Where(x =>
|
||||||
|
db.Teachers.Any(t =>
|
||||||
|
t.UserId == x.Id && t.CollegeId == collegeId.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
var ids = await userIds.Select(x => x.Id).Distinct().ToListAsync(cancellationToken);
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
db.Notifications.Add(new Notification
|
||||||
|
{
|
||||||
|
UserId = id,
|
||||||
|
Title = title,
|
||||||
|
Content = content,
|
||||||
|
LinkUrl = linkUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task SendToUserIdsAsync(
|
||||||
|
AppDbContext db,
|
||||||
|
IEnumerable<Guid> userIds,
|
||||||
|
string title,
|
||||||
|
string content,
|
||||||
|
string? linkUrl = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
foreach (var userId in userIds.Distinct())
|
||||||
|
{
|
||||||
|
db.Notifications.Add(new Notification
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
Title = title,
|
||||||
|
Content = content,
|
||||||
|
LinkUrl = linkUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { Operation } from '@element-plus/icons-vue'
|
import { Bell, Operation } from '@element-plus/icons-vue'
|
||||||
|
import http from '../api/http'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const unreadNotifCount = ref(0)
|
||||||
|
|
||||||
|
async function pollUnread() {
|
||||||
|
try {
|
||||||
|
const { data } = await http.get('/notifications/unread-count')
|
||||||
|
unreadNotifCount.value = data.count
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
interface NavigationItem {
|
interface NavigationItem {
|
||||||
path: string
|
path: string
|
||||||
label: string
|
label: string
|
||||||
@@ -197,7 +207,11 @@ function signOut() {
|
|||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => auth.refresh().catch(() => undefined))
|
onMounted(() => {
|
||||||
|
auth.refresh().catch(() => undefined)
|
||||||
|
pollUnread()
|
||||||
|
setInterval(pollUnread, 60000)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -229,6 +243,9 @@ onMounted(() => auth.refresh().catch(() => undefined))
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="user-block">
|
<div class="user-block">
|
||||||
|
<el-badge :value="unreadNotifCount" :hidden="!unreadNotifCount" :max="99">
|
||||||
|
<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="avatar">{{ auth.user?.displayName?.slice(0, 1) ?? '管' }}</div>
|
||||||
<div class="user-copy">
|
<div class="user-copy">
|
||||||
<b>{{ auth.user?.displayName ?? '系统管理员' }}</b>
|
<b>{{ auth.user?.displayName ?? '系统管理员' }}</b>
|
||||||
|
|||||||
@@ -197,6 +197,11 @@ const router = createRouter({
|
|||||||
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher'],
|
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'notifications',
|
||||||
|
name: 'notifications',
|
||||||
|
component: () => import('../views/NotificationCenterView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'student-status-changes',
|
path: 'student-status-changes',
|
||||||
name: 'student-status-changes',
|
name: 'student-status-changes',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { Bell, Check, Close, Plus, Refresh } from '@element-plus/icons-vue'
|
import { Bell, Check, Plus, Refresh } from '@element-plus/icons-vue'
|
||||||
import http, { apiErrorMessage } from '../api/http'
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ const form = reactive({
|
|||||||
|
|
||||||
const typeLabels: Record<string, string> = { Reschedule: '调课', Cancel: '停课', Makeup: '补课', Substitute: '代课' }
|
const typeLabels: Record<string, string> = { Reschedule: '调课', Cancel: '停课', Makeup: '补课', Substitute: '代课' }
|
||||||
const statusLabels: Record<string, string> = { Draft: '草稿', Submitted: '待审核', Approved: '已通过', Rejected: '已退回' }
|
const statusLabels: Record<string, string> = { Draft: '草稿', Submitted: '待审核', Approved: '已通过', Rejected: '已退回' }
|
||||||
const statusColors: Record<string, string> = { Draft: 'info', Submitted: 'warning', Approved: 'success', Rejected: 'danger' }
|
const statusColors: Record<string, 'info' | 'warning' | 'success' | 'danger'> = { Draft: 'info', Submitted: 'warning', Approved: 'success', Rejected: 'danger' }
|
||||||
|
|
||||||
function periodOptions() {
|
function periodOptions() {
|
||||||
return Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: `第 ${i + 1} 节` }))
|
return Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: `第 ${i + 1} 节` }))
|
||||||
@@ -67,7 +67,7 @@ async function load() {
|
|||||||
|
|
||||||
async function loadNotifications() {
|
async function loadNotifications() {
|
||||||
try {
|
try {
|
||||||
const { data } = await http.get('/course-adjustments/notifications')
|
const { data } = await http.get('/notifications', { params: { pageSize: 50 } })
|
||||||
notifications.value = data.items
|
notifications.value = data.items
|
||||||
unreadCount.value = data.unreadCount
|
unreadCount.value = data.unreadCount
|
||||||
} catch (_) { /* notifications are optional */ }
|
} catch (_) { /* notifications are optional */ }
|
||||||
@@ -148,7 +148,7 @@ async function doReject() {
|
|||||||
async function markRead(n: any) {
|
async function markRead(n: any) {
|
||||||
if (n.isRead) return
|
if (n.isRead) return
|
||||||
try {
|
try {
|
||||||
await http.post(`/course-adjustments/notifications/${n.id}/read`)
|
await http.post(`/notifications/${n.id}/read`)
|
||||||
n.isRead = true
|
n.isRead = true
|
||||||
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -156,7 +156,7 @@ async function markRead(n: any) {
|
|||||||
|
|
||||||
async function markAllRead() {
|
async function markAllRead() {
|
||||||
try {
|
try {
|
||||||
await http.post('/course-adjustments/notifications/read-all')
|
await http.post('/notifications/read-all')
|
||||||
notifications.value.forEach(n => n.isRead = true)
|
notifications.value.forEach(n => n.isRead = true)
|
||||||
unreadCount.value = 0
|
unreadCount.value = 0
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -340,7 +340,6 @@ function showCancel(type: string) { return type === 'Cancel' }
|
|||||||
</div>
|
</div>
|
||||||
<el-form-item label="教室(可选)">
|
<el-form-item label="教室(可选)">
|
||||||
<el-select v-model="form.classroomId" clearable filterable placeholder="留空不调整教室">
|
<el-select v-model="form.classroomId" clearable filterable placeholder="留空不调整教室">
|
||||||
<el-option v-for="r in []" :key="r" /> <!-- classrooms loaded separately if needed -->
|
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</template>
|
</template>
|
||||||
@@ -348,7 +347,6 @@ function showCancel(type: string) { return type === 'Cancel' }
|
|||||||
<template v-if="showSub(form.type)">
|
<template v-if="showSub(form.type)">
|
||||||
<el-form-item label="代课教师" required>
|
<el-form-item label="代课教师" required>
|
||||||
<el-select v-model="form.substituteTeacherId" filterable placeholder="选择代课教师">
|
<el-select v-model="form.substituteTeacherId" filterable placeholder="选择代课教师">
|
||||||
<el-option v-for="t in []" :key="t" /> <!-- teachers loaded separately if needed -->
|
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -30,12 +30,6 @@ const statusLabels: Record<string, string> = {
|
|||||||
Draft: '草稿', Published: '已发布', Archived: '已归档',
|
Draft: '草稿', Published: '已发布', Archived: '已归档',
|
||||||
}
|
}
|
||||||
|
|
||||||
function dateText(value: string) {
|
|
||||||
return new Intl.DateTimeFormat('zh-CN', {
|
|
||||||
month: '2-digit', day: '2-digit', weekday: 'short',
|
|
||||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
|
||||||
}).format(new Date(value))
|
|
||||||
}
|
|
||||||
function timeText(startsAt: string) {
|
function timeText(startsAt: string) {
|
||||||
return new Date(startsAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
return new Date(startsAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { Bell, Check, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
|
|
||||||
|
const notifications = ref<any[]>([])
|
||||||
|
const unreadCount = ref(0)
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
const unreadOnly = ref(false)
|
||||||
|
|
||||||
|
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 },
|
||||||
|
})
|
||||||
|
notifications.value = data.items
|
||||||
|
total.value = data.total
|
||||||
|
unreadCount.value = data.unreadCount
|
||||||
|
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
ElMessage.success('已全部标为已读')
|
||||||
|
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function goLink(link: string | null) {
|
||||||
|
if (!link) return
|
||||||
|
const router = (window as any).__router__
|
||||||
|
if (router) router.push(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => load())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-stack notif-page">
|
||||||
|
<section class="page-intro">
|
||||||
|
<div>
|
||||||
|
<span class="section-kicker">NOTIFICATION 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>
|
||||||
|
</article>
|
||||||
|
<el-empty v-if="!notifications.length" description="暂无消息通知" />
|
||||||
|
</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>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
.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