diff --git a/src/Jiaowu.Api/Controllers/AttendanceController.cs b/src/Jiaowu.Api/Controllers/AttendanceController.cs index 636ca58..c61eb50 100644 --- a/src/Jiaowu.Api/Controllers/AttendanceController.cs +++ b/src/Jiaowu.Api/Controllers/AttendanceController.cs @@ -3,6 +3,7 @@ using System.Security.Claims; using System.Security.Cryptography; using System.Text; using ClosedXML.Excel; +using Jiaowu.Api.Contracts; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; @@ -33,14 +34,32 @@ public sealed class AttendanceController( [Authorize(Roles = AttendanceRoles)] public async Task GetMyTasks( Guid? academicTermId, - CancellationToken cancellationToken) + string? keyword = null, + int page = 1, + int pageSize = 20, + CancellationToken cancellationToken = default) { + page = Math.Max(1, page); + pageSize = Math.Clamp(pageSize, 10, 50); var tasks = AccessibleTasks().AsNoTracking() .Where(x => x.Status == TeachingTaskStatus.Published); if (academicTermId.HasValue) tasks = tasks.Where(x => x.AcademicTermId == academicTermId); + if (!string.IsNullOrWhiteSpace(keyword)) + { + keyword = keyword.Trim(); + tasks = tasks.Where(x => + x.TaskNumber.Contains(keyword) || + x.Name.Contains(keyword) || + x.Course!.Code.Contains(keyword) || + x.Course.Name.Contains(keyword)); + } + var total = await tasks.CountAsync(cancellationToken); var result = await tasks .OrderBy(x => x.Course!.Code) + .ThenBy(x => x.TaskNumber) + .Skip((page - 1) * pageSize) + .Take(pageSize) .Select(x => new { x.Id, @@ -63,22 +82,44 @@ public sealed class AttendanceController( sheet.TeachingTaskId == x.Id) }) .ToListAsync(cancellationToken); - return Ok(result); + return Ok(new PagedResult(result, total, page, pageSize)); } [HttpGet("sheets")] [Authorize(Roles = AttendanceRoles)] public async Task GetSheets( Guid teachingTaskId, - CancellationToken cancellationToken) + string? keyword = null, + AttendanceSheetStatus? status = null, + AttendanceCheckInMethod? checkInMethod = null, + int page = 1, + int pageSize = 20, + CancellationToken cancellationToken = default) { + page = Math.Max(1, page); + pageSize = Math.Clamp(pageSize, 10, 50); var task = await AccessibleTasks().AsNoTracking() .FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken); if (task is null) return NotFound(); - var sheets = await db.AttendanceSheets.AsNoTracking() - .Where(x => x.TeachingTaskId == teachingTaskId) + var source = db.AttendanceSheets.AsNoTracking() + .Where(x => x.TeachingTaskId == teachingTaskId); + if (!string.IsNullOrWhiteSpace(keyword)) + { + keyword = keyword.Trim(); + source = source.Where(x => x.Name.Contains(keyword) || x.Notes!.Contains(keyword)); + } + if (status.HasValue) + source = source.Where(x => x.Status == status.Value); + if (checkInMethod.HasValue) + source = source.Where(x => x.CheckInMethod == checkInMethod.Value); + + var total = await source.CountAsync(cancellationToken); + var sheets = await source .OrderByDescending(x => x.AttendanceDate) + .ThenByDescending(x => x.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) .Select(x => new { x.Id, @@ -99,7 +140,7 @@ public sealed class AttendanceController( TotalCount = x.Records.Count }) .ToListAsync(cancellationToken); - return Ok(sheets); + return Ok(new PagedResult(sheets, total, page, pageSize)); } [HttpPost("sheets")] diff --git a/src/Jiaowu.Api/Infrastructure/Analytics/ClickHouseAnalyticsProjectionWorker.cs b/src/Jiaowu.Api/Infrastructure/Analytics/ClickHouseAnalyticsProjectionWorker.cs index c516a27..b77a70b 100644 --- a/src/Jiaowu.Api/Infrastructure/Analytics/ClickHouseAnalyticsProjectionWorker.cs +++ b/src/Jiaowu.Api/Infrastructure/Analytics/ClickHouseAnalyticsProjectionWorker.cs @@ -90,7 +90,7 @@ public sealed class ClickHouseAnalyticsProjectionWorker( (x.AttendanceSheetId.CompareTo(cursorSheetId) > 0 || x.AttendanceSheetId == cursorSheetId && x.StudentId.CompareTo(cursorStudentId) > 0)) .OrderBy(x => x.AttendanceSheet!.AttendanceDate).ThenBy(x => x.AttendanceSheetId).ThenBy(x => x.StudentId).Take(options.BatchSize) - .Select(x => new { x.AttendanceSheetId, x.StudentId, AttendanceDate = x.AttendanceSheet!.AttendanceDate, TeachingTaskId = x.AttendanceSheet.TeachingTaskId, AcademicTermId = x.AttendanceSheet.TeachingTask!.AcademicTermId, CollegeId = x.AttendanceSheet.TeachingTask.Course!.CollegeId, Status = (byte)x.Status, x.CheckInAt, CheckedInMethod = x.CheckedInMethod == null ? null : (byte?)x.CheckedInMethod, AppealStatus = (byte)x.AppealStatus, ProjectedAt = projectedAt }) + .Select(x => new { x.AttendanceSheetId, x.StudentId, AttendanceDate = x.AttendanceSheet!.AttendanceDate, TeachingTaskId = x.AttendanceSheet.TeachingTaskId, AcademicTermId = x.AttendanceSheet.TeachingTask!.AcademicTermId, CollegeId = x.AttendanceSheet.TeachingTask.Course!.CollegeId, x.Status, x.CheckInAt, x.CheckedInMethod, x.AppealStatus, ProjectedAt = projectedAt }) .ToListAsync(cancellationToken); if (rows.Count == 0) return count; await client.InsertAsync("attendanceRecords", rows, cancellationToken); diff --git a/tests/Jiaowu.Api.Tests/AttendancePaginationTests.cs b/tests/Jiaowu.Api.Tests/AttendancePaginationTests.cs new file mode 100644 index 0000000..188ff98 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/AttendancePaginationTests.cs @@ -0,0 +1,77 @@ +using Jiaowu.Api.Contracts; +using Jiaowu.Api.Controllers; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Tests; + +public sealed class AttendancePaginationTests +{ + [Fact] + public async Task TasksAndSheets_AreFilteredAndReturnedByPage() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + await using var db = new AppDbContext( + new DbContextOptionsBuilder().UseSqlite(connection).Options); + await db.Database.EnsureCreatedAsync(); + + var college = new College { Code = "CS", Name = "计算机学院" }; + var course = new Course + { + Code = "CS101", Name = "程序设计基础", CollegeId = college.Id, + Credits = 4, TotalHours = 64, LectureHours = 48, PracticeHours = 16, + Nature = CourseNature.MajorRequired, AssessmentMethod = AssessmentMethod.Examination + }; + var term = new AcademicTerm + { + Code = "2026-1", Name = "2026—2027 学年第一学期", AcademicYear = "2026-2027", + Season = TermSeason.Autumn, StartDate = new DateOnly(2026, 9, 1), + EndDate = new DateOnly(2027, 1, 20) + }; + var tasks = Enumerable.Range(1, 11).Select(number => new TeachingTask + { + TaskNumber = $"2026-1-CS101-{number:00}", Name = $"程序设计教学班 {number}", + AcademicTermId = term.Id, CourseId = course.Id, Capacity = 60, + Status = TeachingTaskStatus.Published + }).ToList(); + var sheets = Enumerable.Range(1, 11).Select(number => new AttendanceSheet + { + TeachingTaskId = tasks[0].Id, + Name = number == 11 ? "期末扫码点名" : $"第 {number} 周点名", + AttendanceDate = new DateTime(2026, 9, number), + Status = number == 11 ? AttendanceSheetStatus.Submitted : AttendanceSheetStatus.Draft, + CheckInMethod = number == 11 ? AttendanceCheckInMethod.QrCode : AttendanceCheckInMethod.Manual + }).ToList(); + db.AddRange(college, course, term); + db.AddRange(tasks); + db.AddRange(sheets); + await db.SaveChangesAsync(); + + var controller = new AttendanceController(db, new AllScope()); + var taskResult = Assert.IsType(await controller.GetMyTasks( + term.Id, "程序设计", 1, 10, CancellationToken.None)); + var taskPage = Assert.IsType>(taskResult.Value); + Assert.Equal(11, taskPage.Total); + Assert.Equal(10, taskPage.Items.Count); + + var sheetResult = Assert.IsType(await controller.GetSheets( + tasks[0].Id, "期末", AttendanceSheetStatus.Submitted, + AttendanceCheckInMethod.QrCode, 1, 10, CancellationToken.None)); + var sheetPage = Assert.IsType>(sheetResult.Value); + Assert.Equal(1, sheetPage.Total); + Assert.Single(sheetPage.Items); + } + + private sealed class AllScope : ICurrentUserDataScope + { + public CurrentUserScope Current { get; } = new( + Guid.NewGuid(), "测试管理员", null, DataScope.All, + new HashSet([SystemRoles.SuperAdmin])); + } +} diff --git a/web/src/views/TeacherAttendanceView.vue b/web/src/views/TeacherAttendanceView.vue index 3141a5b..6a2aac6 100644 --- a/web/src/views/TeacherAttendanceView.vue +++ b/web/src/views/TeacherAttendanceView.vue @@ -53,6 +53,16 @@ const isNativeApp = Capacitor.isNativePlatform() const now = ref(Date.now()) const fileInput = ref() const termId = ref() +const taskKeyword = ref('') +const taskPage = ref(1) +const taskPageSize = 12 +const taskTotal = ref(0) +const sheetKeyword = ref('') +const sheetStatus = ref() +const sheetMethod = ref() +const sheetPage = ref(1) +const sheetPageSize = 12 +const sheetTotal = ref(0) const activeMode = ref<'rollcall' | 'statistics'>('rollcall') const studentKeyword = ref('') const classFilter = ref('') @@ -108,18 +118,27 @@ const filteredStudents = computed(() => { }) }) -async function loadTasks() { +async function loadTasks(resetPage = false) { loading.value = true try { - tasks.value = (await http.get('/attendance/my-tasks', { - params: { academicTermId: termId.value || undefined }, + if (resetPage) taskPage.value = 1 + const response = (await http.get('/attendance/my-tasks', { + params: { + academicTermId: termId.value || undefined, + keyword: taskKeyword.value.trim() || undefined, + page: taskPage.value, + pageSize: taskPageSize, + }, })).data + tasks.value = response.items + taskTotal.value = response.total const preferred = tasks.value.find((item: any) => item.id === selectedTask.value?.id) ?? tasks.value[0] if (preferred) await selectTask(preferred) else { selectedTask.value = null sheets.value = [] + sheetTotal.value = 0 statistics.value = null } } catch (error) { @@ -137,13 +156,37 @@ async function selectTask(task: any) { studentKeyword.value = '' classFilter.value = '' attentionFilter.value = '' + sheetPage.value = 1 + sheetKeyword.value = '' + sheetStatus.value = undefined + sheetMethod.value = undefined + await loadSheets() + if (activeMode.value === 'statistics') await loadStatistics() +} + +async function loadSheets(resetPage = false) { + if (!selectedTask.value) return try { - sheets.value = (await http.get('/attendance/sheets', { - params: { teachingTaskId: task.id }, + if (resetPage) sheetPage.value = 1 + const response = (await http.get('/attendance/sheets', { + params: { + teachingTaskId: selectedTask.value.id, + keyword: sheetKeyword.value.trim() || undefined, + status: sheetStatus.value, + checkInMethod: sheetMethod.value, + page: sheetPage.value, + pageSize: sheetPageSize, + }, })).data - if (activeMode.value === 'statistics') await loadStatistics() + sheets.value = response.items + sheetTotal.value = response.total + if (!sheets.value.some((sheet: any) => sheet.id === selectedSheet.value?.id)) { + selectedSheet.value = null + sheetDetail.value = null + } } catch (error) { sheets.value = [] + sheetTotal.value = 0 ElMessage.error(apiErrorMessage(error)) } } @@ -680,14 +723,22 @@ onUnmounted(() => {

教学点名

完成课堂点名,并持续查看本课程每位学生的出勤表现。

- 刷新 + 刷新
- + + - 共 {{ tasks.length }} 个教学班 + 查询 + 共 {{ taskTotal }} 个教学班 点名记录 课程统计 @@ -712,6 +763,16 @@ onUnmounted(() => { {{ task.termName }} · {{ task.studentCount }} 名学生 · {{ task.sheetCount }} 张考勤表 + @@ -1192,7 +1282,8 @@ onUnmounted(() => { border: 1px solid var(--line); background: #fbfcfd; } -.attendance-toolbar .el-select { width: 260px; } +.attendance-toolbar .el-input, +.attendance-toolbar .el-select { width: 220px; } .attendance-toolbar > span { color: var(--muted); font-size: 12px; } .mode-switch { margin-left: auto; } .attendance-workspace { @@ -1256,6 +1347,21 @@ onUnmounted(() => { display: block; } .attendance-sheet-list { flex: 1; overflow-y: auto; } +.attendance-sheet-filters { + display: flex; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--line); + background: #fbfcfd; +} +.attendance-sheet-filters .el-input { flex: 1; min-width: 140px; } +.attendance-sheet-filters .el-select { width: 115px; } +.attendance-task-list :deep(.el-pagination), +.attendance-detail :deep(.el-pagination) { + justify-content: center; + padding: 10px; + border-top: 1px solid var(--line); +} .attendance-sheet-list > button { width: 100%; padding: 12px 16px; @@ -1708,7 +1814,11 @@ onUnmounted(() => { @media (max-width: 720px) { .attendance-toolbar { align-items: stretch; flex-wrap: wrap; } + .attendance-toolbar .el-input, .attendance-toolbar .el-select { width: 100%; } + .attendance-sheet-filters { flex-wrap: wrap; } + .attendance-sheet-filters .el-input, + .attendance-sheet-filters .el-select { width: 100%; flex-basis: 100%; } .attendance-toolbar > span { align-self: center; } .mode-switch { margin-left: auto; } .statistics-head { align-items: flex-start; flex-direction: column; }