update
This commit is contained in:
@@ -47,6 +47,26 @@ dotnet run --project src/Jiaowu.Api
|
|||||||
|
|
||||||
访问 `http://localhost:5255`。`/api` 和静态页面由同一个 ASP.NET Core 服务提供,`/base-data` 等前端路由刷新时也会回退到 `index.html`。
|
访问 `http://localhost:5255`。`/api` 和静态页面由同一个 ASP.NET Core 服务提供,`/base-data` 等前端路由刷新时也会回退到 `index.html`。
|
||||||
|
|
||||||
|
## Capacitor Android App
|
||||||
|
|
||||||
|
`web/.env.capacitor` 配置 App 使用的 HTTPS API 与公开站点地址。生成或更新
|
||||||
|
Android 工程前先构建并同步原生插件:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-Location web
|
||||||
|
npm ci
|
||||||
|
npm run build:capacitor
|
||||||
|
npm run cap:sync
|
||||||
|
npm run cap:open:android
|
||||||
|
```
|
||||||
|
|
||||||
|
学生在 App 的“我的考勤”中可调用原生相机扫描教师展示的签到二维码;二维码由服务端
|
||||||
|
签名、每 10 秒刷新并在 20 秒后失效,扫码后先显示课程和签到时限,仍需学生确认才
|
||||||
|
提交。教师可直接在手机 App 发起定位签到,以教师手机的原生精确位置作为签到点;
|
||||||
|
教室电脑没有定位模块时不影响该流程。服务端校验课程名单、签到时间、距离和定位精度,
|
||||||
|
并记录签到设备摘要、IP、失败次数和异常频率,供任课教师在考勤明细中复核。Android
|
||||||
|
最低版本为 API 26;相机和精确位置权限均按需申请。
|
||||||
|
|
||||||
## MySQL 8.4 生产部署
|
## MySQL 8.4 生产部署
|
||||||
|
|
||||||
非 Development 环境只允许使用 MySQL。构建发布包与数据库配置相互独立:
|
非 Development 环境只允许使用 MySQL。构建发布包与数据库配置相互独立:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using ClosedXML.Excel;
|
using ClosedXML.Excel;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
@@ -180,7 +181,6 @@ public sealed class AttendanceController(
|
|||||||
{
|
{
|
||||||
sheet.Id,
|
sheet.Id,
|
||||||
sheet.CheckInMethod,
|
sheet.CheckInMethod,
|
||||||
sheet.CheckInToken,
|
|
||||||
sheet.CheckInStartsAt,
|
sheet.CheckInStartsAt,
|
||||||
sheet.CheckInEndsAt
|
sheet.CheckInEndsAt
|
||||||
});
|
});
|
||||||
@@ -200,7 +200,6 @@ public sealed class AttendanceController(
|
|||||||
x.AttendanceDate,
|
x.AttendanceDate,
|
||||||
x.Status,
|
x.Status,
|
||||||
x.CheckInMethod,
|
x.CheckInMethod,
|
||||||
x.CheckInToken,
|
|
||||||
x.CheckInStartsAt,
|
x.CheckInStartsAt,
|
||||||
x.CheckInEndsAt,
|
x.CheckInEndsAt,
|
||||||
x.TargetLatitude,
|
x.TargetLatitude,
|
||||||
@@ -247,6 +246,103 @@ public sealed class AttendanceController(
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage;
|
var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage;
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
var attempts = await db.AttendanceCheckInAttempts.AsNoTracking()
|
||||||
|
.Where(x => x.AttendanceSheetId == id)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.StudentId,
|
||||||
|
x.CreatedAt,
|
||||||
|
x.IsSuccessful,
|
||||||
|
x.FailureCode,
|
||||||
|
x.DeviceIdentifierHash,
|
||||||
|
x.DevicePlatform,
|
||||||
|
x.IpAddress,
|
||||||
|
x.RiskFlags
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var attemptsByStudent = attempts
|
||||||
|
.GroupBy(x => x.StudentId)
|
||||||
|
.ToDictionary(x => x.Key, x => x.OrderByDescending(a => a.CreatedAt).ToList());
|
||||||
|
var deviceHashes = attempts
|
||||||
|
.Where(x => x.IsSuccessful && x.DeviceIdentifierHash != null)
|
||||||
|
.Select(x => x.DeviceIdentifierHash!)
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
var deviceReuseCounts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||||
|
if (deviceHashes.Count > 0)
|
||||||
|
{
|
||||||
|
var reuseRows = await db.AttendanceCheckInAttempts.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.IsSuccessful &&
|
||||||
|
x.CreatedAt >= now.AddHours(-24) &&
|
||||||
|
x.DeviceIdentifierHash != null &&
|
||||||
|
deviceHashes.Contains(x.DeviceIdentifierHash))
|
||||||
|
.GroupBy(x => x.DeviceIdentifierHash!)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
DeviceIdentifierHash = x.Key,
|
||||||
|
StudentCount = x.Select(a => a.StudentId).Distinct().Count()
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
deviceReuseCounts = reuseRows.ToDictionary(
|
||||||
|
x => x.DeviceIdentifierHash,
|
||||||
|
x => x.StudentCount,
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
var responseRecords = sheet.Records.Select(r =>
|
||||||
|
{
|
||||||
|
var studentAttempts = attemptsByStudent.GetValueOrDefault(r.StudentId) ?? [];
|
||||||
|
var latestSuccess = studentAttempts.FirstOrDefault(x => x.IsSuccessful);
|
||||||
|
var riskFlags = studentAttempts
|
||||||
|
.SelectMany(x => ParseRiskFlags(x.RiskFlags))
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
if (studentAttempts.Count(x => !x.IsSuccessful) >= 3)
|
||||||
|
riskFlags.Add("RepeatedFailures");
|
||||||
|
if (studentAttempts.Count(x => x.CreatedAt >= now.AddMinutes(-2)) >= 6)
|
||||||
|
riskFlags.Add("HighFrequency");
|
||||||
|
var sharedDeviceStudentCount = latestSuccess?.DeviceIdentifierHash is { } deviceHash
|
||||||
|
? deviceReuseCounts.GetValueOrDefault(deviceHash)
|
||||||
|
: 0;
|
||||||
|
if (sharedDeviceStudentCount > 1)
|
||||||
|
riskFlags.Add("SharedDevice");
|
||||||
|
var sameIpStudentCount = latestSuccess?.IpAddress is { } ipAddress
|
||||||
|
? attempts
|
||||||
|
.Where(x => x.IsSuccessful && x.IpAddress == ipAddress)
|
||||||
|
.Select(x => x.StudentId)
|
||||||
|
.Distinct()
|
||||||
|
.Count()
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
r.StudentId,
|
||||||
|
r.StudentNumber,
|
||||||
|
r.Name,
|
||||||
|
r.ClassName,
|
||||||
|
r.Status,
|
||||||
|
r.Notes,
|
||||||
|
r.CheckInAt,
|
||||||
|
r.CheckedInMethod,
|
||||||
|
r.CheckInAccuracyMeters,
|
||||||
|
r.CheckInDistanceMeters,
|
||||||
|
IsExempt = exemptStudentIds.Contains(r.StudentId),
|
||||||
|
IsDeferred = deferredStudentIds.Contains(r.StudentId),
|
||||||
|
CheckInAudit = latestSuccess is null
|
||||||
|
? null
|
||||||
|
: new
|
||||||
|
{
|
||||||
|
latestSuccess.IpAddress,
|
||||||
|
latestSuccess.DevicePlatform,
|
||||||
|
DeviceCode = latestSuccess.DeviceIdentifierHash?[..8],
|
||||||
|
SharedDeviceStudentCount = sharedDeviceStudentCount,
|
||||||
|
SameIpStudentCount = sameIpStudentCount
|
||||||
|
},
|
||||||
|
AttemptCount = studentAttempts.Count,
|
||||||
|
FailedAttemptCount = studentAttempts.Count(x => !x.IsSuccessful),
|
||||||
|
RiskFlags = riskFlags.OrderBy(x => x).ToArray()
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
Sheet = new
|
Sheet = new
|
||||||
@@ -257,7 +353,6 @@ public sealed class AttendanceController(
|
|||||||
sheet.AttendanceDate,
|
sheet.AttendanceDate,
|
||||||
sheet.Status,
|
sheet.Status,
|
||||||
sheet.CheckInMethod,
|
sheet.CheckInMethod,
|
||||||
CheckInToken = canManage ? sheet.CheckInToken : null,
|
|
||||||
sheet.CheckInStartsAt,
|
sheet.CheckInStartsAt,
|
||||||
sheet.CheckInEndsAt,
|
sheet.CheckInEndsAt,
|
||||||
sheet.TargetLatitude,
|
sheet.TargetLatitude,
|
||||||
@@ -276,21 +371,16 @@ public sealed class AttendanceController(
|
|||||||
sheet.TaskName,
|
sheet.TaskName,
|
||||||
sheet.CourseCode,
|
sheet.CourseCode,
|
||||||
sheet.CourseName,
|
sheet.CourseName,
|
||||||
Records = sheet.Records.Select(r => new
|
Records = responseRecords,
|
||||||
|
RiskSummary = new
|
||||||
{
|
{
|
||||||
r.StudentId,
|
RiskStudentCount = responseRecords.Count(x => x.RiskFlags.Length > 0),
|
||||||
r.StudentNumber,
|
SharedDeviceStudentCount = responseRecords.Count(
|
||||||
r.Name,
|
x => x.RiskFlags.Contains("SharedDevice")),
|
||||||
r.ClassName,
|
FrequentAttemptStudentCount = responseRecords.Count(
|
||||||
r.Status,
|
x => x.RiskFlags.Contains("HighFrequency") ||
|
||||||
r.Notes,
|
x.RiskFlags.Contains("RepeatedFailures"))
|
||||||
r.CheckInAt,
|
}
|
||||||
r.CheckedInMethod,
|
|
||||||
r.CheckInAccuracyMeters,
|
|
||||||
r.CheckInDistanceMeters,
|
|
||||||
IsExempt = exemptStudentIds.Contains(r.StudentId),
|
|
||||||
IsDeferred = deferredStudentIds.Contains(r.StudentId)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
CanEdit = canEdit
|
CanEdit = canEdit
|
||||||
});
|
});
|
||||||
@@ -503,6 +593,39 @@ public sealed class AttendanceController(
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("sheets/{id:guid}/qr-challenge")]
|
||||||
|
[Authorize(Roles = AttendanceRoles)]
|
||||||
|
public async Task<ActionResult> GetQrChallenge(
|
||||||
|
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.CheckInMethod != AttendanceCheckInMethod.QrCode ||
|
||||||
|
string.IsNullOrWhiteSpace(sheet.CheckInToken))
|
||||||
|
return ConflictProblem("该考勤表不是扫码签到。");
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
if (!IsCheckInOpen(
|
||||||
|
sheet.Status,
|
||||||
|
sheet.CheckInMethod,
|
||||||
|
sheet.CheckInStartsAt,
|
||||||
|
sheet.CheckInEndsAt,
|
||||||
|
now))
|
||||||
|
return ConflictProblem("签到尚未开始或已经结束。");
|
||||||
|
|
||||||
|
var challenge = AttendanceCheckInChallenge.Create(
|
||||||
|
sheet.Id,
|
||||||
|
sheet.CheckInToken,
|
||||||
|
now);
|
||||||
|
return Ok(challenge);
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════ Student endpoints ═══════════════
|
// ═══════════════ Student endpoints ═══════════════
|
||||||
|
|
||||||
[HttpGet("check-in-info")]
|
[HttpGet("check-in-info")]
|
||||||
@@ -516,11 +639,13 @@ public sealed class AttendanceController(
|
|||||||
return ConflictProblem("当前账号未关联学生档案。");
|
return ConflictProblem("当前账号未关联学生档案。");
|
||||||
if (string.IsNullOrWhiteSpace(token))
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
if (!AttendanceCheckInChallenge.TryReadSheetId(token, out var sheetId))
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
var activity = await db.AttendanceRecords.AsNoTracking()
|
var activity = await db.AttendanceRecords.AsNoTracking()
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
x.StudentId == studentId.Value &&
|
x.StudentId == studentId.Value &&
|
||||||
x.AttendanceSheet!.CheckInToken == token.Trim())
|
x.AttendanceSheetId == sheetId)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
SheetId = x.AttendanceSheetId,
|
SheetId = x.AttendanceSheetId,
|
||||||
@@ -530,6 +655,7 @@ public sealed class AttendanceController(
|
|||||||
x.AttendanceSheet.CheckInMethod,
|
x.AttendanceSheet.CheckInMethod,
|
||||||
x.AttendanceSheet.CheckInStartsAt,
|
x.AttendanceSheet.CheckInStartsAt,
|
||||||
x.AttendanceSheet.CheckInEndsAt,
|
x.AttendanceSheet.CheckInEndsAt,
|
||||||
|
x.AttendanceSheet.CheckInToken,
|
||||||
CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
|
CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
|
||||||
CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
|
CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
|
||||||
TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
|
TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
|
||||||
@@ -539,6 +665,13 @@ public sealed class AttendanceController(
|
|||||||
if (activity is null) return NotFound();
|
if (activity is null) return NotFound();
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
if (activity.CheckInMethod != AttendanceCheckInMethod.QrCode ||
|
||||||
|
!AttendanceCheckInChallenge.IsValid(
|
||||||
|
token,
|
||||||
|
activity.SheetId,
|
||||||
|
activity.CheckInToken,
|
||||||
|
now))
|
||||||
|
return NotFound();
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
activity.SheetId,
|
activity.SheetId,
|
||||||
@@ -610,8 +743,11 @@ public sealed class AttendanceController(
|
|||||||
.Where(x => x.StudentId == studentId.Value);
|
.Where(x => x.StudentId == studentId.Value);
|
||||||
if (!string.IsNullOrWhiteSpace(request.Token))
|
if (!string.IsNullOrWhiteSpace(request.Token))
|
||||||
{
|
{
|
||||||
var token = request.Token.Trim();
|
if (!AttendanceCheckInChallenge.TryReadSheetId(
|
||||||
source = source.Where(x => x.AttendanceSheet!.CheckInToken == token);
|
request.Token.Trim(),
|
||||||
|
out var tokenSheetId))
|
||||||
|
return NotFound();
|
||||||
|
source = source.Where(x => x.AttendanceSheetId == tokenSheetId);
|
||||||
}
|
}
|
||||||
else if (request.AttendanceSheetId.HasValue)
|
else if (request.AttendanceSheetId.HasValue)
|
||||||
{
|
{
|
||||||
@@ -627,8 +763,59 @@ public sealed class AttendanceController(
|
|||||||
if (record?.AttendanceSheet is null) return NotFound();
|
if (record?.AttendanceSheet is null) return NotFound();
|
||||||
var sheet = record.AttendanceSheet;
|
var sheet = record.AttendanceSheet;
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
async Task<ActionResult> RejectAttemptAsync(
|
||||||
|
string failureCode,
|
||||||
|
string detail,
|
||||||
|
double? distanceMeters = null)
|
||||||
|
{
|
||||||
|
await AddCheckInAttemptAsync(
|
||||||
|
sheet,
|
||||||
|
studentId.Value,
|
||||||
|
request,
|
||||||
|
false,
|
||||||
|
failureCode,
|
||||||
|
distanceMeters,
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return ConflictProblem(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode &&
|
||||||
|
!AttendanceCheckInChallenge.IsValid(
|
||||||
|
request.Token,
|
||||||
|
sheet.Id,
|
||||||
|
sheet.CheckInToken,
|
||||||
|
now))
|
||||||
|
return await RejectAttemptAsync(
|
||||||
|
"InvalidQrChallenge",
|
||||||
|
"签到二维码已失效,请重新扫描教师当前展示的二维码。");
|
||||||
|
if (!IsCheckInOpen(
|
||||||
|
sheet.Status,
|
||||||
|
sheet.CheckInMethod,
|
||||||
|
sheet.CheckInStartsAt,
|
||||||
|
sheet.CheckInEndsAt,
|
||||||
|
now))
|
||||||
|
return await RejectAttemptAsync(
|
||||||
|
"CheckInClosed",
|
||||||
|
"签到尚未开始或已经结束。");
|
||||||
|
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
|
||||||
|
return await RejectAttemptAsync(
|
||||||
|
"ManualSheet",
|
||||||
|
"该考勤表不支持学生在线签到。");
|
||||||
if (record.CheckInAt.HasValue)
|
if (record.CheckInAt.HasValue)
|
||||||
{
|
{
|
||||||
|
await AddCheckInAttemptAsync(
|
||||||
|
sheet,
|
||||||
|
studentId.Value,
|
||||||
|
request,
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
record.CheckInDistanceMeters,
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
AlreadyCheckedIn = true,
|
AlreadyCheckedIn = true,
|
||||||
@@ -636,37 +823,35 @@ public sealed class AttendanceController(
|
|||||||
record.CheckInDistanceMeters
|
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;
|
double? distanceMeters = null;
|
||||||
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode)
|
if (sheet.CheckInMethod == AttendanceCheckInMethod.Location)
|
||||||
{
|
|
||||||
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 ||
|
if (request.Latitude is < -90 or > 90 ||
|
||||||
request.Longitude is < -180 or > 180 ||
|
request.Longitude is < -180 or > 180 ||
|
||||||
request.Latitude is null ||
|
request.Latitude is null ||
|
||||||
request.Longitude is null)
|
request.Longitude is null)
|
||||||
return ConflictProblem("未获取到有效的当前位置。");
|
return await RejectAttemptAsync(
|
||||||
|
"InvalidLocation",
|
||||||
|
"未获取到有效的当前位置。");
|
||||||
if (sheet.TargetLatitude is null ||
|
if (sheet.TargetLatitude is null ||
|
||||||
sheet.TargetLongitude is null ||
|
sheet.TargetLongitude is null ||
|
||||||
sheet.LocationRadiusMeters is null)
|
sheet.LocationRadiusMeters is null)
|
||||||
return ConflictProblem("签到活动没有配置有效的位置范围。");
|
return await RejectAttemptAsync(
|
||||||
|
"LocationNotConfigured",
|
||||||
|
"签到活动没有配置有效的位置范围。");
|
||||||
|
var maximumAllowedAccuracyMeters = Math.Min(
|
||||||
|
100d,
|
||||||
|
sheet.LocationRadiusMeters.Value);
|
||||||
|
if (request.AccuracyMeters is null ||
|
||||||
|
!double.IsFinite(request.AccuracyMeters.Value) ||
|
||||||
|
request.AccuracyMeters <= 0 ||
|
||||||
|
request.AccuracyMeters > maximumAllowedAccuracyMeters)
|
||||||
|
{
|
||||||
|
return await RejectAttemptAsync(
|
||||||
|
"InsufficientAccuracy",
|
||||||
|
$"当前定位精度不足,请在精度达到 {Math.Round(maximumAllowedAccuracyMeters)} 米以内后重试。");
|
||||||
|
}
|
||||||
|
|
||||||
distanceMeters = CalculateDistanceMeters(
|
distanceMeters = CalculateDistanceMeters(
|
||||||
(double)sheet.TargetLatitude.Value,
|
(double)sheet.TargetLatitude.Value,
|
||||||
@@ -675,8 +860,10 @@ public sealed class AttendanceController(
|
|||||||
(double)request.Longitude.Value);
|
(double)request.Longitude.Value);
|
||||||
if (distanceMeters > sheet.LocationRadiusMeters.Value)
|
if (distanceMeters > sheet.LocationRadiusMeters.Value)
|
||||||
{
|
{
|
||||||
return ConflictProblem(
|
return await RejectAttemptAsync(
|
||||||
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。");
|
"OutsideGeofence",
|
||||||
|
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。",
|
||||||
|
distanceMeters);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,6 +880,15 @@ public sealed class AttendanceController(
|
|||||||
? request.AccuracyMeters
|
? request.AccuracyMeters
|
||||||
: null;
|
: null;
|
||||||
record.CheckInDistanceMeters = distanceMeters;
|
record.CheckInDistanceMeters = distanceMeters;
|
||||||
|
await AddCheckInAttemptAsync(
|
||||||
|
sheet,
|
||||||
|
studentId.Value,
|
||||||
|
request,
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
distanceMeters,
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
@@ -986,6 +1182,103 @@ public sealed class AttendanceController(
|
|||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task AddCheckInAttemptAsync(
|
||||||
|
AttendanceSheet sheet,
|
||||||
|
Guid studentId,
|
||||||
|
AttendanceCheckInRequest request,
|
||||||
|
bool isSuccessful,
|
||||||
|
string? failureCode,
|
||||||
|
double? distanceMeters,
|
||||||
|
DateTime now,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var deviceIdentifierHash = HashDeviceIdentifier(request.DeviceId);
|
||||||
|
var riskFlags = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
if (deviceIdentifierHash is null)
|
||||||
|
{
|
||||||
|
riskFlags.Add("MissingDeviceId");
|
||||||
|
}
|
||||||
|
else if (await db.AttendanceCheckInAttempts.AsNoTracking().AnyAsync(
|
||||||
|
x =>
|
||||||
|
x.IsSuccessful &&
|
||||||
|
x.StudentId != studentId &&
|
||||||
|
x.DeviceIdentifierHash == deviceIdentifierHash &&
|
||||||
|
x.CreatedAt >= now.AddHours(-24),
|
||||||
|
cancellationToken))
|
||||||
|
{
|
||||||
|
riskFlags.Add("SharedDevice");
|
||||||
|
}
|
||||||
|
|
||||||
|
var recentAttemptCount = await db.AttendanceCheckInAttempts.AsNoTracking()
|
||||||
|
.CountAsync(
|
||||||
|
x => x.StudentId == studentId &&
|
||||||
|
x.CreatedAt >= now.AddMinutes(-2),
|
||||||
|
cancellationToken);
|
||||||
|
if (recentAttemptCount >= 5)
|
||||||
|
riskFlags.Add("HighFrequency");
|
||||||
|
|
||||||
|
if (!isSuccessful)
|
||||||
|
{
|
||||||
|
var recentFailureCount = await db.AttendanceCheckInAttempts.AsNoTracking()
|
||||||
|
.CountAsync(
|
||||||
|
x => x.StudentId == studentId &&
|
||||||
|
!x.IsSuccessful &&
|
||||||
|
x.CreatedAt >= now.AddMinutes(-5),
|
||||||
|
cancellationToken);
|
||||||
|
if (recentFailureCount >= 2)
|
||||||
|
riskFlags.Add("RepeatedFailures");
|
||||||
|
}
|
||||||
|
|
||||||
|
var context = ControllerContext.HttpContext;
|
||||||
|
db.AttendanceCheckInAttempts.Add(new AttendanceCheckInAttempt
|
||||||
|
{
|
||||||
|
AttendanceSheetId = sheet.Id,
|
||||||
|
StudentId = studentId,
|
||||||
|
CheckInMethod = sheet.CheckInMethod,
|
||||||
|
IsSuccessful = isSuccessful,
|
||||||
|
FailureCode = failureCode,
|
||||||
|
DeviceIdentifierHash = deviceIdentifierHash,
|
||||||
|
DevicePlatform = Limit(Normalize(request.DevicePlatform), 32),
|
||||||
|
IpAddress = Limit(
|
||||||
|
context?.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
64),
|
||||||
|
UserAgent = Limit(
|
||||||
|
context?.Request.Headers.UserAgent.ToString(),
|
||||||
|
500),
|
||||||
|
RiskFlags = riskFlags.Count == 0
|
||||||
|
? null
|
||||||
|
: string.Join(',', riskFlags.OrderBy(x => x)),
|
||||||
|
Latitude = request.Latitude,
|
||||||
|
Longitude = request.Longitude,
|
||||||
|
AccuracyMeters = request.AccuracyMeters,
|
||||||
|
DistanceMeters = distanceMeters,
|
||||||
|
CreatedAt = now,
|
||||||
|
UpdatedAt = now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? HashDeviceIdentifier(string? deviceId)
|
||||||
|
{
|
||||||
|
var normalized = Normalize(deviceId)?.ToLowerInvariant();
|
||||||
|
return normalized is null
|
||||||
|
? null
|
||||||
|
: Convert.ToHexString(
|
||||||
|
SHA256.HashData(Encoding.UTF8.GetBytes(normalized)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> ParseRiskFlags(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value)
|
||||||
|
? []
|
||||||
|
: value.Split(
|
||||||
|
',',
|
||||||
|
StringSplitOptions.RemoveEmptyEntries |
|
||||||
|
StringSplitOptions.TrimEntries);
|
||||||
|
|
||||||
|
private static string? Limit(string? value, int maximumLength) =>
|
||||||
|
value is null || value.Length <= maximumLength
|
||||||
|
? value
|
||||||
|
: value[..maximumLength];
|
||||||
|
|
||||||
private static bool IsCheckInOpen(
|
private static bool IsCheckInOpen(
|
||||||
AttendanceSheetStatus status,
|
AttendanceSheetStatus status,
|
||||||
AttendanceCheckInMethod method,
|
AttendanceCheckInMethod method,
|
||||||
@@ -1380,7 +1673,9 @@ public sealed record AttendanceCheckInRequest(
|
|||||||
[MaxLength(64)] string? Token,
|
[MaxLength(64)] string? Token,
|
||||||
[Range(-90, 90)] decimal? Latitude,
|
[Range(-90, 90)] decimal? Latitude,
|
||||||
[Range(-180, 180)] decimal? Longitude,
|
[Range(-180, 180)] decimal? Longitude,
|
||||||
[Range(0, 5000)] double? AccuracyMeters);
|
[Range(0, 5000)] double? AccuracyMeters,
|
||||||
|
[MaxLength(128)] string? DeviceId = null,
|
||||||
|
[MaxLength(32)] string? DevicePlatform = null);
|
||||||
|
|
||||||
public sealed record AttendanceCourseStatistics(
|
public sealed record AttendanceCourseStatistics(
|
||||||
AttendanceStatisticsCourse Course,
|
AttendanceStatisticsCourse Course,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public sealed class AttendanceSheet : EntityBase
|
|||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public DateTime? SubmittedAt { get; set; }
|
public DateTime? SubmittedAt { get; set; }
|
||||||
public ICollection<AttendanceRecord> Records { get; set; } = [];
|
public ICollection<AttendanceRecord> Records { get; set; } = [];
|
||||||
|
public ICollection<AttendanceCheckInAttempt> CheckInAttempts { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class AttendanceRecord
|
public sealed class AttendanceRecord
|
||||||
@@ -43,6 +44,26 @@ public sealed class AttendanceRecord
|
|||||||
public DateTime? AppealReviewedAt { get; set; }
|
public DateTime? AppealReviewedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class AttendanceCheckInAttempt : EntityBase
|
||||||
|
{
|
||||||
|
public Guid AttendanceSheetId { get; set; }
|
||||||
|
public AttendanceSheet? AttendanceSheet { get; set; }
|
||||||
|
public Guid StudentId { get; set; }
|
||||||
|
public Student? Student { get; set; }
|
||||||
|
public AttendanceCheckInMethod CheckInMethod { get; set; }
|
||||||
|
public bool IsSuccessful { get; set; }
|
||||||
|
public string? FailureCode { get; set; }
|
||||||
|
public string? DeviceIdentifierHash { get; set; }
|
||||||
|
public string? DevicePlatform { get; set; }
|
||||||
|
public string? IpAddress { get; set; }
|
||||||
|
public string? UserAgent { get; set; }
|
||||||
|
public string? RiskFlags { get; set; }
|
||||||
|
public decimal? Latitude { get; set; }
|
||||||
|
public decimal? Longitude { get; set; }
|
||||||
|
public double? AccuracyMeters { get; set; }
|
||||||
|
public double? DistanceMeters { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public enum AttendanceSheetStatus
|
public enum AttendanceSheetStatus
|
||||||
{
|
{
|
||||||
Draft = 1,
|
Draft = 1,
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>();
|
public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>();
|
||||||
public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>();
|
public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>();
|
||||||
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
|
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
|
||||||
|
public DbSet<AttendanceCheckInAttempt> AttendanceCheckInAttempts =>
|
||||||
|
Set<AttendanceCheckInAttempt>();
|
||||||
public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>();
|
public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>();
|
||||||
public DbSet<ExamArrangementJob> ExamArrangementJobs =>
|
public DbSet<ExamArrangementJob> ExamArrangementJobs =>
|
||||||
Set<ExamArrangementJob>();
|
Set<ExamArrangementJob>();
|
||||||
@@ -698,6 +700,29 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<AttendanceCheckInAttempt>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.FailureCode).HasMaxLength(64);
|
||||||
|
entity.Property(x => x.DeviceIdentifierHash).HasMaxLength(64);
|
||||||
|
entity.Property(x => x.DevicePlatform).HasMaxLength(32);
|
||||||
|
entity.Property(x => x.IpAddress).HasMaxLength(64);
|
||||||
|
entity.Property(x => x.UserAgent).HasMaxLength(500);
|
||||||
|
entity.Property(x => x.RiskFlags).HasMaxLength(300);
|
||||||
|
entity.Property(x => x.Latitude).HasPrecision(10, 7);
|
||||||
|
entity.Property(x => x.Longitude).HasPrecision(10, 7);
|
||||||
|
entity.HasIndex(x => new { x.AttendanceSheetId, x.StudentId, x.CreatedAt });
|
||||||
|
entity.HasIndex(x => new { x.DeviceIdentifierHash, x.CreatedAt });
|
||||||
|
entity.HasIndex(x => new { x.IpAddress, x.CreatedAt });
|
||||||
|
entity.HasOne(x => x.AttendanceSheet)
|
||||||
|
.WithMany(x => x.CheckInAttempts)
|
||||||
|
.HasForeignKey(x => x.AttendanceSheetId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.Student)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.StudentId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<ExamPlan>(entity =>
|
builder.Entity<ExamPlan>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.Name).HasMaxLength(120);
|
entity.Property(x => x.Name).HasMaxLength(120);
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"20260727_35_academic_planning_prerequisites";
|
"20260727_35_academic_planning_prerequisites";
|
||||||
private const string ExamRoomMixingMigration =
|
private const string ExamRoomMixingMigration =
|
||||||
"20260727_36_exam_room_mixing";
|
"20260727_36_exam_room_mixing";
|
||||||
|
private const string AttendanceCheckInAuditMigration =
|
||||||
|
"20260728_37_attendance_check_in_audit";
|
||||||
|
|
||||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -324,6 +326,18 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
AttendanceCheckInMigration,
|
AttendanceCheckInMigration,
|
||||||
attendanceCheckInExists ? [] : AttendanceCheckInStatements,
|
attendanceCheckInExists ? [] : AttendanceCheckInStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
var attendanceCheckInAttemptsExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table' AND name = 'AttendanceCheckInAttempts'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
AttendanceCheckInAuditMigration,
|
||||||
|
attendanceCheckInAttemptsExist ? [] : AttendanceCheckInAuditStatements,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
var approvalTablesExist = await db.Database
|
var approvalTablesExist = await db.Database
|
||||||
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CourseExemptions'")
|
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CourseExemptions'")
|
||||||
@@ -1929,6 +1943,47 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInDistanceMeters" REAL NULL;"""
|
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInDistanceMeters" REAL NULL;"""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] AttendanceCheckInAuditStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS "AttendanceCheckInAttempts" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_AttendanceCheckInAttempts" PRIMARY KEY,
|
||||||
|
"AttendanceSheetId" TEXT NOT NULL,
|
||||||
|
"StudentId" TEXT NOT NULL,
|
||||||
|
"CheckInMethod" INTEGER NOT NULL,
|
||||||
|
"IsSuccessful" INTEGER NOT NULL,
|
||||||
|
"FailureCode" TEXT NULL,
|
||||||
|
"DeviceIdentifierHash" TEXT NULL,
|
||||||
|
"DevicePlatform" TEXT NULL,
|
||||||
|
"IpAddress" TEXT NULL,
|
||||||
|
"UserAgent" TEXT NULL,
|
||||||
|
"RiskFlags" TEXT NULL,
|
||||||
|
"Latitude" TEXT NULL,
|
||||||
|
"Longitude" TEXT NULL,
|
||||||
|
"AccuracyMeters" REAL NULL,
|
||||||
|
"DistanceMeters" REAL NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_AttendanceCheckInAttempts_AttendanceSheets_AttendanceSheetId"
|
||||||
|
FOREIGN KEY ("AttendanceSheetId") REFERENCES "AttendanceSheets" ("Id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "FK_AttendanceCheckInAttempts_Students_StudentId"
|
||||||
|
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_AttendanceCheckInAttempts_AttendanceSheetId_StudentId_CreatedAt"
|
||||||
|
ON "AttendanceCheckInAttempts" ("AttendanceSheetId", "StudentId", "CreatedAt");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_AttendanceCheckInAttempts_DeviceIdentifierHash_CreatedAt"
|
||||||
|
ON "AttendanceCheckInAttempts" ("DeviceIdentifierHash", "CreatedAt");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_AttendanceCheckInAttempts_IpAddress_CreatedAt"
|
||||||
|
ON "AttendanceCheckInAttempts" ("IpAddress", "CreatedAt");
|
||||||
|
"""
|
||||||
|
];
|
||||||
|
|
||||||
private static readonly string[] ApprovalTableStatements =
|
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);""",
|
"""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);""",
|
||||||
|
|||||||
+5417
File diff suppressed because it is too large
Load Diff
+82
@@ -0,0 +1,82 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AttendanceCheckInAudit : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AttendanceCheckInAttempts",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
AttendanceSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
CheckInMethod = table.Column<int>(type: "int", nullable: false),
|
||||||
|
IsSuccessful = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
FailureCode = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true),
|
||||||
|
DeviceIdentifierHash = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true),
|
||||||
|
DevicePlatform = table.Column<string>(type: "varchar(32)", maxLength: 32, nullable: true),
|
||||||
|
IpAddress = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true),
|
||||||
|
UserAgent = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||||
|
RiskFlags = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true),
|
||||||
|
Latitude = table.Column<decimal>(type: "decimal(10,7)", precision: 10, scale: 7, nullable: true),
|
||||||
|
Longitude = table.Column<decimal>(type: "decimal(10,7)", precision: 10, scale: 7, nullable: true),
|
||||||
|
AccuracyMeters = table.Column<double>(type: "double", nullable: true),
|
||||||
|
DistanceMeters = table.Column<double>(type: "double", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AttendanceCheckInAttempts", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AttendanceCheckInAttempts_AttendanceSheets_AttendanceSheetId",
|
||||||
|
column: x => x.AttendanceSheetId,
|
||||||
|
principalTable: "AttendanceSheets",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AttendanceCheckInAttempts_Students_StudentId",
|
||||||
|
column: x => x.StudentId,
|
||||||
|
principalTable: "Students",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AttendanceCheckInAttempts_AttendanceSheetId_StudentId_Create~",
|
||||||
|
table: "AttendanceCheckInAttempts",
|
||||||
|
columns: new[] { "AttendanceSheetId", "StudentId", "CreatedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AttendanceCheckInAttempts_DeviceIdentifierHash_CreatedAt",
|
||||||
|
table: "AttendanceCheckInAttempts",
|
||||||
|
columns: new[] { "DeviceIdentifierHash", "CreatedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AttendanceCheckInAttempts_IpAddress_CreatedAt",
|
||||||
|
table: "AttendanceCheckInAttempts",
|
||||||
|
columns: new[] { "IpAddress", "CreatedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AttendanceCheckInAttempts_StudentId",
|
||||||
|
table: "AttendanceCheckInAttempts",
|
||||||
|
column: "StudentId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AttendanceCheckInAttempts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
@@ -137,6 +137,81 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("AdministrativeClasses");
|
b.ToTable("AdministrativeClasses");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceCheckInAttempt", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<double?>("AccuracyMeters")
|
||||||
|
.HasColumnType("double");
|
||||||
|
|
||||||
|
b.Property<Guid>("AttendanceSheetId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int>("CheckInMethod")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceIdentifierHash")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("varchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("DevicePlatform")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("varchar(32)");
|
||||||
|
|
||||||
|
b.Property<double?>("DistanceMeters")
|
||||||
|
.HasColumnType("double");
|
||||||
|
|
||||||
|
b.Property<string>("FailureCode")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("varchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("IpAddress")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("varchar(64)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSuccessful")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("Latitude")
|
||||||
|
.HasPrecision(10, 7)
|
||||||
|
.HasColumnType("decimal(10,7)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("Longitude")
|
||||||
|
.HasPrecision(10, 7)
|
||||||
|
.HasColumnType("decimal(10,7)");
|
||||||
|
|
||||||
|
b.Property<string>("RiskFlags")
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("varchar(300)");
|
||||||
|
|
||||||
|
b.Property<Guid>("StudentId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("UserAgent")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("varchar(500)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("StudentId");
|
||||||
|
|
||||||
|
b.HasIndex("DeviceIdentifierHash", "CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("IpAddress", "CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("AttendanceSheetId", "StudentId", "CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("AttendanceCheckInAttempts");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceRecord", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceRecord", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("AttendanceSheetId")
|
b.Property<Guid>("AttendanceSheetId")
|
||||||
@@ -3938,6 +4013,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("Major");
|
b.Navigation("Major");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceCheckInAttempt", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.AttendanceSheet", "AttendanceSheet")
|
||||||
|
.WithMany("CheckInAttempts")
|
||||||
|
.HasForeignKey("AttendanceSheetId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StudentId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("AttendanceSheet");
|
||||||
|
|
||||||
|
b.Navigation("Student");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceRecord", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceRecord", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Jiaowu.Api.Domain.Academic.AttendanceSheet", "AttendanceSheet")
|
b.HasOne("Jiaowu.Api.Domain.Academic.AttendanceSheet", "AttendanceSheet")
|
||||||
@@ -5163,6 +5257,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceSheet", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceSheet", b =>
|
||||||
{
|
{
|
||||||
|
b.Navigation("CheckInAttempts");
|
||||||
|
|
||||||
b.Navigation("Records");
|
b.Navigation("Records");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Teaching;
|
||||||
|
|
||||||
|
public static class AttendanceCheckInChallenge
|
||||||
|
{
|
||||||
|
public const int LifetimeSeconds = 20;
|
||||||
|
public const int RefreshSeconds = 10;
|
||||||
|
private const int MaximumClockSkewSeconds = 2;
|
||||||
|
private const string Base36Digits = "0123456789abcdefghijklmnopqrstuvwxyz";
|
||||||
|
|
||||||
|
public static AttendanceCheckInChallengeResult Create(
|
||||||
|
Guid attendanceSheetId,
|
||||||
|
string secret,
|
||||||
|
DateTime nowUtc)
|
||||||
|
{
|
||||||
|
var issuedAt = new DateTimeOffset(
|
||||||
|
DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc));
|
||||||
|
var payload = CreatePayload(
|
||||||
|
attendanceSheetId,
|
||||||
|
ToBase36(issuedAt.ToUnixTimeSeconds()));
|
||||||
|
var signature = CreateSignature(payload, secret);
|
||||||
|
return new AttendanceCheckInChallengeResult(
|
||||||
|
$"{payload}.{signature}",
|
||||||
|
issuedAt.UtcDateTime,
|
||||||
|
issuedAt.AddSeconds(LifetimeSeconds).UtcDateTime,
|
||||||
|
issuedAt.AddSeconds(RefreshSeconds).UtcDateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryReadSheetId(string? token, out Guid attendanceSheetId)
|
||||||
|
{
|
||||||
|
attendanceSheetId = Guid.Empty;
|
||||||
|
if (!TryParse(token, out var sheetIdText, out _, out _))
|
||||||
|
return false;
|
||||||
|
return Guid.TryParseExact(sheetIdText, "N", out attendanceSheetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsValid(
|
||||||
|
string? token,
|
||||||
|
Guid attendanceSheetId,
|
||||||
|
string? secret,
|
||||||
|
DateTime nowUtc)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(secret) ||
|
||||||
|
!TryParse(token, out var sheetIdText, out var issuedAtText, out var signature) ||
|
||||||
|
!Guid.TryParseExact(sheetIdText, "N", out var tokenSheetId) ||
|
||||||
|
tokenSheetId != attendanceSheetId ||
|
||||||
|
!TryParseBase36(issuedAtText, out var issuedAtUnixSeconds))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nowUnixSeconds = new DateTimeOffset(
|
||||||
|
DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc)).ToUnixTimeSeconds();
|
||||||
|
var ageSeconds = nowUnixSeconds - issuedAtUnixSeconds;
|
||||||
|
if (ageSeconds < -MaximumClockSkewSeconds || ageSeconds > LifetimeSeconds)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
byte[] providedSignature;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
providedSignature = WebEncoders.Base64UrlDecode(signature);
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var expectedSignature = WebEncoders.Base64UrlDecode(
|
||||||
|
CreateSignature(
|
||||||
|
CreatePayload(attendanceSheetId, issuedAtText),
|
||||||
|
secret));
|
||||||
|
return CryptographicOperations.FixedTimeEquals(
|
||||||
|
providedSignature,
|
||||||
|
expectedSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreatePayload(Guid attendanceSheetId, string issuedAtText) =>
|
||||||
|
$"{attendanceSheetId:N}.{issuedAtText}";
|
||||||
|
|
||||||
|
private static string CreateSignature(string payload, string secret)
|
||||||
|
{
|
||||||
|
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
||||||
|
var digest = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
|
||||||
|
return WebEncoders.Base64UrlEncode(digest[..16]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParse(
|
||||||
|
string? token,
|
||||||
|
out string sheetIdText,
|
||||||
|
out string issuedAtText,
|
||||||
|
out string signature)
|
||||||
|
{
|
||||||
|
sheetIdText = string.Empty;
|
||||||
|
issuedAtText = string.Empty;
|
||||||
|
signature = string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(token) || token.Length > 64)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var parts = token.Split('.');
|
||||||
|
if (parts.Length != 3 ||
|
||||||
|
parts[0].Length != 32 ||
|
||||||
|
parts[1].Length is < 1 or > 12 ||
|
||||||
|
parts[2].Length != 22)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
sheetIdText = parts[0];
|
||||||
|
issuedAtText = parts[1];
|
||||||
|
signature = parts[2];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToBase36(long value)
|
||||||
|
{
|
||||||
|
if (value == 0) return "0";
|
||||||
|
Span<char> buffer = stackalloc char[13];
|
||||||
|
var index = buffer.Length;
|
||||||
|
while (value > 0)
|
||||||
|
{
|
||||||
|
buffer[--index] = Base36Digits[(int)(value % 36)];
|
||||||
|
value /= 36;
|
||||||
|
}
|
||||||
|
return new string(buffer[index..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseBase36(string value, out long result)
|
||||||
|
{
|
||||||
|
result = 0;
|
||||||
|
foreach (var character in value)
|
||||||
|
{
|
||||||
|
var digit = Base36Digits.IndexOf(
|
||||||
|
char.ToLower(character, CultureInfo.InvariantCulture));
|
||||||
|
if (digit < 0) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = checked(result * 36 + digit);
|
||||||
|
}
|
||||||
|
catch (OverflowException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AttendanceCheckInChallengeResult(
|
||||||
|
string Token,
|
||||||
|
DateTime IssuedAt,
|
||||||
|
DateTime ExpiresAt,
|
||||||
|
DateTime RefreshAt);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class AttendanceCheckInChallengeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Challenge_IsBoundToSheetSecretAndTwentySecondLifetime()
|
||||||
|
{
|
||||||
|
var sheetId = Guid.NewGuid();
|
||||||
|
var issuedAt = new DateTime(2026, 7, 28, 8, 0, 0, DateTimeKind.Utc);
|
||||||
|
var challenge = AttendanceCheckInChallenge.Create(
|
||||||
|
sheetId,
|
||||||
|
"sheet-secret",
|
||||||
|
issuedAt);
|
||||||
|
|
||||||
|
Assert.True(AttendanceCheckInChallenge.TryReadSheetId(
|
||||||
|
challenge.Token,
|
||||||
|
out var parsedSheetId));
|
||||||
|
Assert.Equal(sheetId, parsedSheetId);
|
||||||
|
Assert.True(AttendanceCheckInChallenge.IsValid(
|
||||||
|
challenge.Token,
|
||||||
|
sheetId,
|
||||||
|
"sheet-secret",
|
||||||
|
issuedAt.AddSeconds(20)));
|
||||||
|
Assert.False(AttendanceCheckInChallenge.IsValid(
|
||||||
|
challenge.Token,
|
||||||
|
sheetId,
|
||||||
|
"sheet-secret",
|
||||||
|
issuedAt.AddSeconds(21)));
|
||||||
|
Assert.False(AttendanceCheckInChallenge.IsValid(
|
||||||
|
challenge.Token,
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"sheet-secret",
|
||||||
|
issuedAt));
|
||||||
|
Assert.False(AttendanceCheckInChallenge.IsValid(
|
||||||
|
challenge.Token,
|
||||||
|
sheetId,
|
||||||
|
"different-secret",
|
||||||
|
issuedAt));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,10 +55,19 @@ public sealed class AttendanceControllerTests
|
|||||||
EnrollmentYear = 2026,
|
EnrollmentYear = 2026,
|
||||||
EnrollmentDate = new DateOnly(2026, 9, 1)
|
EnrollmentDate = new DateOnly(2026, 9, 1)
|
||||||
};
|
};
|
||||||
|
var secondStudentUserId = Guid.NewGuid();
|
||||||
|
var secondStudentUser = new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = secondStudentUserId,
|
||||||
|
UserName = "202601002",
|
||||||
|
NormalizedUserName = "202601002",
|
||||||
|
DisplayName = "吴同学"
|
||||||
|
};
|
||||||
var secondStudent = new Student
|
var secondStudent = new Student
|
||||||
{
|
{
|
||||||
StudentNumber = "202601002",
|
StudentNumber = "202601002",
|
||||||
Name = "吴同学",
|
Name = "吴同学",
|
||||||
|
UserId = secondStudentUserId,
|
||||||
AdministrativeClassId = administrativeClass.Id,
|
AdministrativeClassId = administrativeClass.Id,
|
||||||
EnrollmentYear = 2026,
|
EnrollmentYear = 2026,
|
||||||
EnrollmentDate = new DateOnly(2026, 9, 1)
|
EnrollmentDate = new DateOnly(2026, 9, 1)
|
||||||
@@ -95,6 +104,7 @@ public sealed class AttendanceControllerTests
|
|||||||
};
|
};
|
||||||
db.AddRange(
|
db.AddRange(
|
||||||
studentUser,
|
studentUser,
|
||||||
|
secondStudentUser,
|
||||||
college,
|
college,
|
||||||
major,
|
major,
|
||||||
administrativeClass,
|
administrativeClass,
|
||||||
@@ -198,6 +208,11 @@ public sealed class AttendanceControllerTests
|
|||||||
StudentId = firstStudent.Id,
|
StudentId = firstStudent.Id,
|
||||||
Status = AttendanceStatus.Absent
|
Status = AttendanceStatus.Absent
|
||||||
};
|
};
|
||||||
|
var secondQrRecord = new AttendanceRecord
|
||||||
|
{
|
||||||
|
StudentId = secondStudent.Id,
|
||||||
|
Status = AttendanceStatus.Absent
|
||||||
|
};
|
||||||
var qrSheet = new AttendanceSheet
|
var qrSheet = new AttendanceSheet
|
||||||
{
|
{
|
||||||
TeachingTaskId = task.Id,
|
TeachingTaskId = task.Id,
|
||||||
@@ -208,10 +223,17 @@ public sealed class AttendanceControllerTests
|
|||||||
CheckInToken = "TEST-QR-TOKEN",
|
CheckInToken = "TEST-QR-TOKEN",
|
||||||
CheckInStartsAt = now.AddMinutes(-1),
|
CheckInStartsAt = now.AddMinutes(-1),
|
||||||
CheckInEndsAt = now.AddMinutes(10),
|
CheckInEndsAt = now.AddMinutes(10),
|
||||||
Records = [qrRecord]
|
Records = [qrRecord, secondQrRecord]
|
||||||
};
|
};
|
||||||
db.AttendanceSheets.Add(qrSheet);
|
db.AttendanceSheets.Add(qrSheet);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
var qrChallengeResult = await controller.GetQrChallenge(
|
||||||
|
qrSheet.Id,
|
||||||
|
CancellationToken.None);
|
||||||
|
var qrChallengeOk = Assert.IsType<OkObjectResult>(qrChallengeResult);
|
||||||
|
var qrToken = Assert.IsType<string>(
|
||||||
|
qrChallengeOk.Value!.GetType().GetProperty("Token")!.GetValue(
|
||||||
|
qrChallengeOk.Value));
|
||||||
|
|
||||||
var studentController = new AttendanceController(
|
var studentController = new AttendanceController(
|
||||||
db,
|
db,
|
||||||
@@ -231,23 +253,55 @@ public sealed class AttendanceControllerTests
|
|||||||
item.GetType().GetProperty("TeachingTaskId")!.GetValue(item)));
|
item.GetType().GetProperty("TeachingTaskId")!.GetValue(item)));
|
||||||
|
|
||||||
var infoResult = await studentController.GetCheckInInfo(
|
var infoResult = await studentController.GetCheckInInfo(
|
||||||
qrSheet.CheckInToken,
|
qrToken,
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
Assert.IsType<OkObjectResult>(infoResult);
|
Assert.IsType<OkObjectResult>(infoResult);
|
||||||
|
Assert.IsType<NotFoundResult>(
|
||||||
|
await studentController.GetCheckInInfo(
|
||||||
|
qrSheet.CheckInToken,
|
||||||
|
CancellationToken.None));
|
||||||
|
|
||||||
var qrCheckInResult = await studentController.CheckIn(
|
var qrCheckInResult = await studentController.CheckIn(
|
||||||
new AttendanceCheckInRequest(
|
new AttendanceCheckInRequest(
|
||||||
null,
|
null,
|
||||||
qrSheet.CheckInToken,
|
qrToken,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null),
|
null,
|
||||||
|
"test-device-1",
|
||||||
|
"android"),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
Assert.IsType<OkObjectResult>(qrCheckInResult);
|
Assert.IsType<OkObjectResult>(qrCheckInResult);
|
||||||
Assert.Equal(AttendanceStatus.Present, qrRecord.Status);
|
Assert.Equal(AttendanceStatus.Present, qrRecord.Status);
|
||||||
Assert.Equal(AttendanceCheckInMethod.QrCode, qrRecord.CheckedInMethod);
|
Assert.Equal(AttendanceCheckInMethod.QrCode, qrRecord.CheckedInMethod);
|
||||||
Assert.NotNull(qrRecord.CheckInAt);
|
Assert.NotNull(qrRecord.CheckInAt);
|
||||||
Assert.Null(qrRecord.CheckInLatitude);
|
Assert.Null(qrRecord.CheckInLatitude);
|
||||||
|
var qrAttempt = await db.AttendanceCheckInAttempts.SingleAsync(
|
||||||
|
x => x.AttendanceSheetId == qrSheet.Id);
|
||||||
|
Assert.True(qrAttempt.IsSuccessful);
|
||||||
|
Assert.Equal("android", qrAttempt.DevicePlatform);
|
||||||
|
Assert.NotNull(qrAttempt.DeviceIdentifierHash);
|
||||||
|
|
||||||
|
var secondStudentController = new AttendanceController(
|
||||||
|
db,
|
||||||
|
new StudentDataScope(secondStudentUserId));
|
||||||
|
Assert.IsType<OkObjectResult>(
|
||||||
|
await secondStudentController.CheckIn(
|
||||||
|
new AttendanceCheckInRequest(
|
||||||
|
null,
|
||||||
|
qrToken,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"test-device-1",
|
||||||
|
"android"),
|
||||||
|
CancellationToken.None));
|
||||||
|
var sharedDeviceAttempt = await db.AttendanceCheckInAttempts.SingleAsync(
|
||||||
|
x => x.AttendanceSheetId == qrSheet.Id &&
|
||||||
|
x.StudentId == secondStudent.Id);
|
||||||
|
Assert.Contains(
|
||||||
|
"SharedDevice",
|
||||||
|
sharedDeviceAttempt.RiskFlags ?? string.Empty);
|
||||||
|
|
||||||
var locationRecord = new AttendanceRecord
|
var locationRecord = new AttendanceRecord
|
||||||
{
|
{
|
||||||
@@ -282,6 +336,40 @@ public sealed class AttendanceControllerTests
|
|||||||
Assert.IsType<ConflictObjectResult>(outsideResult);
|
Assert.IsType<ConflictObjectResult>(outsideResult);
|
||||||
Assert.Null(locationRecord.CheckInAt);
|
Assert.Null(locationRecord.CheckInAt);
|
||||||
|
|
||||||
|
var inaccurateResult = await studentController.CheckIn(
|
||||||
|
new AttendanceCheckInRequest(
|
||||||
|
locationSheet.Id,
|
||||||
|
null,
|
||||||
|
39.9001m,
|
||||||
|
116.4m,
|
||||||
|
150),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.IsType<ConflictObjectResult>(inaccurateResult);
|
||||||
|
Assert.Null(locationRecord.CheckInAt);
|
||||||
|
|
||||||
|
var missingAccuracyResult = await studentController.CheckIn(
|
||||||
|
new AttendanceCheckInRequest(
|
||||||
|
locationSheet.Id,
|
||||||
|
null,
|
||||||
|
39.9001m,
|
||||||
|
116.4m,
|
||||||
|
null),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.IsType<ConflictObjectResult>(missingAccuracyResult);
|
||||||
|
Assert.Null(locationRecord.CheckInAt);
|
||||||
|
var locationFailures = await db.AttendanceCheckInAttempts
|
||||||
|
.Where(x =>
|
||||||
|
x.AttendanceSheetId == locationSheet.Id &&
|
||||||
|
!x.IsSuccessful)
|
||||||
|
.OrderBy(x => x.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
Assert.Equal(3, locationFailures.Count);
|
||||||
|
Assert.Contains(
|
||||||
|
locationFailures,
|
||||||
|
x => (x.RiskFlags ?? string.Empty).Contains(
|
||||||
|
"RepeatedFailures",
|
||||||
|
StringComparison.Ordinal));
|
||||||
|
|
||||||
var nearbyResult = await studentController.CheckIn(
|
var nearbyResult = await studentController.CheckIn(
|
||||||
new AttendanceCheckInRequest(
|
new AttendanceCheckInRequest(
|
||||||
locationSheet.Id,
|
locationSheet.Id,
|
||||||
|
|||||||
Generated
+38
@@ -9,7 +9,9 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "^8.4.2",
|
"@capacitor/android": "^8.4.2",
|
||||||
|
"@capacitor/barcode-scanner": "^3.1.0",
|
||||||
"@capacitor/core": "^8.4.2",
|
"@capacitor/core": "^8.4.2",
|
||||||
|
"@capacitor/geolocation": "^8.2.0",
|
||||||
"@capacitor/ios": "^8.4.2",
|
"@capacitor/ios": "^8.4.2",
|
||||||
"@ckeditor/ckeditor5-vue": "^8.2.0",
|
"@ckeditor/ckeditor5-vue": "^8.2.0",
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
@@ -102,6 +104,18 @@
|
|||||||
"@capacitor/core": "^8.4.0"
|
"@capacitor/core": "^8.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@capacitor/barcode-scanner": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@capacitor/barcode-scanner/-/barcode-scanner-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-uE4njvsQGVfhjChg5ZU8ayYX9i2LM9Dg9/TWXG/L1s+pRVR5JG6PxsC3dCuKa9F6OVCYrKcEmdSi3E6KT+fMbg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"html5-qrcode": "2.3.8"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@capacitor/cli": {
|
"node_modules/@capacitor/cli": {
|
||||||
"version": "8.4.2",
|
"version": "8.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.4.2.tgz",
|
||||||
@@ -144,6 +158,18 @@
|
|||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@capacitor/geolocation": {
|
||||||
|
"version": "8.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@capacitor/geolocation/-/geolocation-8.2.0.tgz",
|
||||||
|
"integrity": "sha512-N29QcoIPmme0xSxRkm7+3hjoHp6mBAOarxecvtCCZKyOBeKiJsFUq981cezg2XWBa6fhCXJMCCjQPngKK/dIag==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@capacitor/synapse": "^1.0.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@capacitor/ios": {
|
"node_modules/@capacitor/ios": {
|
||||||
"version": "8.4.2",
|
"version": "8.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.4.2.tgz",
|
||||||
@@ -153,6 +179,12 @@
|
|||||||
"@capacitor/core": "^8.4.0"
|
"@capacitor/core": "^8.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@capacitor/synapse": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/@ckeditor/ckeditor5-adapter-ckfinder": {
|
"node_modules/@ckeditor/ckeditor5-adapter-ckfinder": {
|
||||||
"version": "48.3.1",
|
"version": "48.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-adapter-ckfinder/-/ckeditor5-adapter-ckfinder-48.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-adapter-ckfinder/-/ckeditor5-adapter-ckfinder-48.3.1.tgz",
|
||||||
@@ -3405,6 +3437,12 @@
|
|||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html5-qrcode": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/https-proxy-agent": {
|
"node_modules/https-proxy-agent": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||||
|
|||||||
+4
-1
@@ -8,7 +8,8 @@
|
|||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"build:capacitor": "vue-tsc -b && vite build --mode capacitor",
|
"build:capacitor": "vue-tsc -b && vite build --mode capacitor",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"cap:sync": "npx cap sync",
|
"cap:configure": "node scripts/configure-capacitor.mjs",
|
||||||
|
"cap:sync": "npx cap sync && npm run cap:configure",
|
||||||
"cap:open:android": "npx cap open android",
|
"cap:open:android": "npx cap open android",
|
||||||
"cap:open:ios": "npx cap open ios",
|
"cap:open:ios": "npx cap open ios",
|
||||||
"cap:run:android": "npx cap run android",
|
"cap:run:android": "npx cap run android",
|
||||||
@@ -16,7 +17,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "^8.4.2",
|
"@capacitor/android": "^8.4.2",
|
||||||
|
"@capacitor/barcode-scanner": "^3.1.0",
|
||||||
"@capacitor/core": "^8.4.2",
|
"@capacitor/core": "^8.4.2",
|
||||||
|
"@capacitor/geolocation": "^8.2.0",
|
||||||
"@capacitor/ios": "^8.4.2",
|
"@capacitor/ios": "^8.4.2",
|
||||||
"@ckeditor/ckeditor5-vue": "^8.2.0",
|
"@ckeditor/ckeditor5-vue": "^8.2.0",
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const webRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
|
||||||
|
function updateFile(path, update) {
|
||||||
|
if (!existsSync(path)) return false
|
||||||
|
const current = readFileSync(path, 'utf8')
|
||||||
|
const next = update(current)
|
||||||
|
if (next !== current) writeFileSync(path, next, 'utf8')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureAndroid() {
|
||||||
|
const variablesPath = resolve(webRoot, 'android', 'variables.gradle')
|
||||||
|
const manifestPath = resolve(
|
||||||
|
webRoot,
|
||||||
|
'android',
|
||||||
|
'app',
|
||||||
|
'src',
|
||||||
|
'main',
|
||||||
|
'AndroidManifest.xml',
|
||||||
|
)
|
||||||
|
if (!existsSync(variablesPath) || !existsSync(manifestPath)) return false
|
||||||
|
|
||||||
|
updateFile(variablesPath, content => {
|
||||||
|
if (!/minSdkVersion\s*=\s*\d+/.test(content)) {
|
||||||
|
throw new Error('未在 android/variables.gradle 中找到 minSdkVersion。')
|
||||||
|
}
|
||||||
|
return content.replace(/minSdkVersion\s*=\s*\d+/, 'minSdkVersion = 26')
|
||||||
|
})
|
||||||
|
|
||||||
|
updateFile(manifestPath, content => {
|
||||||
|
const declarations = [
|
||||||
|
'<uses-permission android:name="android.permission.CAMERA" />',
|
||||||
|
'<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />',
|
||||||
|
'<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />',
|
||||||
|
'<uses-feature android:name="android.hardware.camera" android:required="false" />',
|
||||||
|
'<uses-feature android:name="android.hardware.location.gps" android:required="false" />',
|
||||||
|
]
|
||||||
|
const missing = declarations.filter(declaration => !content.includes(declaration))
|
||||||
|
if (!missing.length) return content
|
||||||
|
return content.replace(
|
||||||
|
'</manifest>',
|
||||||
|
` ${missing.join('\n ')}\n</manifest>`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureIos() {
|
||||||
|
const infoPlistPath = resolve(webRoot, 'ios', 'App', 'App', 'Info.plist')
|
||||||
|
if (!existsSync(infoPlistPath)) return false
|
||||||
|
|
||||||
|
updateFile(infoPlistPath, content => {
|
||||||
|
const descriptions = [
|
||||||
|
['NSCameraUsageDescription', '用于扫描教师展示的课堂签到二维码。'],
|
||||||
|
['NSLocationWhenInUseUsageDescription', '用于在课堂签到时校验当前位置。'],
|
||||||
|
['NSLocationAlwaysAndWhenInUseUsageDescription', '用于在课堂签到时校验当前位置。'],
|
||||||
|
]
|
||||||
|
const missing = descriptions.filter(([key]) => !content.includes(`<key>${key}</key>`))
|
||||||
|
if (!missing.length) return content
|
||||||
|
const entries = missing
|
||||||
|
.map(([key, value]) => `\t<key>${key}</key>\n\t<string>${value}</string>`)
|
||||||
|
.join('\n')
|
||||||
|
return content.replace('</dict>', `${entries}\n</dict>`)
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredPlatforms = [
|
||||||
|
configureAndroid() ? 'Android' : null,
|
||||||
|
configureIos() ? 'iOS' : null,
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
if (!configuredPlatforms.length) {
|
||||||
|
throw new Error('尚未生成 Capacitor 原生工程,请先运行 npx cap add android 或 ios。')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`已配置 ${configuredPlatforms.join('、')} 的扫码和定位权限。`)
|
||||||
@@ -1,16 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Capacitor } from '@capacitor/core'
|
||||||
|
import {
|
||||||
|
CapacitorBarcodeScanner,
|
||||||
|
CapacitorBarcodeScannerCameraDirection,
|
||||||
|
CapacitorBarcodeScannerScanOrientation,
|
||||||
|
CapacitorBarcodeScannerTypeHint,
|
||||||
|
} from '@capacitor/barcode-scanner'
|
||||||
|
import { Geolocation } from '@capacitor/geolocation'
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { Check, Clock, Location, Refresh, Warning } from '@element-plus/icons-vue'
|
import { Camera, Check, Clock, Location, Refresh, Warning } from '@element-plus/icons-vue'
|
||||||
import http, { apiErrorMessage } from '../api/http'
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const isNativeApp = Capacitor.isNativePlatform()
|
||||||
|
const attendanceDeviceId = getOrCreateAttendanceDeviceId()
|
||||||
|
const attendanceDevicePlatform = Capacitor.getPlatform()
|
||||||
const records = ref<any[]>([])
|
const records = ref<any[]>([])
|
||||||
const openActivities = ref<any[]>([])
|
const openActivities = ref<any[]>([])
|
||||||
const scannedActivity = ref<any>(null)
|
const scannedActivity = ref<any>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const scanLoading = ref(false)
|
const scanLoading = ref(false)
|
||||||
|
const nativeScanLoading = ref(false)
|
||||||
const checkInTargetId = ref('')
|
const checkInTargetId = ref('')
|
||||||
const now = ref(Date.now())
|
const now = ref(Date.now())
|
||||||
const appealDialog = ref(false)
|
const appealDialog = ref(false)
|
||||||
@@ -26,6 +38,24 @@ const statusColors: Record<string, 'success' | 'danger' | 'warning' | 'info' | u
|
|||||||
const appealStatusLabels: Record<string, string> = {
|
const appealStatusLabels: Record<string, string> = {
|
||||||
None: '', Pending: '申诉中', Approved: '已通过', Rejected: '已驳回',
|
None: '', Pending: '申诉中', Approved: '已通过', Rejected: '已驳回',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getOrCreateAttendanceDeviceId() {
|
||||||
|
const storageKey = 'jiaowu_attendance_device_id'
|
||||||
|
const existing = localStorage.getItem(storageKey)
|
||||||
|
if (existing) return existing
|
||||||
|
const created = typeof crypto.randomUUID === 'function'
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now().toString(36)}-${crypto.getRandomValues(new Uint32Array(4)).join('-')}`
|
||||||
|
localStorage.setItem(storageKey, created)
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceAuditPayload() {
|
||||||
|
return {
|
||||||
|
deviceId: attendanceDeviceId,
|
||||||
|
devicePlatform: attendanceDevicePlatform,
|
||||||
|
}
|
||||||
|
}
|
||||||
const courseGroups = computed(() => {
|
const courseGroups = computed(() => {
|
||||||
const groups = new Map<string, any>()
|
const groups = new Map<string, any>()
|
||||||
records.value.forEach((record: any) => {
|
records.value.forEach((record: any) => {
|
||||||
@@ -104,6 +134,64 @@ async function loadScannedActivity(token: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractCheckInToken(value: string) {
|
||||||
|
const scannedValue = value.trim()
|
||||||
|
const isToken = (token: string) => /^[a-z\d._-]{20,64}$/i.test(token)
|
||||||
|
if (isToken(scannedValue)) return scannedValue
|
||||||
|
|
||||||
|
try {
|
||||||
|
const scannedUrl = new URL(scannedValue)
|
||||||
|
const token = scannedUrl.searchParams.get('token')
|
||||||
|
if (token && isToken(token)) return token
|
||||||
|
|
||||||
|
const hashQuery = scannedUrl.hash.includes('?')
|
||||||
|
? scannedUrl.hash.slice(scannedUrl.hash.indexOf('?'))
|
||||||
|
: ''
|
||||||
|
const hashToken = new URLSearchParams(hashQuery).get('token')
|
||||||
|
if (hashToken && isToken(hashToken)) return hashToken
|
||||||
|
} catch {
|
||||||
|
// 不是网址时继续按签到参数格式解析。
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryToken = /(?:[?&]token=)([^&#]+)/i.exec(scannedValue)?.[1]
|
||||||
|
if (!queryToken) return ''
|
||||||
|
try {
|
||||||
|
const decodedToken = decodeURIComponent(queryToken)
|
||||||
|
return isToken(decodedToken) ? decodedToken : ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanCheckInCode() {
|
||||||
|
nativeScanLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await CapacitorBarcodeScanner.scanBarcode({
|
||||||
|
hint: CapacitorBarcodeScannerTypeHint.QR_CODE,
|
||||||
|
scanInstructions: '请扫描教师展示的课堂签到二维码',
|
||||||
|
cameraDirection: CapacitorBarcodeScannerCameraDirection.BACK,
|
||||||
|
scanOrientation: CapacitorBarcodeScannerScanOrientation.ADAPTIVE,
|
||||||
|
cancelButtonAccessibilityLabel: '取消扫码',
|
||||||
|
torchButtonOnAccessibilityLabel: '关闭手电筒',
|
||||||
|
torchButtonOffAccessibilityLabel: '打开手电筒',
|
||||||
|
})
|
||||||
|
const token = extractCheckInToken(result.ScanResult)
|
||||||
|
if (!token) {
|
||||||
|
ElMessage.warning('这不是有效的课堂签到二维码。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await router.replace({ path: route.path, query: { token } })
|
||||||
|
await loadScannedActivity(token)
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = String(error?.message ?? error ?? '')
|
||||||
|
if (!/cancel/i.test(message)) {
|
||||||
|
ElMessage.error('无法启动扫码,请在系统设置中允许本应用使用相机。')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
nativeScanLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function serverUtcTime(value: string | null | undefined) {
|
function serverUtcTime(value: string | null | undefined) {
|
||||||
if (!value) return Number.NaN
|
if (!value) return Number.NaN
|
||||||
const normalized = /(?:Z|[+-]\d{2}:\d{2})$/i.test(value) ? value : `${value}Z`
|
const normalized = /(?:Z|[+-]\d{2}:\d{2})$/i.test(value) ? value : `${value}Z`
|
||||||
@@ -131,7 +219,10 @@ async function confirmQrCheckIn() {
|
|||||||
if (!token || !scannedActivity.value) return
|
if (!token || !scannedActivity.value) return
|
||||||
checkInTargetId.value = scannedActivity.value.sheetId
|
checkInTargetId.value = scannedActivity.value.sheetId
|
||||||
try {
|
try {
|
||||||
const { data } = await http.post('/attendance/check-in', { token })
|
const { data } = await http.post('/attendance/check-in', {
|
||||||
|
token,
|
||||||
|
...deviceAuditPayload(),
|
||||||
|
})
|
||||||
ElMessage.success(data.alreadyCheckedIn ? '你已完成本次签到' : '签到成功')
|
ElMessage.success(data.alreadyCheckedIn ? '你已完成本次签到' : '签到成功')
|
||||||
await router.replace({ path: route.path, query: {} })
|
await router.replace({ path: route.path, query: {} })
|
||||||
await load()
|
await load()
|
||||||
@@ -143,6 +234,20 @@ async function confirmQrCheckIn() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function captureLocation() {
|
async function captureLocation() {
|
||||||
|
if (isNativeApp) {
|
||||||
|
let permission = await Geolocation.checkPermissions()
|
||||||
|
if (permission.location !== 'granted') {
|
||||||
|
permission = await Geolocation.requestPermissions({ permissions: ['location'] })
|
||||||
|
}
|
||||||
|
if (permission.location !== 'granted') {
|
||||||
|
throw Object.assign(new Error('permission denied'), { code: 1 })
|
||||||
|
}
|
||||||
|
return await Geolocation.getCurrentPosition({
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 12000,
|
||||||
|
maximumAge: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
if (!navigator.geolocation) throw new Error('unsupported')
|
if (!navigator.geolocation) throw new Error('unsupported')
|
||||||
return await new Promise<GeolocationPosition>((resolve, reject) => {
|
return await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||||
@@ -162,6 +267,7 @@ async function locationCheckIn(activity: any) {
|
|||||||
latitude: position.coords.latitude,
|
latitude: position.coords.latitude,
|
||||||
longitude: position.coords.longitude,
|
longitude: position.coords.longitude,
|
||||||
accuracyMeters: position.coords.accuracy,
|
accuracyMeters: position.coords.accuracy,
|
||||||
|
...deviceAuditPayload(),
|
||||||
})
|
})
|
||||||
ElMessage.success(data.alreadyCheckedIn
|
ElMessage.success(data.alreadyCheckedIn
|
||||||
? '你已完成本次签到'
|
? '你已完成本次签到'
|
||||||
@@ -172,7 +278,9 @@ async function locationCheckIn(activity: any) {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (!error?.response) {
|
if (!error?.response) {
|
||||||
const message = error?.code === 1
|
const message = error?.code === 1
|
||||||
? '定位权限被拒绝,请在浏览器中允许本网站获取位置。'
|
? isNativeApp
|
||||||
|
? '定位权限被拒绝,请在系统设置中允许本应用获取精确位置。'
|
||||||
|
: '定位权限被拒绝,请在浏览器中允许本网站获取位置。'
|
||||||
: error?.code === 3
|
: error?.code === 3
|
||||||
? '获取位置超时,请移到信号较好的位置后重试。'
|
? '获取位置超时,请移到信号较好的位置后重试。'
|
||||||
: '无法获取当前位置,请检查手机定位服务后重试。'
|
: '无法获取当前位置,请检查手机定位服务后重试。'
|
||||||
@@ -244,7 +352,16 @@ onUnmounted(() => {
|
|||||||
<h2>我的考勤</h2>
|
<h2>我的考勤</h2>
|
||||||
<p>完成课堂扫码或定位签到,并查看已提交的考勤记录。</p>
|
<p>完成课堂扫码或定位签到,并查看已提交的考勤记录。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
<div class="intro-actions">
|
||||||
|
<el-button
|
||||||
|
v-if="isNativeApp"
|
||||||
|
type="primary"
|
||||||
|
:icon="Camera"
|
||||||
|
:loading="nativeScanLoading"
|
||||||
|
@click="scanCheckInCode"
|
||||||
|
>扫码签到</el-button>
|
||||||
|
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@@ -402,6 +519,11 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.intro-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
.check-in-board {
|
.check-in-board {
|
||||||
border: 1px solid #cdd9e5;
|
border: 1px solid #cdd9e5;
|
||||||
background: #f6f9fc;
|
background: #f6f9fc;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Capacitor } from '@capacitor/core'
|
||||||
|
import { Geolocation } from '@capacitor/geolocation'
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
Clock,
|
Clock,
|
||||||
CopyDocument,
|
|
||||||
Download,
|
Download,
|
||||||
Grid,
|
Grid,
|
||||||
Location,
|
Location,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
Refresh,
|
Refresh,
|
||||||
Search,
|
Search,
|
||||||
Upload,
|
Upload,
|
||||||
|
Warning,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import * as QRCode from 'qrcode'
|
import * as QRCode from 'qrcode'
|
||||||
import * as echarts from 'echarts/core'
|
import * as echarts from 'echarts/core'
|
||||||
@@ -46,6 +48,8 @@ const createSubmitting = ref(false)
|
|||||||
const teacherLocationLoading = ref(false)
|
const teacherLocationLoading = ref(false)
|
||||||
const qrDataUrl = ref('')
|
const qrDataUrl = ref('')
|
||||||
const qrCheckInUrl = ref('')
|
const qrCheckInUrl = ref('')
|
||||||
|
const qrChallengeExpiresAt = ref(0)
|
||||||
|
const isNativeApp = Capacitor.isNativePlatform()
|
||||||
const now = ref(Date.now())
|
const now = ref(Date.now())
|
||||||
const fileInput = ref<HTMLInputElement>()
|
const fileInput = ref<HTMLInputElement>()
|
||||||
const termId = ref<string>()
|
const termId = ref<string>()
|
||||||
@@ -67,6 +71,7 @@ const createForm = ref({
|
|||||||
})
|
})
|
||||||
const chartInstances: echarts.ECharts[] = []
|
const chartInstances: echarts.ECharts[] = []
|
||||||
let clockTimer: number | undefined
|
let clockTimer: number | undefined
|
||||||
|
let qrRefreshTimer: number | undefined
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
{ value: 'Present', label: '出勤' },
|
{ value: 'Present', label: '出勤' },
|
||||||
@@ -188,8 +193,35 @@ function openCreate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function captureTeacherLocation() {
|
async function captureTeacherLocation() {
|
||||||
|
if (isNativeApp) {
|
||||||
|
teacherLocationLoading.value = true
|
||||||
|
try {
|
||||||
|
let permission = await Geolocation.checkPermissions()
|
||||||
|
if (permission.location !== 'granted') {
|
||||||
|
permission = await Geolocation.requestPermissions({ permissions: ['location'] })
|
||||||
|
}
|
||||||
|
if (permission.location !== 'granted') {
|
||||||
|
throw Object.assign(new Error('permission denied'), { code: 1 })
|
||||||
|
}
|
||||||
|
const position = await Geolocation.getCurrentPosition({
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 12000,
|
||||||
|
maximumAge: 0,
|
||||||
|
})
|
||||||
|
applyTeacherLocation(position)
|
||||||
|
return true
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error?.code === 1
|
||||||
|
? '定位权限被拒绝,请在系统设置中允许本应用获取精确位置。'
|
||||||
|
: '暂时无法获取位置,请检查手机定位服务后重试。'
|
||||||
|
ElMessage.error(message)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
teacherLocationLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
ElMessage.error('当前浏览器不支持定位,请更换浏览器或使用扫码签到。')
|
ElMessage.error('当前电脑没有可用定位,请改用教师手机 App 发起定位签到。')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
teacherLocationLoading.value = true
|
teacherLocationLoading.value = true
|
||||||
@@ -201,10 +233,7 @@ async function captureTeacherLocation() {
|
|||||||
maximumAge: 0,
|
maximumAge: 0,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
createForm.value.targetLatitude = Number(position.coords.latitude.toFixed(7))
|
applyTeacherLocation(position)
|
||||||
createForm.value.targetLongitude = Number(position.coords.longitude.toFixed(7))
|
|
||||||
createForm.value.locationAccuracyMeters = Math.round(position.coords.accuracy)
|
|
||||||
ElMessage.success('已获取当前签到点')
|
|
||||||
return true
|
return true
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message = error?.code === 1
|
const message = error?.code === 1
|
||||||
@@ -219,6 +248,15 @@ async function captureTeacherLocation() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyTeacherLocation(position: {
|
||||||
|
coords: { latitude: number; longitude: number; accuracy: number }
|
||||||
|
}) {
|
||||||
|
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('已获取教师手机当前位置')
|
||||||
|
}
|
||||||
|
|
||||||
async function createSheet() {
|
async function createSheet() {
|
||||||
if (!createForm.value.name.trim()) {
|
if (!createForm.value.name.trim()) {
|
||||||
ElMessage.warning('请填写考勤表名称。')
|
ElMessage.warning('请填写考勤表名称。')
|
||||||
@@ -308,37 +346,64 @@ function remainingLabel(sheet: any) {
|
|||||||
return `${minutes}分${String(seconds).padStart(2, '0')}秒后结束`
|
return `${minutes}分${String(seconds).padStart(2, '0')}秒后结束`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showQrCode() {
|
async function refreshQrCode(showError: boolean) {
|
||||||
const sheet = sheetDetail.value?.sheet
|
const sheet = sheetDetail.value?.sheet
|
||||||
if (!sheet?.checkInToken) {
|
if (!sheet || !isCheckInOpen(sheet)) return false
|
||||||
ElMessage.warning('未取得签到码,请刷新考勤表后重试。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const publicBase = import.meta.env.VITE_PUBLIC_BASE_URL ?? window.location.origin
|
|
||||||
qrCheckInUrl.value =
|
|
||||||
`${publicBase}/my-attendance?token=${encodeURIComponent(sheet.checkInToken)}`
|
|
||||||
try {
|
try {
|
||||||
|
const { data } = await http.get(`/attendance/sheets/${sheet.id}/qr-challenge`)
|
||||||
|
const publicBase = import.meta.env.VITE_PUBLIC_BASE_URL ?? window.location.origin
|
||||||
|
qrCheckInUrl.value =
|
||||||
|
`${publicBase}/my-attendance?token=${encodeURIComponent(data.token)}`
|
||||||
qrDataUrl.value = await QRCode.toDataURL(qrCheckInUrl.value, {
|
qrDataUrl.value = await QRCode.toDataURL(qrCheckInUrl.value, {
|
||||||
width: 360,
|
width: 360,
|
||||||
margin: 2,
|
margin: 2,
|
||||||
errorCorrectionLevel: 'M',
|
errorCorrectionLevel: 'M',
|
||||||
color: { dark: '#172b4d', light: '#ffffff' },
|
color: { dark: '#172b4d', light: '#ffffff' },
|
||||||
})
|
})
|
||||||
qrDialog.value = true
|
qrChallengeExpiresAt.value = serverUtcTime(data.expiresAt)
|
||||||
} catch {
|
return true
|
||||||
ElMessage.error('签到二维码生成失败,请刷新页面后重试。')
|
} catch (error) {
|
||||||
|
if (showError) ElMessage.error(apiErrorMessage(error))
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyCheckInLink() {
|
async function showQrCode() {
|
||||||
try {
|
stopQrRefresh()
|
||||||
await navigator.clipboard.writeText(qrCheckInUrl.value)
|
if (!await refreshQrCode(true)) return
|
||||||
ElMessage.success('签到链接已复制')
|
qrDialog.value = true
|
||||||
} catch {
|
qrRefreshTimer = window.setInterval(async () => {
|
||||||
ElMessage.error('无法自动复制,请手动选择签到链接。')
|
if (!qrDialog.value || !isCheckInOpen(sheetDetail.value?.sheet)) return
|
||||||
|
await refreshQrCode(false)
|
||||||
|
}, 10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopQrRefresh() {
|
||||||
|
if (qrRefreshTimer) {
|
||||||
|
window.clearInterval(qrRefreshTimer)
|
||||||
|
qrRefreshTimer = undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function qrChallengeRemainingLabel() {
|
||||||
|
const seconds = Math.max(0, Math.ceil((qrChallengeExpiresAt.value - now.value) / 1000))
|
||||||
|
return `${seconds} 秒内有效`
|
||||||
|
}
|
||||||
|
|
||||||
|
const riskLabels: Record<string, string> = {
|
||||||
|
SharedDevice: '多人共用设备',
|
||||||
|
HighFrequency: '高频尝试',
|
||||||
|
RepeatedFailures: '多次失败',
|
||||||
|
MissingDeviceId: '缺少设备标识',
|
||||||
|
}
|
||||||
|
|
||||||
|
function devicePlatformLabel(value: string | null | undefined) {
|
||||||
|
if (value === 'android') return 'Android App'
|
||||||
|
if (value === 'ios') return 'iOS App'
|
||||||
|
if (value === 'web') return '浏览器'
|
||||||
|
return value || '未知设备'
|
||||||
|
}
|
||||||
|
|
||||||
async function closeCheckIn() {
|
async function closeCheckIn() {
|
||||||
if (!sheetDetail.value?.sheet) return
|
if (!sheetDetail.value?.sheet) return
|
||||||
try {
|
try {
|
||||||
@@ -350,6 +415,7 @@ async function closeCheckIn() {
|
|||||||
await http.post(`/attendance/sheets/${sheetDetail.value.sheet.id}/close-check-in`)
|
await http.post(`/attendance/sheets/${sheetDetail.value.sheet.id}/close-check-in`)
|
||||||
ElMessage.success('签到已结束')
|
ElMessage.success('签到已结束')
|
||||||
qrDialog.value = false
|
qrDialog.value = false
|
||||||
|
stopQrRefresh()
|
||||||
await selectTask(selectedTask.value)
|
await selectTask(selectedTask.value)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||||
@@ -601,6 +667,7 @@ onMounted(async () => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('resize', resizeCharts)
|
window.removeEventListener('resize', resizeCharts)
|
||||||
if (clockTimer) window.clearInterval(clockTimer)
|
if (clockTimer) window.clearInterval(clockTimer)
|
||||||
|
stopQrRefresh()
|
||||||
disposeCharts()
|
disposeCharts()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -776,6 +843,14 @@ onUnmounted(() => {
|
|||||||
· 签到点 {{ sheetDetail.sheet.locationRadiusMeters }} 米内有效
|
· 签到点 {{ sheetDetail.sheet.locationRadiusMeters }} 米内有效
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="sheetDetail.sheet.riskSummary?.riskStudentCount"
|
||||||
|
class="risk-summary"
|
||||||
|
>
|
||||||
|
<Warning />
|
||||||
|
{{ sheetDetail.sheet.riskSummary.riskStudentCount }} 人存在签到风险信号,
|
||||||
|
其中共用设备 {{ sheetDetail.sheet.riskSummary.sharedDeviceStudentCount }} 人
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="sheetDetail.canEdit" class="batch-row">
|
<div v-if="sheetDetail.canEdit" class="batch-row">
|
||||||
<span>批量设置:</span>
|
<span>批量设置:</span>
|
||||||
@@ -811,7 +886,7 @@ onUnmounted(() => {
|
|||||||
<el-table-column
|
<el-table-column
|
||||||
v-if="isOnlineMethod(sheetDetail.sheet.checkInMethod)"
|
v-if="isOnlineMethod(sheetDetail.sheet.checkInMethod)"
|
||||||
label="自主签到"
|
label="自主签到"
|
||||||
min-width="138"
|
min-width="250"
|
||||||
>
|
>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div v-if="row.checkInAt" class="record-check-in">
|
<div v-if="row.checkInAt" class="record-check-in">
|
||||||
@@ -820,6 +895,27 @@ onUnmounted(() => {
|
|||||||
<small v-if="row.checkInDistanceMeters !== null">
|
<small v-if="row.checkInDistanceMeters !== null">
|
||||||
距签到点 {{ Math.round(row.checkInDistanceMeters) }} 米
|
距签到点 {{ Math.round(row.checkInDistanceMeters) }} 米
|
||||||
</small>
|
</small>
|
||||||
|
<small v-if="row.checkInAudit">
|
||||||
|
{{ devicePlatformLabel(row.checkInAudit.devicePlatform) }}
|
||||||
|
· 设备 {{ row.checkInAudit.deviceCode || '未识别' }}
|
||||||
|
</small>
|
||||||
|
<small v-if="row.checkInAudit?.ipAddress">
|
||||||
|
IP {{ row.checkInAudit.ipAddress }}
|
||||||
|
<template v-if="row.checkInAudit.sameIpStudentCount > 1">
|
||||||
|
· 本次同 IP {{ row.checkInAudit.sameIpStudentCount }} 人
|
||||||
|
</template>
|
||||||
|
</small>
|
||||||
|
<div v-if="row.riskFlags?.length" class="check-in-risks">
|
||||||
|
<el-tag
|
||||||
|
v-for="flag in row.riskFlags"
|
||||||
|
:key="flag"
|
||||||
|
size="small"
|
||||||
|
type="danger"
|
||||||
|
>{{ riskLabels[flag] || flag }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<small v-if="row.failedAttemptCount">
|
||||||
|
共尝试 {{ row.attemptCount }} 次,失败 {{ row.failedAttemptCount }} 次
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<span v-else class="muted-cell">尚未签到</span>
|
<span v-else class="muted-cell">尚未签到</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -1025,7 +1121,11 @@ onUnmounted(() => {
|
|||||||
<span v-if="createForm.targetLatitude !== null">
|
<span v-if="createForm.targetLatitude !== null">
|
||||||
已定位,精度约 {{ createForm.locationAccuracyMeters }} 米
|
已定位,精度约 {{ createForm.locationAccuracyMeters }} 米
|
||||||
</span>
|
</span>
|
||||||
<span v-else>创建前需要允许浏览器获取位置</span>
|
<span v-else>
|
||||||
|
{{ isNativeApp
|
||||||
|
? '创建前需要允许 App 获取精确位置'
|
||||||
|
: '电脑无定位时,请改用教师手机 App 发起' }}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button
|
<el-button
|
||||||
@@ -1052,6 +1152,7 @@ onUnmounted(() => {
|
|||||||
title="课堂扫码签到"
|
title="课堂扫码签到"
|
||||||
width="520px"
|
width="520px"
|
||||||
align-center
|
align-center
|
||||||
|
@closed="stopQrRefresh"
|
||||||
>
|
>
|
||||||
<div v-if="sheetDetail" class="qr-stage">
|
<div v-if="sheetDetail" class="qr-stage">
|
||||||
<div class="qr-course">
|
<div class="qr-course">
|
||||||
@@ -1062,14 +1163,11 @@ onUnmounted(() => {
|
|||||||
<img :src="qrDataUrl" alt="课堂签到二维码" />
|
<img :src="qrDataUrl" alt="课堂签到二维码" />
|
||||||
<div class="qr-status" :class="{ ended: !isCheckInOpen(sheetDetail.sheet) }">
|
<div class="qr-status" :class="{ ended: !isCheckInOpen(sheetDetail.sheet) }">
|
||||||
<span />
|
<span />
|
||||||
{{ isCheckInOpen(sheetDetail.sheet) ? remainingLabel(sheetDetail.sheet) : '本次签到已结束' }}
|
{{ isCheckInOpen(sheetDetail.sheet)
|
||||||
|
? `动态二维码 · ${qrChallengeRemainingLabel()}`
|
||||||
|
: '本次签到已结束' }}
|
||||||
</div>
|
</div>
|
||||||
<p>学生使用手机扫码,登录教务系统后完成签到</p>
|
<p>二维码每 10 秒自动刷新,过期截图和旧链接不能签到</p>
|
||||||
<el-input v-model="qrCheckInUrl" readonly>
|
|
||||||
<template #append>
|
|
||||||
<el-button :icon="CopyDocument" @click="copyCheckInLink">复制链接</el-button>
|
|
||||||
</template>
|
|
||||||
</el-input>
|
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button
|
<el-button
|
||||||
@@ -1291,6 +1389,14 @@ onUnmounted(() => {
|
|||||||
.record-check-in span,
|
.record-check-in span,
|
||||||
.record-check-in small,
|
.record-check-in small,
|
||||||
.muted-cell { color: var(--muted); font-size: 10px; }
|
.muted-cell { color: var(--muted); font-size: 10px; }
|
||||||
|
.check-in-risks {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
.check-in-risks :deep(.el-tag) { height: 19px; font-size: 9px; }
|
||||||
|
.risk-summary { color: #ffdbb0 !important; font-weight: 700; }
|
||||||
.full-width { width: 100%; }
|
.full-width { width: 100%; }
|
||||||
|
|
||||||
.method-options {
|
.method-options {
|
||||||
|
|||||||
@@ -167,11 +167,21 @@ const weekEntries = computed(() =>
|
|||||||
const dayEntries = computed(() =>
|
const dayEntries = computed(() =>
|
||||||
weekEntries.value.filter((entry: any) => entry.dayOfWeek === selectedDay.value),
|
weekEntries.value.filter((entry: any) => entry.dayOfWeek === selectedDay.value),
|
||||||
)
|
)
|
||||||
|
const sortedExamEntries = computed(() =>
|
||||||
|
examEntries.value.slice().sort((a: any, b: any) =>
|
||||||
|
String(a.examDate).localeCompare(String(b.examDate)) ||
|
||||||
|
a.startPeriod - b.startPeriod ||
|
||||||
|
String(a.courseCode).localeCompare(String(b.courseCode)),
|
||||||
|
),
|
||||||
|
)
|
||||||
const visibleGridEntries = computed(() =>
|
const visibleGridEntries = computed(() =>
|
||||||
viewMode.value === 'overview'
|
viewMode.value === 'overview'
|
||||||
? timedEntries.value
|
? (timetable.value?.entries ?? [])
|
||||||
: weekEntries.value,
|
: weekEntries.value,
|
||||||
)
|
)
|
||||||
|
const hasOverviewCourseEntries = computed(() =>
|
||||||
|
(timetable.value?.entries ?? []).length > 0,
|
||||||
|
)
|
||||||
const selectedWeekLabel = computed(() => weekLabel(selectedWeek.value))
|
const selectedWeekLabel = computed(() => weekLabel(selectedWeek.value))
|
||||||
const selectedDayDate = computed(() => {
|
const selectedDayDate = computed(() => {
|
||||||
if (!termMonday.value) return ''
|
if (!termMonday.value) return ''
|
||||||
@@ -910,7 +920,7 @@ onMounted(async () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="(viewMode === 'overview' || viewMode === 'week') && hasTimedEntries"
|
v-if="(viewMode === 'week' && hasTimedEntries) || (viewMode === 'overview' && hasOverviewCourseEntries)"
|
||||||
class="timetable-view-section"
|
class="timetable-view-section"
|
||||||
>
|
>
|
||||||
<div class="view-context">
|
<div class="view-context">
|
||||||
@@ -918,11 +928,11 @@ onMounted(async () => {
|
|||||||
<strong>{{ viewMode === 'overview' ? '全学期总览' : selectedWeekLabel }}</strong>
|
<strong>{{ viewMode === 'overview' ? '全学期总览' : selectedWeekLabel }}</strong>
|
||||||
<span>
|
<span>
|
||||||
{{ viewMode === 'overview'
|
{{ viewMode === 'overview'
|
||||||
? '固定课程与已发布考试按节次合并显示'
|
? '固定课程按节次展示,考试安排按日期列于下方'
|
||||||
: `本周共 ${weekEntries.length} 项课程、考试安排` }}
|
: `本周共 ${weekEntries.length} 项课程、考试安排` }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<small v-if="viewMode === 'overview'">考试卡片显示具体日期,课程卡片保留周次信息</small>
|
<small v-if="viewMode === 'overview'">课程卡片保留起止周与单双周信息</small>
|
||||||
<small v-else-if="!weekEntries.length">本周没有课程或考试安排</small>
|
<small v-else-if="!weekEntries.length">本周没有课程或考试安排</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="timetable-scroll">
|
<div class="timetable-scroll">
|
||||||
@@ -1012,9 +1022,38 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-empty
|
<el-empty
|
||||||
v-else-if="timetable && !loading && !timetable.flexibleCourses?.length"
|
v-else-if="timetable && !loading && !hasTimedEntries && !timetable.flexibleCourses?.length"
|
||||||
:description="timetable.plan ? '该课表暂时没有课程或考试安排' : '所选学期尚未发布课表或考试安排'"
|
:description="timetable.plan ? '该课表暂时没有课程或考试安排' : '所选学期尚未发布课表或考试安排'"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<section v-if="viewMode === 'overview' && sortedExamEntries.length" class="exam-overview">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<span>EXAM AGENDA</span>
|
||||||
|
<strong>已发布考试安排</strong>
|
||||||
|
</div>
|
||||||
|
<small>共 {{ sortedExamEntries.length }} 场 · 按考试日期排序</small>
|
||||||
|
</header>
|
||||||
|
<div class="exam-overview-list">
|
||||||
|
<article v-for="entry in sortedExamEntries" :key="entryKey(entry)">
|
||||||
|
<div class="exam-date">
|
||||||
|
<strong>{{ formatExamDate(entry.examDate) }}</strong>
|
||||||
|
<span>{{ weekdays[entry.dayOfWeek] }}</span>
|
||||||
|
<small>第 {{ entry.startWeek }} 周</small>
|
||||||
|
</div>
|
||||||
|
<div class="exam-summary">
|
||||||
|
<span>{{ entry.courseCode }} · {{ entry.examPlanName || '已发布考试' }}</span>
|
||||||
|
<strong>{{ entry.courseName }}</strong>
|
||||||
|
<p>{{ location(entry) }}</p>
|
||||||
|
<small>
|
||||||
|
第 {{ entry.startWeek }} 周 ·
|
||||||
|
第 {{ entry.startPeriod }}—{{ entry.startPeriod + entry.periodCount - 1 }} 节
|
||||||
|
</small>
|
||||||
|
<small>{{ entry.teacherNames.join('、') || '监考教师待定' }}</small>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -1189,6 +1228,23 @@ onMounted(async () => {
|
|||||||
.course-block.exam-block strong, .day-course-block.exam-block strong { color: #78391e; }
|
.course-block.exam-block strong, .day-course-block.exam-block strong { color: #78391e; }
|
||||||
.course-block.exam-block small, .day-course-block.exam-block small { color: #8b5b43; }
|
.course-block.exam-block small, .day-course-block.exam-block small { color: #8b5b43; }
|
||||||
.entry-kind { display: inline-block; margin-right: 5px; padding: 1px 5px; border-radius: 2px; background: #b65b32; color: #fff; font-size: 10px !important; line-height: 1.5; vertical-align: 1px; }
|
.entry-kind { display: inline-block; margin-right: 5px; padding: 1px 5px; border-radius: 2px; background: #b65b32; color: #fff; font-size: 10px !important; line-height: 1.5; vertical-align: 1px; }
|
||||||
|
.exam-overview { margin-top: 20px; border: 1px solid #e2d6cf; background: #fffaf7; }
|
||||||
|
.exam-overview > header { padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid #eaded7; background: #fff5ef; }
|
||||||
|
.exam-overview > header > div { display: flex; align-items: baseline; gap: 10px; }
|
||||||
|
.exam-overview > header span { color: #a34f2b; font: 700 10px/1.4 Consolas, monospace; letter-spacing: .08em; }
|
||||||
|
.exam-overview > header strong { color: #633824; font-size: 15px; }
|
||||||
|
.exam-overview > header small { color: #8e6f60; }
|
||||||
|
.exam-overview-list { padding: 14px; display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; }
|
||||||
|
.exam-overview-list article { min-width: 0; display: grid; grid-template-columns: 88px minmax(0, 1fr); border: 1px solid #eadbd2; border-left: 4px solid #b65b32; background: #fff; }
|
||||||
|
.exam-date { padding: 13px 10px; display: grid; align-content: start; gap: 4px; border-right: 1px solid #efe3dc; background: #fff7f2; text-align: center; }
|
||||||
|
.exam-date strong { color: #8c4022; font-size: 14px; }
|
||||||
|
.exam-date span { color: #76594b; font-size: 12px; }
|
||||||
|
.exam-date small { color: #9a7a6b; font-size: 11px; }
|
||||||
|
.exam-summary { min-width: 0; padding: 12px 14px; display: grid; gap: 5px; }
|
||||||
|
.exam-summary > span { overflow: hidden; color: #a0502d; font: 700 10px/1.4 Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.exam-summary > strong { color: #533225; font-size: 15px; }
|
||||||
|
.exam-summary p { margin: 0; color: #6e584e; font-size: 12px; }
|
||||||
|
.exam-summary small { color: #8a7469; font-size: 11px; line-height: 1.5; }
|
||||||
.day-view { border: 1px solid #dce4eb; }
|
.day-view { border: 1px solid #dce4eb; }
|
||||||
.day-view > header { padding: 14px 16px; display: flex; align-items: baseline; justify-content: space-between; background: #173e72; color: #fff; }
|
.day-view > header { padding: 14px 16px; display: flex; align-items: baseline; justify-content: space-between; background: #173e72; color: #fff; }
|
||||||
.day-view > header > div { display: grid; gap: 3px; }
|
.day-view > header > div { display: grid; gap: 3px; }
|
||||||
@@ -1225,6 +1281,9 @@ onMounted(async () => {
|
|||||||
.calendar-actions .el-button, .calendar-security-actions .el-button { width: 100%; margin-left: 0; }
|
.calendar-actions .el-button, .calendar-security-actions .el-button { width: 100%; margin-left: 0; }
|
||||||
.timetable-sheet { padding: 12px; }
|
.timetable-sheet { padding: 12px; }
|
||||||
.view-context, .day-view > header { align-items: flex-start; flex-direction: column; gap: 5px; }
|
.view-context, .day-view > header { align-items: flex-start; flex-direction: column; gap: 5px; }
|
||||||
|
.exam-overview > header { align-items: flex-start; flex-direction: column; }
|
||||||
|
.exam-overview-list { grid-template-columns: 1fr; }
|
||||||
|
.exam-overview-list article { grid-template-columns: 78px minmax(0, 1fr); }
|
||||||
.day-grid { grid-template-columns: 90px minmax(0, 1fr); }
|
.day-grid { grid-template-columns: 90px minmax(0, 1fr); }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user