From 08b81eec42a36b5da756c33a8ad4494b83c9ddc3 Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 25 Jul 2026 20:47:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E2=80=9C=E8=AF=BE=E7=A8=8B?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E2=80=9D=E6=A8=A1=E5=BC=8F=EF=BC=8C=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E6=80=BB=E4=BD=93=E5=87=BA=E5=8B=A4=E7=8E=87=E3=80=81?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=88=86=E5=B8=83=E3=80=81=E5=8E=86=E6=AC=A1?= =?UTF-8?q?=E8=B6=8B=E5=8A=BF=E5=92=8C=E6=AF=8F=E4=BD=8D=E5=AD=A6=E7=94=9F?= =?UTF-8?q?=E7=9A=84=E5=87=BA=E5=8B=A4=E6=98=8E=E7=BB=86=E3=80=82=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=8C=89=E5=A7=93=E5=90=8D/=E5=AD=A6?= =?UTF-8?q?=E5=8F=B7=E3=80=81=E8=A1=8C=E6=94=BF=E7=8F=AD=E5=8F=8A=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E6=83=85=E5=86=B5=E7=AD=9B=E9=80=89=EF=BC=8C=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E4=BC=98=E5=85=88=E6=98=BE=E7=A4=BA=E4=BD=8E=E5=87=BA?= =?UTF-8?q?=E5=8B=A4=E7=8E=87=E5=AD=A6=E7=94=9F=E3=80=82=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E8=AF=BE=E7=A8=8B=E7=BB=9F=E8=AE=A1=20Excel=20?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=EF=BC=8C=E5=8C=85=E5=90=AB=E8=AF=BE=E7=A8=8B?= =?UTF-8?q?=E6=A6=82=E8=A7=88=E3=80=81=E5=AD=A6=E7=94=9F=E5=87=BA=E5=8B=A4?= =?UTF-8?q?=E6=98=8E=E7=BB=86=E3=80=81=E5=8E=86=E6=AC=A1=E7=82=B9=E5=90=8D?= =?UTF-8?q?=E8=B6=8B=E5=8A=BF=E4=B8=89=E4=B8=AA=E5=B7=A5=E4=BD=9C=E8=A1=A8?= =?UTF-8?q?=E3=80=82=20=E7=BB=9F=E8=AE=A1=E4=BB=85=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E5=B7=B2=E6=8F=90=E4=BA=A4=E7=82=B9=E5=90=8D=EF=BC=9B=E5=87=BA?= =?UTF-8?q?=E5=8B=A4=E5=92=8C=E8=BF=9F=E5=88=B0=E8=AE=A1=E4=B8=BA=E5=88=B0?= =?UTF-8?q?=E8=AF=BE=EF=BC=8C=E5=85=8D=E4=BF=AE=E4=B8=8D=E8=AE=A1=E5=85=A5?= =?UTF-8?q?=E5=BA=94=E5=88=B0=E6=AC=A1=E6=95=B0=E3=80=82=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=BA=86=E6=9E=9A=E4=B8=BE=E5=85=BC=E5=AE=B9=E9=97=AE?= =?UTF-8?q?=E9=A2=98=EF=BC=8C=E5=B7=B2=E6=8F=90=E4=BA=A4=E7=82=B9=E5=90=8D?= =?UTF-8?q?=E4=B8=8D=E4=BC=9A=E5=86=8D=E9=94=99=E8=AF=AF=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E4=B8=BA=E2=80=9C=E8=8D=89=E7=A8=BF=E2=80=9D=EF=BC=8C=E8=80=83?= =?UTF-8?q?=E5=8B=A4=E7=8A=B6=E6=80=81=E4=B8=AD=E6=96=87=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E6=AD=A3=E5=B8=B8=E3=80=82=20=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E4=B8=8E=E5=AF=BC=E5=87=BA=E6=8E=A5=E5=8F=A3=E5=9D=87?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E6=95=99=E5=B8=88/=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E6=9D=83=E9=99=90=E6=A0=A1=E9=AA=8C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/AttendanceController.cs | 450 +++++++- .../AttendanceControllerTests.cs | 195 ++++ web/src/views/TeacherAttendanceView.vue | 965 +++++++++++++++--- 3 files changed, 1478 insertions(+), 132 deletions(-) create mode 100644 tests/Jiaowu.Api.Tests/AttendanceControllerTests.cs diff --git a/src/Jiaowu.Api/Controllers/AttendanceController.cs b/src/Jiaowu.Api/Controllers/AttendanceController.cs index 1561816..34ffd53 100644 --- a/src/Jiaowu.Api/Controllers/AttendanceController.cs +++ b/src/Jiaowu.Api/Controllers/AttendanceController.cs @@ -158,6 +158,9 @@ public sealed class AttendanceController( }) .FirstOrDefaultAsync(cancellationToken); if (sheet is null) return NotFound(); + if (!await AccessibleTasks().AsNoTracking() + .AnyAsync(x => x.Id == sheet.TeachingTaskId, cancellationToken)) + return NotFound(); // Load exemption/deferred in separate query to avoid SQL APPLY (unsupported on SQLite) var exemptStudentIds = await db.CourseExemptions @@ -167,7 +170,10 @@ public sealed class AttendanceController( .Where(e => e.TeachingTaskId == sheet.TeachingTaskId && e.Status == ApprovalStatus.Approved) .Select(e => e.StudentId).ToHashSetAsync(cancellationToken); - var canEdit = sheet.Status == AttendanceSheetStatus.Draft; + var canEdit = sheet.Status == AttendanceSheetStatus.Draft && + await CanManageTaskAsync( + sheet.TeachingTaskId, + cancellationToken); return Ok(new { Sheet = new @@ -277,6 +283,7 @@ public sealed class AttendanceController( .Where(x => x.Id == id) .Select(x => new { + x.TeachingTaskId, x.Name, x.AttendanceDate, TaskNumber = x.TeachingTask!.TaskNumber, @@ -295,6 +302,9 @@ public sealed class AttendanceController( }) .FirstOrDefaultAsync(cancellationToken); if (sheetData is null) return NotFound(); + if (!await AccessibleTasks().AsNoTracking() + .AnyAsync(x => x.Id == sheetData.TeachingTaskId, cancellationToken)) + return NotFound(); var statusLabels = new Dictionary { @@ -316,6 +326,36 @@ public sealed class AttendanceController( $"考勤表-{sheetData.TaskNumber}-{sheetData.AttendanceDate:yyyyMMdd}.xlsx"); } + [HttpGet("tasks/{teachingTaskId:guid}/statistics")] + [Authorize(Roles = AttendanceRoles)] + public async Task GetTaskStatistics( + Guid teachingTaskId, + CancellationToken cancellationToken) + { + var statistics = await LoadTaskStatisticsAsync( + teachingTaskId, + cancellationToken); + return statistics is null ? NotFound() : Ok(statistics); + } + + [HttpGet("tasks/{teachingTaskId:guid}/statistics.xlsx")] + [Authorize(Roles = AttendanceRoles)] + public async Task ExportTaskStatistics( + Guid teachingTaskId, + CancellationToken cancellationToken) + { + var statistics = await LoadTaskStatisticsAsync( + teachingTaskId, + cancellationToken); + if (statistics is null) return NotFound(); + + var bytes = CreateStatisticsWorkbook(statistics); + return File( + bytes, + ExcelWorkbookHelper.ContentType, + $"课程考勤统计-{statistics.Course.TaskNumber}.xlsx"); + } + [HttpPost("sheets/{id:guid}/submit")] [Authorize(Roles = AttendanceRoles)] public async Task Submit(Guid id, CancellationToken cancellationToken) @@ -592,6 +632,339 @@ public sealed class AttendanceController( sheet.TeachingTask!.Teachers.Any(x => x.Teacher?.UserId == currentUserDataScope.Current.UserId); + private async Task CanManageTaskAsync( + Guid teachingTaskId, + CancellationToken cancellationToken) + { + var scope = currentUserDataScope.Current; + if (scope.IsInRole(SystemRoles.SuperAdmin) || + scope.IsInRole(SystemRoles.AcademicAdmin)) + return true; + + return await db.TeachingTaskTeachers.AsNoTracking() + .AnyAsync( + x => x.TeachingTaskId == teachingTaskId && + x.Teacher!.UserId == scope.UserId, + cancellationToken); + } + + private async Task LoadTaskStatisticsAsync( + Guid teachingTaskId, + CancellationToken cancellationToken) + { + var course = await AccessibleTasks().AsNoTracking() + .Where(x => x.Id == teachingTaskId) + .Select(x => new AttendanceStatisticsCourse( + x.Id, + x.TaskNumber, + x.Name, + x.Course!.Code, + x.Course.Name, + x.AcademicTerm!.Name)) + .FirstOrDefaultAsync(cancellationToken); + if (course is null) return null; + + var enrolledStudents = await db.CourseEnrollments.AsNoTracking() + .Where(x => + x.Status == CourseEnrollmentStatus.Enrolled && + x.CourseSelectionOffering!.TeachingTaskId == teachingTaskId) + .Select(x => new AttendanceStatisticsStudentIdentity( + x.StudentId, + x.Student!.StudentNumber, + x.Student.Name, + x.Student.AdministrativeClass!.Name)) + .Distinct() + .ToListAsync(cancellationToken); + + var submittedSheets = await db.AttendanceSheets.AsNoTracking() + .Where(x => + x.TeachingTaskId == teachingTaskId && + x.Status == AttendanceSheetStatus.Submitted) + .OrderBy(x => x.AttendanceDate) + .ThenBy(x => x.Name) + .Select(x => new AttendanceStatisticsSheet( + x.Id, + x.Name, + x.AttendanceDate)) + .ToListAsync(cancellationToken); + + var records = await db.AttendanceRecords.AsNoTracking() + .Where(x => + x.AttendanceSheet!.TeachingTaskId == teachingTaskId && + x.AttendanceSheet.Status == AttendanceSheetStatus.Submitted) + .Select(x => new AttendanceStatisticsRecord( + x.AttendanceSheetId, + x.StudentId, + x.Student!.StudentNumber, + x.Student.Name, + x.Student.AdministrativeClass!.Name, + x.Status)) + .ToListAsync(cancellationToken); + + var identities = enrolledStudents + .Concat(records.Select(x => new AttendanceStatisticsStudentIdentity( + x.StudentId, + x.StudentNumber, + x.StudentName, + x.ClassName))) + .GroupBy(x => x.StudentId) + .Select(x => x.First()) + .ToList(); + + var recordsByStudent = records + .GroupBy(x => x.StudentId) + .ToDictionary(x => x.Key, x => x.ToList()); + var students = identities.Select(identity => + { + var studentRecords = recordsByStudent.GetValueOrDefault(identity.StudentId) ?? []; + var present = studentRecords.Count(x => x.Status == AttendanceStatus.Present); + var absent = studentRecords.Count(x => x.Status == AttendanceStatus.Absent); + var late = studentRecords.Count(x => x.Status == AttendanceStatus.Late); + var leave = studentRecords.Count(x => x.Status == AttendanceStatus.Leave); + var excused = studentRecords.Count(x => x.Status == AttendanceStatus.Excused); + var required = studentRecords.Count - excused; + return new AttendanceStudentStatistics( + identity.StudentId, + identity.StudentNumber, + identity.StudentName, + identity.ClassName, + studentRecords.Count, + required, + present, + absent, + late, + leave, + excused, + CalculateAttendanceRate(present, late, required)); + }) + .OrderBy(x => x.AttendanceRate ?? decimal.MaxValue) + .ThenBy(x => x.StudentNumber) + .ToList(); + + var recordsBySheet = records + .GroupBy(x => x.AttendanceSheetId) + .ToDictionary(x => x.Key, x => x.ToList()); + var sessions = submittedSheets.Select(sheet => + { + var sheetRecords = recordsBySheet.GetValueOrDefault(sheet.Id) ?? []; + var present = sheetRecords.Count(x => x.Status == AttendanceStatus.Present); + var absent = sheetRecords.Count(x => x.Status == AttendanceStatus.Absent); + var late = sheetRecords.Count(x => x.Status == AttendanceStatus.Late); + var leave = sheetRecords.Count(x => x.Status == AttendanceStatus.Leave); + var excused = sheetRecords.Count(x => x.Status == AttendanceStatus.Excused); + var required = sheetRecords.Count - excused; + return new AttendanceSessionStatistics( + sheet.Id, + sheet.Name, + sheet.AttendanceDate, + sheetRecords.Count, + required, + present, + absent, + late, + leave, + excused, + CalculateAttendanceRate(present, late, required)); + }).ToList(); + + var totalPresent = records.Count(x => x.Status == AttendanceStatus.Present); + var totalAbsent = records.Count(x => x.Status == AttendanceStatus.Absent); + var totalLate = records.Count(x => x.Status == AttendanceStatus.Late); + var totalLeave = records.Count(x => x.Status == AttendanceStatus.Leave); + var totalExcused = records.Count(x => x.Status == AttendanceStatus.Excused); + var totalRequired = records.Count - totalExcused; + var summary = new AttendanceStatisticsSummary( + students.Count, + sessions.Count, + records.Count, + totalRequired, + CalculateAttendanceRate(totalPresent, totalLate, totalRequired), + students.Count(x => + x.RequiredCount > 0 && + x.PresentCount == x.RequiredCount), + students.Count(x => x.AbsentCount > 0 || x.LateCount > 0)); + var distribution = new[] + { + new AttendanceStatusStatistics( + AttendanceStatus.Present, + "出勤", + totalPresent), + new AttendanceStatusStatistics( + AttendanceStatus.Absent, + "缺勤", + totalAbsent), + new AttendanceStatusStatistics( + AttendanceStatus.Late, + "迟到", + totalLate), + new AttendanceStatusStatistics( + AttendanceStatus.Leave, + "请假", + totalLeave), + new AttendanceStatusStatistics( + AttendanceStatus.Excused, + "免修", + totalExcused) + }; + + return new AttendanceCourseStatistics( + course, + summary, + distribution, + sessions, + students); + } + + private static decimal? CalculateAttendanceRate( + int present, + int late, + int required) + { + if (required <= 0) return null; + return Math.Round( + (present + late) * 100m / required, + 1, + MidpointRounding.AwayFromZero); + } + + private static byte[] CreateStatisticsWorkbook( + AttendanceCourseStatistics statistics) + { + using var workbook = new XLWorkbook(); + var overview = workbook.Worksheets.Add("课程概览"); + overview.Cell("A1").Value = "课程考勤统计"; + overview.Cell("A1").Style.Font.Bold = true; + overview.Cell("A1").Style.Font.FontSize = 16; + overview.Cell("A1").Style.Font.FontColor = XLColor.FromHtml("#1F3A6D"); + var overviewRows = new (string Label, object? Value)[] + { + ("课程", $"{statistics.Course.CourseCode} {statistics.Course.CourseName}"), + ("教学任务", statistics.Course.TaskNumber), + ("学期", statistics.Course.TermName), + ("学生人数", statistics.Summary.StudentCount), + ("已提交点名", statistics.Summary.SubmittedSheetCount), + ("考勤记录", statistics.Summary.RecordCount), + ("课程总体出勤率", statistics.Summary.OverallAttendanceRate), + ("全勤人数", statistics.Summary.PerfectAttendanceCount), + ("存在缺勤或迟到人数", statistics.Summary.AbnormalStudentCount), + ("统计口径", "仅统计已提交点名;出勤和迟到计为到课,免修不计入应到次数。") + }; + for (var index = 0; index < overviewRows.Length; index++) + { + var row = index + 3; + overview.Cell(row, 1).Value = overviewRows[index].Label; + overview.Cell(row, 1).Style.Font.Bold = true; + if (overviewRows[index].Value is decimal rate) + { + overview.Cell(row, 2).Value = rate / 100m; + overview.Cell(row, 2).Style.NumberFormat.Format = "0.0%"; + } + else + { + overview.Cell(row, 2).Value = + overviewRows[index].Value?.ToString() ?? "暂无"; + } + } + overview.Column(1).Width = 22; + overview.Column(2).Width = 58; + overview.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + + var studentSheet = workbook.Worksheets.Add("学生出勤明细"); + var studentHeaders = new[] + { + "学号", "姓名", "行政班", "统计次数", "应到次数", + "出勤", "缺勤", "迟到", "请假", "免修", "出勤率" + }; + WriteHeader(studentSheet, studentHeaders); + for (var index = 0; index < statistics.Students.Count; index++) + { + var student = statistics.Students[index]; + var row = index + 2; + studentSheet.Cell(row, 1).Value = student.StudentNumber; + studentSheet.Cell(row, 2).Value = student.StudentName; + studentSheet.Cell(row, 3).Value = student.ClassName; + studentSheet.Cell(row, 4).Value = student.TotalCount; + studentSheet.Cell(row, 5).Value = student.RequiredCount; + studentSheet.Cell(row, 6).Value = student.PresentCount; + studentSheet.Cell(row, 7).Value = student.AbsentCount; + studentSheet.Cell(row, 8).Value = student.LateCount; + studentSheet.Cell(row, 9).Value = student.LeaveCount; + studentSheet.Cell(row, 10).Value = student.ExcusedCount; + if (student.AttendanceRate.HasValue) + { + studentSheet.Cell(row, 11).Value = + student.AttendanceRate.Value / 100m; + studentSheet.Cell(row, 11).Style.NumberFormat.Format = "0.0%"; + } + else + { + studentSheet.Cell(row, 11).Value = "暂无"; + } + } + FinishDataSheet(studentSheet, studentHeaders.Length); + + var sessionSheet = workbook.Worksheets.Add("历次点名趋势"); + var sessionHeaders = new[] + { + "日期", "点名名称", "记录数", "应到人数", + "出勤", "缺勤", "迟到", "请假", "免修", "出勤率" + }; + WriteHeader(sessionSheet, sessionHeaders); + for (var index = 0; index < statistics.Sessions.Count; index++) + { + var session = statistics.Sessions[index]; + var row = index + 2; + sessionSheet.Cell(row, 1).Value = session.AttendanceDate; + sessionSheet.Cell(row, 1).Style.DateFormat.Format = "yyyy-mm-dd"; + sessionSheet.Cell(row, 2).Value = session.Name; + sessionSheet.Cell(row, 3).Value = session.TotalCount; + sessionSheet.Cell(row, 4).Value = session.RequiredCount; + sessionSheet.Cell(row, 5).Value = session.PresentCount; + sessionSheet.Cell(row, 6).Value = session.AbsentCount; + sessionSheet.Cell(row, 7).Value = session.LateCount; + sessionSheet.Cell(row, 8).Value = session.LeaveCount; + sessionSheet.Cell(row, 9).Value = session.ExcusedCount; + if (session.AttendanceRate.HasValue) + { + sessionSheet.Cell(row, 10).Value = + session.AttendanceRate.Value / 100m; + sessionSheet.Cell(row, 10).Style.NumberFormat.Format = "0.0%"; + } + else + { + sessionSheet.Cell(row, 10).Value = "暂无"; + } + } + FinishDataSheet(sessionSheet, sessionHeaders.Length); + + using var stream = new MemoryStream(); + workbook.SaveAs(stream); + return stream.ToArray(); + } + + private static void WriteHeader( + IXLWorksheet sheet, + IReadOnlyList headers) + { + for (var index = 0; index < headers.Count; index++) + { + var cell = sheet.Cell(1, index + 1); + cell.Value = headers[index]; + cell.Style.Font.Bold = true; + cell.Style.Font.FontColor = XLColor.White; + cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#1F3A6D"); + cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + } + } + + private static void FinishDataSheet(IXLWorksheet sheet, int columnCount) + { + sheet.SheetView.FreezeRows(1); + sheet.RangeUsed()?.SetAutoFilter(); + sheet.Columns(1, columnCount).AdjustToContents(10, 28); + sheet.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + } + private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); @@ -625,3 +998,78 @@ public sealed record AttendanceAppealRequest( public sealed record AttendanceAppealReviewRequest( bool Approve, [MaxLength(300)] string? Comment); + +public sealed record AttendanceCourseStatistics( + AttendanceStatisticsCourse Course, + AttendanceStatisticsSummary Summary, + IReadOnlyList StatusDistribution, + IReadOnlyList Sessions, + IReadOnlyList Students); + +public sealed record AttendanceStatisticsCourse( + Guid Id, + string TaskNumber, + string TaskName, + string CourseCode, + string CourseName, + string TermName); + +public sealed record AttendanceStatisticsSummary( + int StudentCount, + int SubmittedSheetCount, + int RecordCount, + int RequiredCount, + decimal? OverallAttendanceRate, + int PerfectAttendanceCount, + int AbnormalStudentCount); + +public sealed record AttendanceStatusStatistics( + AttendanceStatus Status, + string Label, + int Count); + +public sealed record AttendanceSessionStatistics( + Guid Id, + string Name, + DateTime AttendanceDate, + int TotalCount, + int RequiredCount, + int PresentCount, + int AbsentCount, + int LateCount, + int LeaveCount, + int ExcusedCount, + decimal? AttendanceRate); + +public sealed record AttendanceStudentStatistics( + Guid StudentId, + string StudentNumber, + string StudentName, + string ClassName, + int TotalCount, + int RequiredCount, + int PresentCount, + int AbsentCount, + int LateCount, + int LeaveCount, + int ExcusedCount, + decimal? AttendanceRate); + +internal sealed record AttendanceStatisticsStudentIdentity( + Guid StudentId, + string StudentNumber, + string StudentName, + string ClassName); + +internal sealed record AttendanceStatisticsSheet( + Guid Id, + string Name, + DateTime AttendanceDate); + +internal sealed record AttendanceStatisticsRecord( + Guid AttendanceSheetId, + Guid StudentId, + string StudentNumber, + string StudentName, + string ClassName, + AttendanceStatus Status); diff --git a/tests/Jiaowu.Api.Tests/AttendanceControllerTests.cs b/tests/Jiaowu.Api.Tests/AttendanceControllerTests.cs new file mode 100644 index 0000000..747e021 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/AttendanceControllerTests.cs @@ -0,0 +1,195 @@ +using ClosedXML.Excel; +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 AttendanceControllerTests +{ + [Fact] + public async Task TaskStatistics_UsesSubmittedSheetsAndExportsAllDetails() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new AppDbContext(options); + await db.Database.EnsureCreatedAsync(); + + var college = new College { Code = "CS", Name = "计算机学院" }; + var major = new Major + { + Code = "080901", + Name = "计算机科学与技术", + CollegeId = college.Id, + DegreeType = "工学学士" + }; + var administrativeClass = new AdministrativeClass + { + Code = "CS2026-01", + Name = "计科 2026-1 班", + MajorId = major.Id, + Grade = 2026 + }; + var firstStudent = new Student + { + StudentNumber = "202601001", + Name = "周同学", + AdministrativeClassId = administrativeClass.Id, + EnrollmentYear = 2026, + EnrollmentDate = new DateOnly(2026, 9, 1) + }; + var secondStudent = new Student + { + StudentNumber = "202601002", + Name = "吴同学", + AdministrativeClassId = administrativeClass.Id, + EnrollmentYear = 2026, + EnrollmentDate = new DateOnly(2026, 9, 1) + }; + 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 task = new TeachingTask + { + TaskNumber = "2026-1-CS101-01", + Name = "程序设计基础教学班", + AcademicTermId = term.Id, + CourseId = course.Id, + Capacity = 60, + Status = TeachingTaskStatus.Published + }; + db.AddRange( + college, + major, + administrativeClass, + firstStudent, + secondStudent, + course, + term, + task); + await db.SaveChangesAsync(); + + db.AttendanceSheets.AddRange( + new AttendanceSheet + { + TeachingTaskId = task.Id, + Name = "第1周点名", + AttendanceDate = new DateTime(2026, 9, 3), + Status = AttendanceSheetStatus.Submitted, + Records = + [ + new AttendanceRecord + { + StudentId = firstStudent.Id, + Status = AttendanceStatus.Present + }, + new AttendanceRecord + { + StudentId = secondStudent.Id, + Status = AttendanceStatus.Absent + } + ] + }, + new AttendanceSheet + { + TeachingTaskId = task.Id, + Name = "第2周点名", + AttendanceDate = new DateTime(2026, 9, 10), + Status = AttendanceSheetStatus.Submitted, + Records = + [ + new AttendanceRecord + { + StudentId = firstStudent.Id, + Status = AttendanceStatus.Late + }, + new AttendanceRecord + { + StudentId = secondStudent.Id, + Status = AttendanceStatus.Excused + } + ] + }, + new AttendanceSheet + { + TeachingTaskId = task.Id, + Name = "未提交点名", + AttendanceDate = new DateTime(2026, 9, 17), + Status = AttendanceSheetStatus.Draft, + Records = + [ + new AttendanceRecord + { + StudentId = firstStudent.Id, + Status = AttendanceStatus.Absent + } + ] + }); + await db.SaveChangesAsync(); + + var controller = new AttendanceController(db, new AllDataScope()); + var result = await controller.GetTaskStatistics( + task.Id, + CancellationToken.None); + + var ok = Assert.IsType(result); + var statistics = Assert.IsType(ok.Value); + Assert.Equal(2, statistics.Summary.SubmittedSheetCount); + Assert.Equal(4, statistics.Summary.RecordCount); + Assert.Equal(3, statistics.Summary.RequiredCount); + Assert.Equal(66.7m, statistics.Summary.OverallAttendanceRate); + Assert.Equal(0, statistics.Summary.PerfectAttendanceCount); + Assert.Equal(2, statistics.Students.Count); + Assert.Equal(0m, statistics.Students[0].AttendanceRate); + Assert.Equal(100m, statistics.Students[1].AttendanceRate); + + var exportResult = await controller.ExportTaskStatistics( + task.Id, + CancellationToken.None); + var file = Assert.IsType(exportResult); + using var workbook = new XLWorkbook(new MemoryStream(file.FileContents)); + Assert.NotNull(workbook.Worksheet("课程概览")); + Assert.Equal( + 3, + workbook.Worksheet("学生出勤明细").LastRowUsed()!.RowNumber()); + Assert.Equal( + 3, + workbook.Worksheet("历次点名趋势").LastRowUsed()!.RowNumber()); + } + + private sealed class AllDataScope : 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 1b33b8c..2abce76 100644 --- a/web/src/views/TeacherAttendanceView.vue +++ b/web/src/views/TeacherAttendanceView.vue @@ -1,25 +1,78 @@