From 3b73292d9394ca40f6ca6f8de17461e60f5161b6 Mon Sep 17 00:00:00 2001 From: biss Date: Sun, 26 Jul 2026 10:49:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=B2=E5=AE=8C=E6=88=90=EF=BC=8C=E5=AD=A6?= =?UTF-8?q?=E7=94=9F=E7=AB=AF=E8=80=83=E5=8B=A4=E8=AE=B0=E5=BD=95=E7=8E=B0?= =?UTF-8?q?=E5=9C=A8=E6=8C=89=E8=AF=BE=E7=A8=8B=E5=B1=95=E7=A4=BA=E3=80=82?= =?UTF-8?q?=20=E6=AF=8F=E9=97=A8=E8=AF=BE=E7=A8=8B=E4=BC=9A=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E6=98=BE=E7=A4=BA=EF=BC=9A=20=E8=AF=BE=E7=A8=8B?= =?UTF-8?q?=E5=90=8D=E7=A7=B0=E3=80=81=E4=BB=A3=E7=A0=81=E3=80=81=E6=95=99?= =?UTF-8?q?=E5=AD=A6=E7=8F=AD=E5=92=8C=E4=BB=BB=E8=AF=BE=E6=95=99=E5=B8=88?= =?UTF-8?q?=E3=80=82=20=E8=AF=BE=E7=A8=8B=E6=80=BB=E4=BD=93=E5=87=BA?= =?UTF-8?q?=E5=8B=A4=E7=8E=87=E3=80=82=20=E5=87=BA=E5=8B=A4=E3=80=81?= =?UTF-8?q?=E8=BF=9F=E5=88=B0=E3=80=81=E7=BC=BA=E5=8B=A4=E3=80=81=E8=AF=B7?= =?UTF-8?q?=E5=81=87=E3=80=81=E5=85=8D=E4=BF=AE=E6=AC=A1=E6=95=B0=E3=80=82?= =?UTF-8?q?=20=E6=8C=89=E6=97=A5=E6=9C=9F=E6=8E=92=E5=88=97=E7=9A=84?= =?UTF-8?q?=E5=8E=86=E6=AC=A1=E7=82=B9=E5=90=8D=E8=AE=B0=E5=BD=95=E3=80=82?= =?UTF-8?q?=20=E7=BC=BA=E5=8B=A4=E6=88=96=E8=BF=9F=E5=88=B0=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E4=BB=8D=E5=8F=AF=E5=8D=95=E7=8B=AC=E7=94=B3=E8=AF=89?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/AttendanceController.cs | 385 ++++++++++- .../Domain/Academic/AttendanceEntities.cs | 21 + .../Persistence/AppDbContext.cs | 6 + .../Persistence/DevelopmentSqliteMigrator.cs | 33 + .../MySql/20260726022634_AttendanceCheckIn.cs | 131 ++++ .../MySql/AppDbContextModelSnapshot.cs | 47 ++ .../AttendanceControllerTests.cs | 125 ++++ tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs | 4 +- web/package-lock.json | 317 +++++++++ web/package.json | 2 + web/src/views/StudentAttendanceView.vue | 615 +++++++++++++++++- web/src/views/TeacherAttendanceView.vue | 540 ++++++++++++++- 12 files changed, 2172 insertions(+), 54 deletions(-) create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726022634_AttendanceCheckIn.cs diff --git a/src/Jiaowu.Api/Controllers/AttendanceController.cs b/src/Jiaowu.Api/Controllers/AttendanceController.cs index 75403b2..020499d 100644 --- a/src/Jiaowu.Api/Controllers/AttendanceController.cs +++ b/src/Jiaowu.Api/Controllers/AttendanceController.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.Security.Claims; +using System.Security.Cryptography; using ClosedXML.Excel; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; @@ -76,8 +77,12 @@ public sealed class AttendanceController( x.Name, x.AttendanceDate, x.Status, + x.CheckInMethod, + x.CheckInStartsAt, + x.CheckInEndsAt, x.Notes, x.SubmittedAt, + CheckedInCount = x.Records.Count(r => r.CheckInAt != null), PresentCount = x.Records.Count(r => r.Status == AttendanceStatus.Present), AbsentCount = x.Records.Count(r => r.Status == AttendanceStatus.Absent), LateCount = x.Records.Count(r => r.Status == AttendanceStatus.Late), @@ -109,20 +114,71 @@ public sealed class AttendanceController( if (studentIds.Count == 0) return ConflictProblem("该教学班没有有效选课学生。"); + var checkInMethod = request.CheckInMethod ?? AttendanceCheckInMethod.Manual; + if (!Enum.IsDefined(checkInMethod)) + return ConflictProblem("不支持该签到方式。"); + + var now = DateTime.UtcNow; + DateTime? checkInEndsAt = null; + string? checkInToken = null; + if (checkInMethod != AttendanceCheckInMethod.Manual) + { + if (request.CheckInDurationMinutes is < 1 or > 180) + return ConflictProblem("签到时长应为 1 至 180 分钟。"); + checkInEndsAt = now.AddMinutes(request.CheckInDurationMinutes!.Value); + } + if (checkInMethod == AttendanceCheckInMethod.QrCode) + checkInToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(24)); + if (checkInMethod == AttendanceCheckInMethod.Location) + { + if (request.TargetLatitude is < -90 or > 90 || + request.TargetLongitude is < -180 or > 180 || + request.TargetLatitude is null || + request.TargetLongitude is null) + return ConflictProblem("请获取有效的签到位置。"); + if (request.LocationRadiusMeters is < 20 or > 1000) + return ConflictProblem("定位签到范围应为 20 至 1000 米。"); + } + var sheet = new AttendanceSheet { TeachingTaskId = request.TeachingTaskId, Name = request.Name.Trim(), AttendanceDate = request.AttendanceDate, + CheckInMethod = checkInMethod, + CheckInToken = checkInToken, + CheckInStartsAt = checkInMethod == AttendanceCheckInMethod.Manual + ? null + : now, + CheckInEndsAt = checkInEndsAt, + TargetLatitude = checkInMethod == AttendanceCheckInMethod.Location + ? request.TargetLatitude + : null, + TargetLongitude = checkInMethod == AttendanceCheckInMethod.Location + ? request.TargetLongitude + : null, + LocationRadiusMeters = checkInMethod == AttendanceCheckInMethod.Location + ? request.LocationRadiusMeters + : null, Notes = Normalize(request.Notes), Records = studentIds.Select(studentId => new AttendanceRecord { - StudentId = studentId + StudentId = studentId, + Status = checkInMethod == AttendanceCheckInMethod.Manual + ? AttendanceStatus.Present + : AttendanceStatus.Absent }).ToList() }; db.AttendanceSheets.Add(sheet); await db.SaveChangesAsync(cancellationToken); - return Created(string.Empty, new { sheet.Id }); + return Created(string.Empty, new + { + sheet.Id, + sheet.CheckInMethod, + sheet.CheckInToken, + sheet.CheckInStartsAt, + sheet.CheckInEndsAt + }); } [HttpGet("sheets/{id:guid}")] @@ -138,6 +194,13 @@ public sealed class AttendanceController( x.Name, x.AttendanceDate, x.Status, + x.CheckInMethod, + x.CheckInToken, + x.CheckInStartsAt, + x.CheckInEndsAt, + x.TargetLatitude, + x.TargetLongitude, + x.LocationRadiusMeters, x.Notes, x.SubmittedAt, TaskNumber = x.TeachingTask!.TaskNumber, @@ -153,7 +216,11 @@ public sealed class AttendanceController( r.Student.Name, ClassName = r.Student.AdministrativeClass!.Name, r.Status, - r.Notes + r.Notes, + r.CheckInAt, + r.CheckedInMethod, + r.CheckInAccuracyMeters, + r.CheckInDistanceMeters }) }) .FirstOrDefaultAsync(cancellationToken); @@ -170,10 +237,11 @@ 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 && - await CanManageTaskAsync( - sheet.TeachingTaskId, - cancellationToken); + var canManage = await CanManageTaskAsync( + sheet.TeachingTaskId, + cancellationToken); + var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage; + var now = DateTime.UtcNow; return Ok(new { Sheet = new @@ -183,6 +251,20 @@ public sealed class AttendanceController( sheet.Name, sheet.AttendanceDate, sheet.Status, + sheet.CheckInMethod, + CheckInToken = canManage ? sheet.CheckInToken : null, + sheet.CheckInStartsAt, + sheet.CheckInEndsAt, + sheet.TargetLatitude, + sheet.TargetLongitude, + sheet.LocationRadiusMeters, + IsCheckInOpen = IsCheckInOpen( + sheet.Status, + sheet.CheckInMethod, + sheet.CheckInStartsAt, + sheet.CheckInEndsAt, + now), + CheckedInCount = sheet.Records.Count(r => r.CheckInAt != null), sheet.Notes, sheet.SubmittedAt, sheet.TaskNumber, @@ -197,6 +279,10 @@ public sealed class AttendanceController( r.ClassName, r.Status, r.Notes, + r.CheckInAt, + r.CheckedInMethod, + r.CheckInAccuracyMeters, + r.CheckInDistanceMeters, IsExempt = exemptStudentIds.Contains(r.StudentId), IsDeferred = deferredStudentIds.Contains(r.StudentId) }) @@ -387,8 +473,231 @@ public sealed class AttendanceController( return NoContent(); } + [HttpPost("sheets/{id:guid}/close-check-in")] + [Authorize(Roles = AttendanceRoles)] + public async Task CloseCheckIn( + Guid id, + CancellationToken cancellationToken) + { + var sheet = await db.AttendanceSheets + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Teachers) + .ThenInclude(x => x.Teacher) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (sheet is null) return NotFound(); + if (!CanManageSheet(sheet)) return Forbid(); + if (sheet.Status != AttendanceSheetStatus.Draft) + return ConflictProblem("考勤表已提交,签到活动已经结束。"); + if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual) + return ConflictProblem("普通点名没有在线签到活动。"); + + var now = DateTime.UtcNow; + if (sheet.CheckInEndsAt is null || sheet.CheckInEndsAt > now) + sheet.CheckInEndsAt = now; + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + // ═══════════════ Student endpoints ═══════════════ + [HttpGet("check-in-info")] + [Authorize(Roles = SystemRoles.Student)] + public async Task GetCheckInInfo( + [FromQuery, MaxLength(64)] string token, + CancellationToken cancellationToken) + { + var studentId = await GetCurrentStudentIdAsync(cancellationToken); + if (!studentId.HasValue) + return ConflictProblem("当前账号未关联学生档案。"); + if (string.IsNullOrWhiteSpace(token)) + return NotFound(); + + var activity = await db.AttendanceRecords.AsNoTracking() + .Where(x => + x.StudentId == studentId.Value && + x.AttendanceSheet!.CheckInToken == token.Trim()) + .Select(x => new + { + SheetId = x.AttendanceSheetId, + SheetName = x.AttendanceSheet!.Name, + x.AttendanceSheet.AttendanceDate, + x.AttendanceSheet.Status, + x.AttendanceSheet.CheckInMethod, + x.AttendanceSheet.CheckInStartsAt, + x.AttendanceSheet.CheckInEndsAt, + CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code, + CourseName = x.AttendanceSheet.TeachingTask.Course.Name, + TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber, + x.CheckInAt + }) + .FirstOrDefaultAsync(cancellationToken); + if (activity is null) return NotFound(); + + var now = DateTime.UtcNow; + return Ok(new + { + activity.SheetId, + activity.SheetName, + activity.AttendanceDate, + activity.CheckInMethod, + activity.CheckInStartsAt, + activity.CheckInEndsAt, + activity.CourseCode, + activity.CourseName, + activity.TaskNumber, + activity.CheckInAt, + IsOpen = IsCheckInOpen( + activity.Status, + activity.CheckInMethod, + activity.CheckInStartsAt, + activity.CheckInEndsAt, + now) + }); + } + + [HttpGet("open-check-ins")] + [Authorize(Roles = SystemRoles.Student)] + public async Task GetOpenCheckIns( + CancellationToken cancellationToken) + { + var studentId = await GetCurrentStudentIdAsync(cancellationToken); + if (!studentId.HasValue) + return ConflictProblem("当前账号未关联学生档案。"); + + var now = DateTime.UtcNow; + return Ok(await db.AttendanceRecords.AsNoTracking() + .Where(x => + x.StudentId == studentId.Value && + x.AttendanceSheet!.Status == AttendanceSheetStatus.Draft && + x.AttendanceSheet.CheckInMethod == AttendanceCheckInMethod.Location && + x.AttendanceSheet.CheckInStartsAt <= now && + x.AttendanceSheet.CheckInEndsAt >= now) + .OrderBy(x => x.AttendanceSheet!.CheckInEndsAt) + .Select(x => new + { + SheetId = x.AttendanceSheetId, + SheetName = x.AttendanceSheet!.Name, + x.AttendanceSheet.AttendanceDate, + x.AttendanceSheet.CheckInMethod, + x.AttendanceSheet.CheckInStartsAt, + x.AttendanceSheet.CheckInEndsAt, + x.AttendanceSheet.LocationRadiusMeters, + CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code, + CourseName = x.AttendanceSheet.TeachingTask.Course.Name, + TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber, + x.CheckInAt + }) + .ToListAsync(cancellationToken)); + } + + [HttpPost("check-in")] + [Authorize(Roles = SystemRoles.Student)] + public async Task CheckIn( + AttendanceCheckInRequest request, + CancellationToken cancellationToken) + { + var studentId = await GetCurrentStudentIdAsync(cancellationToken); + if (!studentId.HasValue) + return ConflictProblem("当前账号未关联学生档案。"); + + var source = db.AttendanceRecords + .Include(x => x.AttendanceSheet) + .Where(x => x.StudentId == studentId.Value); + if (!string.IsNullOrWhiteSpace(request.Token)) + { + var token = request.Token.Trim(); + source = source.Where(x => x.AttendanceSheet!.CheckInToken == token); + } + else if (request.AttendanceSheetId.HasValue) + { + source = source.Where(x => + x.AttendanceSheetId == request.AttendanceSheetId.Value); + } + else + { + return ConflictProblem("缺少签到活动信息。"); + } + + var record = await source.FirstOrDefaultAsync(cancellationToken); + if (record?.AttendanceSheet is null) return NotFound(); + var sheet = record.AttendanceSheet; + var now = DateTime.UtcNow; + if (record.CheckInAt.HasValue) + { + return Ok(new + { + AlreadyCheckedIn = true, + record.CheckInAt, + record.CheckInDistanceMeters + }); + } + if (!IsCheckInOpen( + sheet.Status, + sheet.CheckInMethod, + sheet.CheckInStartsAt, + sheet.CheckInEndsAt, + now)) + return ConflictProblem("签到尚未开始或已经结束。"); + if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual) + return ConflictProblem("该考勤表不支持学生在线签到。"); + + double? distanceMeters = null; + if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode) + { + if (string.IsNullOrWhiteSpace(request.Token) || + !string.Equals( + sheet.CheckInToken, + request.Token.Trim(), + StringComparison.Ordinal)) + return NotFound(); + } + else if (sheet.CheckInMethod == AttendanceCheckInMethod.Location) + { + if (request.Latitude is < -90 or > 90 || + request.Longitude is < -180 or > 180 || + request.Latitude is null || + request.Longitude is null) + return ConflictProblem("未获取到有效的当前位置。"); + if (sheet.TargetLatitude is null || + sheet.TargetLongitude is null || + sheet.LocationRadiusMeters is null) + return ConflictProblem("签到活动没有配置有效的位置范围。"); + + distanceMeters = CalculateDistanceMeters( + (double)sheet.TargetLatitude.Value, + (double)sheet.TargetLongitude.Value, + (double)request.Latitude.Value, + (double)request.Longitude.Value); + if (distanceMeters > sheet.LocationRadiusMeters.Value) + { + return ConflictProblem( + $"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。"); + } + } + + record.Status = AttendanceStatus.Present; + record.CheckInAt = now; + record.CheckedInMethod = sheet.CheckInMethod; + record.CheckInLatitude = sheet.CheckInMethod == AttendanceCheckInMethod.Location + ? request.Latitude + : null; + record.CheckInLongitude = sheet.CheckInMethod == AttendanceCheckInMethod.Location + ? request.Longitude + : null; + record.CheckInAccuracyMeters = sheet.CheckInMethod == AttendanceCheckInMethod.Location + ? request.AccuracyMeters + : null; + record.CheckInDistanceMeters = distanceMeters; + await db.SaveChangesAsync(cancellationToken); + + return Ok(new + { + AlreadyCheckedIn = false, + record.CheckInAt, + record.CheckInDistanceMeters + }); + } + [HttpGet("my-records")] [Authorize(Roles = SystemRoles.Student)] public async Task GetMyRecords( @@ -415,6 +724,7 @@ public sealed class AttendanceController( .Select(r => new { r.AttendanceSheetId, + r.AttendanceSheet!.TeachingTaskId, SheetName = r.AttendanceSheet!.Name, r.AttendanceSheet.AttendanceDate, TaskNumber = r.AttendanceSheet.TeachingTask!.TaskNumber, @@ -657,9 +967,54 @@ public sealed class AttendanceController( .AnyAsync( x => x.TeachingTaskId == teachingTaskId && x.Teacher!.UserId == scope.UserId, - cancellationToken); + cancellationToken); } + private async Task GetCurrentStudentIdAsync( + CancellationToken cancellationToken) + { + var userId = currentUserDataScope.Current.UserId; + return await db.Students.AsNoTracking() + .Where(x => x.UserId == userId) + .Select(x => (Guid?)x.Id) + .FirstOrDefaultAsync(cancellationToken); + } + + private static bool IsCheckInOpen( + AttendanceSheetStatus status, + AttendanceCheckInMethod method, + DateTime? startsAt, + DateTime? endsAt, + DateTime now) => + status == AttendanceSheetStatus.Draft && + method != AttendanceCheckInMethod.Manual && + startsAt.HasValue && + endsAt.HasValue && + startsAt.Value <= now && + endsAt.Value >= now; + + internal static double CalculateDistanceMeters( + double latitude1, + double longitude1, + double latitude2, + double longitude2) + { + const double earthRadiusMeters = 6_371_000; + var latitudeDelta = DegreesToRadians(latitude2 - latitude1); + var longitudeDelta = DegreesToRadians(longitude2 - longitude1); + var startLatitude = DegreesToRadians(latitude1); + var endLatitude = DegreesToRadians(latitude2); + var haversine = + Math.Sin(latitudeDelta / 2) * Math.Sin(latitudeDelta / 2) + + Math.Cos(startLatitude) * Math.Cos(endLatitude) * + Math.Sin(longitudeDelta / 2) * Math.Sin(longitudeDelta / 2); + return earthRadiusMeters * 2 * + Math.Atan2(Math.Sqrt(haversine), Math.Sqrt(1 - haversine)); + } + + private static double DegreesToRadians(double degrees) => + degrees * Math.PI / 180; + private async Task LoadTaskStatisticsAsync( Guid teachingTaskId, CancellationToken cancellationToken) @@ -993,7 +1348,12 @@ public sealed record AttendanceSheetRequest( Guid TeachingTaskId, [MaxLength(120)] string Name, DateTime AttendanceDate, - [MaxLength(500)] string? Notes); + [MaxLength(500)] string? Notes, + AttendanceCheckInMethod? CheckInMethod, + [Range(1, 180)] int? CheckInDurationMinutes, + [Range(-90, 90)] decimal? TargetLatitude, + [Range(-180, 180)] decimal? TargetLongitude, + [Range(20, 1000)] int? LocationRadiusMeters); public sealed record AttendanceRecordsRequest( IReadOnlyCollection Records); @@ -1011,6 +1371,13 @@ public sealed record AttendanceAppealReviewRequest( bool Approve, [MaxLength(300)] string? Comment); +public sealed record AttendanceCheckInRequest( + Guid? AttendanceSheetId, + [MaxLength(64)] string? Token, + [Range(-90, 90)] decimal? Latitude, + [Range(-180, 180)] decimal? Longitude, + [Range(0, 5000)] double? AccuracyMeters); + public sealed record AttendanceCourseStatistics( AttendanceStatisticsCourse Course, AttendanceStatisticsSummary Summary, diff --git a/src/Jiaowu.Api/Domain/Academic/AttendanceEntities.cs b/src/Jiaowu.Api/Domain/Academic/AttendanceEntities.cs index 49c8490..f23b10a 100644 --- a/src/Jiaowu.Api/Domain/Academic/AttendanceEntities.cs +++ b/src/Jiaowu.Api/Domain/Academic/AttendanceEntities.cs @@ -9,6 +9,14 @@ public sealed class AttendanceSheet : EntityBase public required string Name { get; set; } public DateTime AttendanceDate { get; set; } public AttendanceSheetStatus Status { get; set; } = AttendanceSheetStatus.Draft; + public AttendanceCheckInMethod CheckInMethod { get; set; } = + AttendanceCheckInMethod.Manual; + public string? CheckInToken { get; set; } + public DateTime? CheckInStartsAt { get; set; } + public DateTime? CheckInEndsAt { get; set; } + public decimal? TargetLatitude { get; set; } + public decimal? TargetLongitude { get; set; } + public int? LocationRadiusMeters { get; set; } public string? Notes { get; set; } public DateTime? SubmittedAt { get; set; } public ICollection Records { get; set; } = []; @@ -22,6 +30,12 @@ public sealed class AttendanceRecord public Student? Student { get; set; } public AttendanceStatus Status { get; set; } = AttendanceStatus.Present; public string? Notes { get; set; } + public DateTime? CheckInAt { get; set; } + public AttendanceCheckInMethod? CheckedInMethod { get; set; } + public decimal? CheckInLatitude { get; set; } + public decimal? CheckInLongitude { get; set; } + public double? CheckInAccuracyMeters { get; set; } + public double? CheckInDistanceMeters { get; set; } public AttendanceAppealStatus AppealStatus { get; set; } = AttendanceAppealStatus.None; public string? AppealReason { get; set; } public DateTime? AppealSubmittedAt { get; set; } @@ -44,6 +58,13 @@ public enum AttendanceStatus Excused = 5 } +public enum AttendanceCheckInMethod +{ + Manual = 1, + QrCode = 2, + Location = 3 +} + public enum AttendanceAppealStatus { None = 0, diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index c691985..2fdc56b 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -561,8 +561,12 @@ public sealed class AppDbContext(DbContextOptions options) builder.Entity(entity => { entity.Property(x => x.Name).HasMaxLength(120); + entity.Property(x => x.CheckInToken).HasMaxLength(64); + entity.Property(x => x.TargetLatitude).HasPrecision(10, 7); + entity.Property(x => x.TargetLongitude).HasPrecision(10, 7); entity.Property(x => x.Notes).HasMaxLength(500); entity.HasIndex(x => new { x.TeachingTaskId, x.AttendanceDate }); + entity.HasIndex(x => x.CheckInToken).IsUnique(); entity.HasOne(x => x.TeachingTask) .WithMany() .HasForeignKey(x => x.TeachingTaskId) @@ -575,6 +579,8 @@ public sealed class AppDbContext(DbContextOptions options) entity.Property(x => x.Notes).HasMaxLength(300); entity.Property(x => x.AppealReason).HasMaxLength(500); entity.Property(x => x.AppealReviewComment).HasMaxLength(300); + entity.Property(x => x.CheckInLatitude).HasPrecision(10, 7); + entity.Property(x => x.CheckInLongitude).HasPrecision(10, 7); entity.HasIndex(x => x.AppealStatus); entity.HasOne(x => x.AttendanceSheet) .WithMany(x => x.Records) diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index 7780ca0..9501a52 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -40,6 +40,8 @@ public sealed class DevelopmentSqliteMigrator( "20260725_22_course_adjustments"; private const string AttendanceAppealMigration = "20260725_23_attendance_appeal"; + private const string AttendanceCheckInMigration = + "20260726_26_attendance_check_in"; private const string ApprovalTablesMigration = "20260725_24_approval_tables"; private const string AcademicWarningsMigration = @@ -275,6 +277,19 @@ public sealed class DevelopmentSqliteMigrator( attendanceAppealExists ? [] : AttendanceAppealStatements, cancellationToken); + var attendanceCheckInExists = await db.Database + .SqlQueryRaw( + """ + SELECT COUNT(*) AS "Value" + FROM pragma_table_info('AttendanceSheets') + WHERE name = 'CheckInMethod' + """) + .AnyAsync(value => value > 0, cancellationToken); + await ApplyMigrationAsync( + AttendanceCheckInMigration, + attendanceCheckInExists ? [] : AttendanceCheckInStatements, + cancellationToken); + var approvalTablesExist = await db.Database .SqlQueryRaw("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CourseExemptions'") .AnyAsync(value => value > 0, cancellationToken); @@ -1623,6 +1638,24 @@ public sealed class DevelopmentSqliteMigrator( """CREATE INDEX IF NOT EXISTS "IX_AttendanceRecords_AppealStatus" ON "AttendanceRecords" ("AppealStatus");""" ]; + private static readonly string[] AttendanceCheckInStatements = + [ + """ALTER TABLE "AttendanceSheets" ADD COLUMN "CheckInMethod" INTEGER NOT NULL DEFAULT 1;""", + """ALTER TABLE "AttendanceSheets" ADD COLUMN "CheckInToken" TEXT NULL;""", + """ALTER TABLE "AttendanceSheets" ADD COLUMN "CheckInStartsAt" TEXT NULL;""", + """ALTER TABLE "AttendanceSheets" ADD COLUMN "CheckInEndsAt" TEXT NULL;""", + """ALTER TABLE "AttendanceSheets" ADD COLUMN "TargetLatitude" TEXT NULL;""", + """ALTER TABLE "AttendanceSheets" ADD COLUMN "TargetLongitude" TEXT NULL;""", + """ALTER TABLE "AttendanceSheets" ADD COLUMN "LocationRadiusMeters" INTEGER NULL;""", + """CREATE UNIQUE INDEX IF NOT EXISTS "IX_AttendanceSheets_CheckInToken" ON "AttendanceSheets" ("CheckInToken");""", + """ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInAt" TEXT NULL;""", + """ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckedInMethod" INTEGER NULL;""", + """ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInLatitude" TEXT NULL;""", + """ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInLongitude" TEXT NULL;""", + """ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInAccuracyMeters" REAL NULL;""", + """ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInDistanceMeters" REAL NULL;""" + ]; + private static readonly string[] ApprovalTableStatements = [ """CREATE TABLE "CourseExemptions" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseExemptions" PRIMARY KEY, "StudentId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "SubmittedAt" TEXT NOT NULL, "ReviewedAt" TEXT NULL, "ReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseExemptions_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseExemptions_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT);""", diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726022634_AttendanceCheckIn.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726022634_AttendanceCheckIn.cs new file mode 100644 index 0000000..125fd58 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726022634_AttendanceCheckIn.cs @@ -0,0 +1,131 @@ +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql; + +[DbContext(typeof(AppDbContext))] +[Migration("20260726022634_AttendanceCheckIn")] +public partial class AttendanceCheckIn : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CheckInMethod", + table: "AttendanceSheets", + type: "int", + nullable: false, + defaultValue: 1); + + migrationBuilder.AddColumn( + name: "CheckInToken", + table: "AttendanceSheets", + type: "varchar(64)", + maxLength: 64, + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "CheckInStartsAt", + table: "AttendanceSheets", + type: "datetime(6)", + nullable: true); + + migrationBuilder.AddColumn( + name: "CheckInEndsAt", + table: "AttendanceSheets", + type: "datetime(6)", + nullable: true); + + migrationBuilder.AddColumn( + name: "TargetLatitude", + table: "AttendanceSheets", + type: "decimal(10,7)", + precision: 10, + scale: 7, + nullable: true); + + migrationBuilder.AddColumn( + name: "TargetLongitude", + table: "AttendanceSheets", + type: "decimal(10,7)", + precision: 10, + scale: 7, + nullable: true); + + migrationBuilder.AddColumn( + name: "LocationRadiusMeters", + table: "AttendanceSheets", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "CheckInAt", + table: "AttendanceRecords", + type: "datetime(6)", + nullable: true); + + migrationBuilder.AddColumn( + name: "CheckedInMethod", + table: "AttendanceRecords", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "CheckInLatitude", + table: "AttendanceRecords", + type: "decimal(10,7)", + precision: 10, + scale: 7, + nullable: true); + + migrationBuilder.AddColumn( + name: "CheckInLongitude", + table: "AttendanceRecords", + type: "decimal(10,7)", + precision: 10, + scale: 7, + nullable: true); + + migrationBuilder.AddColumn( + name: "CheckInAccuracyMeters", + table: "AttendanceRecords", + type: "double", + nullable: true); + + migrationBuilder.AddColumn( + name: "CheckInDistanceMeters", + table: "AttendanceRecords", + type: "double", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_AttendanceSheets_CheckInToken", + table: "AttendanceSheets", + column: "CheckInToken", + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_AttendanceSheets_CheckInToken", + table: "AttendanceSheets"); + + migrationBuilder.DropColumn(name: "CheckInMethod", table: "AttendanceSheets"); + migrationBuilder.DropColumn(name: "CheckInToken", table: "AttendanceSheets"); + migrationBuilder.DropColumn(name: "CheckInStartsAt", table: "AttendanceSheets"); + migrationBuilder.DropColumn(name: "CheckInEndsAt", table: "AttendanceSheets"); + migrationBuilder.DropColumn(name: "TargetLatitude", table: "AttendanceSheets"); + migrationBuilder.DropColumn(name: "TargetLongitude", table: "AttendanceSheets"); + migrationBuilder.DropColumn(name: "LocationRadiusMeters", table: "AttendanceSheets"); + migrationBuilder.DropColumn(name: "CheckInAt", table: "AttendanceRecords"); + migrationBuilder.DropColumn(name: "CheckedInMethod", table: "AttendanceRecords"); + migrationBuilder.DropColumn(name: "CheckInLatitude", table: "AttendanceRecords"); + migrationBuilder.DropColumn(name: "CheckInLongitude", table: "AttendanceRecords"); + migrationBuilder.DropColumn(name: "CheckInAccuracyMeters", table: "AttendanceRecords"); + migrationBuilder.DropColumn(name: "CheckInDistanceMeters", table: "AttendanceRecords"); + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs index dd4b68f..9a30431 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs @@ -154,6 +154,26 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Property("AppealSubmittedAt") .HasColumnType("datetime(6)"); + b.Property("CheckInAccuracyMeters") + .HasColumnType("double"); + + b.Property("CheckInAt") + .HasColumnType("datetime(6)"); + + b.Property("CheckInDistanceMeters") + .HasColumnType("double"); + + b.Property("CheckInLatitude") + .HasPrecision(10, 7) + .HasColumnType("decimal(10,7)"); + + b.Property("CheckInLongitude") + .HasPrecision(10, 7) + .HasColumnType("decimal(10,7)"); + + b.Property("CheckedInMethod") + .HasColumnType("int"); + b.Property("Notes") .HasMaxLength(300) .HasColumnType("varchar(300)"); @@ -179,9 +199,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Property("AttendanceDate") .HasColumnType("datetime(6)"); + b.Property("CheckInEndsAt") + .HasColumnType("datetime(6)"); + + b.Property("CheckInMethod") + .HasColumnType("int"); + + b.Property("CheckInStartsAt") + .HasColumnType("datetime(6)"); + + b.Property("CheckInToken") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + b.Property("CreatedAt") .HasColumnType("datetime(6)"); + b.Property("LocationRadiusMeters") + .HasColumnType("int"); + b.Property("Name") .IsRequired() .HasMaxLength(120) @@ -197,6 +233,14 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Property("SubmittedAt") .HasColumnType("datetime(6)"); + b.Property("TargetLatitude") + .HasPrecision(10, 7) + .HasColumnType("decimal(10,7)"); + + b.Property("TargetLongitude") + .HasPrecision(10, 7) + .HasColumnType("decimal(10,7)"); + b.Property("TeachingTaskId") .HasColumnType("char(36)"); @@ -205,6 +249,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.HasKey("Id"); + b.HasIndex("CheckInToken") + .IsUnique(); + b.HasIndex("TeachingTaskId", "AttendanceDate"); b.ToTable("AttendanceSheets"); diff --git a/tests/Jiaowu.Api.Tests/AttendanceControllerTests.cs b/tests/Jiaowu.Api.Tests/AttendanceControllerTests.cs index 747e021..22af233 100644 --- a/tests/Jiaowu.Api.Tests/AttendanceControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/AttendanceControllerTests.cs @@ -38,10 +38,19 @@ public sealed class AttendanceControllerTests MajorId = major.Id, Grade = 2026 }; + var studentUserId = Guid.NewGuid(); + var studentUser = new ApplicationUser + { + Id = studentUserId, + UserName = "202601001", + NormalizedUserName = "202601001", + DisplayName = "周同学" + }; var firstStudent = new Student { StudentNumber = "202601001", Name = "周同学", + UserId = studentUserId, AdministrativeClassId = administrativeClass.Id, EnrollmentYear = 2026, EnrollmentDate = new DateOnly(2026, 9, 1) @@ -85,6 +94,7 @@ public sealed class AttendanceControllerTests Status = TeachingTaskStatus.Published }; db.AddRange( + studentUser, college, major, administrativeClass, @@ -181,6 +191,111 @@ public sealed class AttendanceControllerTests Assert.Equal( 3, workbook.Worksheet("历次点名趋势").LastRowUsed()!.RowNumber()); + + var now = DateTime.UtcNow; + var qrRecord = new AttendanceRecord + { + StudentId = firstStudent.Id, + Status = AttendanceStatus.Absent + }; + var qrSheet = new AttendanceSheet + { + TeachingTaskId = task.Id, + Name = "课堂扫码签到", + AttendanceDate = now, + Status = AttendanceSheetStatus.Draft, + CheckInMethod = AttendanceCheckInMethod.QrCode, + CheckInToken = "TEST-QR-TOKEN", + CheckInStartsAt = now.AddMinutes(-1), + CheckInEndsAt = now.AddMinutes(10), + Records = [qrRecord] + }; + db.AttendanceSheets.Add(qrSheet); + await db.SaveChangesAsync(); + + var studentController = new AttendanceController( + db, + new StudentDataScope(studentUserId)); + var myRecordsResult = await studentController.GetMyRecords( + null, + CancellationToken.None); + var myRecordsOk = Assert.IsType(myRecordsResult); + var myRecords = Assert + .IsAssignableFrom(myRecordsOk.Value) + .Cast() + .ToList(); + Assert.Equal(2, myRecords.Count); + Assert.All(myRecords, item => + Assert.Equal( + task.Id, + item.GetType().GetProperty("TeachingTaskId")!.GetValue(item))); + + var infoResult = await studentController.GetCheckInInfo( + qrSheet.CheckInToken, + CancellationToken.None); + Assert.IsType(infoResult); + + var qrCheckInResult = await studentController.CheckIn( + new AttendanceCheckInRequest( + null, + qrSheet.CheckInToken, + null, + null, + null), + CancellationToken.None); + Assert.IsType(qrCheckInResult); + Assert.Equal(AttendanceStatus.Present, qrRecord.Status); + Assert.Equal(AttendanceCheckInMethod.QrCode, qrRecord.CheckedInMethod); + Assert.NotNull(qrRecord.CheckInAt); + Assert.Null(qrRecord.CheckInLatitude); + + var locationRecord = new AttendanceRecord + { + StudentId = firstStudent.Id, + Status = AttendanceStatus.Absent + }; + var locationSheet = new AttendanceSheet + { + TeachingTaskId = task.Id, + Name = "课堂定位签到", + AttendanceDate = now, + Status = AttendanceSheetStatus.Draft, + CheckInMethod = AttendanceCheckInMethod.Location, + CheckInStartsAt = now.AddMinutes(-1), + CheckInEndsAt = now.AddMinutes(10), + TargetLatitude = 39.9m, + TargetLongitude = 116.4m, + LocationRadiusMeters = 100, + Records = [locationRecord] + }; + db.AttendanceSheets.Add(locationSheet); + await db.SaveChangesAsync(); + + var outsideResult = await studentController.CheckIn( + new AttendanceCheckInRequest( + locationSheet.Id, + null, + 39.91m, + 116.4m, + 8), + CancellationToken.None); + Assert.IsType(outsideResult); + Assert.Null(locationRecord.CheckInAt); + + var nearbyResult = await studentController.CheckIn( + new AttendanceCheckInRequest( + locationSheet.Id, + null, + 39.9001m, + 116.4m, + 8), + CancellationToken.None); + Assert.IsType(nearbyResult); + Assert.Equal(AttendanceStatus.Present, locationRecord.Status); + Assert.Equal( + AttendanceCheckInMethod.Location, + locationRecord.CheckedInMethod); + Assert.InRange(locationRecord.CheckInDistanceMeters!.Value, 1, 100); } private sealed class AllDataScope : ICurrentUserDataScope @@ -192,4 +307,14 @@ public sealed class AttendanceControllerTests DataScope.All, new HashSet([SystemRoles.SuperAdmin])); } + + private sealed class StudentDataScope(Guid userId) : ICurrentUserDataScope + { + public CurrentUserScope Current { get; } = new( + userId, + "测试学生", + null, + DataScope.Self, + new HashSet([SystemRoles.Student])); + } } diff --git a/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs b/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs index 98d9a1a..168578f 100644 --- a/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs +++ b/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs @@ -8,7 +8,7 @@ namespace Jiaowu.Api.Tests; public sealed class MySqlMigrationTests { private const string LatestMigration = - "20260725120917_ProductionSchemaCompletion"; + "20260726022634_AttendanceCheckIn"; [Fact] public void Production_migration_is_discoverable_and_generates_mysql_sql() @@ -25,6 +25,8 @@ public sealed class MySqlMigrationTests Assert.Contains("CREATE TABLE `GradeItems`", script); Assert.Contains("CREATE TABLE `EvaluationSetups`", script); Assert.Contains("CREATE TABLE `WarningRules`", script); + Assert.Contains("ADD `CheckInMethod` int NOT NULL DEFAULT 1", script); + Assert.Contains("CREATE UNIQUE INDEX `IX_AttendanceSheets_CheckInToken`", script); Assert.Contains("SET `ExamDate` = DATE(`StartsAt`)", script); Assert.Contains("DEFAULT 1", script); Assert.DoesNotContain("0001-01-01", script); diff --git a/web/package-lock.json b/web/package-lock.json index d860fdb..7da62ad 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,11 +15,13 @@ "html2canvas": "^1.4.1", "jspdf": "^4.2.1", "pinia": "^4.0.2", + "qrcode": "1.5.4", "vue": "^3.5.39", "vue-router": "^4.6.4" }, "devDependencies": { "@types/node": "^24.13.2", + "@types/qrcode": "1.5.6", "@vitejs/plugin-vue": "^6.0.7", "@vue/tsconfig": "^0.9.1", "typescript": "~6.0.2", @@ -581,6 +583,16 @@ "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/raf": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", @@ -880,6 +892,30 @@ "dev": true, "license": "MIT" }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/async-validator": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", @@ -936,6 +972,15 @@ "node": ">= 0.4" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/canvg": { "version": "3.0.11", "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", @@ -972,6 +1017,35 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1041,6 +1115,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1060,6 +1143,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.4.12", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", @@ -1126,6 +1215,12 @@ "vue": "^3.3.7" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -1244,6 +1339,19 @@ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "license": "MIT" }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -1304,6 +1412,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1431,6 +1548,15 @@ "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", "license": "MIT" }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -1746,6 +1872,18 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -1903,6 +2041,42 @@ "node": ">=12.20.0" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pako": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", @@ -1926,6 +2100,15 @@ "dev": true, "license": "MIT" }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2003,6 +2186,15 @@ "pathe": "^2.0.3" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.22", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", @@ -2040,6 +2232,23 @@ "node": ">=10" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -2088,6 +2297,21 @@ "license": "MIT", "optional": true }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/rgbcolor": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", @@ -2139,6 +2363,12 @@ "dev": true, "license": "MIT" }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2158,6 +2388,32 @@ "node": ">=0.1.14" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -2599,6 +2855,67 @@ "dev": true, "license": "MIT" }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/zrender": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", diff --git a/web/package.json b/web/package.json index 7d6d2bf..07d2c68 100644 --- a/web/package.json +++ b/web/package.json @@ -16,11 +16,13 @@ "html2canvas": "^1.4.1", "jspdf": "^4.2.1", "pinia": "^4.0.2", + "qrcode": "1.5.4", "vue": "^3.5.39", "vue-router": "^4.6.4" }, "devDependencies": { "@types/node": "^24.13.2", + "@types/qrcode": "1.5.6", "@vitejs/plugin-vue": "^6.0.7", "@vue/tsconfig": "^0.9.1", "typescript": "~6.0.2", diff --git a/web/src/views/StudentAttendanceView.vue b/web/src/views/StudentAttendanceView.vue index 6c68459..c4221e9 100644 --- a/web/src/views/StudentAttendanceView.vue +++ b/web/src/views/StudentAttendanceView.vue @@ -1,10 +1,18 @@ diff --git a/web/src/views/TeacherAttendanceView.vue b/web/src/views/TeacherAttendanceView.vue index 2abce76..3a766c3 100644 --- a/web/src/views/TeacherAttendanceView.vue +++ b/web/src/views/TeacherAttendanceView.vue @@ -1,6 +1,18 @@ @@ -482,10 +667,20 @@ onUnmounted(() => { @click="selectSheet(sheet)" >
- {{ sheet.name }} + + {{ sheet.name }} + {{ methodLabel(sheet.checkInMethod) }} + {{ new Date(sheet.attendanceDate).toLocaleDateString('zh-CN') }}
+ 已签到 {{ sheet.checkedInCount }} 出勤 {{ sheet.presentCount }} 缺勤 {{ sheet.absentCount }} 迟到 {{ sheet.lateCount }} @@ -510,9 +705,26 @@ onUnmounted(() => {
{{ sheetDetail.sheet.name }} {{ sheetDetail.sheet.courseCode }} · {{ sheetDetail.sheet.taskNumber }} - {{ new Date(sheetDetail.sheet.attendanceDate).toLocaleDateString('zh-CN') }} · {{ isSubmitted(sheetDetail.sheet.status) ? '已提交' : '草稿' }} + + {{ new Date(sheetDetail.sheet.attendanceDate).toLocaleDateString('zh-CN') }} + · {{ methodLabel(sheetDetail.sheet.checkInMethod) }} + · {{ isSubmitted(sheetDetail.sheet.status) ? '已提交' : '草稿' }} +
+ 显示签到码 + 提前结束 { >提交
+
+
+ + {{ isCheckInOpen(sheetDetail.sheet) ? '签到进行中' : '签到已结束' }} +
+ + {{ sheetDetail.sheet.checkedInCount }} + / {{ sheetDetail.sheet.records.length }} 人已自主签到 + +

+ + {{ remainingLabel(sheetDetail.sheet) }} + +

+
批量设置: 全部出勤 @@ -573,6 +806,22 @@ onUnmounted(() => { {{ statusLabel(row.status) }} + + +