展板优化

This commit is contained in:
biss committed 2026-08-19 11:50:46 +08:00
1 parent b96d0f04a4
commit 7e169d64a2
14 files changed
+8412 -18

No files matched your search

@@ -0,0 +1,104 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)]
[Route("api/venue-display-announcements")]
public sealed class VenueDisplayAnnouncementsController(AppDbContext db) : ControllerBase
{
[HttpGet]
public async Task<ActionResult> Get(CancellationToken cancellationToken) => Ok(await db.VenueDisplayAnnouncements.AsNoTracking()
.OrderByDescending(x => x.IsPinned).ThenByDescending(x => x.IsUrgent).ThenByDescending(x => x.Priority).ThenByDescending(x => x.UpdatedAt)
.Select(x => new { x.Id, x.Title, x.Content, x.Scope, x.TargetId, x.IsPublished, x.IsPinned, x.IsUrgent, x.Priority, x.StartsAt, x.EndsAt, x.UpdatedAt })
.ToListAsync(cancellationToken));
[HttpGet("options")]
public async Task<ActionResult> Options(CancellationToken cancellationToken) => Ok(new
{
Campuses = await db.Campuses.AsNoTracking().Where(x => x.IsEnabled).OrderBy(x => x.Name).Select(x => new { x.Id, x.Name }).ToListAsync(cancellationToken),
Buildings = await db.Buildings.AsNoTracking().Where(x => x.IsEnabled).OrderBy(x => x.Name).Select(x => new { x.Id, x.Name, x.CampusId }).ToListAsync(cancellationToken),
Classrooms = await db.Classrooms.AsNoTracking().Where(x => x.IsEnabled).OrderBy(x => x.Building!.Name).ThenBy(x => x.Code).Select(x => new { x.Id, x.Name, x.BuildingId, BuildingName = x.Building!.Name }).ToListAsync(cancellationToken)
});
[HttpPost]
public async Task<ActionResult> Create(UpsertVenueDisplayAnnouncementRequest request, CancellationToken cancellationToken)
{
var announcement = new VenueDisplayAnnouncement { Title = request.Title.Trim(), Content = request.Content.Trim() };
var error = Validate(announcement, request);
if (error is not null) return ValidationProblem(error);
if (!await TargetExistsAsync(request.Scope, request.TargetId, cancellationToken)) return ValidationProblem("公告投放目标不存在或已停用。");
Apply(announcement, request);
db.VenueDisplayAnnouncements.Add(announcement);
await db.SaveChangesAsync(cancellationToken);
return CreatedAtAction(nameof(Get), new { announcement.Id }, new { announcement.Id });
}
[HttpPut("{id:guid}")]
public async Task<ActionResult> Update(Guid id, UpsertVenueDisplayAnnouncementRequest request, CancellationToken cancellationToken)
{
var announcement = await db.VenueDisplayAnnouncements.FindAsync([id], cancellationToken);
if (announcement is null) return NotFound();
announcement.Title = request.Title.Trim(); announcement.Content = request.Content.Trim();
var error = Validate(announcement, request);
if (error is not null) return ValidationProblem(error);
if (!await TargetExistsAsync(request.Scope, request.TargetId, cancellationToken)) return ValidationProblem("公告投放目标不存在或已停用。");
Apply(announcement, request);
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpDelete("{id:guid}")]
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken)
{
var announcement = await db.VenueDisplayAnnouncements.FindAsync([id], cancellationToken);
if (announcement is null) return NotFound();
db.VenueDisplayAnnouncements.Remove(announcement);
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
private static string? Validate(VenueDisplayAnnouncement announcement, UpsertVenueDisplayAnnouncementRequest request)
{
if (announcement.Title.Length is < 1 or > 120) return "公告标题需为 1 至 120 个字符。";
if (announcement.Content.Length is < 1 or > 1000) return "公告内容需为 1 至 1000 个字符。";
if (request.Scope != VenueDisplayAnnouncementScope.School && !request.TargetId.HasValue) return "请选择公告投放目标。";
if (request.Scope == VenueDisplayAnnouncementScope.School && request.TargetId.HasValue) return "全校公告不应指定目标。";
if (request.EndsAt.HasValue && request.StartsAt.HasValue && request.EndsAt <= request.StartsAt) return "结束时间必须晚于开始时间。";
return null;
}
private static void Apply(VenueDisplayAnnouncement announcement, UpsertVenueDisplayAnnouncementRequest request)
{
announcement.Scope = request.Scope; announcement.TargetId = request.Scope == VenueDisplayAnnouncementScope.School ? null : request.TargetId;
announcement.IsPublished = request.IsPublished; announcement.IsPinned = request.IsPinned; announcement.IsUrgent = request.IsUrgent;
announcement.Priority = request.Priority; announcement.StartsAt = request.StartsAt; announcement.EndsAt = request.EndsAt; announcement.UpdatedAt = DateTime.UtcNow;
}
private Task<bool> TargetExistsAsync(VenueDisplayAnnouncementScope scope, Guid? targetId, CancellationToken cancellationToken) => scope switch
{
VenueDisplayAnnouncementScope.School => Task.FromResult(true),
VenueDisplayAnnouncementScope.Campus => db.Campuses.AnyAsync(x => x.Id == targetId && x.IsEnabled, cancellationToken),
VenueDisplayAnnouncementScope.Building => db.Buildings.AnyAsync(x => x.Id == targetId && x.IsEnabled, cancellationToken),
VenueDisplayAnnouncementScope.Classroom => db.Classrooms.AnyAsync(x => x.Id == targetId && x.IsEnabled, cancellationToken),
_ => Task.FromResult(false)
};
}
public sealed record UpsertVenueDisplayAnnouncementRequest(
[property: Required, StringLength(120)] string Title,
[property: Required, StringLength(1000)] string Content,
VenueDisplayAnnouncementScope Scope,
Guid? TargetId,
bool IsPublished,
bool IsPinned,
bool IsUrgent,
[property: Range(0, 100)] int Priority,
DateTime? StartsAt,
DateTime? EndsAt);
@@ -21,7 +21,7 @@ public sealed class VenueDisplaysController(
var term = await CurrentTermAsync(cancellationToken);
var classroom = await db.Classrooms.AsNoTracking()
.Where(x => x.Id == classroomId && x.IsEnabled && x.Building!.IsEnabled)
.Select(x => new { x.Id, x.Name, x.Code, BuildingName = x.Building!.Name, CampusName = x.Building.Campus!.Name })
.Select(x => new { x.Id, x.Name, x.Code, x.BuildingId, CampusId = x.Building!.CampusId, BuildingName = x.Building.Name, CampusName = x.Building.Campus!.Name })
.FirstOrDefaultAsync(cancellationToken);
if (classroom is null) return NotFound();
if (term is null) return Ok(new { Classroom = classroom, Today = DateOnly.FromDateTime(DateTime.Now), Entries = Array.Empty<object>() });
@@ -57,7 +57,8 @@ public sealed class VenueDisplaysController(
.ToListAsync(cancellationToken);
entries.AddRange(reservations);
entries.Sort((left, right) => left.StartPeriod.CompareTo(right.StartPeriod));
return Ok(new { Classroom = classroom, Term = term.Name, Today = today, Week = week, Slots = sheet?.Slots ?? [], Entries = entries });
var announcements = await GetAnnouncementsAsync(classroom.CampusId, classroom.BuildingId, classroom.Id, cancellationToken);
return Ok(new { Classroom = classroom, Term = term.Name, Today = today, Week = week, Slots = sheet?.Slots ?? [], Entries = entries, Announcements = announcements });
}
[HttpGet("buildings/{buildingId:guid}")]
@@ -65,7 +66,7 @@ public sealed class VenueDisplaysController(
{
var term = await CurrentTermAsync(cancellationToken);
var building = await db.Buildings.AsNoTracking().Where(x => x.Id == buildingId && x.IsEnabled)
.Select(x => new { x.Id, x.Name, x.Code, CampusName = x.Campus!.Name }).FirstOrDefaultAsync(cancellationToken);
.Select(x => new { x.Id, x.Name, x.Code, x.CampusId, CampusName = x.Campus!.Name }).FirstOrDefaultAsync(cancellationToken);
if (building is null) return NotFound();
if (term is null) return Ok(new { Building = building, Today = DateOnly.FromDateTime(DateTime.Now), Slots = Array.Empty<object>(), Rooms = Array.Empty<object>() });
@@ -74,10 +75,25 @@ public sealed class VenueDisplaysController(
.OrderBy(x => x.PeriodNumber).Select(x => new { x.PeriodNumber, x.Name, x.StartsAt, x.EndsAt }).ToListAsync(cancellationToken);
var rooms = await db.Classrooms.AsNoTracking().Where(x => x.BuildingId == buildingId && x.IsEnabled)
.OrderBy(x => x.Code).Select(x => new { x.Id, x.Code, x.Name, x.Capacity }).ToListAsync(cancellationToken);
var occupied = new Dictionary<int, HashSet<Guid>>();
foreach (var slot in slots)
occupied[slot.PeriodNumber] = await availabilityService.GetOccupiedClassroomIdsAsync(term, today, slot.PeriodNumber, 1, null, cancellationToken);
return Ok(new { Building = building, Term = term.Name, Today = today, Slots = slots, Rooms = rooms.Select(room => new { room.Code, room.Name, room.Capacity, FreePeriods = slots.Where(slot => !occupied[slot.PeriodNumber].Contains(room.Id)).Select(slot => slot.PeriodNumber) }) });
var occupied = await availabilityService.GetOccupiedClassroomIdsByPeriodAsync(
term, today, slots.Select(slot => slot.PeriodNumber).ToArray(), cancellationToken);
var announcements = await GetAnnouncementsAsync(building.CampusId, building.Id, null, cancellationToken);
return Ok(new { Building = building, Term = term.Name, Today = today, Slots = slots, Rooms = rooms.Select(room => new { room.Code, room.Name, room.Capacity, FreePeriods = slots.Where(slot => !occupied[slot.PeriodNumber].Contains(room.Id)).Select(slot => slot.PeriodNumber) }), Announcements = announcements });
}
private Task<List<object>> GetAnnouncementsAsync(Guid campusId, Guid buildingId, Guid? classroomId, CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
return db.VenueDisplayAnnouncements.AsNoTracking()
.Where(x => x.IsPublished && (!x.StartsAt.HasValue || x.StartsAt <= now) && (!x.EndsAt.HasValue || x.EndsAt > now) &&
(x.Scope == VenueDisplayAnnouncementScope.School ||
x.Scope == VenueDisplayAnnouncementScope.Campus && x.TargetId == campusId ||
x.Scope == VenueDisplayAnnouncementScope.Building && x.TargetId == buildingId ||
classroomId.HasValue && x.Scope == VenueDisplayAnnouncementScope.Classroom && x.TargetId == classroomId))
.OrderByDescending(x => x.IsPinned).ThenByDescending(x => x.IsUrgent).ThenByDescending(x => x.Priority).ThenByDescending(x => x.UpdatedAt)
.Take(8)
.Select(x => (object)new { x.Id, x.Title, x.Content, x.IsPinned, x.IsUrgent })
.ToListAsync(cancellationToken);
}
private Task<AcademicTerm?> CurrentTermAsync(CancellationToken cancellationToken) => db.AcademicTerms.AsNoTracking()
@@ -0,0 +1,25 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class VenueDisplayAnnouncement : EntityBase
{
public required string Title { get; set; }
public required string Content { get; set; }
public VenueDisplayAnnouncementScope Scope { get; set; } = VenueDisplayAnnouncementScope.School;
public Guid? TargetId { get; set; }
public bool IsPublished { get; set; }
public bool IsPinned { get; set; }
public bool IsUrgent { get; set; }
public int Priority { get; set; }
public DateTime? StartsAt { get; set; }
public DateTime? EndsAt { get; set; }
}
public enum VenueDisplayAnnouncementScope
{
School = 1,
Campus = 2,
Building = 3,
Classroom = 4
}
Loaded 3 of 14 files, more files were not shown because too many files have changed in this diff. Show more