已完成,学生端考勤记录现在按课程展示。
每门课程会独立显示: 课程名称、代码、教学班和任课教师。 课程总体出勤率。 出勤、迟到、缺勤、请假、免修次数。 按日期排列的历次点名记录。 缺勤或迟到记录仍可单独申诉。
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using ClosedXML.Excel;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
@@ -76,8 +77,12 @@ public sealed class AttendanceController(
|
||||
x.Name,
|
||||
x.AttendanceDate,
|
||||
x.Status,
|
||||
x.CheckInMethod,
|
||||
x.CheckInStartsAt,
|
||||
x.CheckInEndsAt,
|
||||
x.Notes,
|
||||
x.SubmittedAt,
|
||||
CheckedInCount = x.Records.Count(r => r.CheckInAt != null),
|
||||
PresentCount = x.Records.Count(r => r.Status == AttendanceStatus.Present),
|
||||
AbsentCount = x.Records.Count(r => r.Status == AttendanceStatus.Absent),
|
||||
LateCount = x.Records.Count(r => r.Status == AttendanceStatus.Late),
|
||||
@@ -109,20 +114,71 @@ public sealed class AttendanceController(
|
||||
if (studentIds.Count == 0)
|
||||
return ConflictProblem("该教学班没有有效选课学生。");
|
||||
|
||||
var checkInMethod = request.CheckInMethod ?? AttendanceCheckInMethod.Manual;
|
||||
if (!Enum.IsDefined(checkInMethod))
|
||||
return ConflictProblem("不支持该签到方式。");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
DateTime? checkInEndsAt = null;
|
||||
string? checkInToken = null;
|
||||
if (checkInMethod != AttendanceCheckInMethod.Manual)
|
||||
{
|
||||
if (request.CheckInDurationMinutes is < 1 or > 180)
|
||||
return ConflictProblem("签到时长应为 1 至 180 分钟。");
|
||||
checkInEndsAt = now.AddMinutes(request.CheckInDurationMinutes!.Value);
|
||||
}
|
||||
if (checkInMethod == AttendanceCheckInMethod.QrCode)
|
||||
checkInToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(24));
|
||||
if (checkInMethod == AttendanceCheckInMethod.Location)
|
||||
{
|
||||
if (request.TargetLatitude is < -90 or > 90 ||
|
||||
request.TargetLongitude is < -180 or > 180 ||
|
||||
request.TargetLatitude is null ||
|
||||
request.TargetLongitude is null)
|
||||
return ConflictProblem("请获取有效的签到位置。");
|
||||
if (request.LocationRadiusMeters is < 20 or > 1000)
|
||||
return ConflictProblem("定位签到范围应为 20 至 1000 米。");
|
||||
}
|
||||
|
||||
var sheet = new AttendanceSheet
|
||||
{
|
||||
TeachingTaskId = request.TeachingTaskId,
|
||||
Name = request.Name.Trim(),
|
||||
AttendanceDate = request.AttendanceDate,
|
||||
CheckInMethod = checkInMethod,
|
||||
CheckInToken = checkInToken,
|
||||
CheckInStartsAt = checkInMethod == AttendanceCheckInMethod.Manual
|
||||
? null
|
||||
: now,
|
||||
CheckInEndsAt = checkInEndsAt,
|
||||
TargetLatitude = checkInMethod == AttendanceCheckInMethod.Location
|
||||
? request.TargetLatitude
|
||||
: null,
|
||||
TargetLongitude = checkInMethod == AttendanceCheckInMethod.Location
|
||||
? request.TargetLongitude
|
||||
: null,
|
||||
LocationRadiusMeters = checkInMethod == AttendanceCheckInMethod.Location
|
||||
? request.LocationRadiusMeters
|
||||
: null,
|
||||
Notes = Normalize(request.Notes),
|
||||
Records = studentIds.Select(studentId => new AttendanceRecord
|
||||
{
|
||||
StudentId = studentId
|
||||
StudentId = studentId,
|
||||
Status = checkInMethod == AttendanceCheckInMethod.Manual
|
||||
? AttendanceStatus.Present
|
||||
: AttendanceStatus.Absent
|
||||
}).ToList()
|
||||
};
|
||||
db.AttendanceSheets.Add(sheet);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Created(string.Empty, new { sheet.Id });
|
||||
return Created(string.Empty, new
|
||||
{
|
||||
sheet.Id,
|
||||
sheet.CheckInMethod,
|
||||
sheet.CheckInToken,
|
||||
sheet.CheckInStartsAt,
|
||||
sheet.CheckInEndsAt
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("sheets/{id:guid}")]
|
||||
@@ -138,6 +194,13 @@ public sealed class AttendanceController(
|
||||
x.Name,
|
||||
x.AttendanceDate,
|
||||
x.Status,
|
||||
x.CheckInMethod,
|
||||
x.CheckInToken,
|
||||
x.CheckInStartsAt,
|
||||
x.CheckInEndsAt,
|
||||
x.TargetLatitude,
|
||||
x.TargetLongitude,
|
||||
x.LocationRadiusMeters,
|
||||
x.Notes,
|
||||
x.SubmittedAt,
|
||||
TaskNumber = x.TeachingTask!.TaskNumber,
|
||||
@@ -153,7 +216,11 @@ public sealed class AttendanceController(
|
||||
r.Student.Name,
|
||||
ClassName = r.Student.AdministrativeClass!.Name,
|
||||
r.Status,
|
||||
r.Notes
|
||||
r.Notes,
|
||||
r.CheckInAt,
|
||||
r.CheckedInMethod,
|
||||
r.CheckInAccuracyMeters,
|
||||
r.CheckInDistanceMeters
|
||||
})
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
@@ -170,10 +237,11 @@ public sealed class AttendanceController(
|
||||
.Where(e => e.TeachingTaskId == sheet.TeachingTaskId && e.Status == ApprovalStatus.Approved)
|
||||
.Select(e => e.StudentId).ToHashSetAsync(cancellationToken);
|
||||
|
||||
var canEdit = sheet.Status == AttendanceSheetStatus.Draft &&
|
||||
await CanManageTaskAsync(
|
||||
sheet.TeachingTaskId,
|
||||
cancellationToken);
|
||||
var canManage = await CanManageTaskAsync(
|
||||
sheet.TeachingTaskId,
|
||||
cancellationToken);
|
||||
var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage;
|
||||
var now = DateTime.UtcNow;
|
||||
return Ok(new
|
||||
{
|
||||
Sheet = new
|
||||
@@ -183,6 +251,20 @@ public sealed class AttendanceController(
|
||||
sheet.Name,
|
||||
sheet.AttendanceDate,
|
||||
sheet.Status,
|
||||
sheet.CheckInMethod,
|
||||
CheckInToken = canManage ? sheet.CheckInToken : null,
|
||||
sheet.CheckInStartsAt,
|
||||
sheet.CheckInEndsAt,
|
||||
sheet.TargetLatitude,
|
||||
sheet.TargetLongitude,
|
||||
sheet.LocationRadiusMeters,
|
||||
IsCheckInOpen = IsCheckInOpen(
|
||||
sheet.Status,
|
||||
sheet.CheckInMethod,
|
||||
sheet.CheckInStartsAt,
|
||||
sheet.CheckInEndsAt,
|
||||
now),
|
||||
CheckedInCount = sheet.Records.Count(r => r.CheckInAt != null),
|
||||
sheet.Notes,
|
||||
sheet.SubmittedAt,
|
||||
sheet.TaskNumber,
|
||||
@@ -197,6 +279,10 @@ public sealed class AttendanceController(
|
||||
r.ClassName,
|
||||
r.Status,
|
||||
r.Notes,
|
||||
r.CheckInAt,
|
||||
r.CheckedInMethod,
|
||||
r.CheckInAccuracyMeters,
|
||||
r.CheckInDistanceMeters,
|
||||
IsExempt = exemptStudentIds.Contains(r.StudentId),
|
||||
IsDeferred = deferredStudentIds.Contains(r.StudentId)
|
||||
})
|
||||
@@ -387,8 +473,231 @@ public sealed class AttendanceController(
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("sheets/{id:guid}/close-check-in")]
|
||||
[Authorize(Roles = AttendanceRoles)]
|
||||
public async Task<ActionResult> CloseCheckIn(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await db.AttendanceSheets
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
if (!CanManageSheet(sheet)) return Forbid();
|
||||
if (sheet.Status != AttendanceSheetStatus.Draft)
|
||||
return ConflictProblem("考勤表已提交,签到活动已经结束。");
|
||||
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
|
||||
return ConflictProblem("普通点名没有在线签到活动。");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
if (sheet.CheckInEndsAt is null || sheet.CheckInEndsAt > now)
|
||||
sheet.CheckInEndsAt = now;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ═══════════════ Student endpoints ═══════════════
|
||||
|
||||
[HttpGet("check-in-info")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> GetCheckInInfo(
|
||||
[FromQuery, MaxLength(64)] string token,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var studentId = await GetCurrentStudentIdAsync(cancellationToken);
|
||||
if (!studentId.HasValue)
|
||||
return ConflictProblem("当前账号未关联学生档案。");
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return NotFound();
|
||||
|
||||
var activity = await db.AttendanceRecords.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == studentId.Value &&
|
||||
x.AttendanceSheet!.CheckInToken == token.Trim())
|
||||
.Select(x => new
|
||||
{
|
||||
SheetId = x.AttendanceSheetId,
|
||||
SheetName = x.AttendanceSheet!.Name,
|
||||
x.AttendanceSheet.AttendanceDate,
|
||||
x.AttendanceSheet.Status,
|
||||
x.AttendanceSheet.CheckInMethod,
|
||||
x.AttendanceSheet.CheckInStartsAt,
|
||||
x.AttendanceSheet.CheckInEndsAt,
|
||||
CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
|
||||
CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
|
||||
TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
|
||||
x.CheckInAt
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (activity is null) return NotFound();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
return Ok(new
|
||||
{
|
||||
activity.SheetId,
|
||||
activity.SheetName,
|
||||
activity.AttendanceDate,
|
||||
activity.CheckInMethod,
|
||||
activity.CheckInStartsAt,
|
||||
activity.CheckInEndsAt,
|
||||
activity.CourseCode,
|
||||
activity.CourseName,
|
||||
activity.TaskNumber,
|
||||
activity.CheckInAt,
|
||||
IsOpen = IsCheckInOpen(
|
||||
activity.Status,
|
||||
activity.CheckInMethod,
|
||||
activity.CheckInStartsAt,
|
||||
activity.CheckInEndsAt,
|
||||
now)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("open-check-ins")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> GetOpenCheckIns(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var studentId = await GetCurrentStudentIdAsync(cancellationToken);
|
||||
if (!studentId.HasValue)
|
||||
return ConflictProblem("当前账号未关联学生档案。");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
return Ok(await db.AttendanceRecords.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == studentId.Value &&
|
||||
x.AttendanceSheet!.Status == AttendanceSheetStatus.Draft &&
|
||||
x.AttendanceSheet.CheckInMethod == AttendanceCheckInMethod.Location &&
|
||||
x.AttendanceSheet.CheckInStartsAt <= now &&
|
||||
x.AttendanceSheet.CheckInEndsAt >= now)
|
||||
.OrderBy(x => x.AttendanceSheet!.CheckInEndsAt)
|
||||
.Select(x => new
|
||||
{
|
||||
SheetId = x.AttendanceSheetId,
|
||||
SheetName = x.AttendanceSheet!.Name,
|
||||
x.AttendanceSheet.AttendanceDate,
|
||||
x.AttendanceSheet.CheckInMethod,
|
||||
x.AttendanceSheet.CheckInStartsAt,
|
||||
x.AttendanceSheet.CheckInEndsAt,
|
||||
x.AttendanceSheet.LocationRadiusMeters,
|
||||
CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
|
||||
CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
|
||||
TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
|
||||
x.CheckInAt
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("check-in")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> CheckIn(
|
||||
AttendanceCheckInRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var studentId = await GetCurrentStudentIdAsync(cancellationToken);
|
||||
if (!studentId.HasValue)
|
||||
return ConflictProblem("当前账号未关联学生档案。");
|
||||
|
||||
var source = db.AttendanceRecords
|
||||
.Include(x => x.AttendanceSheet)
|
||||
.Where(x => x.StudentId == studentId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(request.Token))
|
||||
{
|
||||
var token = request.Token.Trim();
|
||||
source = source.Where(x => x.AttendanceSheet!.CheckInToken == token);
|
||||
}
|
||||
else if (request.AttendanceSheetId.HasValue)
|
||||
{
|
||||
source = source.Where(x =>
|
||||
x.AttendanceSheetId == request.AttendanceSheetId.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
return ConflictProblem("缺少签到活动信息。");
|
||||
}
|
||||
|
||||
var record = await source.FirstOrDefaultAsync(cancellationToken);
|
||||
if (record?.AttendanceSheet is null) return NotFound();
|
||||
var sheet = record.AttendanceSheet;
|
||||
var now = DateTime.UtcNow;
|
||||
if (record.CheckInAt.HasValue)
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
AlreadyCheckedIn = true,
|
||||
record.CheckInAt,
|
||||
record.CheckInDistanceMeters
|
||||
});
|
||||
}
|
||||
if (!IsCheckInOpen(
|
||||
sheet.Status,
|
||||
sheet.CheckInMethod,
|
||||
sheet.CheckInStartsAt,
|
||||
sheet.CheckInEndsAt,
|
||||
now))
|
||||
return ConflictProblem("签到尚未开始或已经结束。");
|
||||
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
|
||||
return ConflictProblem("该考勤表不支持学生在线签到。");
|
||||
|
||||
double? distanceMeters = null;
|
||||
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Token) ||
|
||||
!string.Equals(
|
||||
sheet.CheckInToken,
|
||||
request.Token.Trim(),
|
||||
StringComparison.Ordinal))
|
||||
return NotFound();
|
||||
}
|
||||
else if (sheet.CheckInMethod == AttendanceCheckInMethod.Location)
|
||||
{
|
||||
if (request.Latitude is < -90 or > 90 ||
|
||||
request.Longitude is < -180 or > 180 ||
|
||||
request.Latitude is null ||
|
||||
request.Longitude is null)
|
||||
return ConflictProblem("未获取到有效的当前位置。");
|
||||
if (sheet.TargetLatitude is null ||
|
||||
sheet.TargetLongitude is null ||
|
||||
sheet.LocationRadiusMeters is null)
|
||||
return ConflictProblem("签到活动没有配置有效的位置范围。");
|
||||
|
||||
distanceMeters = CalculateDistanceMeters(
|
||||
(double)sheet.TargetLatitude.Value,
|
||||
(double)sheet.TargetLongitude.Value,
|
||||
(double)request.Latitude.Value,
|
||||
(double)request.Longitude.Value);
|
||||
if (distanceMeters > sheet.LocationRadiusMeters.Value)
|
||||
{
|
||||
return ConflictProblem(
|
||||
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。");
|
||||
}
|
||||
}
|
||||
|
||||
record.Status = AttendanceStatus.Present;
|
||||
record.CheckInAt = now;
|
||||
record.CheckedInMethod = sheet.CheckInMethod;
|
||||
record.CheckInLatitude = sheet.CheckInMethod == AttendanceCheckInMethod.Location
|
||||
? request.Latitude
|
||||
: null;
|
||||
record.CheckInLongitude = sheet.CheckInMethod == AttendanceCheckInMethod.Location
|
||||
? request.Longitude
|
||||
: null;
|
||||
record.CheckInAccuracyMeters = sheet.CheckInMethod == AttendanceCheckInMethod.Location
|
||||
? request.AccuracyMeters
|
||||
: null;
|
||||
record.CheckInDistanceMeters = distanceMeters;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
AlreadyCheckedIn = false,
|
||||
record.CheckInAt,
|
||||
record.CheckInDistanceMeters
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("my-records")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> GetMyRecords(
|
||||
@@ -415,6 +724,7 @@ public sealed class AttendanceController(
|
||||
.Select(r => new
|
||||
{
|
||||
r.AttendanceSheetId,
|
||||
r.AttendanceSheet!.TeachingTaskId,
|
||||
SheetName = r.AttendanceSheet!.Name,
|
||||
r.AttendanceSheet.AttendanceDate,
|
||||
TaskNumber = r.AttendanceSheet.TeachingTask!.TaskNumber,
|
||||
@@ -657,9 +967,54 @@ public sealed class AttendanceController(
|
||||
.AnyAsync(
|
||||
x => x.TeachingTaskId == teachingTaskId &&
|
||||
x.Teacher!.UserId == scope.UserId,
|
||||
cancellationToken);
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Guid?> GetCurrentStudentIdAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
return await db.Students.AsNoTracking()
|
||||
.Where(x => x.UserId == userId)
|
||||
.Select(x => (Guid?)x.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static bool IsCheckInOpen(
|
||||
AttendanceSheetStatus status,
|
||||
AttendanceCheckInMethod method,
|
||||
DateTime? startsAt,
|
||||
DateTime? endsAt,
|
||||
DateTime now) =>
|
||||
status == AttendanceSheetStatus.Draft &&
|
||||
method != AttendanceCheckInMethod.Manual &&
|
||||
startsAt.HasValue &&
|
||||
endsAt.HasValue &&
|
||||
startsAt.Value <= now &&
|
||||
endsAt.Value >= now;
|
||||
|
||||
internal static double CalculateDistanceMeters(
|
||||
double latitude1,
|
||||
double longitude1,
|
||||
double latitude2,
|
||||
double longitude2)
|
||||
{
|
||||
const double earthRadiusMeters = 6_371_000;
|
||||
var latitudeDelta = DegreesToRadians(latitude2 - latitude1);
|
||||
var longitudeDelta = DegreesToRadians(longitude2 - longitude1);
|
||||
var startLatitude = DegreesToRadians(latitude1);
|
||||
var endLatitude = DegreesToRadians(latitude2);
|
||||
var haversine =
|
||||
Math.Sin(latitudeDelta / 2) * Math.Sin(latitudeDelta / 2) +
|
||||
Math.Cos(startLatitude) * Math.Cos(endLatitude) *
|
||||
Math.Sin(longitudeDelta / 2) * Math.Sin(longitudeDelta / 2);
|
||||
return earthRadiusMeters * 2 *
|
||||
Math.Atan2(Math.Sqrt(haversine), Math.Sqrt(1 - haversine));
|
||||
}
|
||||
|
||||
private static double DegreesToRadians(double degrees) =>
|
||||
degrees * Math.PI / 180;
|
||||
|
||||
private async Task<AttendanceCourseStatistics?> LoadTaskStatisticsAsync(
|
||||
Guid teachingTaskId,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -993,7 +1348,12 @@ public sealed record AttendanceSheetRequest(
|
||||
Guid TeachingTaskId,
|
||||
[MaxLength(120)] string Name,
|
||||
DateTime AttendanceDate,
|
||||
[MaxLength(500)] string? Notes);
|
||||
[MaxLength(500)] string? Notes,
|
||||
AttendanceCheckInMethod? CheckInMethod,
|
||||
[Range(1, 180)] int? CheckInDurationMinutes,
|
||||
[Range(-90, 90)] decimal? TargetLatitude,
|
||||
[Range(-180, 180)] decimal? TargetLongitude,
|
||||
[Range(20, 1000)] int? LocationRadiusMeters);
|
||||
|
||||
public sealed record AttendanceRecordsRequest(
|
||||
IReadOnlyCollection<AttendanceRecordRequest> Records);
|
||||
@@ -1011,6 +1371,13 @@ public sealed record AttendanceAppealReviewRequest(
|
||||
bool Approve,
|
||||
[MaxLength(300)] string? Comment);
|
||||
|
||||
public sealed record AttendanceCheckInRequest(
|
||||
Guid? AttendanceSheetId,
|
||||
[MaxLength(64)] string? Token,
|
||||
[Range(-90, 90)] decimal? Latitude,
|
||||
[Range(-180, 180)] decimal? Longitude,
|
||||
[Range(0, 5000)] double? AccuracyMeters);
|
||||
|
||||
public sealed record AttendanceCourseStatistics(
|
||||
AttendanceStatisticsCourse Course,
|
||||
AttendanceStatisticsSummary Summary,
|
||||
|
||||
Reference in New Issue
Block a user