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( .Select(x => new ClassroomListItem(
x.Id, x.Code, x.Name, x.BuildingId, x.Building!.CampusId, x.Id, x.Code, x.Name, x.BuildingId, x.Building!.CampusId,
x.Building.Name, x.Building.Campus!.Name, x.Capacity, 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)) x.IsEnabled, x.SortOrder))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return Ok(new PagedResult<ClassroomListItem>( return Ok(new PagedResult<ClassroomListItem>(
@@ -582,6 +582,7 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
x.RoomType, x.RoomType,
x.TeachingVenueNature, x.TeachingVenueNature,
x.Equipment, x.Equipment,
x.AvailabilityStatus,
x.IsEnabled, x.IsEnabled,
x.SortOrder)) x.SortOrder))
.ToListAsync(token), .ToListAsync(token),
@@ -887,5 +888,6 @@ public sealed record ClassroomListItem(
string RoomType, string RoomType,
TeachingVenueNature TeachingVenueNature, TeachingVenueNature TeachingVenueNature,
string? Equipment, string? Equipment,
ClassroomAvailabilityStatus AvailabilityStatus,
bool IsEnabled, bool IsEnabled,
int SortOrder); 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(); .ToList();
var classrooms = await db.Classrooms.AsNoTracking() var classrooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled) .Where(x => x.IsEnabled && x.AvailabilityStatus == ClassroomAvailabilityStatus.Normal)
.OrderBy(x => x.Building!.Campus!.SortOrder) .OrderBy(x => x.Building!.Campus!.SortOrder)
.ThenBy(x => x.Building!.SortOrder) .ThenBy(x => x.Building!.SortOrder)
.ThenBy(x => x.SortOrder) .ThenBy(x => x.SortOrder)
@@ -614,9 +614,10 @@ public sealed class CourseAdjustmentsController(
if (request.ClassroomId.HasValue && if (request.ClassroomId.HasValue &&
!await db.Classrooms.AsNoTracking().AnyAsync(x => !await db.Classrooms.AsNoTracking().AnyAsync(x =>
x.Id == request.ClassroomId && x.IsEnabled, x.Id == request.ClassroomId && x.IsEnabled &&
x.AvailabilityStatus == ClassroomAvailabilityStatus.Normal,
cancellationToken)) cancellationToken))
return ValidationProblem("目标教室不存在或已停用。"); return ValidationProblem("目标教室不存在、已停用或正在维修。");
return null; return null;
} }
Loaded 3 of 24 files, more files were not shown because too many files have changed in this diff. Show more