已完成,学生端考勤记录现在按课程展示。
每门课程会独立显示: 课程名称、代码、教学班和任课教师。 课程总体出勤率。 出勤、迟到、缺勤、请假、免修次数。 按日期排列的历次点名记录。 缺勤或迟到记录仍可单独申诉。
This commit is contained in:
@@ -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(
|
||||
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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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,
|
||||
@@ -660,6 +970,51 @@ public sealed class AttendanceController(
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Guid?> 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<AttendanceCourseStatistics?> 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<AttendanceRecordRequest> 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,
|
||||
|
||||
@@ -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<AttendanceRecord> 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,
|
||||
|
||||
@@ -561,8 +561,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
builder.Entity<AttendanceSheet>(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<AppDbContext> 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)
|
||||
|
||||
@@ -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<int>(
|
||||
"""
|
||||
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<int>("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);""",
|
||||
|
||||
+131
@@ -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<int>(
|
||||
name: "CheckInMethod",
|
||||
table: "AttendanceSheets",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CheckInToken",
|
||||
table: "AttendanceSheets",
|
||||
type: "varchar(64)",
|
||||
maxLength: 64,
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CheckInStartsAt",
|
||||
table: "AttendanceSheets",
|
||||
type: "datetime(6)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CheckInEndsAt",
|
||||
table: "AttendanceSheets",
|
||||
type: "datetime(6)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "TargetLatitude",
|
||||
table: "AttendanceSheets",
|
||||
type: "decimal(10,7)",
|
||||
precision: 10,
|
||||
scale: 7,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "TargetLongitude",
|
||||
table: "AttendanceSheets",
|
||||
type: "decimal(10,7)",
|
||||
precision: 10,
|
||||
scale: 7,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "LocationRadiusMeters",
|
||||
table: "AttendanceSheets",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CheckInAt",
|
||||
table: "AttendanceRecords",
|
||||
type: "datetime(6)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "CheckedInMethod",
|
||||
table: "AttendanceRecords",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "CheckInLatitude",
|
||||
table: "AttendanceRecords",
|
||||
type: "decimal(10,7)",
|
||||
precision: 10,
|
||||
scale: 7,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "CheckInLongitude",
|
||||
table: "AttendanceRecords",
|
||||
type: "decimal(10,7)",
|
||||
precision: 10,
|
||||
scale: 7,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "CheckInAccuracyMeters",
|
||||
table: "AttendanceRecords",
|
||||
type: "double",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<double>(
|
||||
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");
|
||||
}
|
||||
}
|
||||
+47
@@ -154,6 +154,26 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<DateTime?>("AppealSubmittedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<double?>("CheckInAccuracyMeters")
|
||||
.HasColumnType("double");
|
||||
|
||||
b.Property<DateTime?>("CheckInAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<double?>("CheckInDistanceMeters")
|
||||
.HasColumnType("double");
|
||||
|
||||
b.Property<decimal?>("CheckInLatitude")
|
||||
.HasPrecision(10, 7)
|
||||
.HasColumnType("decimal(10,7)");
|
||||
|
||||
b.Property<decimal?>("CheckInLongitude")
|
||||
.HasPrecision(10, 7)
|
||||
.HasColumnType("decimal(10,7)");
|
||||
|
||||
b.Property<int?>("CheckedInMethod")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("varchar(300)");
|
||||
@@ -179,9 +199,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<DateTime>("AttendanceDate")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateTime?>("CheckInEndsAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("CheckInMethod")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("CheckInStartsAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("CheckInToken")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int?>("LocationRadiusMeters")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
@@ -197,6 +233,14 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<DateTime?>("SubmittedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<decimal?>("TargetLatitude")
|
||||
.HasPrecision(10, 7)
|
||||
.HasColumnType("decimal(10,7)");
|
||||
|
||||
b.Property<decimal?>("TargetLongitude")
|
||||
.HasPrecision(10, 7)
|
||||
.HasColumnType("decimal(10,7)");
|
||||
|
||||
b.Property<Guid>("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");
|
||||
|
||||
@@ -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<OkObjectResult>(myRecordsResult);
|
||||
var myRecords = Assert
|
||||
.IsAssignableFrom<System.Collections.IEnumerable>(myRecordsOk.Value)
|
||||
.Cast<object>()
|
||||
.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<OkObjectResult>(infoResult);
|
||||
|
||||
var qrCheckInResult = await studentController.CheckIn(
|
||||
new AttendanceCheckInRequest(
|
||||
null,
|
||||
qrSheet.CheckInToken,
|
||||
null,
|
||||
null,
|
||||
null),
|
||||
CancellationToken.None);
|
||||
Assert.IsType<OkObjectResult>(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<ConflictObjectResult>(outsideResult);
|
||||
Assert.Null(locationRecord.CheckInAt);
|
||||
|
||||
var nearbyResult = await studentController.CheckIn(
|
||||
new AttendanceCheckInRequest(
|
||||
locationSheet.Id,
|
||||
null,
|
||||
39.9001m,
|
||||
116.4m,
|
||||
8),
|
||||
CancellationToken.None);
|
||||
Assert.IsType<OkObjectResult>(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<string>([SystemRoles.SuperAdmin]));
|
||||
}
|
||||
|
||||
private sealed class StudentDataScope(Guid userId) : ICurrentUserDataScope
|
||||
{
|
||||
public CurrentUserScope Current { get; } = new(
|
||||
userId,
|
||||
"测试学生",
|
||||
null,
|
||||
DataScope.Self,
|
||||
new HashSet<string>([SystemRoles.Student]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Generated
+317
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Refresh, Warning } from '@element-plus/icons-vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Check, Clock, Location, Refresh, Warning } from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const records = ref<any[]>([])
|
||||
const openActivities = ref<any[]>([])
|
||||
const scannedActivity = ref<any>(null)
|
||||
const loading = ref(false)
|
||||
const scanLoading = ref(false)
|
||||
const checkInTargetId = ref('')
|
||||
const now = ref(Date.now())
|
||||
const appealDialog = ref(false)
|
||||
const appealTarget = ref<any>(null)
|
||||
const appealReason = ref('')
|
||||
@@ -18,14 +26,165 @@ const statusColors: Record<string, 'success' | 'danger' | 'warning' | 'info' | u
|
||||
const appealStatusLabels: Record<string, string> = {
|
||||
None: '', Pending: '申诉中', Approved: '已通过', Rejected: '已驳回',
|
||||
}
|
||||
const courseGroups = computed(() => {
|
||||
const groups = new Map<string, any>()
|
||||
records.value.forEach((record: any) => {
|
||||
const key = record.teachingTaskId ?? `${record.courseCode}-${record.taskNumber}`
|
||||
let group = groups.get(key)
|
||||
if (!group) {
|
||||
group = {
|
||||
key,
|
||||
courseCode: record.courseCode,
|
||||
courseName: record.courseName,
|
||||
taskNumber: record.taskNumber,
|
||||
teacherNames: [...record.teacherNames],
|
||||
records: [],
|
||||
presentCount: 0,
|
||||
absentCount: 0,
|
||||
lateCount: 0,
|
||||
leaveCount: 0,
|
||||
excusedCount: 0,
|
||||
requiredCount: 0,
|
||||
attendedCount: 0,
|
||||
attendanceRate: null,
|
||||
latestAt: 0,
|
||||
}
|
||||
groups.set(key, group)
|
||||
}
|
||||
group.records.push(record)
|
||||
group.latestAt = Math.max(group.latestAt, new Date(record.attendanceDate).getTime())
|
||||
const countKey = `${record.status.charAt(0).toLowerCase()}${record.status.slice(1)}Count`
|
||||
if (countKey in group) group[countKey] += 1
|
||||
if (record.status !== 'Excused') group.requiredCount += 1
|
||||
if (record.status === 'Present' || record.status === 'Late') group.attendedCount += 1
|
||||
})
|
||||
return [...groups.values()]
|
||||
.map(group => ({
|
||||
...group,
|
||||
attendanceRate: group.requiredCount > 0
|
||||
? Math.round(group.attendedCount * 1000 / group.requiredCount) / 10
|
||||
: null,
|
||||
}))
|
||||
.sort((a, b) => b.latestAt - a.latestAt)
|
||||
})
|
||||
let clockTimer: number | undefined
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try { records.value = (await http.get('/attendance/my-records')).data }
|
||||
try {
|
||||
const [recordResponse, activityResponse] = await Promise.all([
|
||||
http.get('/attendance/my-records'),
|
||||
http.get('/attendance/open-check-ins'),
|
||||
])
|
||||
records.value = recordResponse.data
|
||||
openActivities.value = activityResponse.data
|
||||
const token = typeof route.query.token === 'string' ? route.query.token : ''
|
||||
if (token) await loadScannedActivity(token)
|
||||
else scannedActivity.value = null
|
||||
}
|
||||
catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function loadScannedActivity(token: string) {
|
||||
scanLoading.value = true
|
||||
try {
|
||||
scannedActivity.value = (
|
||||
await http.get('/attendance/check-in-info', { params: { token } })
|
||||
).data
|
||||
} catch (error: any) {
|
||||
scannedActivity.value = null
|
||||
if (error?.response?.status === 404) {
|
||||
ElMessage.error('签到码无效,或你不在本次课程名单中。')
|
||||
} else {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
} finally {
|
||||
scanLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function serverUtcTime(value: string | null | undefined) {
|
||||
if (!value) return Number.NaN
|
||||
const normalized = /(?:Z|[+-]\d{2}:\d{2})$/i.test(value) ? value : `${value}Z`
|
||||
return new Date(normalized).getTime()
|
||||
}
|
||||
|
||||
function formatServerTime(value: string) {
|
||||
return new Date(serverUtcTime(value)).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function remainingLabel(activity: any) {
|
||||
if (!activity?.checkInEndsAt) return ''
|
||||
const remaining = serverUtcTime(activity.checkInEndsAt) - now.value
|
||||
if (remaining <= 0) return '签到已结束'
|
||||
const minutes = Math.floor(remaining / 60000)
|
||||
const seconds = Math.floor((remaining % 60000) / 1000)
|
||||
return `${minutes}分${String(seconds).padStart(2, '0')}秒后结束`
|
||||
}
|
||||
|
||||
async function confirmQrCheckIn() {
|
||||
const token = typeof route.query.token === 'string' ? route.query.token : ''
|
||||
if (!token || !scannedActivity.value) return
|
||||
checkInTargetId.value = scannedActivity.value.sheetId
|
||||
try {
|
||||
const { data } = await http.post('/attendance/check-in', { token })
|
||||
ElMessage.success(data.alreadyCheckedIn ? '你已完成本次签到' : '签到成功')
|
||||
await router.replace({ path: route.path, query: {} })
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
checkInTargetId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function captureLocation() {
|
||||
if (!navigator.geolocation) throw new Error('unsupported')
|
||||
return await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 12000,
|
||||
maximumAge: 0,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function locationCheckIn(activity: any) {
|
||||
checkInTargetId.value = activity.sheetId
|
||||
try {
|
||||
const position = await captureLocation()
|
||||
const { data } = await http.post('/attendance/check-in', {
|
||||
attendanceSheetId: activity.sheetId,
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
accuracyMeters: position.coords.accuracy,
|
||||
})
|
||||
ElMessage.success(data.alreadyCheckedIn
|
||||
? '你已完成本次签到'
|
||||
: `签到成功${data.checkInDistanceMeters === null
|
||||
? ''
|
||||
: `,距签到点约 ${Math.round(data.checkInDistanceMeters)} 米`}`)
|
||||
await load()
|
||||
} catch (error: any) {
|
||||
if (!error?.response) {
|
||||
const message = error?.code === 1
|
||||
? '定位权限被拒绝,请在浏览器中允许本网站获取位置。'
|
||||
: error?.code === 3
|
||||
? '获取位置超时,请移到信号较好的位置后重试。'
|
||||
: '无法获取当前位置,请检查手机定位服务后重试。'
|
||||
ElMessage.error(message)
|
||||
} else {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
} finally {
|
||||
checkInTargetId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function openAppeal(record: any) {
|
||||
appealTarget.value = record
|
||||
appealReason.value = ''
|
||||
@@ -49,7 +208,32 @@ function canAppeal(record: any) {
|
||||
return record.appealStatus === 'None' || record.appealStatus === 'Rejected'
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
function formatRate(value: number | null) {
|
||||
return value === null ? '暂无' : `${value.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function rateTone(value: number | null) {
|
||||
if (value === null) return 'neutral'
|
||||
if (value < 80) return 'danger'
|
||||
if (value < 90) return 'warning'
|
||||
return 'good'
|
||||
}
|
||||
|
||||
function recordDate(value: string) {
|
||||
const date = new Date(value)
|
||||
return {
|
||||
month: `${date.getMonth() + 1}月`,
|
||||
day: String(date.getDate()).padStart(2, '0'),
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
clockTimer = window.setInterval(() => { now.value = Date.now() }, 1000)
|
||||
load()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (clockTimer) window.clearInterval(clockTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -58,39 +242,149 @@ onMounted(load)
|
||||
<div>
|
||||
<span class="section-kicker">ATTENDANCE RECORD</span>
|
||||
<h2>我的考勤</h2>
|
||||
<p>查看所有已提交的考勤记录。对记录有异议可以提交申诉,辅导员将进行审核。</p>
|
||||
<p>完成课堂扫码或定位签到,并查看已提交的考勤记录。</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||
</section>
|
||||
|
||||
<section v-loading="loading" class="att-list">
|
||||
<article v-for="r in records" :key="`${r.attendanceSheetId}`" class="att-card">
|
||||
<div class="att-info">
|
||||
<span>{{ r.courseCode }} · {{ r.taskNumber }}</span>
|
||||
<h3>{{ r.courseName }}</h3>
|
||||
<p>{{ r.sheetName }} · {{ new Date(r.attendanceDate).toLocaleDateString('zh-CN') }} · {{ r.teacherNames.join('、') }}</p>
|
||||
<section
|
||||
v-if="route.query.token || openActivities.length"
|
||||
v-loading="loading || scanLoading"
|
||||
class="check-in-board"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span>LIVE CHECK-IN</span>
|
||||
<strong>待签到</strong>
|
||||
</div>
|
||||
<div class="att-status">
|
||||
<el-tag :type="statusColors[r.status]" size="small">{{ statusLabels[r.status] }}</el-tag>
|
||||
<span v-if="r.notes" class="att-note">{{ r.notes }}</span>
|
||||
<small>签到由服务器校验课程名单、有效时间和位置范围</small>
|
||||
</header>
|
||||
|
||||
<article v-if="scannedActivity" class="scan-ticket">
|
||||
<div class="ticket-mark"><el-icon><Check /></el-icon></div>
|
||||
<div class="ticket-course">
|
||||
<span>{{ scannedActivity.courseCode }} · {{ scannedActivity.taskNumber }}</span>
|
||||
<strong>{{ scannedActivity.courseName }}</strong>
|
||||
<p>{{ scannedActivity.sheetName }} · 扫码签到</p>
|
||||
</div>
|
||||
<div class="att-appeal">
|
||||
<div class="ticket-time">
|
||||
<Clock />
|
||||
<b>{{ remainingLabel(scannedActivity) }}</b>
|
||||
<small v-if="scannedActivity.checkInAt">
|
||||
已于 {{ formatServerTime(scannedActivity.checkInAt) }} 签到
|
||||
</small>
|
||||
<small v-else>请确认课程信息后完成签到</small>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:disabled="!scannedActivity.isOpen || Boolean(scannedActivity.checkInAt)"
|
||||
:loading="checkInTargetId === scannedActivity.sheetId"
|
||||
@click="confirmQrCheckIn"
|
||||
>{{ scannedActivity.checkInAt ? '已签到' : scannedActivity.isOpen ? '确认签到' : '签到已结束' }}</el-button>
|
||||
</article>
|
||||
|
||||
<div v-if="openActivities.length" class="location-list">
|
||||
<article v-for="activity in openActivities" :key="activity.sheetId">
|
||||
<div class="location-pin"><Location /></div>
|
||||
<div>
|
||||
<span>{{ activity.courseCode }} · {{ activity.taskNumber }}</span>
|
||||
<strong>{{ activity.courseName }}</strong>
|
||||
<p>{{ activity.sheetName }} · {{ activity.locationRadiusMeters }} 米范围内</p>
|
||||
</div>
|
||||
<div class="location-clock">
|
||||
<b>{{ remainingLabel(activity) }}</b>
|
||||
<small v-if="activity.checkInAt">
|
||||
已于 {{ formatServerTime(activity.checkInAt) }} 签到
|
||||
</small>
|
||||
<small v-else>将获取一次当前位置用于本次签到</small>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="Boolean(activity.checkInAt)"
|
||||
:loading="checkInTargetId === activity.sheetId"
|
||||
@click="locationCheckIn(activity)"
|
||||
>{{ activity.checkInAt ? '已签到' : '定位并签到' }}</el-button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="record-head">
|
||||
<div>
|
||||
<strong>课程考勤档案</strong>
|
||||
<span>按课程汇总出勤率,仅统计教师已经提交的考勤结果</span>
|
||||
</div>
|
||||
<span>{{ courseGroups.length }} 门课程 · {{ records.length }} 次点名</span>
|
||||
</section>
|
||||
|
||||
<section v-loading="loading" class="course-archive">
|
||||
<article v-for="course in courseGroups" :key="course.key" class="course-record">
|
||||
<header>
|
||||
<div class="course-identity">
|
||||
<span>{{ course.courseCode }} · {{ course.taskNumber }}</span>
|
||||
<h3>{{ course.courseName }}</h3>
|
||||
<small>{{ course.teacherNames.join('、') || '任课教师未登记' }}</small>
|
||||
</div>
|
||||
<div class="course-rate" :class="rateTone(course.attendanceRate)">
|
||||
<span>课程出勤率</span>
|
||||
<strong>{{ formatRate(course.attendanceRate) }}</strong>
|
||||
<small>{{ course.attendedCount }} / {{ course.requiredCount }} 次到课</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="course-summary">
|
||||
<div><b>{{ course.records.length }}</b><span>点名次数</span></div>
|
||||
<div class="present"><b>{{ course.presentCount }}</b><span>出勤</span></div>
|
||||
<div class="late"><b>{{ course.lateCount }}</b><span>迟到</span></div>
|
||||
<div class="absent"><b>{{ course.absentCount }}</b><span>缺勤</span></div>
|
||||
<div><b>{{ course.leaveCount }}</b><span>请假</span></div>
|
||||
<div><b>{{ course.excusedCount }}</b><span>免修</span></div>
|
||||
</div>
|
||||
|
||||
<div class="session-list">
|
||||
<div
|
||||
v-for="r in course.records"
|
||||
:key="r.attendanceSheetId"
|
||||
class="session-row"
|
||||
>
|
||||
<div class="session-date" aria-hidden="true">
|
||||
<span>{{ recordDate(r.attendanceDate).month }}</span>
|
||||
<strong>{{ recordDate(r.attendanceDate).day }}</strong>
|
||||
</div>
|
||||
<div class="session-info">
|
||||
<strong>{{ r.sheetName }}</strong>
|
||||
<span v-if="r.notes">{{ r.notes }}</span>
|
||||
<span v-else>本次点名没有备注</span>
|
||||
</div>
|
||||
<div class="session-result">
|
||||
<el-tag :type="statusColors[r.status]" size="small">
|
||||
{{ statusLabels[r.status] }}
|
||||
</el-tag>
|
||||
<template v-if="r.appealStatus !== 'None'">
|
||||
<el-tag size="small" :type="r.appealStatus === 'Pending' ? 'warning' : r.appealStatus === 'Approved' ? 'success' : 'danger'">
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="r.appealStatus === 'Pending' ? 'warning' : r.appealStatus === 'Approved' ? 'success' : 'danger'"
|
||||
>
|
||||
{{ appealStatusLabels[r.appealStatus] }}
|
||||
</el-tag>
|
||||
<span v-if="r.appealReviewComment" class="att-note">{{ r.appealReviewComment }}</span>
|
||||
<span v-if="r.appealReviewComment" class="review-comment">
|
||||
{{ r.appealReviewComment }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="canAppeal(r) && (r.status === 'Absent' || r.status === 'Late')"
|
||||
size="small"
|
||||
type="warning"
|
||||
plain
|
||||
:icon="Warning"
|
||||
@click="openAppeal(r)"
|
||||
>申诉</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<el-empty v-if="!records.length" description="暂无考勤记录" />
|
||||
<el-empty v-if="!courseGroups.length" description="还没有已提交的课程考勤记录" />
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="appealDialog" title="考勤申诉" width="500px">
|
||||
@@ -108,12 +402,261 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.att-list { display: grid; gap: 10px; }
|
||||
.att-card { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 18px; background: #fff; border: 1px solid #e4e7ed; border-radius: 8px; flex-wrap: wrap; }
|
||||
.att-info span { font-size: 11px; color: var(--muted); }
|
||||
.att-info h3 { font-size: 14px; margin: 2px 0; }
|
||||
.att-info p { font-size: 12px; color: var(--muted); margin: 0; }
|
||||
.att-status { display: flex; align-items: center; gap: 8px; }
|
||||
.att-appeal { display: flex; align-items: center; gap: 8px; }
|
||||
.att-note { font-size: 11px; color: var(--muted); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.check-in-board {
|
||||
border: 1px solid #cdd9e5;
|
||||
background: #f6f9fc;
|
||||
}
|
||||
.check-in-board > header {
|
||||
min-height: 58px;
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
color: white;
|
||||
background: #17395e;
|
||||
}
|
||||
.check-in-board > header > div { display: grid; }
|
||||
.check-in-board > header span {
|
||||
color: #6de0c0;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .14em;
|
||||
}
|
||||
.check-in-board > header strong { font-size: 17px; }
|
||||
.check-in-board > header small { font-size: 10px; opacity: .72; }
|
||||
.scan-ticket {
|
||||
margin: 14px;
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(180px, 1fr) minmax(150px, .7fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
border-left: 4px solid #2d8975;
|
||||
background: white;
|
||||
box-shadow: 0 4px 14px rgba(23, 43, 77, .07);
|
||||
}
|
||||
.ticket-mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #247261;
|
||||
font-size: 23px;
|
||||
background: #e8f5ef;
|
||||
}
|
||||
.ticket-course,
|
||||
.ticket-time { display: grid; gap: 2px; }
|
||||
.ticket-course span,
|
||||
.location-list article > div:nth-child(2) > span {
|
||||
color: #176b87;
|
||||
font: 700 10px/1.2 Consolas, monospace;
|
||||
}
|
||||
.ticket-course strong,
|
||||
.location-list article strong { color: #172b4d; font-size: 15px; }
|
||||
.ticket-course p,
|
||||
.location-list article p { margin: 0; color: var(--muted); font-size: 10px; }
|
||||
.ticket-time { grid-template-columns: 16px minmax(0, 1fr); }
|
||||
.ticket-time svg { width: 14px; color: #176b87; }
|
||||
.ticket-time b { color: #344054; font-size: 12px; }
|
||||
.ticket-time small { grid-column: 2; color: var(--muted); font-size: 9px; }
|
||||
.location-list { border-top: 1px solid #dce4ed; }
|
||||
.location-list article {
|
||||
padding: 13px 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(180px, 1fr) minmax(160px, .7fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid #e3e9ef;
|
||||
background: white;
|
||||
}
|
||||
.location-list article:last-child { border-bottom: none; }
|
||||
.location-pin {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #176b87;
|
||||
background: #e9f3f5;
|
||||
}
|
||||
.location-pin svg { width: 19px; }
|
||||
.location-list article > div:nth-child(2) { display: grid; gap: 2px; }
|
||||
.location-clock { display: grid; gap: 2px; }
|
||||
.location-clock b { color: #247261; font-size: 11px; }
|
||||
.location-clock small { color: var(--muted); font-size: 9px; }
|
||||
.record-head {
|
||||
padding: 11px 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 1px solid var(--line);
|
||||
background: #fbfcfd;
|
||||
}
|
||||
.record-head > div { display: grid; gap: 1px; }
|
||||
.record-head strong { color: #172b4d; font-size: 14px; }
|
||||
.record-head span { color: var(--muted); font-size: 10px; }
|
||||
.course-archive { display: grid; gap: 14px; }
|
||||
.course-record {
|
||||
overflow: hidden;
|
||||
border: 1px solid #d8e0e9;
|
||||
background: white;
|
||||
}
|
||||
.course-record > header {
|
||||
min-height: 104px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 176px;
|
||||
border-bottom: 1px solid #dce4ed;
|
||||
}
|
||||
.course-identity {
|
||||
padding: 18px 20px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.course-identity > span {
|
||||
color: #176b87;
|
||||
font: 700 10px/1.2 Consolas, monospace;
|
||||
letter-spacing: .03em;
|
||||
}
|
||||
.course-identity h3 {
|
||||
margin: 2px 0;
|
||||
color: #172b4d;
|
||||
font-size: 20px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.course-identity small { color: var(--muted); font-size: 10px; }
|
||||
.course-rate {
|
||||
padding: 14px 18px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
color: white;
|
||||
background: #17395e;
|
||||
}
|
||||
.course-rate > span {
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .08em;
|
||||
opacity: .75;
|
||||
}
|
||||
.course-rate strong {
|
||||
margin: 3px 0;
|
||||
font: 700 30px/1 "Arial Narrow", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
.course-rate small { font-size: 9px; opacity: .72; }
|
||||
.course-rate.good { background: #245f57; }
|
||||
.course-rate.warning { background: #8b5a18; }
|
||||
.course-rate.danger { background: #8d403d; }
|
||||
.course-rate.neutral { background: #526276; }
|
||||
.course-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
border-bottom: 1px solid #e3e9ef;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.course-summary > div {
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
border-right: 1px solid #e3e9ef;
|
||||
}
|
||||
.course-summary > div:last-child { border-right: none; }
|
||||
.course-summary b {
|
||||
color: #344054;
|
||||
font: 700 18px/1.1 "Arial Narrow", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
.course-summary span { color: var(--muted); font-size: 9px; }
|
||||
.course-summary .present b { color: #247261; }
|
||||
.course-summary .late b { color: #a66716; }
|
||||
.course-summary .absent b { color: #a9433e; }
|
||||
.session-list { display: grid; }
|
||||
.session-row {
|
||||
min-height: 68px;
|
||||
padding: 9px 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 46px minmax(160px, 1fr) minmax(120px, auto) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid #edf0f4;
|
||||
}
|
||||
.session-row:last-child { border-bottom: none; }
|
||||
.session-date {
|
||||
width: 42px;
|
||||
height: 46px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
color: #17395e;
|
||||
border: 1px solid #cbd7e3;
|
||||
background: #f5f8fb;
|
||||
}
|
||||
.session-date span { font-size: 8px; font-weight: 700; }
|
||||
.session-date strong {
|
||||
font: 700 19px/1 "Arial Narrow", sans-serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.session-info { min-width: 0; display: grid; gap: 3px; }
|
||||
.session-info strong {
|
||||
overflow: hidden;
|
||||
color: #344054;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.session-info span,
|
||||
.review-comment {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 9px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.session-result {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.review-comment { max-width: 150px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.check-in-board > header { align-items: flex-start; flex-direction: column; }
|
||||
.scan-ticket,
|
||||
.location-list article {
|
||||
grid-template-columns: 38px minmax(0, 1fr);
|
||||
}
|
||||
.ticket-time,
|
||||
.location-clock,
|
||||
.scan-ticket > .el-button,
|
||||
.location-list article > .el-button {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.scan-ticket > .el-button,
|
||||
.location-list article > .el-button { width: 100%; }
|
||||
.ticket-time { grid-template-columns: 16px minmax(0, 1fr); }
|
||||
.record-head { align-items: flex-start; flex-direction: column; gap: 5px; }
|
||||
.record-head > span { text-align: left; }
|
||||
.course-record > header { grid-template-columns: minmax(0, 1fr) 118px; }
|
||||
.course-identity { padding: 15px 13px; }
|
||||
.course-identity h3 { font-size: 17px; }
|
||||
.course-rate { padding: 12px; }
|
||||
.course-rate strong { font-size: 22px; }
|
||||
.course-summary { grid-template-columns: repeat(3, 1fr); }
|
||||
.course-summary > div:nth-child(3) { border-right: none; }
|
||||
.course-summary > div:nth-child(-n + 3) { border-bottom: 1px solid #e3e9ef; }
|
||||
.session-row {
|
||||
grid-template-columns: 42px minmax(0, 1fr) auto;
|
||||
gap: 9px;
|
||||
}
|
||||
.session-result {
|
||||
grid-column: 2 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.session-row > .el-button {
|
||||
grid-column: 2 / -1;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Check, Download, Plus, Refresh, Search, Upload } from '@element-plus/icons-vue'
|
||||
import {
|
||||
Check,
|
||||
Clock,
|
||||
CopyDocument,
|
||||
Download,
|
||||
Grid,
|
||||
Location,
|
||||
Plus,
|
||||
Refresh,
|
||||
Search,
|
||||
Upload,
|
||||
} from '@element-plus/icons-vue'
|
||||
import * as QRCode from 'qrcode'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { LineChart, PieChart } from 'echarts/charts'
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
@@ -28,6 +40,12 @@ const selectedSheet = ref<any>(null)
|
||||
const sheetDetail = ref<any>(null)
|
||||
const statistics = ref<any>(null)
|
||||
const createDialog = ref(false)
|
||||
const qrDialog = ref(false)
|
||||
const createSubmitting = ref(false)
|
||||
const teacherLocationLoading = ref(false)
|
||||
const qrDataUrl = ref('')
|
||||
const qrCheckInUrl = ref('')
|
||||
const now = ref(Date.now())
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const termId = ref<string>()
|
||||
const activeMode = ref<'rollcall' | 'statistics'>('rollcall')
|
||||
@@ -36,8 +54,18 @@ const classFilter = ref('')
|
||||
const attentionFilter = ref('')
|
||||
const statusChartEl = ref<HTMLElement>()
|
||||
const trendChartEl = ref<HTMLElement>()
|
||||
const createForm = ref({ name: '', attendanceDate: '' })
|
||||
const createForm = ref({
|
||||
name: '',
|
||||
attendanceDate: '',
|
||||
checkInMethod: 'Manual',
|
||||
checkInDurationMinutes: 15,
|
||||
targetLatitude: null as number | null,
|
||||
targetLongitude: null as number | null,
|
||||
locationRadiusMeters: 100,
|
||||
locationAccuracyMeters: null as number | null,
|
||||
})
|
||||
const chartInstances: echarts.ECharts[] = []
|
||||
let clockTimer: number | undefined
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'Present', label: '出勤' },
|
||||
@@ -148,26 +176,181 @@ function openCreate() {
|
||||
createForm.value = {
|
||||
name: '',
|
||||
attendanceDate: new Date().toISOString().slice(0, 10),
|
||||
checkInMethod: 'Manual',
|
||||
checkInDurationMinutes: 15,
|
||||
targetLatitude: null,
|
||||
targetLongitude: null,
|
||||
locationRadiusMeters: 100,
|
||||
locationAccuracyMeters: null,
|
||||
}
|
||||
createDialog.value = true
|
||||
}
|
||||
|
||||
async function captureTeacherLocation() {
|
||||
if (!navigator.geolocation) {
|
||||
ElMessage.error('当前浏览器不支持定位,请更换浏览器或使用扫码签到。')
|
||||
return false
|
||||
}
|
||||
teacherLocationLoading.value = true
|
||||
try {
|
||||
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 12000,
|
||||
maximumAge: 0,
|
||||
})
|
||||
})
|
||||
createForm.value.targetLatitude = Number(position.coords.latitude.toFixed(7))
|
||||
createForm.value.targetLongitude = Number(position.coords.longitude.toFixed(7))
|
||||
createForm.value.locationAccuracyMeters = Math.round(position.coords.accuracy)
|
||||
ElMessage.success('已获取当前签到点')
|
||||
return true
|
||||
} catch (error: any) {
|
||||
const message = error?.code === 1
|
||||
? '定位权限被拒绝,请在浏览器地址栏中允许本网站使用位置信息。'
|
||||
: error?.code === 3
|
||||
? '获取位置超时,请移到信号较好的位置后重试。'
|
||||
: '暂时无法获取位置,请检查系统定位服务。'
|
||||
ElMessage.error(message)
|
||||
return false
|
||||
} finally {
|
||||
teacherLocationLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createSheet() {
|
||||
if (!createForm.value.name.trim()) {
|
||||
ElMessage.warning('请填写考勤表名称。')
|
||||
return
|
||||
}
|
||||
if (createForm.value.checkInMethod === 'Location' &&
|
||||
(createForm.value.targetLatitude === null ||
|
||||
createForm.value.targetLongitude === null) &&
|
||||
!await captureTeacherLocation()) return
|
||||
createSubmitting.value = true
|
||||
try {
|
||||
await http.post('/attendance/sheets', {
|
||||
const { data } = await http.post('/attendance/sheets', {
|
||||
teachingTaskId: selectedTask.value.id,
|
||||
name: createForm.value.name,
|
||||
attendanceDate: new Date(createForm.value.attendanceDate).toISOString(),
|
||||
checkInMethod: createForm.value.checkInMethod,
|
||||
checkInDurationMinutes: createForm.value.checkInMethod === 'Manual'
|
||||
? null
|
||||
: createForm.value.checkInDurationMinutes,
|
||||
targetLatitude: createForm.value.checkInMethod === 'Location'
|
||||
? createForm.value.targetLatitude
|
||||
: null,
|
||||
targetLongitude: createForm.value.checkInMethod === 'Location'
|
||||
? createForm.value.targetLongitude
|
||||
: null,
|
||||
locationRadiusMeters: createForm.value.checkInMethod === 'Location'
|
||||
? createForm.value.locationRadiusMeters
|
||||
: null,
|
||||
})
|
||||
createDialog.value = false
|
||||
ElMessage.success('考勤表已建立')
|
||||
ElMessage.success(createForm.value.checkInMethod === 'Manual'
|
||||
? '考勤表已建立'
|
||||
: '签到活动已发起')
|
||||
await selectTask(selectedTask.value)
|
||||
const createdSheet = sheets.value.find((sheet: any) => sheet.id === data.id)
|
||||
if (createdSheet) {
|
||||
await selectSheet(createdSheet)
|
||||
if (data.checkInMethod === 'QrCode') await showQrCode()
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
createSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isOnlineMethod(method: string | number) {
|
||||
return method === 'QrCode' || method === 'Location' || method === 2 || method === 3
|
||||
}
|
||||
|
||||
function methodLabel(method: string | number) {
|
||||
if (method === 'QrCode' || method === 2) return '扫码签到'
|
||||
if (method === 'Location' || method === 3) return '定位签到'
|
||||
return '教师点名'
|
||||
}
|
||||
|
||||
function isQrCode(method: string | number) {
|
||||
return method === 'QrCode' || method === 2
|
||||
}
|
||||
|
||||
function serverUtcTime(value: string | null | undefined) {
|
||||
if (!value) return Number.NaN
|
||||
const normalized = /(?:Z|[+-]\d{2}:\d{2})$/i.test(value) ? value : `${value}Z`
|
||||
return new Date(normalized).getTime()
|
||||
}
|
||||
|
||||
function formatServerTime(value: string) {
|
||||
return new Date(serverUtcTime(value)).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function isCheckInOpen(sheet: any) {
|
||||
if (!sheet || !isDraft(sheet.status) || !isOnlineMethod(sheet.checkInMethod)) return false
|
||||
const start = serverUtcTime(sheet.checkInStartsAt)
|
||||
const end = serverUtcTime(sheet.checkInEndsAt)
|
||||
return start <= now.value && now.value < end
|
||||
}
|
||||
|
||||
function remainingLabel(sheet: any) {
|
||||
if (!sheet?.checkInEndsAt) return ''
|
||||
const remaining = serverUtcTime(sheet.checkInEndsAt) - now.value
|
||||
if (remaining <= 0) return '签到已结束'
|
||||
const minutes = Math.floor(remaining / 60000)
|
||||
const seconds = Math.floor((remaining % 60000) / 1000)
|
||||
return `${minutes}分${String(seconds).padStart(2, '0')}秒后结束`
|
||||
}
|
||||
|
||||
async function showQrCode() {
|
||||
const sheet = sheetDetail.value?.sheet
|
||||
if (!sheet?.checkInToken) {
|
||||
ElMessage.warning('未取得签到码,请刷新考勤表后重试。')
|
||||
return
|
||||
}
|
||||
qrCheckInUrl.value =
|
||||
`${window.location.origin}/my-attendance?token=${encodeURIComponent(sheet.checkInToken)}`
|
||||
try {
|
||||
qrDataUrl.value = await QRCode.toDataURL(qrCheckInUrl.value, {
|
||||
width: 360,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: { dark: '#172b4d', light: '#ffffff' },
|
||||
})
|
||||
qrDialog.value = true
|
||||
} catch {
|
||||
ElMessage.error('签到二维码生成失败,请刷新页面后重试。')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCheckInLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(qrCheckInUrl.value)
|
||||
ElMessage.success('签到链接已复制')
|
||||
} catch {
|
||||
ElMessage.error('无法自动复制,请手动选择签到链接。')
|
||||
}
|
||||
}
|
||||
|
||||
async function closeCheckIn() {
|
||||
if (!sheetDetail.value?.sheet) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'提前结束后,学生将不能再扫码或定位签到,仍可由教师调整名单。',
|
||||
'提前结束签到',
|
||||
{ type: 'warning', confirmButtonText: '结束签到', cancelButtonText: '继续签到' },
|
||||
)
|
||||
await http.post(`/attendance/sheets/${sheetDetail.value.sheet.id}/close-check-in`)
|
||||
ElMessage.success('签到已结束')
|
||||
qrDialog.value = false
|
||||
await selectTask(selectedTask.value)
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +590,7 @@ watch(activeMode, async mode => {
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', resizeCharts)
|
||||
clockTimer = window.setInterval(() => { now.value = Date.now() }, 1000)
|
||||
terms.value = (await http.get('/base-data/terms')).data
|
||||
termId.value = terms.value.find((item: any) => item.isCurrent)?.id
|
||||
await loadTasks()
|
||||
@@ -414,6 +598,7 @@ onMounted(async () => {
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', resizeCharts)
|
||||
if (clockTimer) window.clearInterval(clockTimer)
|
||||
disposeCharts()
|
||||
})
|
||||
</script>
|
||||
@@ -482,10 +667,20 @@ onUnmounted(() => {
|
||||
@click="selectSheet(sheet)"
|
||||
>
|
||||
<div>
|
||||
<b>{{ sheet.name }}</b>
|
||||
<b>
|
||||
{{ sheet.name }}
|
||||
<span
|
||||
v-if="isOnlineMethod(sheet.checkInMethod)"
|
||||
class="method-mark"
|
||||
>{{ methodLabel(sheet.checkInMethod) }}</span>
|
||||
</b>
|
||||
<span>{{ new Date(sheet.attendanceDate).toLocaleDateString('zh-CN') }}</span>
|
||||
</div>
|
||||
<div class="sheet-stats">
|
||||
<span
|
||||
v-if="isOnlineMethod(sheet.checkInMethod)"
|
||||
class="checked-in"
|
||||
>已签到 {{ sheet.checkedInCount }}</span>
|
||||
<span class="present">出勤 {{ sheet.presentCount }}</span>
|
||||
<span class="absent" v-if="sheet.absentCount">缺勤 {{ sheet.absentCount }}</span>
|
||||
<span class="late" v-if="sheet.lateCount">迟到 {{ sheet.lateCount }}</span>
|
||||
@@ -510,9 +705,26 @@ onUnmounted(() => {
|
||||
<div>
|
||||
<strong>{{ sheetDetail.sheet.name }}</strong>
|
||||
<span>{{ sheetDetail.sheet.courseCode }} · {{ sheetDetail.sheet.taskNumber }}</span>
|
||||
<small>{{ new Date(sheetDetail.sheet.attendanceDate).toLocaleDateString('zh-CN') }} · {{ isSubmitted(sheetDetail.sheet.status) ? '已提交' : '草稿' }}</small>
|
||||
<small>
|
||||
{{ new Date(sheetDetail.sheet.attendanceDate).toLocaleDateString('zh-CN') }}
|
||||
· {{ methodLabel(sheetDetail.sheet.checkInMethod) }}
|
||||
· {{ isSubmitted(sheetDetail.sheet.status) ? '已提交' : '草稿' }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<el-button
|
||||
v-if="sheetDetail.canEdit && isQrCode(sheetDetail.sheet.checkInMethod)"
|
||||
size="small"
|
||||
:icon="Grid"
|
||||
@click="showQrCode"
|
||||
>显示签到码</el-button>
|
||||
<el-button
|
||||
v-if="sheetDetail.canEdit && isCheckInOpen(sheetDetail.sheet)"
|
||||
size="small"
|
||||
type="warning"
|
||||
plain
|
||||
@click="closeCheckIn"
|
||||
>提前结束</el-button>
|
||||
<el-button
|
||||
v-if="sheetDetail.canEdit"
|
||||
size="small"
|
||||
@@ -542,6 +754,27 @@ onUnmounted(() => {
|
||||
>提交</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
v-if="isOnlineMethod(sheetDetail.sheet.checkInMethod)"
|
||||
class="check-in-console"
|
||||
:class="{ open: isCheckInOpen(sheetDetail.sheet) }"
|
||||
>
|
||||
<div class="check-in-signal">
|
||||
<span />
|
||||
{{ isCheckInOpen(sheetDetail.sheet) ? '签到进行中' : '签到已结束' }}
|
||||
</div>
|
||||
<strong>
|
||||
{{ sheetDetail.sheet.checkedInCount }}
|
||||
<small>/ {{ sheetDetail.sheet.records.length }} 人已自主签到</small>
|
||||
</strong>
|
||||
<p>
|
||||
<Clock />
|
||||
{{ remainingLabel(sheetDetail.sheet) }}
|
||||
<template v-if="sheetDetail.sheet.locationRadiusMeters">
|
||||
· 签到点 {{ sheetDetail.sheet.locationRadiusMeters }} 米内有效
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="sheetDetail.canEdit" class="batch-row">
|
||||
<span>批量设置:</span>
|
||||
<el-button size="small" @click="batchStatus('Present')">全部出勤</el-button>
|
||||
@@ -573,6 +806,22 @@ onUnmounted(() => {
|
||||
<span v-else>{{ statusLabel(row.status) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="isOnlineMethod(sheetDetail.sheet.checkInMethod)"
|
||||
label="自主签到"
|
||||
min-width="138"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.checkInAt" class="record-check-in">
|
||||
<b>{{ methodLabel(row.checkedInMethod) }}</b>
|
||||
<span>{{ formatServerTime(row.checkInAt) }}</span>
|
||||
<small v-if="row.checkInDistanceMeters !== null">
|
||||
距签到点 {{ Math.round(row.checkInDistanceMeters) }} 米
|
||||
</small>
|
||||
</div>
|
||||
<span v-else class="muted-cell">尚未签到</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
@@ -713,7 +962,7 @@ onUnmounted(() => {
|
||||
</main>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="createDialog" title="新建考勤表" width="500px">
|
||||
<el-dialog v-model="createDialog" title="发起课堂考勤" width="620px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="考勤名称" required>
|
||||
<el-input v-model="createForm.name" placeholder="如:第3周课堂点名" maxlength="120" />
|
||||
@@ -725,10 +974,109 @@ onUnmounted(() => {
|
||||
class="full-width"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="签到方式" required>
|
||||
<div class="method-options">
|
||||
<button
|
||||
v-for="method in [
|
||||
{ value: 'Manual', icon: Check, name: '教师点名', hint: '逐人确认,可随时修改' },
|
||||
{ value: 'QrCode', icon: Grid, name: '扫码签到', hint: '投屏二维码,学生登录后签到' },
|
||||
{ value: 'Location', icon: Location, name: '定位签到', hint: '在指定签到点范围内签到' },
|
||||
]"
|
||||
:key="method.value"
|
||||
type="button"
|
||||
:class="{ active: createForm.checkInMethod === method.value }"
|
||||
@click="createForm.checkInMethod = method.value"
|
||||
>
|
||||
<el-icon><component :is="method.icon" /></el-icon>
|
||||
<b>{{ method.name }}</b>
|
||||
<span>{{ method.hint }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<div v-if="createForm.checkInMethod !== 'Manual'" class="online-settings">
|
||||
<el-form-item label="签到时长" required>
|
||||
<el-input-number
|
||||
v-model="createForm.checkInDurationMinutes"
|
||||
:min="1"
|
||||
:max="180"
|
||||
:step="5"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="field-unit">分钟</span>
|
||||
</el-form-item>
|
||||
<template v-if="createForm.checkInMethod === 'Location'">
|
||||
<el-form-item label="有效范围" required>
|
||||
<el-input-number
|
||||
v-model="createForm.locationRadiusMeters"
|
||||
:min="20"
|
||||
:max="1000"
|
||||
:step="10"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="field-unit">米</span>
|
||||
</el-form-item>
|
||||
<div class="location-anchor">
|
||||
<div>
|
||||
<Location />
|
||||
<p>
|
||||
<b>教师当前位置作为签到点</b>
|
||||
<span v-if="createForm.targetLatitude !== null">
|
||||
已定位,精度约 {{ createForm.locationAccuracyMeters }} 米
|
||||
</span>
|
||||
<span v-else>创建前需要允许浏览器获取位置</span>
|
||||
</p>
|
||||
</div>
|
||||
<el-button
|
||||
:loading="teacherLocationLoading"
|
||||
@click="captureTeacherLocation"
|
||||
>{{ createForm.targetLatitude === null ? '获取位置' : '重新定位' }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="createSheet">建立考勤表</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="createSubmitting"
|
||||
@click="createSheet"
|
||||
>{{ createForm.checkInMethod === 'Manual' ? '建立考勤表' : '立即发起签到' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="qrDialog"
|
||||
class="qr-dialog"
|
||||
title="课堂扫码签到"
|
||||
width="520px"
|
||||
align-center
|
||||
>
|
||||
<div v-if="sheetDetail" class="qr-stage">
|
||||
<div class="qr-course">
|
||||
<span>{{ sheetDetail.sheet.courseCode }} · {{ sheetDetail.sheet.taskNumber }}</span>
|
||||
<strong>{{ sheetDetail.sheet.name }}</strong>
|
||||
<small>{{ sheetDetail.sheet.courseName }}</small>
|
||||
</div>
|
||||
<img :src="qrDataUrl" alt="课堂签到二维码" />
|
||||
<div class="qr-status" :class="{ ended: !isCheckInOpen(sheetDetail.sheet) }">
|
||||
<span />
|
||||
{{ isCheckInOpen(sheetDetail.sheet) ? remainingLabel(sheetDetail.sheet) : '本次签到已结束' }}
|
||||
</div>
|
||||
<p>学生使用手机扫码,登录教务系统后完成签到</p>
|
||||
<el-input v-model="qrCheckInUrl" readonly>
|
||||
<template #append>
|
||||
<el-button :icon="CopyDocument" @click="copyCheckInLink">复制链接</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button
|
||||
v-if="sheetDetail && isCheckInOpen(sheetDetail.sheet)"
|
||||
type="warning"
|
||||
plain
|
||||
@click="closeCheckIn"
|
||||
>提前结束签到</el-button>
|
||||
<el-button type="primary" @click="qrDialog = false">完成</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@@ -833,6 +1181,16 @@ onUnmounted(() => {
|
||||
}
|
||||
.attendance-sheet-list > button b { font-size: 14px; }
|
||||
.attendance-sheet-list > button span { color: var(--muted); font-size: 11px; }
|
||||
.method-mark {
|
||||
margin-left: 5px;
|
||||
padding: 2px 5px;
|
||||
color: #176b87 !important;
|
||||
font-size: 9px !important;
|
||||
font-weight: 700;
|
||||
vertical-align: middle;
|
||||
border: 1px solid #b9d5dc;
|
||||
background: #edf7f8;
|
||||
}
|
||||
.attendance-sheet-list > button i {
|
||||
color: var(--indigo);
|
||||
font-size: 10px;
|
||||
@@ -842,6 +1200,7 @@ onUnmounted(() => {
|
||||
.sheet-stats { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.sheet-stats span { font-size: 10px !important; font-weight: 700; }
|
||||
.sheet-stats .present { color: #2d8975; }
|
||||
.sheet-stats .checked-in { color: #176b87; }
|
||||
.sheet-stats .absent { color: #b34e48; }
|
||||
.sheet-stats .late { color: #c78724; }
|
||||
.sheet-stats .leave { color: #79579a; }
|
||||
@@ -864,6 +1223,56 @@ onUnmounted(() => {
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.check-in-console {
|
||||
padding: 13px 15px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: #64748b;
|
||||
border-bottom: 1px solid #dce4ed;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.check-in-console.open {
|
||||
color: #d8f4ec;
|
||||
border-color: #21506d;
|
||||
background: #17395e;
|
||||
}
|
||||
.check-in-signal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .08em;
|
||||
}
|
||||
.check-in-signal > span,
|
||||
.qr-status > span {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
}
|
||||
.check-in-console.open .check-in-signal > span,
|
||||
.qr-status:not(.ended) > span {
|
||||
background: #4fe0b1;
|
||||
box-shadow: 0 0 0 4px rgba(79, 224, 177, .14);
|
||||
}
|
||||
.check-in-console > strong {
|
||||
color: #334155;
|
||||
font: 700 30px/1.1 "Arial Narrow", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
.check-in-console.open > strong { color: white; }
|
||||
.check-in-console > strong small {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.check-in-console > p {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.check-in-console > p svg { width: 12px; }
|
||||
.batch-row {
|
||||
padding: 8px 14px;
|
||||
display: flex;
|
||||
@@ -875,8 +1284,119 @@ onUnmounted(() => {
|
||||
.batch-row > span { font-size: 11px; color: var(--muted); }
|
||||
.batch-row > small { margin-left: auto; font-size: 10px; color: var(--muted); }
|
||||
.student-flag { margin-left: 4px; }
|
||||
.record-check-in { display: grid; gap: 1px; line-height: 1.25; }
|
||||
.record-check-in b { color: #176b87; font-size: 10px; }
|
||||
.record-check-in span,
|
||||
.record-check-in small,
|
||||
.muted-cell { color: var(--muted); font-size: 10px; }
|
||||
.full-width { width: 100%; }
|
||||
|
||||
.method-options {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.method-options > button {
|
||||
min-width: 0;
|
||||
padding: 13px 10px;
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr);
|
||||
gap: 2px 7px;
|
||||
color: #344054;
|
||||
text-align: left;
|
||||
border: 1px solid #d8dee7;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
.method-options > button:hover { border-color: #87aeb9; }
|
||||
.method-options > button.active {
|
||||
color: #17395e;
|
||||
border-color: #176b87;
|
||||
box-shadow: inset 0 -3px #176b87;
|
||||
background: #f1f8f9;
|
||||
}
|
||||
.method-options .el-icon {
|
||||
grid-row: 1 / 3;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: #176b87;
|
||||
font-size: 19px;
|
||||
background: #e7f2f4;
|
||||
}
|
||||
.method-options b { font-size: 12px; }
|
||||
.method-options span {
|
||||
overflow: hidden;
|
||||
color: #7b8794;
|
||||
font-size: 9px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.online-settings {
|
||||
padding: 13px 15px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0 16px;
|
||||
border: 1px solid #dce4ed;
|
||||
background: #f7f9fb;
|
||||
}
|
||||
.online-settings :deep(.el-form-item) { margin-bottom: 10px; }
|
||||
.field-unit { margin-left: 8px; color: var(--muted); font-size: 11px; }
|
||||
.location-anchor {
|
||||
grid-column: 1 / -1;
|
||||
padding-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
border-top: 1px solid #dce4ed;
|
||||
}
|
||||
.location-anchor > div {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
.location-anchor svg {
|
||||
width: 22px;
|
||||
color: #176b87;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.location-anchor p { margin: 0; display: grid; }
|
||||
.location-anchor b { color: #344054; font-size: 11px; }
|
||||
.location-anchor span { color: var(--muted); font-size: 9px; }
|
||||
|
||||
.qr-stage {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
.qr-course { display: grid; gap: 2px; }
|
||||
.qr-course span {
|
||||
color: #176b87;
|
||||
font: 700 10px/1.2 Consolas, monospace;
|
||||
}
|
||||
.qr-course strong { color: #172b4d; font-size: 20px; }
|
||||
.qr-course small { color: var(--muted); }
|
||||
.qr-stage > img {
|
||||
width: min(340px, 78vw);
|
||||
aspect-ratio: 1;
|
||||
border: 10px solid white;
|
||||
box-shadow: 0 0 0 1px #d8dee7, 0 12px 32px rgba(23, 43, 77, .12);
|
||||
}
|
||||
.qr-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #247261;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.qr-status.ended { color: #64748b; }
|
||||
.qr-stage > p { margin: 0; color: var(--muted); font-size: 11px; }
|
||||
.qr-stage :deep(.el-input) { width: 100%; }
|
||||
|
||||
.attendance-statistics {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -1094,6 +1614,10 @@ onUnmounted(() => {
|
||||
.student-statistics { margin: 0 12px 12px; }
|
||||
.student-filters .el-input,
|
||||
.student-filters .el-select { width: 100%; }
|
||||
.method-options { grid-template-columns: 1fr; }
|
||||
.method-options > button { grid-template-columns: 32px minmax(0, 1fr); }
|
||||
.online-settings { grid-template-columns: 1fr; }
|
||||
.location-anchor { align-items: flex-start; }
|
||||
.batch-row { align-items: flex-start; flex-wrap: wrap; }
|
||||
.batch-row > small { width: 100%; margin-left: 0; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user