服务器端分页优化
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Domain.System;
|
using Jiaowu.Api.Domain.System;
|
||||||
@@ -23,43 +24,73 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
// ═══════════════ Aggregated pending ═══════════════
|
// ═══════════════ Aggregated pending ═══════════════
|
||||||
[HttpGet("pending")]
|
[HttpGet("pending")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> GetPending(CancellationToken ct)
|
public async Task<ActionResult<PagedResult<ApprovalItem>>> GetPending(
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var items = new List<ApprovalItem>();
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("page 必须大于 0,pageSize 必须在 1 到 100 之间。");
|
||||||
|
|
||||||
|
var take = checked(page * pageSize);
|
||||||
|
var total = 0;
|
||||||
|
var items = new List<ApprovalItem>(take * 8);
|
||||||
|
|
||||||
items.AddRange(await Scoped<CourseExemption>(x => x.Status == ApprovalStatus.Submitted)
|
items.AddRange(await Scoped<CourseExemption>(x => x.Status == ApprovalStatus.Submitted)
|
||||||
|
.OrderByDescending(x => x.SubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.Id, "CourseExemption", "免修", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name))
|
.Select(x => new ApprovalItem(x.Id, "CourseExemption", "免修", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<CourseExemption>(x => x.Status == ApprovalStatus.Submitted).CountAsync(ct);
|
||||||
|
|
||||||
items.AddRange(await Scoped<DeferredExam>(x => x.Status == ApprovalStatus.Submitted)
|
items.AddRange(await Scoped<DeferredExam>(x => x.Status == ApprovalStatus.Submitted)
|
||||||
|
.OrderByDescending(x => x.SubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.Id, "DeferredExam", "缓考", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name))
|
.Select(x => new ApprovalItem(x.Id, "DeferredExam", "缓考", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<DeferredExam>(x => x.Status == ApprovalStatus.Submitted).CountAsync(ct);
|
||||||
|
|
||||||
items.AddRange(await Scoped<GradeModification>(x => x.Status == GradeModificationStatus.TeacherSubmitted || x.Status == GradeModificationStatus.CollegeApproved)
|
items.AddRange(await Scoped<GradeModification>(x => x.Status == GradeModificationStatus.TeacherSubmitted || x.Status == GradeModificationStatus.CollegeApproved)
|
||||||
|
.OrderByDescending(x => x.SubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.Id, "GradeModification", "成绩修改", $"{x.GradeRecord!.Student!.Name} — 《{x.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》", $"{x.CurrentScore} → {x.RequestedScore}:{x.Reason}", x.SubmittedAt, x.GradeRecord.GradeSheet.TeachingTask.Course!.College!.Name))
|
.Select(x => new ApprovalItem(x.Id, "GradeModification", "成绩修改", $"{x.GradeRecord!.Student!.Name} — 《{x.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》", $"{x.CurrentScore} → {x.RequestedScore}:{x.Reason}", x.SubmittedAt, x.GradeRecord.GradeSheet.TeachingTask.Course!.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<GradeModification>(x => x.Status == GradeModificationStatus.TeacherSubmitted || x.Status == GradeModificationStatus.CollegeApproved).CountAsync(ct);
|
||||||
|
|
||||||
items.AddRange(await Scoped<CourseSubstitution>(x => x.Status == ApprovalStatus.Submitted)
|
items.AddRange(await Scoped<CourseSubstitution>(x => x.Status == ApprovalStatus.Submitted)
|
||||||
|
.OrderByDescending(x => x.SubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.Id, "CourseSubstitution", "课程替代", $"{x.Student!.Name}:{x.SubstituteCourse!.Name} → {x.OriginalCourse!.Name}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name))
|
.Select(x => new ApprovalItem(x.Id, "CourseSubstitution", "课程替代", $"{x.Student!.Name}:{x.SubstituteCourse!.Name} → {x.OriginalCourse!.Name}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<CourseSubstitution>(x => x.Status == ApprovalStatus.Submitted).CountAsync(ct);
|
||||||
|
|
||||||
items.AddRange(await Scoped<StudentStatusChange>(x => x.State != StudentStatusChangeState.Approved && x.State != StudentStatusChangeState.Rejected && x.State != StudentStatusChangeState.Cancelled)
|
items.AddRange(await Scoped<StudentStatusChange>(x => x.State != StudentStatusChangeState.Approved && x.State != StudentStatusChangeState.Rejected && x.State != StudentStatusChangeState.Cancelled)
|
||||||
|
.OrderByDescending(x => x.SubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.Id, "StudentStatusChange", "学籍异动", $"{x.Student!.Name} — {SSCLabel(x.Type)}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name))
|
.Select(x => new ApprovalItem(x.Id, "StudentStatusChange", "学籍异动", $"{x.Student!.Name} — {SSCLabel(x.Type)}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<StudentStatusChange>(x => x.State != StudentStatusChangeState.Approved && x.State != StudentStatusChangeState.Rejected && x.State != StudentStatusChangeState.Cancelled).CountAsync(ct);
|
||||||
|
|
||||||
items.AddRange(await Scoped<CourseAdjustment>(x => x.Status == CourseAdjustmentStatus.Submitted)
|
items.AddRange(await Scoped<CourseAdjustment>(x => x.Status == CourseAdjustmentStatus.Submitted)
|
||||||
|
.OrderByDescending(x => x.SubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.Id, "CourseAdjustment", CALabel(x.Type), $"《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name))
|
.Select(x => new ApprovalItem(x.Id, "CourseAdjustment", CALabel(x.Type), $"《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<CourseAdjustment>(x => x.Status == CourseAdjustmentStatus.Submitted).CountAsync(ct);
|
||||||
|
|
||||||
items.AddRange(await Scoped<GradeSheet>(x => x.Status == GradeSheetStatus.Submitted)
|
items.AddRange(await Scoped<GradeSheet>(x => x.Status == GradeSheetStatus.Submitted)
|
||||||
|
.OrderByDescending(x => x.SubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.Id, "GradeSheet", "成绩审核", $"《{x.TeachingTask!.Course!.Name}》— {x.Records.Count}人", "教师已提交成绩", x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name))
|
.Select(x => new ApprovalItem(x.Id, "GradeSheet", "成绩审核", $"《{x.TeachingTask!.Course!.Name}》— {x.Records.Count}人", "教师已提交成绩", x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<GradeSheet>(x => x.Status == GradeSheetStatus.Submitted).CountAsync(ct);
|
||||||
|
|
||||||
items.AddRange(await Scoped<AttendanceRecord>(x => x.AppealStatus == AttendanceAppealStatus.Pending)
|
items.AddRange(await Scoped<AttendanceRecord>(x => x.AppealStatus == AttendanceAppealStatus.Pending)
|
||||||
|
.OrderByDescending(x => x.AppealSubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.StudentId, "AttendanceAppeal", "考勤申诉", $"{x.Student!.Name} — 《{x.AttendanceSheet!.TeachingTask!.Course!.Name}》", x.AppealReason ?? "", x.AppealSubmittedAt!.Value, x.Student.AdministrativeClass!.Major!.College!.Name))
|
.Select(x => new ApprovalItem(x.StudentId, "AttendanceAppeal", "考勤申诉", $"{x.Student!.Name} — 《{x.AttendanceSheet!.TeachingTask!.Course!.Name}》", x.AppealReason ?? "", x.AppealSubmittedAt!.Value, x.Student.AdministrativeClass!.Major!.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<AttendanceRecord>(x => x.AppealStatus == AttendanceAppealStatus.Pending).CountAsync(ct);
|
||||||
|
|
||||||
return Ok(items.OrderByDescending(x => x.Time).ToList());
|
var pageItems = items
|
||||||
|
.OrderByDescending(x => x.Time)
|
||||||
|
.ThenByDescending(x => x.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToArray();
|
||||||
|
return Ok(new PagedResult<ApprovalItem>(pageItems, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════ Course Exemption ═══════════════
|
// ═══════════════ Course Exemption ═══════════════
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
@@ -19,10 +20,35 @@ public sealed class UsersController(
|
|||||||
RoleManager<ApplicationRole> roleManager) : ControllerBase
|
RoleManager<ApplicationRole> roleManager) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<object>> GetUsers(CancellationToken cancellationToken)
|
public async Task<ActionResult<PagedResult<UserListItem>>> GetUsers(
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
string? keyword = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var users = await userManager.Users.AsNoTracking()
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
|
var query = userManager.Users.AsNoTracking();
|
||||||
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
|
{
|
||||||
|
keyword = keyword.Trim();
|
||||||
|
query = query.Where(user =>
|
||||||
|
user.UserName!.Contains(keyword) ||
|
||||||
|
user.DisplayName.Contains(keyword) ||
|
||||||
|
(user.StaffNumber != null && user.StaffNumber.Contains(keyword)) ||
|
||||||
|
(from userRole in db.UserRoles
|
||||||
|
join role in db.Roles on userRole.RoleId equals role.Id
|
||||||
|
where userRole.UserId == user.Id && role.Name!.Contains(keyword)
|
||||||
|
select role.Id).Any());
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = await query.CountAsync(cancellationToken);
|
||||||
|
var users = await query
|
||||||
.OrderBy(x => x.UserName)
|
.OrderBy(x => x.UserName)
|
||||||
|
.ThenBy(x => x.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -36,26 +62,34 @@ public sealed class UsersController(
|
|||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var result = new List<object>();
|
var userIds = users.Select(x => x.Id).ToArray();
|
||||||
foreach (var user in users)
|
var roleRows = await (
|
||||||
{
|
from userRole in db.UserRoles.AsNoTracking()
|
||||||
var identityUser = await userManager.FindByIdAsync(user.Id.ToString());
|
join role in db.Roles.AsNoTracking() on userRole.RoleId equals role.Id
|
||||||
result.Add(new
|
where userIds.Contains(userRole.UserId)
|
||||||
{
|
select new { userRole.UserId, RoleName = role.Name! })
|
||||||
user.Id,
|
.ToListAsync(cancellationToken);
|
||||||
user.UserName,
|
var rolesByUser = roleRows
|
||||||
user.DisplayName,
|
.GroupBy(x => x.UserId)
|
||||||
user.StaffNumber,
|
.ToDictionary(
|
||||||
user.CollegeId,
|
group => group.Key,
|
||||||
user.IsEnabled,
|
group => (IReadOnlyCollection<string>)group
|
||||||
user.LastLoginAt,
|
.Select(x => x.RoleName)
|
||||||
user.CreatedAt,
|
.OrderBy(x => x)
|
||||||
Roles = identityUser is null
|
.ToArray());
|
||||||
? []
|
|
||||||
: await userManager.GetRolesAsync(identityUser)
|
var items = users.Select(user => new UserListItem(
|
||||||
});
|
user.Id,
|
||||||
}
|
user.UserName ?? string.Empty,
|
||||||
return Ok(result);
|
user.DisplayName,
|
||||||
|
user.StaffNumber,
|
||||||
|
user.CollegeId,
|
||||||
|
user.IsEnabled,
|
||||||
|
user.LastLoginAt,
|
||||||
|
user.CreatedAt,
|
||||||
|
rolesByUser.GetValueOrDefault(user.Id, Array.Empty<string>())))
|
||||||
|
.ToArray();
|
||||||
|
return Ok(new PagedResult<UserListItem>(items, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("roles")]
|
[HttpGet("roles")]
|
||||||
@@ -281,6 +315,16 @@ public sealed record CreateUserRequest(
|
|||||||
[MinLength(1)] string[] Roles);
|
[MinLength(1)] string[] Roles);
|
||||||
|
|
||||||
public sealed record SetUserStatusRequest(bool IsEnabled);
|
public sealed record SetUserStatusRequest(bool IsEnabled);
|
||||||
|
public sealed record UserListItem(
|
||||||
|
Guid Id,
|
||||||
|
string UserName,
|
||||||
|
string DisplayName,
|
||||||
|
string? StaffNumber,
|
||||||
|
Guid? CollegeId,
|
||||||
|
bool IsEnabled,
|
||||||
|
DateTime? LastLoginAt,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
IReadOnlyCollection<string> Roles);
|
||||||
public sealed record SetRolesRequest(
|
public sealed record SetRolesRequest(
|
||||||
[MaxLength(30)] string? StaffNumber,
|
[MaxLength(30)] string? StaffNumber,
|
||||||
Guid? CollegeId,
|
Guid? CollegeId,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
@@ -120,8 +121,16 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
|||||||
|
|
||||||
[HttpGet("records")]
|
[HttpGet("records")]
|
||||||
[Authorize(Roles = Managers + "," + SystemRoles.Counselor)]
|
[Authorize(Roles = Managers + "," + SystemRoles.Counselor)]
|
||||||
public async Task<ActionResult> GetRecords(Guid? academicTermId, WarningType? type, CancellationToken ct)
|
public async Task<ActionResult<PagedResult<WarningRecordListItem>>> GetRecords(
|
||||||
|
Guid? academicTermId,
|
||||||
|
WarningType? type,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
var q = db.WarningRecords.AsNoTracking().AsQueryable();
|
var q = db.WarningRecords.AsNoTracking().AsQueryable();
|
||||||
if (scope.Current.Scope == DataScope.College || scope.Current.IsInRole(SystemRoles.Counselor))
|
if (scope.Current.Scope == DataScope.College || scope.Current.IsInRole(SystemRoles.Counselor))
|
||||||
{
|
{
|
||||||
@@ -133,7 +142,28 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
|||||||
}
|
}
|
||||||
if (academicTermId.HasValue) q = q.Where(x => x.AcademicTermId == academicTermId);
|
if (academicTermId.HasValue) q = q.Where(x => x.AcademicTermId == academicTermId);
|
||||||
if (type.HasValue) q = q.Where(x => x.Type == type);
|
if (type.HasValue) q = q.Where(x => x.Type == type);
|
||||||
return Ok(await q.OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.StudentId, StudentName = x.Student!.Name, StudentNumber = x.Student.StudentNumber, ClassName = x.Student.AdministrativeClass!.Name, Type = (int)x.Type, Status = (int)x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt }).ToListAsync(ct));
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var items = await q
|
||||||
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.ThenByDescending(x => x.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(x => new WarningRecordListItem(
|
||||||
|
x.Id,
|
||||||
|
x.StudentId,
|
||||||
|
x.Student!.Name,
|
||||||
|
x.Student.StudentNumber,
|
||||||
|
x.Student.AdministrativeClass!.Name,
|
||||||
|
(int)x.Type,
|
||||||
|
(int)x.Status,
|
||||||
|
x.TriggerValue,
|
||||||
|
x.Detail,
|
||||||
|
x.AcknowledgedAt,
|
||||||
|
x.AcknowledgeComment,
|
||||||
|
x.CreatedAt))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return Ok(new PagedResult<WarningRecordListItem>(items, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════ Student ═══════════
|
// ═══════════ Student ═══════════
|
||||||
@@ -233,5 +263,18 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
|||||||
}
|
}
|
||||||
|
|
||||||
public sealed record StudentInfo(Guid Id, Guid? UserId, string Name, Guid ClassId);
|
public sealed record StudentInfo(Guid Id, Guid? UserId, string Name, Guid ClassId);
|
||||||
|
public sealed record WarningRecordListItem(
|
||||||
|
Guid Id,
|
||||||
|
Guid StudentId,
|
||||||
|
string StudentName,
|
||||||
|
string StudentNumber,
|
||||||
|
string ClassName,
|
||||||
|
int Type,
|
||||||
|
int Status,
|
||||||
|
decimal TriggerValue,
|
||||||
|
string Detail,
|
||||||
|
DateTime? AcknowledgedAt,
|
||||||
|
string? AcknowledgeComment,
|
||||||
|
DateTime CreatedAt);
|
||||||
public sealed record WarningRuleDto(WarningType Type, [MaxLength(100)] string Name, decimal Threshold, bool IsEnabled, bool NotifyStudent, bool NotifyCounselor, [MaxLength(300)] string? Description, bool AutoCheckEnabled, int? CheckDayOfWeek, int CheckHour, int CheckMinute);
|
public sealed record WarningRuleDto(WarningType Type, [MaxLength(100)] string Name, decimal Threshold, bool IsEnabled, bool NotifyStudent, bool NotifyCounselor, [MaxLength(300)] string? Description, bool AutoCheckEnabled, int? CheckDayOfWeek, int CheckHour, int CheckMinute);
|
||||||
public sealed record AckBody([MaxLength(300)] string? Comment);
|
public sealed record AckBody([MaxLength(300)] string? Comment);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Jiaowu.Api.Controllers;
|
using Jiaowu.Api.Controllers;
|
||||||
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
@@ -11,6 +12,40 @@ namespace Jiaowu.Api.Tests;
|
|||||||
|
|
||||||
public sealed class ApprovalsControllerTests
|
public sealed class ApprovalsControllerTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Pending_returns_a_single_server_page_with_total()
|
||||||
|
{
|
||||||
|
await using var fixture = await ApprovalFixture.CreateAsync();
|
||||||
|
fixture.Db.AddRange(
|
||||||
|
new DeferredExam
|
||||||
|
{
|
||||||
|
StudentId = fixture.Student.Id,
|
||||||
|
TeachingTaskId = fixture.CurrentTask.Id,
|
||||||
|
Reason = "较早提交",
|
||||||
|
SubmittedAt = DateTime.UtcNow.AddMinutes(-2)
|
||||||
|
},
|
||||||
|
new CourseExemption
|
||||||
|
{
|
||||||
|
StudentId = fixture.Student.Id,
|
||||||
|
TeachingTaskId = fixture.CurrentTask.Id,
|
||||||
|
Reason = "较晚提交",
|
||||||
|
SubmittedAt = DateTime.UtcNow.AddMinutes(-1)
|
||||||
|
});
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new ApprovalsController(
|
||||||
|
fixture.Db,
|
||||||
|
new ManagerDataScope());
|
||||||
|
var result = await controller.GetPending(2, 1, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
|
var page = Assert.IsType<PagedResult<ApprovalItem>>(ok.Value);
|
||||||
|
Assert.Equal(2, page.Total);
|
||||||
|
Assert.Equal(2, page.Page);
|
||||||
|
var item = Assert.Single(page.Items);
|
||||||
|
Assert.Equal("较早提交", item.Desc);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MyCourses_IncludesPublishedScheduledTaskAfterCurrentTermChanges()
|
public async Task MyCourses_IncludesPublishedScheduledTaskAfterCurrentTermChanges()
|
||||||
{
|
{
|
||||||
@@ -298,4 +333,14 @@ public sealed class ApprovalsControllerTests
|
|||||||
DataScope.Self,
|
DataScope.Self,
|
||||||
new HashSet<string>([SystemRoles.Student]));
|
new HashSet<string>([SystemRoles.Student]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class ManagerDataScope : ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = new(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"测试管理员",
|
||||||
|
null,
|
||||||
|
DataScope.All,
|
||||||
|
new HashSet<string>([SystemRoles.SuperAdmin]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isMana
|
|||||||
const isStudent = computed(() => auth.user?.roles.includes('Student') && !isManager.value)
|
const isStudent = computed(() => auth.user?.roles.includes('Student') && !isManager.value)
|
||||||
|
|
||||||
const pending = ref<any[]>([])
|
const pending = ref<any[]>([])
|
||||||
|
const pendingPage = ref(1)
|
||||||
|
const pendingTotal = ref(0)
|
||||||
|
const pendingPageSize = 20
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const tab = ref(isManager.value ? 'pending' : 'mine')
|
const tab = ref(isManager.value ? 'pending' : 'mine')
|
||||||
@@ -168,7 +171,17 @@ const recordCount = computed(() =>
|
|||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
if (isManager.value) pending.value = (await http.get('/approvals/pending')).data
|
if (isManager.value) {
|
||||||
|
const { data } = await http.get('/approvals/pending', {
|
||||||
|
params: { page: pendingPage.value, pageSize: pendingPageSize },
|
||||||
|
})
|
||||||
|
pending.value = data.items
|
||||||
|
pendingTotal.value = data.total
|
||||||
|
if (!pending.value.length && pendingPage.value > 1) {
|
||||||
|
pendingPage.value--
|
||||||
|
return await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
if (isStudent.value) {
|
if (isStudent.value) {
|
||||||
const [courses, grades, ex, df, substitutions] = await Promise.all([
|
const [courses, grades, ex, df, substitutions] = await Promise.all([
|
||||||
http.get('/approvals/my-courses'),
|
http.get('/approvals/my-courses'),
|
||||||
@@ -358,7 +371,7 @@ onMounted(load)
|
|||||||
<el-segmented
|
<el-segmented
|
||||||
v-model="tab"
|
v-model="tab"
|
||||||
:options="[
|
:options="[
|
||||||
...(isManager ? [{ label: `待审批 (${pending.length})`, value: 'pending' }] : []),
|
...(isManager ? [{ label: `待审批 (${pendingTotal})`, value: 'pending' }] : []),
|
||||||
{ label: `我的记录${isStudent ? ` (${recordCount})` : ''}`, value: 'mine' },
|
{ label: `我的记录${isStudent ? ` (${recordCount})` : ''}`, value: 'mine' },
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
@@ -389,6 +402,15 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<el-empty v-if="!pending.length" description="暂无待审批" />
|
<el-empty v-if="!pending.length" description="暂无待审批" />
|
||||||
|
<el-pagination
|
||||||
|
v-if="pendingTotal > pendingPageSize"
|
||||||
|
v-model:current-page="pendingPage"
|
||||||
|
class="approval-pagination"
|
||||||
|
layout="prev, pager, next"
|
||||||
|
:page-size="pendingPageSize"
|
||||||
|
:total="pendingTotal"
|
||||||
|
@current-change="() => load()"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="tab === 'mine'" v-loading="loading">
|
<section v-if="tab === 'mine'" v-loading="loading">
|
||||||
|
|||||||
+40
-16
@@ -28,6 +28,9 @@ const editingRoles = ref<string[]>([])
|
|||||||
const editingStaffNumber = ref('')
|
const editingStaffNumber = ref('')
|
||||||
const editingCollegeId = ref<string>()
|
const editingCollegeId = ref<string>()
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const total = ref(0)
|
||||||
|
const pageSize = 20
|
||||||
const passwordForm = reactive({ newPassword: '', confirmPassword: '' })
|
const passwordForm = reactive({ newPassword: '', confirmPassword: '' })
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
userName: '', displayName: '', password: '', staffNumber: '',
|
userName: '', displayName: '', password: '', staffNumber: '',
|
||||||
@@ -56,25 +59,29 @@ const editingScope = computed(() => {
|
|||||||
)
|
)
|
||||||
return scopeNames[effective]
|
return scopeNames[effective]
|
||||||
})
|
})
|
||||||
const filteredUsers = computed(() => {
|
async function load(resetPage = false) {
|
||||||
const value = keyword.value.trim().toLowerCase()
|
if (resetPage) page.value = 1
|
||||||
if (!value) return users.value
|
|
||||||
return users.value.filter((user) =>
|
|
||||||
[user.userName, user.displayName, user.staffNumber, ...user.roles]
|
|
||||||
.filter(Boolean)
|
|
||||||
.some((item) => String(item).toLowerCase().includes(value)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [userRes, roleRes, collegeRes] = await Promise.all([
|
const [userRes, roleRes, collegeRes] = await Promise.all([
|
||||||
http.get('/users'), http.get('/users/roles'), http.get('/base-data/colleges'),
|
http.get('/users', {
|
||||||
|
params: {
|
||||||
|
page: page.value,
|
||||||
|
pageSize,
|
||||||
|
keyword: keyword.value.trim() || undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
http.get('/users/roles'),
|
||||||
|
http.get('/base-data/colleges'),
|
||||||
])
|
])
|
||||||
users.value = userRes.data
|
users.value = userRes.data.items
|
||||||
|
total.value = userRes.data.total
|
||||||
roles.value = roleRes.data
|
roles.value = roleRes.data
|
||||||
colleges.value = collegeRes.data
|
colleges.value = collegeRes.data
|
||||||
|
if (users.value.length === 0 && page.value > 1) {
|
||||||
|
page.value--
|
||||||
|
await load()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -216,7 +223,15 @@ onMounted(load)
|
|||||||
|
|
||||||
<section class="data-card">
|
<section class="data-card">
|
||||||
<div class="table-toolbar">
|
<div class="table-toolbar">
|
||||||
<el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="搜索账号、姓名或工号" />
|
<el-input
|
||||||
|
v-model="keyword"
|
||||||
|
:prefix-icon="Search"
|
||||||
|
clearable
|
||||||
|
placeholder="搜索账号、姓名、工号或角色"
|
||||||
|
@clear="load(true)"
|
||||||
|
@keyup.enter="load(true)"
|
||||||
|
/>
|
||||||
|
<el-button :icon="Search" @click="load(true)">查询</el-button>
|
||||||
<el-button :icon="Document" @click="downloadTemplate">下载模板</el-button>
|
<el-button :icon="Document" @click="downloadTemplate">下载模板</el-button>
|
||||||
<el-button :icon="Upload" :loading="importing" @click="chooseImportFile">Excel 导入</el-button>
|
<el-button :icon="Upload" :loading="importing" @click="chooseImportFile">Excel 导入</el-button>
|
||||||
<el-button :icon="Download" @click="exportRows">导出全部</el-button>
|
<el-button :icon="Download" @click="exportRows">导出全部</el-button>
|
||||||
@@ -227,11 +242,11 @@ onMounted(load)
|
|||||||
accept=".xlsx"
|
accept=".xlsx"
|
||||||
@change="handleImport"
|
@change="handleImport"
|
||||||
/>
|
/>
|
||||||
<span>共 {{ filteredUsers.length }} / {{ users.length }} 个账号</span>
|
<span>共 {{ total }} 个账号</span>
|
||||||
</div>
|
</div>
|
||||||
<el-table
|
<el-table
|
||||||
v-loading="loading"
|
v-loading="loading"
|
||||||
:data="filteredUsers"
|
:data="users"
|
||||||
>
|
>
|
||||||
<el-table-column prop="userName" label="账号" min-width="130" />
|
<el-table-column prop="userName" label="账号" min-width="130" />
|
||||||
<el-table-column prop="displayName" label="姓名" min-width="120" />
|
<el-table-column prop="displayName" label="姓名" min-width="120" />
|
||||||
@@ -263,6 +278,15 @@ onMounted(load)
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
<el-pagination
|
||||||
|
v-if="total > pageSize"
|
||||||
|
v-model:current-page="page"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
class="table-pagination"
|
||||||
|
@current-change="() => load()"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<el-dialog v-model="dialogVisible" title="创建账号" width="540px">
|
<el-dialog v-model="dialogVisible" title="创建账号" width="540px">
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ const myWarnings = ref<any[]>([])
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const detecting = ref(false)
|
const detecting = ref(false)
|
||||||
const filterType = ref('')
|
const filterType = ref('')
|
||||||
|
const recordsPage = ref(1)
|
||||||
|
const recordsTotal = ref(0)
|
||||||
|
const recordsPageSize = 20
|
||||||
|
|
||||||
const typeLabels: Record<number, string> = { 1: '不及格学分', 2: '低绩点', 3: '缺勤', 4: '延毕风险' }
|
const typeLabels: Record<number, string> = { 1: '不及格学分', 2: '低绩点', 3: '缺勤', 4: '延毕风险' }
|
||||||
const statusLabels: Record<number, string> = { 1: '生效中', 2: '已确认', 3: '已处理', 4: '已忽略' }
|
const statusLabels: Record<number, string> = { 1: '生效中', 2: '已确认', 3: '已处理', 4: '已忽略' }
|
||||||
@@ -40,7 +43,8 @@ const ruleRows = reactive([
|
|||||||
{ type: 4, name: '延毕风险', threshold: 5, isEnabled: true, notifyStudent: true, notifyCounselor: true, description: '', autoCheckEnabled: false, checkDayOfWeek: 0, checkHour: 8, checkMinute: 0, lastCheckAt: null as string | null },
|
{ type: 4, name: '延毕风险', threshold: 5, isEnabled: true, notifyStudent: true, notifyCounselor: true, description: '', autoCheckEnabled: false, checkDayOfWeek: 0, checkHour: 8, checkMinute: 0, lastCheckAt: null as string | null },
|
||||||
])
|
])
|
||||||
|
|
||||||
async function load() {
|
async function load(resetRecordsPage = false) {
|
||||||
|
if (resetRecordsPage) recordsPage.value = 1
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
if (isWarningManager.value && termId.value) {
|
if (isWarningManager.value && termId.value) {
|
||||||
@@ -63,7 +67,22 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isWarningManager.value || isCounselor.value)
|
if (isWarningManager.value || isCounselor.value)
|
||||||
records.value = (await http.get('/warnings/records', { params: { academicTermId: termId.value || undefined, type: filterType.value || undefined } })).data
|
{
|
||||||
|
const response = await http.get('/warnings/records', {
|
||||||
|
params: {
|
||||||
|
academicTermId: termId.value || undefined,
|
||||||
|
type: filterType.value || undefined,
|
||||||
|
page: recordsPage.value,
|
||||||
|
pageSize: recordsPageSize,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
records.value = response.data.items
|
||||||
|
recordsTotal.value = response.data.total
|
||||||
|
if (records.value.length === 0 && recordsPage.value > 1) {
|
||||||
|
recordsPage.value--
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
if (isStudent.value) myWarnings.value = (await http.get('/warnings/my-warnings')).data
|
if (isStudent.value) myWarnings.value = (await http.get('/warnings/my-warnings')).data
|
||||||
} catch (e) { ElMessage.error(apiErrorMessage(e)) } finally { loading.value = false }
|
} catch (e) { ElMessage.error(apiErrorMessage(e)) } finally { loading.value = false }
|
||||||
}
|
}
|
||||||
@@ -105,8 +124,8 @@ onMounted(async () => {
|
|||||||
<section class="page-intro">
|
<section class="page-intro">
|
||||||
<div><span class="section-kicker">ACADEMIC WARNING</span><h2>{{ isStudent ? '我的预警' : '学业预警' }}</h2><p>{{ isWarningManager ? '配置预警规则,执行检测,查看预警记录。' : isCounselor ? '查看所管学生的学业预警情况。' : '查看并确认您的学业预警通知。' }}</p></div>
|
<div><span class="section-kicker">ACADEMIC WARNING</span><h2>{{ isStudent ? '我的预警' : '学业预警' }}</h2><p>{{ isWarningManager ? '配置预警规则,执行检测,查看预警记录。' : isCounselor ? '查看所管学生的学业预警情况。' : '查看并确认您的学业预警通知。' }}</p></div>
|
||||||
<div style="display:flex;gap:8px;align-items:center">
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
<el-select v-model="termId" clearable @change="load" style="width:240px"><el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" /></el-select>
|
<el-select v-model="termId" clearable @change="load(true)" style="width:240px"><el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" /></el-select>
|
||||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
<el-button :icon="Refresh" @click="() => load()">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -164,7 +183,7 @@ onMounted(async () => {
|
|||||||
<section v-if="isWarningManager || isCounselor" v-loading="loading" class="warn-records">
|
<section v-if="isWarningManager || isCounselor" v-loading="loading" class="warn-records">
|
||||||
<div style="display:flex;gap:12px;align-items:center;margin-bottom:12px">
|
<div style="display:flex;gap:12px;align-items:center;margin-bottom:12px">
|
||||||
<h3 style="margin:0">预警记录</h3>
|
<h3 style="margin:0">预警记录</h3>
|
||||||
<el-select v-model="filterType" clearable placeholder="全部类型" @change="load" style="width:140px"><el-option v-for="(v,k) in typeLabels" :key="k" :label="v" :value="Number(k)" /></el-select>
|
<el-select v-model="filterType" clearable placeholder="全部类型" @change="load(true)" style="width:140px"><el-option v-for="(v,k) in typeLabels" :key="k" :label="v" :value="Number(k)" /></el-select>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="records" size="small" v-if="records.length">
|
<el-table :data="records" size="small" v-if="records.length">
|
||||||
<el-table-column label="学生" min-width="150"><template #default="{row}"><b>{{ row.studentName }}</b><span style="font-size:11px;color:var(--muted);margin-left:6px">{{ row.studentNumber }}</span></template></el-table-column>
|
<el-table-column label="学生" min-width="150"><template #default="{row}"><b>{{ row.studentName }}</b><span style="font-size:11px;color:var(--muted);margin-left:6px">{{ row.studentNumber }}</span></template></el-table-column>
|
||||||
@@ -173,6 +192,15 @@ onMounted(async () => {
|
|||||||
<el-table-column label="阈值/详情" min-width="200"><template #default="{row}"><span>{{ row.triggerValue }}</span><p style="margin:0;font-size:11px;color:var(--muted)">{{ row.detail }}</p></template></el-table-column>
|
<el-table-column label="阈值/详情" min-width="200"><template #default="{row}"><span>{{ row.triggerValue }}</span><p style="margin:0;font-size:11px;color:var(--muted)">{{ row.detail }}</p></template></el-table-column>
|
||||||
<el-table-column label="状态" width="100"><template #default="{row}"><el-tag size="small" :type="row.status===1?'danger':row.status===2?'success':'info'">{{ statusLabels[row.status] }}</el-tag></template></el-table-column>
|
<el-table-column label="状态" width="100"><template #default="{row}"><el-tag size="small" :type="row.status===1?'danger':row.status===2?'success':'info'">{{ statusLabels[row.status] }}</el-tag></template></el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
<el-pagination
|
||||||
|
v-if="recordsTotal > recordsPageSize"
|
||||||
|
v-model:current-page="recordsPage"
|
||||||
|
:page-size="recordsPageSize"
|
||||||
|
:total="recordsTotal"
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
class="table-pagination"
|
||||||
|
@current-change="() => load()"
|
||||||
|
/>
|
||||||
<el-empty v-if="!records.length" description="暂无预警记录,请先执行检测。" />
|
<el-empty v-if="!records.length" description="暂无预警记录,请先执行检测。" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user