diff --git a/src/Jiaowu.Api/Controllers/ApprovalsController.cs b/src/Jiaowu.Api/Controllers/ApprovalsController.cs index a8b80db..00d33cf 100644 --- a/src/Jiaowu.Api/Controllers/ApprovalsController.cs +++ b/src/Jiaowu.Api/Controllers/ApprovalsController.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Jiaowu.Api.Contracts; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.System; @@ -23,43 +24,73 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s // ═══════════════ Aggregated pending ═══════════════ [HttpGet("pending")] [Authorize(Roles = Managers)] - public async Task GetPending(CancellationToken ct) + public async Task>> GetPending( + int page = 1, + int pageSize = 20, + CancellationToken ct = default) { - var items = new List(); + 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(take * 8); items.AddRange(await Scoped(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)) .ToListAsync(ct)); + total += await Scoped(x => x.Status == ApprovalStatus.Submitted).CountAsync(ct); items.AddRange(await Scoped(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)) .ToListAsync(ct)); + total += await Scoped(x => x.Status == ApprovalStatus.Submitted).CountAsync(ct); items.AddRange(await Scoped(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)) .ToListAsync(ct)); + total += await Scoped(x => x.Status == GradeModificationStatus.TeacherSubmitted || x.Status == GradeModificationStatus.CollegeApproved).CountAsync(ct); items.AddRange(await Scoped(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)) .ToListAsync(ct)); + total += await Scoped(x => x.Status == ApprovalStatus.Submitted).CountAsync(ct); items.AddRange(await Scoped(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)) .ToListAsync(ct)); + total += await Scoped(x => x.State != StudentStatusChangeState.Approved && x.State != StudentStatusChangeState.Rejected && x.State != StudentStatusChangeState.Cancelled).CountAsync(ct); items.AddRange(await Scoped(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)) .ToListAsync(ct)); + total += await Scoped(x => x.Status == CourseAdjustmentStatus.Submitted).CountAsync(ct); items.AddRange(await Scoped(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)) .ToListAsync(ct)); + total += await Scoped(x => x.Status == GradeSheetStatus.Submitted).CountAsync(ct); items.AddRange(await Scoped(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)) .ToListAsync(ct)); + total += await Scoped(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(pageItems, total, page, pageSize)); } // ═══════════════ Course Exemption ═══════════════ diff --git a/src/Jiaowu.Api/Controllers/UsersController.cs b/src/Jiaowu.Api/Controllers/UsersController.cs index 82464c5..c5048cb 100644 --- a/src/Jiaowu.Api/Controllers/UsersController.cs +++ b/src/Jiaowu.Api/Controllers/UsersController.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.Security.Claims; +using Jiaowu.Api.Contracts; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Persistence; @@ -19,10 +20,35 @@ public sealed class UsersController( RoleManager roleManager) : ControllerBase { [HttpGet] - public async Task> GetUsers(CancellationToken cancellationToken) + public async Task>> 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) + .ThenBy(x => x.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) .Select(x => new { x.Id, @@ -36,26 +62,34 @@ public sealed class UsersController( }) .ToListAsync(cancellationToken); - var result = new List(); - foreach (var user in users) - { - var identityUser = await userManager.FindByIdAsync(user.Id.ToString()); - result.Add(new - { - user.Id, - user.UserName, - user.DisplayName, - user.StaffNumber, - user.CollegeId, - user.IsEnabled, - user.LastLoginAt, - user.CreatedAt, - Roles = identityUser is null - ? [] - : await userManager.GetRolesAsync(identityUser) - }); - } - return Ok(result); + var userIds = users.Select(x => x.Id).ToArray(); + var roleRows = await ( + from userRole in db.UserRoles.AsNoTracking() + join role in db.Roles.AsNoTracking() on userRole.RoleId equals role.Id + where userIds.Contains(userRole.UserId) + select new { userRole.UserId, RoleName = role.Name! }) + .ToListAsync(cancellationToken); + var rolesByUser = roleRows + .GroupBy(x => x.UserId) + .ToDictionary( + group => group.Key, + group => (IReadOnlyCollection)group + .Select(x => x.RoleName) + .OrderBy(x => x) + .ToArray()); + + var items = users.Select(user => new UserListItem( + user.Id, + user.UserName ?? string.Empty, + user.DisplayName, + user.StaffNumber, + user.CollegeId, + user.IsEnabled, + user.LastLoginAt, + user.CreatedAt, + rolesByUser.GetValueOrDefault(user.Id, Array.Empty()))) + .ToArray(); + return Ok(new PagedResult(items, total, page, pageSize)); } [HttpGet("roles")] @@ -281,6 +315,16 @@ public sealed record CreateUserRequest( [MinLength(1)] string[] Roles); 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 Roles); public sealed record SetRolesRequest( [MaxLength(30)] string? StaffNumber, Guid? CollegeId, diff --git a/src/Jiaowu.Api/Controllers/WarningsController.cs b/src/Jiaowu.Api/Controllers/WarningsController.cs index e024512..a73e97a 100644 --- a/src/Jiaowu.Api/Controllers/WarningsController.cs +++ b/src/Jiaowu.Api/Controllers/WarningsController.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Jiaowu.Api.Contracts; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; @@ -120,8 +121,16 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc [HttpGet("records")] [Authorize(Roles = Managers + "," + SystemRoles.Counselor)] - public async Task GetRecords(Guid? academicTermId, WarningType? type, CancellationToken ct) + public async Task>> 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(); 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 (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(items, total, page, pageSize)); } // ═══════════ 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 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 AckBody([MaxLength(300)] string? Comment); diff --git a/tests/Jiaowu.Api.Tests/ApprovalsControllerTests.cs b/tests/Jiaowu.Api.Tests/ApprovalsControllerTests.cs index f45c4ce..34cf28c 100644 --- a/tests/Jiaowu.Api.Tests/ApprovalsControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/ApprovalsControllerTests.cs @@ -1,4 +1,5 @@ using Jiaowu.Api.Controllers; +using Jiaowu.Api.Contracts; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; @@ -11,6 +12,40 @@ namespace Jiaowu.Api.Tests; 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(result.Result); + var page = Assert.IsType>(ok.Value); + Assert.Equal(2, page.Total); + Assert.Equal(2, page.Page); + var item = Assert.Single(page.Items); + Assert.Equal("较早提交", item.Desc); + } + [Fact] public async Task MyCourses_IncludesPublishedScheduledTaskAfterCurrentTermChanges() { @@ -298,4 +333,14 @@ public sealed class ApprovalsControllerTests DataScope.Self, new HashSet([SystemRoles.Student])); } + + private sealed class ManagerDataScope : ICurrentUserDataScope + { + public CurrentUserScope Current { get; } = new( + Guid.NewGuid(), + "测试管理员", + null, + DataScope.All, + new HashSet([SystemRoles.SuperAdmin])); + } } diff --git a/web/src/views/ApprovalCenterView.vue b/web/src/views/ApprovalCenterView.vue index 3782400..6a03f7e 100644 --- a/web/src/views/ApprovalCenterView.vue +++ b/web/src/views/ApprovalCenterView.vue @@ -54,6 +54,9 @@ const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isMana const isStudent = computed(() => auth.user?.roles.includes('Student') && !isManager.value) const pending = ref([]) +const pendingPage = ref(1) +const pendingTotal = ref(0) +const pendingPageSize = 20 const loading = ref(false) const submitting = ref(false) const tab = ref(isManager.value ? 'pending' : 'mine') @@ -168,7 +171,17 @@ const recordCount = computed(() => async function load() { loading.value = true 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) { const [courses, grades, ex, df, substitutions] = await Promise.all([ http.get('/approvals/my-courses'), @@ -358,7 +371,7 @@ onMounted(load) @@ -389,6 +402,15 @@ onMounted(load) +
diff --git a/web/src/views/UsersView.vue b/web/src/views/UsersView.vue index 656c66e..19626f9 100644 --- a/web/src/views/UsersView.vue +++ b/web/src/views/UsersView.vue @@ -28,6 +28,9 @@ const editingRoles = ref([]) const editingStaffNumber = ref('') const editingCollegeId = ref() const keyword = ref('') +const page = ref(1) +const total = ref(0) +const pageSize = 20 const passwordForm = reactive({ newPassword: '', confirmPassword: '' }) const form = reactive({ userName: '', displayName: '', password: '', staffNumber: '', @@ -56,25 +59,29 @@ const editingScope = computed(() => { ) return scopeNames[effective] }) -const filteredUsers = computed(() => { - const value = keyword.value.trim().toLowerCase() - 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() { +async function load(resetPage = false) { + if (resetPage) page.value = 1 loading.value = true try { 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 colleges.value = collegeRes.data + if (users.value.length === 0 && page.value > 1) { + page.value-- + await load() + } } catch (error) { ElMessage.error(apiErrorMessage(error)) } finally { @@ -216,7 +223,15 @@ onMounted(load)
- + + 查询 下载模板 Excel 导入 导出全部 @@ -227,11 +242,11 @@ onMounted(load) accept=".xlsx" @change="handleImport" /> - 共 {{ filteredUsers.length }} / {{ users.length }} 个账号 + 共 {{ total }} 个账号
@@ -263,6 +278,15 @@ onMounted(load) +
diff --git a/web/src/views/WarningsView.vue b/web/src/views/WarningsView.vue index 40a4ad4..eeefe54 100644 --- a/web/src/views/WarningsView.vue +++ b/web/src/views/WarningsView.vue @@ -17,6 +17,9 @@ const myWarnings = ref([]) const loading = ref(false) const detecting = ref(false) const filterType = ref('') +const recordsPage = ref(1) +const recordsTotal = ref(0) +const recordsPageSize = 20 const typeLabels: Record = { 1: '不及格学分', 2: '低绩点', 3: '缺勤', 4: '延毕风险' } const statusLabels: Record = { 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 }, ]) -async function load() { +async function load(resetRecordsPage = false) { + if (resetRecordsPage) recordsPage.value = 1 loading.value = true try { if (isWarningManager.value && termId.value) { @@ -63,7 +67,22 @@ async function load() { } } 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 } catch (e) { ElMessage.error(apiErrorMessage(e)) } finally { loading.value = false } } @@ -105,8 +124,8 @@ onMounted(async () => {
ACADEMIC WARNING

{{ isStudent ? '我的预警' : '学业预警' }}

{{ isWarningManager ? '配置预警规则,执行检测,查看预警记录。' : isCounselor ? '查看所管学生的学业预警情况。' : '查看并确认您的学业预警通知。' }}

- - 刷新 + + 刷新
@@ -164,7 +183,7 @@ onMounted(async () => {

预警记录

- +
@@ -173,6 +192,15 @@ onMounted(async () => { +