已完成,学生端考勤记录现在按课程展示。

每门课程会独立显示:
课程名称、代码、教学班和任课教师。
课程总体出勤率。
出勤、迟到、缺勤、请假、免修次数。
按日期排列的历次点名记录。
缺勤或迟到记录仍可单独申诉。
This commit is contained in:
2026-07-26 10:49:06 +08:00 Unverified
parent 72297aa23f
commit 3b73292d93
12 changed files with 2172 additions and 54 deletions
@@ -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,