Implement comprehensive academic statistics and grade analysis workflows

This commit is contained in:
biss committed 2026-08-18 11:05:03 +08:00
1 parent 370a82d4d4
commit f677cf8dca
24 files changed
+8703 -42

No files matched your search

@@ -557,7 +557,7 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
.Select(x => new ClassroomListItem(
x.Id, x.Code, x.Name, x.BuildingId, x.Building!.CampusId,
x.Building.Name, x.Building.Campus!.Name, x.Capacity,
x.Floor, x.RoomType, x.TeachingVenueNature, x.Equipment,
x.Floor, x.RoomType, x.TeachingVenueNature, x.Equipment, x.AvailabilityStatus,
x.IsEnabled, x.SortOrder))
.ToListAsync(cancellationToken);
return Ok(new PagedResult<ClassroomListItem>(
@@ -582,6 +582,7 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
x.RoomType,
x.TeachingVenueNature,
x.Equipment,
x.AvailabilityStatus,
x.IsEnabled,
x.SortOrder))
.ToListAsync(token),
@@ -887,5 +888,6 @@ public sealed record ClassroomListItem(
string RoomType,
TeachingVenueNature TeachingVenueNature,
string? Equipment,
ClassroomAvailabilityStatus AvailabilityStatus,
bool IsEnabled,
int SortOrder);
@@ -0,0 +1,247 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Facilities;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using QRCoder;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Route("api/classroom-repairs")]
public sealed class ClassroomRepairsController(
AppDbContext db,
ClassroomRepairNotificationService notificationService) : ControllerBase
{
private const string Managers = SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.MaintenanceAdmin;
private const string Workers = Managers + "," + SystemRoles.MaintenanceStaff;
[AllowAnonymous]
[HttpGet("report/{classroomId:guid}")]
public async Task<ActionResult> GetReportOptions(Guid classroomId, CancellationToken cancellationToken)
{
var room = await db.Classrooms.AsNoTracking().Where(x => x.Id == classroomId && x.IsEnabled)
.Select(x => new { x.Id, x.Code, x.Name, BuildingName = x.Building!.Name })
.FirstOrDefaultAsync(cancellationToken);
if (room is null) return NotFound();
var equipment = await db.ClassroomEquipment.AsNoTracking().Where(x => x.ClassroomId == classroomId)
.OrderBy(x => x.Category).ThenBy(x => x.Name)
.Select(x => new { x.Id, x.Name, x.Category, x.IsAvailable, x.Notes })
.ToListAsync(cancellationToken);
return Ok(new { classroom = room, equipment, categories = Enum.GetValues<ClassroomEquipmentCategory>() });
}
[AllowAnonymous]
[HttpGet("report/{classroomId:guid}/qr")]
public async Task<IActionResult> GetReportQrCode(
Guid classroomId,
CancellationToken cancellationToken)
{
if (!await db.Classrooms.AsNoTracking().AnyAsync(
x => x.Id == classroomId && x.IsEnabled, cancellationToken))
return NotFound();
var reportUrl = $"{Request.Scheme}://{Request.Host}{Request.PathBase}" +
$"/classroom-repairs/report/{classroomId}";
using var generator = new QRCodeGenerator();
using var qrData = generator.CreateQrCode(reportUrl, QRCodeGenerator.ECCLevel.Q);
var svg = new SvgQRCode(qrData).GetGraphic(8);
return Content(svg, "image/svg+xml");
}
[AllowAnonymous]
[HttpPost("report/{classroomId:guid}")]
public async Task<ActionResult> Create(
Guid classroomId,
ClassroomRepairCreateRequest request,
CancellationToken cancellationToken)
{
var classroom = await db.Classrooms.FirstOrDefaultAsync(x => x.Id == classroomId && x.IsEnabled, cancellationToken);
if (classroom is null) return NotFound();
ClassroomEquipment? equipment = null;
if (request.ClassroomEquipmentId.HasValue)
{
equipment = await db.ClassroomEquipment.FirstOrDefaultAsync(
x => x.Id == request.ClassroomEquipmentId && x.ClassroomId == classroomId, cancellationToken);
if (equipment is null) return ValidationProblem("所选设备不属于该教室。");
}
var ticket = new ClassroomRepairTicket
{
ClassroomId = classroomId,
ClassroomEquipmentId = equipment?.Id,
Category = request.Category,
FaultItem = request.FaultItem.Trim(),
Description = request.Description.Trim(),
ReporterName = request.ReporterName.Trim(),
ReporterContact = request.ReporterContact.Trim(),
ReporterUserId = CurrentUserId()
};
classroom.AvailabilityStatus = ClassroomAvailabilityStatus.UnderRepair;
if (equipment is not null) equipment.IsAvailable = false;
db.ClassroomRepairTickets.Add(ticket);
await db.SaveChangesAsync(cancellationToken);
await NotificationService.SendToRoleAsync(db, SystemRoles.MaintenanceAdmin,
"新增教室维修工单", $"{classroom.Name}:{ticket.FaultItem},请尽快分派维修员。",
linkUrl: "/classroom-repairs", cancellationToken: cancellationToken,
category: NotificationCategory.Schedule);
await notificationService.NotifyInitialTeachersAsync(ticket, cancellationToken);
return Created($"api/classroom-repairs/{ticket.Id}", new { ticket.Id });
}
[Authorize(Roles = Workers)]
[HttpGet]
public async Task<ActionResult> List(
ClassroomRepairTicketStatus? status,
string? keyword,
CancellationToken cancellationToken)
{
var query = db.ClassroomRepairTickets.AsNoTracking();
if (!User.IsInRole(SystemRoles.SuperAdmin) && !User.IsInRole(SystemRoles.AcademicAdmin) &&
!User.IsInRole(SystemRoles.MaintenanceAdmin))
query = query.Where(x => x.AssignedToUserId == CurrentUserId());
if (status.HasValue) query = query.Where(x => x.Status == status.Value);
if (!string.IsNullOrWhiteSpace(keyword))
{
keyword = keyword.Trim();
query = query.Where(x => x.FaultItem.Contains(keyword) || x.Description.Contains(keyword) ||
x.Classroom!.Name.Contains(keyword) || x.Classroom.Building!.Name.Contains(keyword));
}
var result = await query.OrderByDescending(x => x.Status != ClassroomRepairTicketStatus.Completed)
.ThenByDescending(x => x.CreatedAt).Select(x => new
{
x.Id, x.ClassroomId, ClassroomName = x.Classroom!.Name, BuildingName = x.Classroom.Building!.Name,
x.ClassroomEquipmentId, EquipmentName = x.ClassroomEquipment != null ? x.ClassroomEquipment.Name : null,
x.Category, x.FaultItem, x.Description, x.ReporterName, x.ReporterContact, x.Status,
x.AssignedToUserId, AssigneeName = x.AssignedToUser != null ? x.AssignedToUser.DisplayName : null,
x.AssignedAt, x.StartedAt, x.CompletedAt, x.RepairResult, x.CreatedAt
}).ToListAsync(cancellationToken);
return Ok(result);
}
[Authorize(Roles = Managers)]
[HttpGet("workers")]
public async Task<ActionResult> GetWorkers(CancellationToken cancellationToken) =>
Ok(await (from user in db.Users.AsNoTracking()
join userRole in db.UserRoles on user.Id equals userRole.UserId
join role in db.Roles on userRole.RoleId equals role.Id
where user.IsEnabled && role.Name == SystemRoles.MaintenanceStaff
orderby user.DisplayName
select new { user.Id, user.DisplayName, user.UserName, user.StaffNumber })
.Distinct().ToListAsync(cancellationToken));
[Authorize(Roles = Managers)]
[HttpPut("{id:guid}/assign")]
public async Task<ActionResult> Assign(Guid id, ClassroomRepairAssignRequest request, CancellationToken cancellationToken)
{
var ticket = await db.ClassroomRepairTickets.FindAsync([id], cancellationToken);
if (ticket is null) return NotFound();
if (ticket.Status == ClassroomRepairTicketStatus.Completed) return Conflict("已完成工单不能重新分派。");
var workerExists = await (from userRole in db.UserRoles
join role in db.Roles on userRole.RoleId equals role.Id
join user in db.Users on userRole.UserId equals user.Id
where userRole.UserId == request.AssignedToUserId && user.IsEnabled &&
role.Name == SystemRoles.MaintenanceStaff
select user.Id).AnyAsync(cancellationToken);
if (!workerExists) return ValidationProblem("所选人员不是启用的维修员。");
ticket.AssignedToUserId = request.AssignedToUserId;
ticket.AssignedByUserId = CurrentUserId();
ticket.AssignedAt = DateTime.UtcNow;
ticket.Status = ClassroomRepairTicketStatus.Assigned;
await db.SaveChangesAsync(cancellationToken);
await NotificationService.SendAsync(db, request.AssignedToUserId, "维修工单已分派",
$"工单“{ticket.FaultItem}”已分派给您,请及时处理。", "/classroom-repairs", cancellationToken,
NotificationCategory.Schedule);
return NoContent();
}
[Authorize(Roles = Workers)]
[HttpPut("{id:guid}/start")]
public async Task<ActionResult> Start(Guid id, CancellationToken cancellationToken)
{
var ticket = await FindWorkerTicketAsync(id, cancellationToken);
if (ticket is null) return Forbid();
if (ticket.Status == ClassroomRepairTicketStatus.Completed) return Conflict("工单已完成。");
if (!ticket.AssignedToUserId.HasValue) return Conflict("工单尚未分派维修员。");
ticket.Status = ClassroomRepairTicketStatus.InProgress;
ticket.StartedAt ??= DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[Authorize(Roles = Workers)]
[HttpPut("{id:guid}/complete")]
public async Task<ActionResult> Complete(Guid id, ClassroomRepairCompleteRequest request, CancellationToken cancellationToken)
{
var ticket = await FindWorkerTicketAsync(id, cancellationToken);
if (ticket is null) return Forbid();
if (ticket.Status == ClassroomRepairTicketStatus.Completed) return Conflict("工单已完成。");
ticket.Status = ClassroomRepairTicketStatus.Completed;
ticket.CompletedAt = DateTime.UtcNow;
ticket.RepairResult = request.RepairResult.Trim();
if (ticket.ClassroomEquipmentId.HasValue)
{
var equipment = await db.ClassroomEquipment.FindAsync([ticket.ClassroomEquipmentId.Value], cancellationToken);
if (equipment is not null) equipment.IsAvailable = true;
}
var hasOpenTicket = await db.ClassroomRepairTickets.AnyAsync(x =>
x.ClassroomId == ticket.ClassroomId && x.Id != ticket.Id &&
x.Status != ClassroomRepairTicketStatus.Completed, cancellationToken);
if (!hasOpenTicket)
{
var classroom = await db.Classrooms.FindAsync([ticket.ClassroomId], cancellationToken);
if (classroom is not null) classroom.AvailabilityStatus = ClassroomAvailabilityStatus.Normal;
}
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[Authorize(Roles = Managers)]
[HttpGet("classrooms")]
public async Task<ActionResult> GetClassrooms(CancellationToken cancellationToken) =>
Ok(await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled)
.OrderBy(x => x.Building!.Campus!.SortOrder).ThenBy(x => x.Building!.SortOrder).ThenBy(x => x.Code)
.Select(x => new { x.Id, x.Code, x.Name, BuildingName = x.Building!.Name, x.AvailabilityStatus })
.ToListAsync(cancellationToken));
[Authorize(Roles = Managers)]
[HttpGet("classrooms/{classroomId:guid}/equipment")]
public async Task<ActionResult> GetEquipment(Guid classroomId, CancellationToken cancellationToken) =>
Ok(await db.ClassroomEquipment.AsNoTracking().Where(x => x.ClassroomId == classroomId)
.OrderBy(x => x.Category).ThenBy(x => x.Name).ToListAsync(cancellationToken));
[Authorize(Roles = Managers)]
[HttpPost("classrooms/{classroomId:guid}/equipment")]
public async Task<ActionResult> CreateEquipment(Guid classroomId, ClassroomEquipmentRequest request, CancellationToken cancellationToken)
{
if (!await db.Classrooms.AnyAsync(x => x.Id == classroomId, cancellationToken)) return NotFound();
var entity = new ClassroomEquipment { ClassroomId = classroomId, Name = request.Name.Trim(), Category = request.Category, Notes = request.Notes?.Trim() };
db.ClassroomEquipment.Add(entity);
await db.SaveChangesAsync(cancellationToken);
return Created($"api/classroom-repairs/classrooms/{classroomId}/equipment/{entity.Id}", entity);
}
private async Task<ClassroomRepairTicket?> FindWorkerTicketAsync(Guid id, CancellationToken cancellationToken)
{
var ticket = await db.ClassroomRepairTickets.FindAsync([id], cancellationToken);
if (ticket is null) return null;
var isManager = User.IsInRole(SystemRoles.SuperAdmin) || User.IsInRole(SystemRoles.AcademicAdmin) || User.IsInRole(SystemRoles.MaintenanceAdmin);
return isManager || ticket.AssignedToUserId == CurrentUserId() ? ticket : null;
}
private Guid? CurrentUserId() => Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
}
public sealed record ClassroomRepairCreateRequest(
Guid? ClassroomEquipmentId, ClassroomEquipmentCategory Category,
[Required, MaxLength(80)] string FaultItem, [Required, MaxLength(2000)] string Description,
[Required, MaxLength(50)] string ReporterName, [Required, MaxLength(100)] string ReporterContact);
public sealed record ClassroomRepairAssignRequest(Guid AssignedToUserId);
public sealed record ClassroomRepairCompleteRequest([Required, MaxLength(2000)] string RepairResult);
public sealed record ClassroomEquipmentRequest([Required, MaxLength(80)] string Name,
ClassroomEquipmentCategory Category, [MaxLength(500)] string? Notes);
@@ -142,7 +142,7 @@ public sealed class CourseAdjustmentsController(
.ToList();
var classrooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled)
.Where(x => x.IsEnabled && x.AvailabilityStatus == ClassroomAvailabilityStatus.Normal)
.OrderBy(x => x.Building!.Campus!.SortOrder)
.ThenBy(x => x.Building!.SortOrder)
.ThenBy(x => x.SortOrder)
@@ -614,9 +614,10 @@ public sealed class CourseAdjustmentsController(
if (request.ClassroomId.HasValue &&
!await db.Classrooms.AsNoTracking().AnyAsync(x =>
x.Id == request.ClassroomId && x.IsEnabled,
x.Id == request.ClassroomId && x.IsEnabled &&
x.AvailabilityStatus == ClassroomAvailabilityStatus.Normal,
cancellationToken))
return ValidationProblem("目标教室不存在或已停用。");
return ValidationProblem("目标教室不存在、已停用或正在维修。");
return null;
}
Loaded 3 of 24 files, more files were not shown because too many files have changed in this diff. Show more