6 Commits
66 changed files with 4835 additions and 1399 deletions
@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography;
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;
@@ -76,8 +77,12 @@ public sealed class AttendanceController(
x.Name, x.Name,
x.AttendanceDate, x.AttendanceDate,
x.Status, x.Status,
x.CheckInMethod,
x.CheckInStartsAt,
x.CheckInEndsAt,
x.Notes, x.Notes,
x.SubmittedAt, x.SubmittedAt,
CheckedInCount = x.Records.Count(r => r.CheckInAt != null),
PresentCount = x.Records.Count(r => r.Status == AttendanceStatus.Present), PresentCount = x.Records.Count(r => r.Status == AttendanceStatus.Present),
AbsentCount = x.Records.Count(r => r.Status == AttendanceStatus.Absent), AbsentCount = x.Records.Count(r => r.Status == AttendanceStatus.Absent),
LateCount = x.Records.Count(r => r.Status == AttendanceStatus.Late), LateCount = x.Records.Count(r => r.Status == AttendanceStatus.Late),
@@ -109,20 +114,71 @@ public sealed class AttendanceController(
if (studentIds.Count == 0) if (studentIds.Count == 0)
return ConflictProblem("该教学班没有有效选课学生。"); 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 var sheet = new AttendanceSheet
{ {
TeachingTaskId = request.TeachingTaskId, TeachingTaskId = request.TeachingTaskId,
Name = request.Name.Trim(), Name = request.Name.Trim(),
AttendanceDate = request.AttendanceDate, 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), Notes = Normalize(request.Notes),
Records = studentIds.Select(studentId => new AttendanceRecord Records = studentIds.Select(studentId => new AttendanceRecord
{ {
StudentId = studentId StudentId = studentId,
Status = checkInMethod == AttendanceCheckInMethod.Manual
? AttendanceStatus.Present
: AttendanceStatus.Absent
}).ToList() }).ToList()
}; };
db.AttendanceSheets.Add(sheet); db.AttendanceSheets.Add(sheet);
await db.SaveChangesAsync(cancellationToken); 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}")] [HttpGet("sheets/{id:guid}")]
@@ -138,6 +194,13 @@ public sealed class AttendanceController(
x.Name, x.Name,
x.AttendanceDate, x.AttendanceDate,
x.Status, x.Status,
x.CheckInMethod,
x.CheckInToken,
x.CheckInStartsAt,
x.CheckInEndsAt,
x.TargetLatitude,
x.TargetLongitude,
x.LocationRadiusMeters,
x.Notes, x.Notes,
x.SubmittedAt, x.SubmittedAt,
TaskNumber = x.TeachingTask!.TaskNumber, TaskNumber = x.TeachingTask!.TaskNumber,
@@ -153,7 +216,11 @@ public sealed class AttendanceController(
r.Student.Name, r.Student.Name,
ClassName = r.Student.AdministrativeClass!.Name, ClassName = r.Student.AdministrativeClass!.Name,
r.Status, r.Status,
r.Notes r.Notes,
r.CheckInAt,
r.CheckedInMethod,
r.CheckInAccuracyMeters,
r.CheckInDistanceMeters
}) })
}) })
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
@@ -170,21 +237,52 @@ public sealed class AttendanceController(
.Where(e => e.TeachingTaskId == sheet.TeachingTaskId && e.Status == ApprovalStatus.Approved) .Where(e => e.TeachingTaskId == sheet.TeachingTaskId && e.Status == ApprovalStatus.Approved)
.Select(e => e.StudentId).ToHashSetAsync(cancellationToken); .Select(e => e.StudentId).ToHashSetAsync(cancellationToken);
var canEdit = sheet.Status == AttendanceSheetStatus.Draft && var canManage = await CanManageTaskAsync(
await CanManageTaskAsync( sheet.TeachingTaskId,
sheet.TeachingTaskId, cancellationToken);
cancellationToken); var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage;
var now = DateTime.UtcNow;
return Ok(new return Ok(new
{ {
Sheet = new Sheet = new
{ {
sheet.Id, sheet.TeachingTaskId, sheet.Name, sheet.AttendanceDate, sheet.Id,
sheet.Status, sheet.Notes, sheet.SubmittedAt, sheet.TeachingTaskId,
sheet.TaskNumber, sheet.TaskName, sheet.CourseCode, sheet.CourseName, 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,
sheet.TaskName,
sheet.CourseCode,
sheet.CourseName,
Records = sheet.Records.Select(r => new Records = sheet.Records.Select(r => new
{ {
r.StudentId, r.StudentNumber, r.Name, r.ClassName, r.StudentId,
r.Status, r.Notes, r.StudentNumber,
r.Name,
r.ClassName,
r.Status,
r.Notes,
r.CheckInAt,
r.CheckedInMethod,
r.CheckInAccuracyMeters,
r.CheckInDistanceMeters,
IsExempt = exemptStudentIds.Contains(r.StudentId), IsExempt = exemptStudentIds.Contains(r.StudentId),
IsDeferred = deferredStudentIds.Contains(r.StudentId) IsDeferred = deferredStudentIds.Contains(r.StudentId)
}) })
@@ -375,8 +473,231 @@ public sealed class AttendanceController(
return NoContent(); 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 ═══════════════ // ═══════════════ 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")] [HttpGet("my-records")]
[Authorize(Roles = SystemRoles.Student)] [Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyRecords( public async Task<ActionResult> GetMyRecords(
@@ -403,6 +724,7 @@ public sealed class AttendanceController(
.Select(r => new .Select(r => new
{ {
r.AttendanceSheetId, r.AttendanceSheetId,
r.AttendanceSheet!.TeachingTaskId,
SheetName = r.AttendanceSheet!.Name, SheetName = r.AttendanceSheet!.Name,
r.AttendanceSheet.AttendanceDate, r.AttendanceSheet.AttendanceDate,
TaskNumber = r.AttendanceSheet.TeachingTask!.TaskNumber, TaskNumber = r.AttendanceSheet.TeachingTask!.TaskNumber,
@@ -504,8 +826,8 @@ public sealed class AttendanceController(
var source = db.AttendanceRecords.AsNoTracking() var source = db.AttendanceRecords.AsNoTracking()
.Where(r => .Where(r =>
r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted && r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted)
targetClassIds.Contains(r.Student!.AdministrativeClassId)); .WhereIn(targetClassIds, r => r.Student!.AdministrativeClassId);
if (academicTermId.HasValue) if (academicTermId.HasValue)
source = source.Where(r => source = source.Where(r =>
r.AttendanceSheet!.TeachingTask!.AcademicTermId == academicTermId); r.AttendanceSheet!.TeachingTask!.AcademicTermId == academicTermId);
@@ -645,9 +967,54 @@ public sealed class AttendanceController(
.AnyAsync( .AnyAsync(
x => x.TeachingTaskId == teachingTaskId && x => x.TeachingTaskId == teachingTaskId &&
x.Teacher!.UserId == scope.UserId, 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( private async Task<AttendanceCourseStatistics?> LoadTaskStatisticsAsync(
Guid teachingTaskId, Guid teachingTaskId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -981,7 +1348,12 @@ public sealed record AttendanceSheetRequest(
Guid TeachingTaskId, Guid TeachingTaskId,
[MaxLength(120)] string Name, [MaxLength(120)] string Name,
DateTime AttendanceDate, 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( public sealed record AttendanceRecordsRequest(
IReadOnlyCollection<AttendanceRecordRequest> Records); IReadOnlyCollection<AttendanceRecordRequest> Records);
@@ -999,6 +1371,13 @@ public sealed record AttendanceAppealReviewRequest(
bool Approve, bool Approve,
[MaxLength(300)] string? Comment); [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( public sealed record AttendanceCourseStatistics(
AttendanceStatisticsCourse Course, AttendanceStatisticsCourse Course,
AttendanceStatisticsSummary Summary, AttendanceStatisticsSummary Summary,
+56 -51
View File
@@ -57,59 +57,64 @@ public sealed class AuthController(
{ {
var name = request.Name.Trim(); var name = request.Name.Trim();
var studentNumber = request.StudentNumber.Trim(); var studentNumber = request.StudentNumber.Trim();
var student = await db.Students var executionStrategy = db.Database.CreateExecutionStrategy();
.Include(x => x.AdministrativeClass) return await executionStrategy.ExecuteAsync<ActionResult>(async () =>
.ThenInclude(x => x!.Major) {
.FirstOrDefaultAsync(x => var student = await db.Students
x.Status == StudentStatus.Active && .Include(x => x.AdministrativeClass)
x.Name == name && .ThenInclude(x => x!.Major)
x.StudentNumber == studentNumber && .FirstOrDefaultAsync(x =>
x.EnrollmentYear == request.Grade && x.Status == StudentStatus.Active &&
x.AdministrativeClassId == request.AdministrativeClassId && x.Name == name &&
x.AdministrativeClass!.Grade == request.Grade && x.StudentNumber == studentNumber &&
x.AdministrativeClass.MajorId == request.MajorId && x.EnrollmentYear == request.Grade &&
x.AdministrativeClass.Major!.CollegeId == request.CollegeId, x.AdministrativeClassId == request.AdministrativeClassId &&
cancellationToken); x.AdministrativeClass!.Grade == request.Grade &&
if (student is null) x.AdministrativeClass.MajorId == request.MajorId &&
return ActivationProblem( x.AdministrativeClass.Major!.CollegeId == request.CollegeId,
"填写的信息与在籍学生档案不完全一致,请核对后重试。", cancellationToken);
StatusCodes.Status400BadRequest); if (student is null)
if (student.UserId.HasValue) return ActivationProblem(
return ActivationProblem( "填写的信息与在籍学生档案不完全一致,请核对后重试。",
"该学号已经激活,请直接登录;如忘记密码请联系管理员重置。", StatusCodes.Status400BadRequest);
StatusCodes.Status409Conflict); if (student.UserId.HasValue)
if (await userManager.FindByNameAsync(studentNumber) is not null) return ActivationProblem(
return ActivationProblem( "该学号已经激活,请直接登录;如忘记密码请联系管理员重置。",
"该学号已有登录账号但未正确关联,请联系管理员处理。", StatusCodes.Status409Conflict);
StatusCodes.Status409Conflict); if (await userManager.FindByNameAsync(studentNumber) is not null)
return ActivationProblem(
"该学号已有登录账号但未正确关联,请联系管理员处理。",
StatusCodes.Status409Conflict);
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); await using var transaction =
var user = new ApplicationUser await db.Database.BeginTransactionAsync(cancellationToken);
{ var user = new ApplicationUser
UserName = studentNumber, {
DisplayName = student.Name, UserName = studentNumber,
StaffNumber = studentNumber, DisplayName = student.Name,
CollegeId = request.CollegeId, StaffNumber = studentNumber,
IsEnabled = true, CollegeId = request.CollegeId,
LockoutEnabled = true IsEnabled = true,
}; LockoutEnabled = true
var result = await userManager.CreateAsync(user, request.Password); };
if (!result.Succeeded) var result = await userManager.CreateAsync(user, request.Password);
{ if (!result.Succeeded)
await transaction.RollbackAsync(cancellationToken); {
return IdentityValidationProblem(result); await transaction.RollbackAsync(cancellationToken);
} return IdentityValidationProblem(result);
result = await userManager.AddToRoleAsync(user, SystemRoles.Student); }
if (!result.Succeeded) result = await userManager.AddToRoleAsync(user, SystemRoles.Student);
{ if (!result.Succeeded)
await transaction.RollbackAsync(cancellationToken); {
return IdentityValidationProblem(result); await transaction.RollbackAsync(cancellationToken);
} return IdentityValidationProblem(result);
}
student.UserId = user.Id; student.UserId = user.Id;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
return Ok(new { UserName = studentNumber }); return Ok(new { UserName = studentNumber });
});
} }
[AllowAnonymous] [AllowAnonymous]
@@ -287,7 +287,9 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
public async Task<ActionResult<IReadOnlyCollection<AcademicTerm>>> GetTerms( public async Task<ActionResult<IReadOnlyCollection<AcademicTerm>>> GetTerms(
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
await db.AcademicTerms.AsNoTracking() await db.AcademicTerms.AsNoTracking()
.OrderByDescending(x => x.StartDate) .OrderByDescending(x => x.IsCurrent)
.ThenBy(x => x.IsArchived)
.ThenByDescending(x => x.StartDate)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
[HttpPost("terms")] [HttpPost("terms")]
@@ -298,11 +300,20 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
{ {
if (request.EndDate <= request.StartDate) if (request.EndDate <= request.StartDate)
return ValidationProblem("学期结束日期必须晚于开始日期。"); return ValidationProblem("学期结束日期必须晚于开始日期。");
if (request.IsCurrent && !request.IsEnabled)
return ValidationProblem("当前学期必须保持启用。");
if (request.IsCurrent) var hasCurrentTerm = await db.AcademicTerms
await db.AcademicTerms.ExecuteUpdateAsync( .AnyAsync(x => x.IsCurrent, cancellationToken);
setters => setters.SetProperty(x => x.IsCurrent, false), var shouldBeCurrent = request.IsCurrent || (!hasCurrentTerm && request.IsEnabled);
cancellationToken); if (shouldBeCurrent)
{
var currentTerms = await db.AcademicTerms
.Where(x => x.IsCurrent)
.ToListAsync(cancellationToken);
foreach (var currentTerm in currentTerms)
currentTerm.IsCurrent = false;
}
var entity = new AcademicTerm var entity = new AcademicTerm
{ {
@@ -312,7 +323,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
Season = request.Season, Season = request.Season,
StartDate = request.StartDate, StartDate = request.StartDate,
EndDate = request.EndDate, EndDate = request.EndDate,
IsCurrent = request.IsCurrent, IsCurrent = shouldBeCurrent,
IsEnabled = request.IsEnabled IsEnabled = request.IsEnabled
}; };
return await CreateAsync(entity, "GetTerms", cancellationToken); return await CreateAsync(entity, "GetTerms", cancellationToken);
@@ -327,10 +338,22 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
{ {
var entity = await db.AcademicTerms.FindAsync([id], cancellationToken); var entity = await db.AcademicTerms.FindAsync([id], cancellationToken);
if (entity is null) return NotFound(); if (entity is null) return NotFound();
if (request.EndDate <= request.StartDate)
return ValidationProblem("学期结束日期必须晚于开始日期。");
if (entity.IsCurrent && !request.IsCurrent)
return ConflictProblem("不能直接取消当前学期,请将另一个学期设为当前。");
if (request.IsCurrent && entity.IsArchived)
return ConflictProblem("已归档学期不能直接设为当前,请先撤销归档。");
if (request.IsCurrent && !request.IsEnabled)
return ValidationProblem("当前学期必须保持启用。");
if (request.IsCurrent) if (request.IsCurrent)
await db.AcademicTerms.Where(x => x.Id != id).ExecuteUpdateAsync( {
setters => setters.SetProperty(x => x.IsCurrent, false), var currentTerms = await db.AcademicTerms
cancellationToken); .Where(x => x.Id != id && x.IsCurrent)
.ToListAsync(cancellationToken);
foreach (var currentTerm in currentTerms)
currentTerm.IsCurrent = false;
}
ApplyCatalog(entity, request); ApplyCatalog(entity, request);
entity.AcademicYear = request.AcademicYear.Trim(); entity.AcademicYear = request.AcademicYear.Trim();
entity.Season = request.Season; entity.Season = request.Season;
@@ -341,6 +364,64 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
return entity; return entity;
} }
[HttpPost("terms/{id:guid}/set-current")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<AcademicTerm>> SetCurrentTerm(
Guid id,
CancellationToken cancellationToken)
{
var entity = await db.AcademicTerms.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
if (entity.IsArchived)
return ConflictProblem("已归档学期不能直接设为当前,请先撤销归档。");
if (!entity.IsEnabled)
return ConflictProblem("停用学期不能设为当前,请先启用。");
if (entity.IsCurrent) return entity;
var currentTerms = await db.AcademicTerms
.Where(x => x.Id != id && x.IsCurrent)
.ToListAsync(cancellationToken);
foreach (var currentTerm in currentTerms)
currentTerm.IsCurrent = false;
entity.IsCurrent = true;
await db.SaveChangesAsync(cancellationToken);
return entity;
}
[HttpPost("terms/{id:guid}/archive")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<AcademicTerm>> ArchiveTerm(
Guid id,
CancellationToken cancellationToken)
{
var entity = await db.AcademicTerms.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
if (entity.IsCurrent)
return ConflictProblem("当前学期不能归档,请先切换到新的当前学期。");
if (entity.IsArchived) return entity;
entity.IsArchived = true;
entity.ArchivedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
return entity;
}
[HttpPost("terms/{id:guid}/unarchive")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<AcademicTerm>> UnarchiveTerm(
Guid id,
CancellationToken cancellationToken)
{
var entity = await db.AcademicTerms.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
if (!entity.IsArchived) return entity;
entity.IsArchived = false;
entity.ArchivedAt = null;
await db.SaveChangesAsync(cancellationToken);
return entity;
}
[HttpGet("classrooms")] [HttpGet("classrooms")]
public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken) => public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken) =>
Ok(await db.Classrooms.AsNoTracking() Ok(await db.Classrooms.AsNoTracking()
@@ -447,6 +528,8 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
}; };
if (entity is null) return NotFound(); if (entity is null) return NotFound();
if (entity is AcademicTerm { IsCurrent: true })
return ConflictProblem("当前学期不能删除,请先切换到其他学期。");
db.Remove(entity); db.Remove(entity);
try try
{ {
@@ -508,6 +591,14 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
entity.SortOrder = request.SortOrder; entity.SortOrder = request.SortOrder;
entity.IsEnabled = request.IsEnabled; entity.IsEnabled = request.IsEnabled;
} }
private ObjectResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "当前操作无法完成",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
} }
public record CatalogRequest( public record CatalogRequest(
@@ -77,45 +77,57 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
} }
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。"); if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。");
var errors = new List<string>(); return await db.ExecuteInRetriableTransactionAsync<
ExcelImportResult result; ActionResult<ExcelImportResult>>(
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); async transaction =>
try
{
result = kind.ToLowerInvariant() switch
{ {
"campuses" => await ImportCampusesAsync(rows, errors, cancellationToken), var errors = new List<string>();
"colleges" => await ImportCollegesAsync(rows, errors, cancellationToken), ExcelImportResult result;
"majors" => await ImportMajorsAsync(rows, errors, cancellationToken), try
"classes" => await ImportClassesAsync(rows, errors, cancellationToken), {
"terms" => await ImportTermsAsync(rows, errors, cancellationToken), result = kind.ToLowerInvariant() switch
"buildings" => await ImportBuildingsAsync(rows, errors, cancellationToken), {
"classrooms" => await ImportClassroomsAsync(rows, errors, cancellationToken), "campuses" => await ImportCampusesAsync(
"course-categories" => await ImportCourseCategoriesAsync( rows, errors, cancellationToken),
rows, errors, cancellationToken), "colleges" => await ImportCollegesAsync(
_ => throw new InvalidOperationException() rows, errors, cancellationToken),
}; "majors" => await ImportMajorsAsync(
rows, errors, cancellationToken),
"classes" => await ImportClassesAsync(
rows, errors, cancellationToken),
"terms" => await ImportTermsAsync(
rows, errors, cancellationToken),
"buildings" => await ImportBuildingsAsync(
rows, errors, cancellationToken),
"classrooms" => await ImportClassroomsAsync(
rows, errors, cancellationToken),
"course-categories" => await ImportCourseCategoriesAsync(
rows, errors, cancellationToken),
_ => throw new InvalidOperationException()
};
if (errors.Count > 0) if (errors.Count > 0)
{ {
await transaction.RollbackAsync(cancellationToken); await transaction.RollbackAsync(cancellationToken);
return ImportValidationProblem(errors); return ImportValidationProblem(errors);
} }
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
return Ok(result); return Ok(result);
} }
catch (DbUpdateException) catch (DbUpdateException)
{ {
await transaction.RollbackAsync(cancellationToken); await transaction.RollbackAsync(cancellationToken);
return Conflict(new ProblemDetails return Conflict(new ProblemDetails
{ {
Title = "导入失败", Title = "导入失败",
Detail = "存在重复编码或无效关联,未写入任何数据。", Detail = "存在重复编码或无效关联,未写入任何数据。",
Status = StatusCodes.Status409Conflict Status = StatusCodes.Status409Conflict
}); });
} }
},
cancellationToken);
} }
private async Task<IReadOnlyList<IReadOnlyList<object?>>> GetExportRowsAsync( private async Task<IReadOnlyList<IReadOnlyList<object?>>> GetExportRowsAsync(
@@ -256,7 +268,9 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
{ {
entity = new Major entity = new Major
{ {
Code = code, Name = name, CollegeId = college.Id, Code = code,
Name = name,
CollegeId = college.Id,
DegreeType = degreeType DegreeType = degreeType
}; };
db.Majors.Add(entity); db.Majors.Add(entity);
@@ -316,7 +330,10 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
{ {
entity = new AdministrativeClass entity = new AdministrativeClass
{ {
Code = code, Name = name, MajorId = major.Id, Grade = grade.Value Code = code,
Name = name,
MajorId = major.Id,
Grade = grade.Value
}; };
db.AdministrativeClasses.Add(entity); db.AdministrativeClasses.Add(entity);
existing[code] = entity; existing[code] = entity;
@@ -362,8 +379,11 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
{ {
entity = new AcademicTerm entity = new AcademicTerm
{ {
Code = code, Name = name, AcademicYear = academicYear, Code = code,
Season = season.Value, StartDate = startDate.Value, Name = name,
AcademicYear = academicYear,
Season = season.Value,
StartDate = startDate.Value,
EndDate = endDate.Value EndDate = endDate.Value
}; };
db.AcademicTerms.Add(entity); db.AcademicTerms.Add(entity);
@@ -461,7 +481,9 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
{ {
entity = new Classroom entity = new Classroom
{ {
Code = code, Name = name, BuildingId = building.Id, Code = code,
Name = name,
BuildingId = building.Id,
RoomType = roomType RoomType = roomType
}; };
db.Classrooms.Add(entity); db.Classrooms.Add(entity);
@@ -58,6 +58,8 @@ public sealed class CourseSelectionsController(
x.Name, x.Name,
x.AcademicTermId, x.AcademicTermId,
TermName = x.AcademicTerm!.Name, TermName = x.AcademicTerm!.Name,
TermIsCurrent = x.AcademicTerm.IsCurrent,
TermIsArchived = x.AcademicTerm.IsArchived,
x.StartsAt, x.StartsAt,
x.EndsAt, x.EndsAt,
x.WithdrawalEndsAt, x.WithdrawalEndsAt,
@@ -369,12 +371,12 @@ public sealed class CourseSelectionsController(
var source = db.Students.AsNoTracking() var source = db.Students.AsNoTracking()
.Where(x => .Where(x =>
x.Status == StudentStatus.Active && x.Status == StudentStatus.Active &&
(offering.IsOpenToAll ||
offering.ClassIds.Contains(x.AdministrativeClassId)) &&
!db.CourseEnrollments.Any(enrollment => !db.CourseEnrollments.Any(enrollment =>
enrollment.CourseSelectionOfferingId == id && enrollment.CourseSelectionOfferingId == id &&
enrollment.StudentId == x.Id && enrollment.StudentId == x.Id &&
enrollment.Status == CourseEnrollmentStatus.Enrolled)); enrollment.Status == CourseEnrollmentStatus.Enrolled));
if (!offering.IsOpenToAll)
source = source.WhereIn(offering.ClassIds, x => x.AdministrativeClassId);
if (!string.IsNullOrWhiteSpace(keyword)) if (!string.IsNullOrWhiteSpace(keyword))
{ {
keyword = keyword.Trim(); keyword = keyword.Trim();
@@ -415,157 +417,160 @@ public sealed class CourseSelectionsController(
if (studentIds.Length > 100) if (studentIds.Length > 100)
return ValidationProblem("单次最多可为 100 名学生代选。"); return ValidationProblem("单次最多可为 100 名学生代选。");
await using var transaction = await db.Database.BeginTransactionAsync( return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
IsolationLevel.Serializable, async transaction =>
cancellationToken);
var offering = await db.CourseSelectionOfferings
.Include(x => x.CourseSelectionRound)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Classes)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (offering is null) return NotFound();
var round = offering.CourseSelectionRound!;
var task = offering.TeachingTask!;
if (!CourseSelectionRules.SupportsProxyEnrollment(task.Course!.Nature))
return ConflictProblem("管理员代选仅适用于公共必修课。");
if (round.Status == CourseSelectionRoundStatus.Draft)
return ConflictProblem("选课批次开放后才能办理管理员代选。");
if (task.Status != TeachingTaskStatus.Published)
return ConflictProblem("该教学班当前不可办理代选。");
var students = await db.Students
.Include(x => x.AdministrativeClass)
.Where(x => studentIds.Contains(x.Id))
.OrderBy(x => x.StudentNumber)
.ToListAsync(cancellationToken);
if (students.Count != studentIds.Length)
return ValidationProblem("存在无效的学生档案。");
var inactive = students.FirstOrDefault(x => x.Status != StudentStatus.Active);
if (inactive is not null)
return ConflictProblem($"学生 {inactive.StudentNumber} {inactive.Name} 当前不是在籍状态。");
var outOfScope = students.FirstOrDefault(student =>
!offering.IsOpenToAll &&
!task.Classes.Any(item =>
item.AdministrativeClassId == student.AdministrativeClassId));
if (outOfScope is not null)
{
return ConflictProblem(
$"学生 {outOfScope.StudentNumber} {outOfScope.Name} 不属于该教学班的选课对象。");
}
var existingEnrollments = await db.CourseEnrollments
.Where(x =>
x.CourseSelectionOfferingId == id &&
studentIds.Contains(x.StudentId))
.ToListAsync(cancellationToken);
var alreadyEnrolled = existingEnrollments.FirstOrDefault(x =>
x.Status == CourseEnrollmentStatus.Enrolled);
if (alreadyEnrolled is not null)
{
var student = students.First(x => x.Id == alreadyEnrolled.StudentId);
return ConflictProblem(
$"学生 {student.StudentNumber} {student.Name} 已在该教学班名单中。");
}
var enrolledCount = await db.CourseEnrollments.CountAsync(
x =>
x.CourseSelectionOfferingId == id &&
x.Status == CourseEnrollmentStatus.Enrolled,
cancellationToken);
if (enrolledCount + students.Count > offering.Capacity)
{
return ConflictProblem(
$"教学班仅剩 {Math.Max(0, offering.Capacity - enrolledCount)} 个名额,无法完成本次代选。");
}
var duplicateStudentIds = await db.CourseEnrollments.AsNoTracking()
.Where(x =>
studentIds.Contains(x.StudentId) &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId ==
round.AcademicTermId)
.Select(x => x.StudentId)
.Distinct()
.ToListAsync(cancellationToken);
if (duplicateStudentIds.Count > 0)
{
var student = students.First(x => duplicateStudentIds.Contains(x.Id));
return ConflictProblem(
$"学生 {student.StudentNumber} {student.Name} 本学期已选择相同课程。");
}
var candidateEntries = await PublishedScheduleEntries(
round.AcademicTermId,
[task.Id],
cancellationToken);
if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) &&
candidateEntries.Count == 0)
return ConflictProblem("该教学班尚未发布课表,暂时不能办理代选。");
foreach (var student in students)
{
var selectedCredits = await db.CourseEnrollments
.Where(x =>
x.StudentId == student.Id &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id)
.SumAsync(
x => (decimal?)x.CourseSelectionOffering!.TeachingTask!.Course!.Credits,
cancellationToken) ?? 0;
if (selectedCredits + task.Course.Credits > round.MaxCredits)
{ {
return ConflictProblem( db.ChangeTracker.Clear();
$"学生 {student.StudentNumber} {student.Name} 代选后将超过本轮 {round.MaxCredits:0.#} 学分上限。"); var offering = await db.CourseSelectionOfferings
} .Include(x => x.CourseSelectionRound)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Classes)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (offering is null) return NotFound();
var round = offering.CourseSelectionRound!;
var task = offering.TeachingTask!;
if (!CourseSelectionRules.SupportsProxyEnrollment(task.Course!.Nature))
return ConflictProblem("管理员代选仅适用于公共必修课。");
if (round.Status == CourseSelectionRoundStatus.Draft)
return ConflictProblem("选课批次开放后才能办理管理员代选。");
if (task.Status != TeachingTaskStatus.Published)
return ConflictProblem("该教学班当前不可办理代选。");
var selectedTaskIds = await db.CourseEnrollments.AsNoTracking() var students = await db.Students
.Where(x => .Include(x => x.AdministrativeClass)
x.StudentId == student.Id && .WhereIn(studentIds, x => x.Id)
x.Status == CourseEnrollmentStatus.Enrolled && .OrderBy(x => x.StudentNumber)
x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId == .ToListAsync(cancellationToken);
round.AcademicTermId) if (students.Count != studentIds.Length)
.Select(x => x.CourseSelectionOffering!.TeachingTaskId) return ValidationProblem("存在无效的学生档案。");
.Distinct() var inactive = students.FirstOrDefault(x => x.Status != StudentStatus.Active);
.ToArrayAsync(cancellationToken); if (inactive is not null)
var selectedEntries = await PublishedScheduleEntries( return ConflictProblem($"学生 {inactive.StudentNumber} {inactive.Name} 当前不是在籍状态。");
round.AcademicTermId, var outOfScope = students.FirstOrDefault(student =>
selectedTaskIds, !offering.IsOpenToAll &&
cancellationToken); !task.Classes.Any(item =>
if (CourseSelectionRules.HasScheduleConflict(candidateEntries, selectedEntries)) item.AdministrativeClassId == student.AdministrativeClassId));
{ if (outOfScope is not null)
return ConflictProblem(
$"学生 {student.StudentNumber} {student.Name} 的已选课程与该教学班时间冲突。");
}
}
var now = DateTime.UtcNow;
foreach (var student in students)
{
var enrollment = existingEnrollments.FirstOrDefault(x =>
x.StudentId == student.Id);
if (enrollment is null)
{
db.CourseEnrollments.Add(new CourseEnrollment
{ {
CourseSelectionOfferingId = id, return ConflictProblem(
StudentId = student.Id, $"学生 {outOfScope.StudentNumber} {outOfScope.Name} 不属于该教学班的选课对象。");
EnrolledAt = now }
});
}
else
{
enrollment.Status = CourseEnrollmentStatus.Enrolled;
enrollment.EnrolledAt = now;
enrollment.WithdrawnAt = null;
}
}
await db.SaveChangesAsync(cancellationToken); var existingEnrollments = await db.CourseEnrollments
await transaction.CommitAsync(cancellationToken); .Where(x => x.CourseSelectionOfferingId == id)
return Ok(new { EnrolledCount = students.Count }); .WhereIn(studentIds, x => x.StudentId)
.ToListAsync(cancellationToken);
var alreadyEnrolled = existingEnrollments.FirstOrDefault(x =>
x.Status == CourseEnrollmentStatus.Enrolled);
if (alreadyEnrolled is not null)
{
var student = students.First(x => x.Id == alreadyEnrolled.StudentId);
return ConflictProblem(
$"学生 {student.StudentNumber} {student.Name} 已在该教学班名单中。");
}
var enrolledCount = await db.CourseEnrollments.CountAsync(
x =>
x.CourseSelectionOfferingId == id &&
x.Status == CourseEnrollmentStatus.Enrolled,
cancellationToken);
if (enrolledCount + students.Count > offering.Capacity)
{
return ConflictProblem(
$"教学班仅剩 {Math.Max(0, offering.Capacity - enrolledCount)} 个名额,无法完成本次代选。");
}
var duplicateStudentIds = await db.CourseEnrollments.AsNoTracking()
.Where(x =>
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId ==
round.AcademicTermId)
.WhereIn(studentIds, x => x.StudentId)
.Select(x => x.StudentId)
.Distinct()
.ToListAsync(cancellationToken);
if (duplicateStudentIds.Count > 0)
{
var student = students.First(x => duplicateStudentIds.Contains(x.Id));
return ConflictProblem(
$"学生 {student.StudentNumber} {student.Name} 本学期已选择相同课程。");
}
var candidateEntries = await PublishedScheduleEntries(
round.AcademicTermId,
[task.Id],
cancellationToken);
if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) &&
candidateEntries.Count == 0)
return ConflictProblem("该教学班尚未发布课表,暂时不能办理代选。");
foreach (var student in students)
{
var selectedCredits = await db.CourseEnrollments
.Where(x =>
x.StudentId == student.Id &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id)
.SumAsync(
x => (decimal?)x.CourseSelectionOffering!.TeachingTask!.Course!.Credits,
cancellationToken) ?? 0;
if (selectedCredits + task.Course.Credits > round.MaxCredits)
{
return ConflictProblem(
$"学生 {student.StudentNumber} {student.Name} 代选后将超过本轮 {round.MaxCredits:0.#} 学分上限。");
}
var selectedTaskIds = await db.CourseEnrollments.AsNoTracking()
.Where(x =>
x.StudentId == student.Id &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId ==
round.AcademicTermId)
.Select(x => x.CourseSelectionOffering!.TeachingTaskId)
.Distinct()
.ToArrayAsync(cancellationToken);
var selectedEntries = await PublishedScheduleEntries(
round.AcademicTermId,
selectedTaskIds,
cancellationToken);
if (CourseSelectionRules.HasScheduleConflict(candidateEntries, selectedEntries))
{
return ConflictProblem(
$"学生 {student.StudentNumber} {student.Name} 的已选课程与该教学班时间冲突。");
}
}
var now = DateTime.UtcNow;
foreach (var student in students)
{
var enrollment = existingEnrollments.FirstOrDefault(x =>
x.StudentId == student.Id);
if (enrollment is null)
{
db.CourseEnrollments.Add(new CourseEnrollment
{
CourseSelectionOfferingId = id,
StudentId = student.Id,
EnrolledAt = now
});
}
else
{
enrollment.Status = CourseEnrollmentStatus.Enrolled;
enrollment.EnrolledAt = now;
enrollment.WithdrawnAt = null;
}
}
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Ok(new { EnrolledCount = students.Count });
},
cancellationToken,
IsolationLevel.Serializable);
} }
[HttpPost("offerings/{offeringId:guid}/force-enroll")] [HttpPost("offerings/{offeringId:guid}/force-enroll")]
@@ -581,73 +586,77 @@ public sealed class CourseSelectionsController(
if (studentIds.Length > 100) if (studentIds.Length > 100)
return ValidationProblem("单次最多可为 100 名学生强制选课。"); return ValidationProblem("单次最多可为 100 名学生强制选课。");
await using var transaction = await db.Database.BeginTransactionAsync( return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
IsolationLevel.Serializable, cancellationToken); async transaction =>
var offering = await db.CourseSelectionOfferings
.Include(x => x.CourseSelectionRound)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.FirstOrDefaultAsync(x => x.Id == offeringId, cancellationToken);
if (offering is null) return NotFound();
var round = offering.CourseSelectionRound!;
var task = offering.TeachingTask!;
if (round.Status == CourseSelectionRoundStatus.Draft)
return ConflictProblem("选课批次开放后才能办理强制选课。");
if (task.Status != TeachingTaskStatus.Published)
return ConflictProblem("该教学班当前不可选。");
var students = await db.Students
.Include(x => x.AdministrativeClass)
.Where(x => studentIds.Contains(x.Id))
.OrderBy(x => x.StudentNumber)
.ToListAsync(cancellationToken);
if (students.Count != studentIds.Length)
return ValidationProblem("存在无效的学生档案。");
var existingEnrollments = await db.CourseEnrollments
.Where(x =>
x.CourseSelectionOfferingId == offeringId &&
studentIds.Contains(x.StudentId))
.ToListAsync(cancellationToken);
var alreadyEnrolled = existingEnrollments
.FirstOrDefault(x => x.Status == CourseEnrollmentStatus.Enrolled);
if (alreadyEnrolled is not null)
{
var dup = students.First(x => x.Id == alreadyEnrolled.StudentId);
return ConflictProblem(
$"学生 {dup.StudentNumber} {dup.Name} 已在该教学班名单中。");
}
var now = DateTime.UtcNow;
var enrolled = 0;
foreach (var student in students)
{
var enrollment = existingEnrollments
.FirstOrDefault(x => x.StudentId == student.Id);
if (enrollment is null)
{ {
db.CourseEnrollments.Add(new CourseEnrollment db.ChangeTracker.Clear();
var offering = await db.CourseSelectionOfferings
.Include(x => x.CourseSelectionRound)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.FirstOrDefaultAsync(x => x.Id == offeringId, cancellationToken);
if (offering is null) return NotFound();
var round = offering.CourseSelectionRound!;
var task = offering.TeachingTask!;
if (round.Status == CourseSelectionRoundStatus.Draft)
return ConflictProblem("选课批次开放后才能办理强制选课。");
if (task.Status != TeachingTaskStatus.Published)
return ConflictProblem("该教学班当前不可选。");
var students = await db.Students
.Include(x => x.AdministrativeClass)
.WhereIn(studentIds, x => x.Id)
.OrderBy(x => x.StudentNumber)
.ToListAsync(cancellationToken);
if (students.Count != studentIds.Length)
return ValidationProblem("存在无效的学生档案。");
var existingEnrollments = await db.CourseEnrollments
.Where(x => x.CourseSelectionOfferingId == offeringId)
.WhereIn(studentIds, x => x.StudentId)
.ToListAsync(cancellationToken);
var alreadyEnrolled = existingEnrollments
.FirstOrDefault(x => x.Status == CourseEnrollmentStatus.Enrolled);
if (alreadyEnrolled is not null)
{ {
CourseSelectionOfferingId = offeringId, var dup = students.First(x => x.Id == alreadyEnrolled.StudentId);
StudentId = student.Id, return ConflictProblem(
EnrollmentType = EnrollmentType.Retake, $"学生 {dup.StudentNumber} {dup.Name} 已在该教学班名单中。");
EnrolledAt = now }
});
}
else
{
enrollment.Status = CourseEnrollmentStatus.Enrolled;
enrollment.EnrolledAt = now;
enrollment.WithdrawnAt = null;
enrollment.EnrollmentType = EnrollmentType.Retake;
}
enrolled++;
}
await db.SaveChangesAsync(cancellationToken); var now = DateTime.UtcNow;
await transaction.CommitAsync(cancellationToken); var enrolled = 0;
return Ok(new { EnrolledCount = enrolled }); foreach (var student in students)
{
var enrollment = existingEnrollments
.FirstOrDefault(x => x.StudentId == student.Id);
if (enrollment is null)
{
db.CourseEnrollments.Add(new CourseEnrollment
{
CourseSelectionOfferingId = offeringId,
StudentId = student.Id,
EnrollmentType = EnrollmentType.Retake,
EnrolledAt = now
});
}
else
{
enrollment.Status = CourseEnrollmentStatus.Enrolled;
enrollment.EnrolledAt = now;
enrollment.WithdrawnAt = null;
enrollment.EnrollmentType = EnrollmentType.Retake;
}
enrolled++;
}
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Ok(new { EnrolledCount = enrolled });
},
cancellationToken,
IsolationLevel.Serializable);
} }
[HttpDelete("offerings/{offeringId:guid}/admin-enrollments/{enrollmentId:guid}")] [HttpDelete("offerings/{offeringId:guid}/admin-enrollments/{enrollmentId:guid}")]
@@ -824,145 +833,149 @@ public sealed class CourseSelectionsController(
StudentEnrollmentRequest request, StudentEnrollmentRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var student = await CurrentStudentAsync(cancellationToken); return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
if (student is null) return ProfileNotFound(); async transaction =>
if (student.Status != StudentStatus.Active)
return ConflictProblem("只有在籍学生可以选课。");
await using var transaction = await db.Database.BeginTransactionAsync(
IsolationLevel.Serializable,
cancellationToken);
var offering = await db.CourseSelectionOfferings
.Include(x => x.CourseSelectionRound)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Classes)
.FirstOrDefaultAsync(x => x.Id == request.OfferingId, cancellationToken);
if (offering is null) return NotFound();
var round = offering.CourseSelectionRound!;
var task = offering.TeachingTask!;
var now = DateTime.UtcNow;
if (!CourseSelectionRules.IsSelectionOpen(round, now))
return ConflictProblem("当前不在该选课批次的开放时间内。");
if (task.Status != TeachingTaskStatus.Published)
return ConflictProblem("该教学班当前不可选。");
if (!offering.IsOpenToAll &&
!task.Classes.Any(x =>
x.AdministrativeClassId == student.AdministrativeClassId))
return Forbid();
// Detect retake: student previously took the same course in any term
var isRetake = await db.CourseEnrollments.AnyAsync(
x =>
x.StudentId == student.Id &&
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId !=
round.AcademicTermId,
cancellationToken);
var existing = await db.CourseEnrollments.FirstOrDefaultAsync(
x =>
x.CourseSelectionOfferingId == offering.Id &&
x.StudentId == student.Id,
cancellationToken);
if (existing?.Status == CourseEnrollmentStatus.Enrolled)
return ConflictProblem("你已经选择了该教学班。");
var enrolledCount = await db.CourseEnrollments.CountAsync(
x =>
x.CourseSelectionOfferingId == offering.Id &&
x.Status == CourseEnrollmentStatus.Enrolled,
cancellationToken);
var effectiveCapacity = isRetake
? CourseSelectionRules.RetakeCapacity(offering.Capacity)
: offering.Capacity;
if (enrolledCount >= effectiveCapacity)
return ConflictProblem("该教学班名额已满。");
// Normal enrollment: no duplicate course in same term
if (!isRetake)
{
var duplicateCourse = await db.CourseEnrollments.AnyAsync(
x =>
x.StudentId == student.Id &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId ==
round.AcademicTermId,
cancellationToken);
if (duplicateCourse)
return ConflictProblem("同一学期不能重复选择相同课程。");
}
// Credit limit check
var selectedCredits = await db.CourseEnrollments
.Where(x =>
x.StudentId == student.Id &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id)
.SumAsync(
x => (decimal?)x.CourseSelectionOffering!.TeachingTask!.Course!.Credits,
cancellationToken) ?? 0;
if (selectedCredits + task.Course!.Credits > round.MaxCredits)
{
return ConflictProblem(
$"选课后将达到 {selectedCredits + task.Course.Credits:0.#} 学分," +
$"超过本轮 {round.MaxCredits:0.#} 学分上限。");
}
// Schedule conflict check
var candidateEntries = await PublishedScheduleEntries(
round.AcademicTermId, [task.Id], cancellationToken);
if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) &&
candidateEntries.Count == 0)
return ConflictProblem("该教学班尚未发布课表,暂时不能选课。");
var selectedTaskIds = await db.CourseEnrollments
.Where(x =>
x.StudentId == student.Id &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId ==
round.AcademicTermId)
.Select(x => x.CourseSelectionOffering!.TeachingTaskId)
.Distinct()
.ToArrayAsync(cancellationToken);
var selectedEntries = await PublishedScheduleEntries(
round.AcademicTermId, selectedTaskIds, cancellationToken);
if (CourseSelectionRules.HasScheduleConflict(candidateEntries, selectedEntries))
{
if (!isRetake)
return ConflictProblem("该教学班与已选课程的上课时间冲突。");
// Retake: allow ≤50% overlap
var overlap = CourseSelectionRules.CalculateScheduleOverlap(
candidateEntries, selectedEntries);
if (overlap > 50)
return ConflictProblem(
$"重修课程时间冲突 {overlap:F0}%,超过 50% 上限,无法选课。");
}
if (existing is null)
{
existing = new CourseEnrollment
{ {
CourseSelectionOfferingId = offering.Id, db.ChangeTracker.Clear();
StudentId = student.Id, var student = await CurrentStudentAsync(cancellationToken);
EnrollmentType = isRetake ? EnrollmentType.Retake : EnrollmentType.Normal if (student is null) return ProfileNotFound();
}; if (student.Status != StudentStatus.Active)
db.CourseEnrollments.Add(existing); return ConflictProblem("只有在籍学生可以选课。");
}
else var offering = await db.CourseSelectionOfferings
{ .Include(x => x.CourseSelectionRound)
existing.Status = CourseEnrollmentStatus.Enrolled; .Include(x => x.TeachingTask)
existing.EnrolledAt = now; .ThenInclude(x => x!.Course)
existing.WithdrawnAt = null; .Include(x => x.TeachingTask)
existing.EnrollmentType = isRetake ? EnrollmentType.Retake : EnrollmentType.Normal; .ThenInclude(x => x!.Classes)
} .FirstOrDefaultAsync(x => x.Id == request.OfferingId, cancellationToken);
await db.SaveChangesAsync(cancellationToken); if (offering is null) return NotFound();
await transaction.CommitAsync(cancellationToken); var round = offering.CourseSelectionRound!;
return Created(string.Empty, new { existing.Id, IsRetake = isRetake }); var task = offering.TeachingTask!;
var now = DateTime.UtcNow;
if (!CourseSelectionRules.IsSelectionOpen(round, now))
return ConflictProblem("当前不在该选课批次的开放时间内。");
if (task.Status != TeachingTaskStatus.Published)
return ConflictProblem("该教学班当前不可选。");
if (!offering.IsOpenToAll &&
!task.Classes.Any(x =>
x.AdministrativeClassId == student.AdministrativeClassId))
return Forbid();
// Detect retake: student previously took the same course in any term
var isRetake = await db.CourseEnrollments.AnyAsync(
x =>
x.StudentId == student.Id &&
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId !=
round.AcademicTermId,
cancellationToken);
var existing = await db.CourseEnrollments.FirstOrDefaultAsync(
x =>
x.CourseSelectionOfferingId == offering.Id &&
x.StudentId == student.Id,
cancellationToken);
if (existing?.Status == CourseEnrollmentStatus.Enrolled)
return ConflictProblem("你已经选择了该教学班。");
var enrolledCount = await db.CourseEnrollments.CountAsync(
x =>
x.CourseSelectionOfferingId == offering.Id &&
x.Status == CourseEnrollmentStatus.Enrolled,
cancellationToken);
var effectiveCapacity = isRetake
? CourseSelectionRules.RetakeCapacity(offering.Capacity)
: offering.Capacity;
if (enrolledCount >= effectiveCapacity)
return ConflictProblem("该教学班名额已满。");
// Normal enrollment: no duplicate course in same term
if (!isRetake)
{
var duplicateCourse = await db.CourseEnrollments.AnyAsync(
x =>
x.StudentId == student.Id &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId ==
round.AcademicTermId,
cancellationToken);
if (duplicateCourse)
return ConflictProblem("同一学期不能重复选择相同课程。");
}
// Credit limit check
var selectedCredits = await db.CourseEnrollments
.Where(x =>
x.StudentId == student.Id &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id)
.SumAsync(
x => (decimal?)x.CourseSelectionOffering!.TeachingTask!.Course!.Credits,
cancellationToken) ?? 0;
if (selectedCredits + task.Course!.Credits > round.MaxCredits)
{
return ConflictProblem(
$"选课后将达到 {selectedCredits + task.Course.Credits:0.#} 学分," +
$"超过本轮 {round.MaxCredits:0.#} 学分上限。");
}
// Schedule conflict check
var candidateEntries = await PublishedScheduleEntries(
round.AcademicTermId, [task.Id], cancellationToken);
if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) &&
candidateEntries.Count == 0)
return ConflictProblem("该教学班尚未发布课表,暂时不能选课。");
var selectedTaskIds = await db.CourseEnrollments
.Where(x =>
x.StudentId == student.Id &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId ==
round.AcademicTermId)
.Select(x => x.CourseSelectionOffering!.TeachingTaskId)
.Distinct()
.ToArrayAsync(cancellationToken);
var selectedEntries = await PublishedScheduleEntries(
round.AcademicTermId, selectedTaskIds, cancellationToken);
if (CourseSelectionRules.HasScheduleConflict(candidateEntries, selectedEntries))
{
if (!isRetake)
return ConflictProblem("该教学班与已选课程的上课时间冲突。");
// Retake: allow ≤50% overlap
var overlap = CourseSelectionRules.CalculateScheduleOverlap(
candidateEntries, selectedEntries);
if (overlap > 50)
return ConflictProblem(
$"重修课程时间冲突 {overlap:F0}%,超过 50% 上限,无法选课。");
}
if (existing is null)
{
existing = new CourseEnrollment
{
CourseSelectionOfferingId = offering.Id,
StudentId = student.Id,
EnrollmentType = isRetake ? EnrollmentType.Retake : EnrollmentType.Normal
};
db.CourseEnrollments.Add(existing);
}
else
{
existing.Status = CourseEnrollmentStatus.Enrolled;
existing.EnrolledAt = now;
existing.WithdrawnAt = null;
existing.EnrollmentType = isRetake ? EnrollmentType.Retake : EnrollmentType.Normal;
}
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Created(string.Empty, new { existing.Id, IsRetake = isRetake });
},
cancellationToken,
IsolationLevel.Serializable);
} }
[HttpDelete("student/enrollments/{id:guid}")] [HttpDelete("student/enrollments/{id:guid}")]
@@ -1159,9 +1172,9 @@ public sealed class CourseSelectionsController(
if (taskIds.Count == 0) return []; if (taskIds.Count == 0) return [];
return await db.ScheduleEntries.AsNoTracking() return await db.ScheduleEntries.AsNoTracking()
.Where(x => .Where(x =>
taskIds.Contains(x.TeachingTaskId) &&
x.SchedulePlan!.AcademicTermId == academicTermId && x.SchedulePlan!.AcademicTermId == academicTermId &&
x.SchedulePlan.Status == SchedulePlanStatus.Published) x.SchedulePlan.Status == SchedulePlanStatus.Published)
.WhereIn(taskIds, x => x.TeachingTaskId)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
@@ -122,32 +122,39 @@ public sealed class CoursesExcelController(
if (rows.Count == 0) if (rows.Count == 0)
return ValidationProblem("Excel 中没有可导入的课程数据。"); return ValidationProblem("Excel 中没有可导入的课程数据。");
var errors = new List<string>(); return await db.ExecuteInRetriableTransactionAsync<
await using var transaction = ActionResult<ExcelImportResult>>(
await db.Database.BeginTransactionAsync(cancellationToken); async transaction =>
try
{
var result = await ImportRowsAsync(rows, errors, cancellationToken);
if (errors.Count > 0)
{ {
await transaction.RollbackAsync(cancellationToken); var errors = new List<string>();
return ImportValidationProblem(errors); try
} {
var result = await ImportRowsAsync(
rows,
errors,
cancellationToken);
if (errors.Count > 0)
{
await transaction.RollbackAsync(cancellationToken);
return ImportValidationProblem(errors);
}
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
return Ok(result); return Ok(result);
} }
catch (DbUpdateException) catch (DbUpdateException)
{ {
await transaction.RollbackAsync(cancellationToken); await transaction.RollbackAsync(cancellationToken);
return Conflict(new ProblemDetails return Conflict(new ProblemDetails
{ {
Title = "导入失败", Title = "导入失败",
Detail = "存在重复课程编码或无效关联,未写入任何课程。", Detail = "存在重复课程编码或无效关联,未写入任何课程。",
Status = StatusCodes.Status409Conflict Status = StatusCodes.Status409Conflict
}); });
} }
},
cancellationToken);
} }
private async Task<ExcelImportResult> ImportRowsAsync( private async Task<ExcelImportResult> ImportRowsAsync(
@@ -103,12 +103,14 @@ public sealed class CurriculumPlansController(
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var accessibleCollegeIds = majorOptions var accessibleMajors = db.Majors.AsNoTracking().Where(x => x.IsEnabled);
.Select(x => x.CollegeId) if (collegeId.HasValue)
.Distinct() accessibleMajors = accessibleMajors.Where(
.ToArray(); x => x.CollegeId == collegeId.Value);
var colleges = await db.Colleges.AsNoTracking() var colleges = await db.Colleges.AsNoTracking()
.Where(x => x.IsEnabled && accessibleCollegeIds.Contains(x.Id)) .Where(x =>
x.IsEnabled &&
accessibleMajors.Any(major => major.CollegeId == x.Id))
.OrderBy(x => x.SortOrder) .OrderBy(x => x.SortOrder)
.ThenBy(x => x.Code) .ThenBy(x => x.Code)
.Select(x => new { x.Id, x.Code, x.Name }) .Select(x => new { x.Id, x.Code, x.Name })
@@ -288,46 +290,57 @@ public sealed class CurriculumPlansController(
[HttpPost("{id:guid}/publish")] [HttpPost("{id:guid}/publish")]
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken) public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
{ {
var plan = await ScopedPlans() return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
.Include(x => x.Major) async transaction =>
.Include(x => x.Modules) {
.ThenInclude(x => x.Courses) db.ChangeTracker.Clear();
.ThenInclude(x => x.Course) var plan = await ScopedPlans()
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); .Include(x => x.Major)
if (plan is null) return NotFound(); .Include(x => x.Modules)
if (plan.Status != CurriculumPlanStatus.Draft) .ThenInclude(x => x.Courses)
return ConflictProblem("只有草稿方案可以发布。"); .ThenInclude(x => x.Course)
if (plan.Modules.Count == 0 || plan.Modules.Any(x => x.Courses.Count == 0)) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
return ConflictProblem("发布前每个课程模块都必须配置课程。"); if (plan is null) return NotFound();
if (plan.Modules.Sum(x => x.RequiredCredits) != plan.TotalCredits) if (plan.Status != CurriculumPlanStatus.Draft)
return ConflictProblem("各模块最低学分之和必须等于方案总学分。"); return ConflictProblem("只有草稿方案可以发布。");
if (plan.Modules.Any(module => if (plan.Modules.Count == 0 ||
module.Courses.Sum(x => x.Course!.Credits) < module.RequiredCredits)) plan.Modules.Any(x => x.Courses.Count == 0))
return ConflictProblem("存在课程学分合计低于最低学分要求的模块。"); return ConflictProblem("发布前每个课程模块都必须配置课程。");
if (plan.Modules.SelectMany(x => x.Courses).GroupBy(x => x.CourseId).Any(x => x.Count() > 1)) if (plan.Modules.Sum(x => x.RequiredCredits) != plan.TotalCredits)
return ConflictProblem("同一门课程不能重复加入多个模块。"); return ConflictProblem("各模块最低学分之和必须等于方案总学分。");
if (plan.Modules.SelectMany(x => x.Courses).Any(x => if (plan.Modules.Any(module =>
x.RecommendedSemester > plan.Major!.SchoolingYears * 2)) module.Courses.Sum(x => x.Course!.Credits) <
return ConflictProblem("建议学期超出了该专业学制。"); module.RequiredCredits))
return ConflictProblem(
"存在课程学分合计低于最低学分要求的模块。");
if (plan.Modules.SelectMany(x => x.Courses)
.GroupBy(x => x.CourseId)
.Any(x => x.Count() > 1))
return ConflictProblem("同一门课程不能重复加入多个模块。");
if (plan.Modules.SelectMany(x => x.Courses).Any(x =>
x.RecommendedSemester >
plan.Major!.SchoolingYears * 2))
return ConflictProblem("建议学期超出了该专业学制。");
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); var previousPlans = await ScopedPlans()
var previousPlans = await ScopedPlans() .Where(x =>
.Where(x => x.Id != plan.Id &&
x.Id != plan.Id && x.MajorId == plan.MajorId &&
x.MajorId == plan.MajorId && x.EffectiveGrade == plan.EffectiveGrade &&
x.EffectiveGrade == plan.EffectiveGrade && x.Status == CurriculumPlanStatus.Published)
x.Status == CurriculumPlanStatus.Published) .ToListAsync(cancellationToken);
.ToListAsync(cancellationToken); foreach (var previous in previousPlans)
foreach (var previous in previousPlans) {
{ previous.Status = CurriculumPlanStatus.Archived;
previous.Status = CurriculumPlanStatus.Archived; }
}
plan.Status = CurriculumPlanStatus.Published; plan.Status = CurriculumPlanStatus.Published;
plan.PublishedAt = DateTime.UtcNow; plan.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
return NoContent(); return NoContent();
},
cancellationToken);
} }
[HttpPost("{planId:guid}/modules")] [HttpPost("{planId:guid}/modules")]
@@ -31,9 +31,15 @@ public sealed class DegreeAwardsController(
.ThenByDescending(x => x.CreatedAt) .ThenByDescending(x => x.CreatedAt)
.Select(x => new .Select(x => new
{ {
x.Id, x.Name, x.GraduationYear, x.DegreeName, x.Id,
x.MinimumGradePoint, x.Status, x.Notes, x.Name,
x.CalculatedAt, x.PublishedAt, x.GraduationYear,
x.DegreeName,
x.MinimumGradePoint,
x.Status,
x.Notes,
x.CalculatedAt,
x.PublishedAt,
ResultCount = x.Results.Count(result => ResultCount = x.Results.Count(result =>
!collegeId.HasValue || !collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId), result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId),
@@ -67,19 +73,32 @@ public sealed class DegreeAwardsController(
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId); x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
return Ok(new return Ok(new
{ {
batch.Id, batch.Name, batch.GraduationYear, batch.DegreeName, batch.Id,
batch.MinimumGradePoint, batch.Status, batch.Notes, batch.Name,
batch.CalculatedAt, batch.PublishedAt, batch.GraduationYear,
batch.DegreeName,
batch.MinimumGradePoint,
batch.Status,
batch.Notes,
batch.CalculatedAt,
batch.PublishedAt,
Results = await source.OrderBy(x => x.Student!.StudentNumber) Results = await source.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new .Select(x => new
{ {
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name, x.Id,
x.StudentId,
x.Student!.StudentNumber,
x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name, ClassName = x.Student.AdministrativeClass!.Name,
MajorName = x.Student.AdministrativeClass.Major!.Name, MajorName = x.Student.AdministrativeClass.Major!.Name,
CollegeName = x.Student.AdministrativeClass.Major.College!.Name, CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
x.AverageGradePoint, x.CalculatedConclusion, x.Conclusion, x.AverageGradePoint,
x.ExceptionReason, x.IsOverridden, x.CalculatedConclusion,
x.ReviewComment, x.ReviewedAt x.Conclusion,
x.ExceptionReason,
x.IsOverridden,
x.ReviewComment,
x.ReviewedAt
}).ToListAsync(token) }).ToListAsync(token)
}); });
} }
@@ -130,9 +149,9 @@ public sealed class DegreeAwardsController(
.ToList(); .ToList();
var studentIds = audits.Select(x => x.StudentId).ToArray(); var studentIds = audits.Select(x => x.StudentId).ToArray();
var gradePoints = await db.GradeRecords.AsNoTracking() var gradePoints = await db.GradeRecords.AsNoTracking()
.Where(x => studentIds.Contains(x.StudentId) && .Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published &&
x.GradeSheet!.Status == GradeSheetStatus.Published &&
x.GradePoint.HasValue) x.GradePoint.HasValue)
.WhereIn(studentIds, x => x.StudentId)
.Select(x => new .Select(x => new
{ {
x.StudentId, x.StudentId,
@@ -241,10 +260,14 @@ public sealed class DegreeAwardsController(
x.DegreeAwardBatch.GraduationYear, x.DegreeAwardBatch.GraduationYear,
x.DegreeAwardBatch.DegreeName, x.DegreeAwardBatch.DegreeName,
x.DegreeAwardBatch.MinimumGradePoint, x.DegreeAwardBatch.MinimumGradePoint,
x.Student!.StudentNumber, x.Student.Name, x.Student!.StudentNumber,
x.Student.Name,
MajorName = x.Student.AdministrativeClass!.Major!.Name, MajorName = x.Student.AdministrativeClass!.Major!.Name,
x.AverageGradePoint, x.Conclusion, x.ExceptionReason, x.AverageGradePoint,
x.IsOverridden, x.ReviewComment, x.Conclusion,
x.ExceptionReason,
x.IsOverridden,
x.ReviewComment,
x.DegreeAwardBatch.PublishedAt x.DegreeAwardBatch.PublishedAt
}).FirstOrDefaultAsync(token); }).FirstOrDefaultAsync(token);
return Ok(result); return Ok(result);
@@ -39,12 +39,21 @@ public sealed class EvaluationsController(
.OrderByDescending(x => x.AcademicTerm!.StartDate) .OrderByDescending(x => x.AcademicTerm!.StartDate)
.Select(x => new .Select(x => new
{ {
x.Id, x.Name, x.Status, x.Id,
x.AcademicTermId, TermName = x.AcademicTerm!.Name, x.Name,
x.StartsAt, x.EndsAt, x.Status,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
TermIsCurrent = x.AcademicTerm.IsCurrent,
TermIsArchived = x.AcademicTerm.IsArchived,
x.StartsAt,
x.EndsAt,
Dimensions = x.Dimensions.Select(d => new Dimensions = x.Dimensions.Select(d => new
{ {
d.Id, d.Name, d.MaxScore, d.SortOrder d.Id,
d.Name,
d.MaxScore,
d.SortOrder
}), }),
RecordCount = x.Records.Count, RecordCount = x.Records.Count,
x.CreatedAt x.CreatedAt
@@ -194,8 +203,10 @@ public sealed class EvaluationsController(
var enrollments = await db.CourseEnrollments.AsNoTracking() var enrollments = await db.CourseEnrollments.AsNoTracking()
.Where(e => .Where(e =>
e.Status == CourseEnrollmentStatus.Enrolled && e.Status == CourseEnrollmentStatus.Enrolled &&
e.StudentId == studentId.Value && e.StudentId == studentId.Value)
termIds.Contains(e.CourseSelectionOffering!.TeachingTask!.AcademicTermId)) .WhereIn(
termIds,
e => e.CourseSelectionOffering!.TeachingTask!.AcademicTermId)
.Select(e => new .Select(e => new
{ {
e.CourseSelectionOffering!.TeachingTaskId, e.CourseSelectionOffering!.TeachingTaskId,
@@ -211,9 +222,10 @@ public sealed class EvaluationsController(
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
// Check which tasks have already been evaluated // Check which tasks have already been evaluated
var setupIds = openSetups.Select(s => s.Id).ToArray();
var evaluatedIds = await db.EvaluationRecords.AsNoTracking() var evaluatedIds = await db.EvaluationRecords.AsNoTracking()
.Where(r => r.StudentId == studentId.Value && .Where(r => r.StudentId == studentId.Value)
openSetups.Select(s => s.Id).Contains(r.EvaluationSetupId)) .WhereIn(setupIds, r => r.EvaluationSetupId)
.Select(r => r.TeachingTaskId) .Select(r => r.TeachingTaskId)
.ToHashSetAsync(cancellationToken); .ToHashSetAsync(cancellationToken);
@@ -251,7 +263,9 @@ public sealed class EvaluationsController(
.Where(x => x.Id == teachingTaskId) .Where(x => x.Id == teachingTaskId)
.Select(x => new .Select(x => new
{ {
x.Id, x.TaskNumber, x.Name, x.Id,
x.TaskNumber,
x.Name,
x.AcademicTermId, x.AcademicTermId,
CourseCode = x.Course!.Code, CourseCode = x.Course!.Code,
CourseName = x.Course.Name, CourseName = x.Course.Name,
@@ -283,7 +297,10 @@ public sealed class EvaluationsController(
setup.Name, setup.Name,
Dimensions = setup.Dimensions.Select(d => new Dimensions = setup.Dimensions.Select(d => new
{ {
d.Id, d.Name, d.MaxScore, d.SortOrder d.Id,
d.Name,
d.MaxScore,
d.SortOrder
}), }),
Task = task Task = task
}); });
@@ -384,8 +401,8 @@ public sealed class EvaluationsController(
foreach (var setup in setups) foreach (var setup in setups)
{ {
var setupTaskIds = await db.EvaluationRecords.AsNoTracking() var setupTaskIds = await db.EvaluationRecords.AsNoTracking()
.Where(r => r.EvaluationSetupId == setup.Id && .Where(r => r.EvaluationSetupId == setup.Id)
taskIds.Contains(r.TeachingTaskId)) .WhereIn(taskIds, r => r.TeachingTaskId)
.Select(r => r.TeachingTaskId) .Select(r => r.TeachingTaskId)
.Distinct() .Distinct()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -396,7 +413,9 @@ public sealed class EvaluationsController(
.Where(x => x.Id == taskId) .Where(x => x.Id == taskId)
.Select(x => new .Select(x => new
{ {
x.Id, x.TaskNumber, x.Name, x.Id,
x.TaskNumber,
x.Name,
x.AcademicTermId, x.AcademicTermId,
CourseCode = x.Course!.Code, CourseCode = x.Course!.Code,
CourseName = x.Course.Name CourseName = x.Course.Name
@@ -525,12 +544,18 @@ public sealed class EvaluationsController(
{ {
Setup = new Setup = new
{ {
setup.Id, setup.Name, setup.Status, setup.Id,
setup.Name,
setup.Status,
TermName = setup.AcademicTerm!.Name, TermName = setup.AcademicTerm!.Name,
setup.StartsAt, setup.EndsAt, setup.StartsAt,
setup.EndsAt,
Dimensions = setup.Dimensions.Select(d => new Dimensions = setup.Dimensions.Select(d => new
{ {
d.Id, d.Name, d.MaxScore, d.SortOrder d.Id,
d.Name,
d.MaxScore,
d.SortOrder
}) })
}, },
ByTask = byTask, ByTask = byTask,
+54 -38
View File
@@ -39,8 +39,16 @@ public sealed class ExamsController(
.ThenByDescending(x => x.CreatedAt) .ThenByDescending(x => x.CreatedAt)
.Select(x => new .Select(x => new
{ {
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, x.Id,
x.Status, SessionCount = x.Sessions.Count, x.Notes, x.PublishedAt x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
TermIsCurrent = x.AcademicTerm.IsCurrent,
TermIsArchived = x.AcademicTerm.IsArchived,
x.Status,
SessionCount = x.Sessions.Count,
x.Notes,
x.PublishedAt
}).ToListAsync(cancellationToken)); }).ToListAsync(cancellationToken));
} }
@@ -72,37 +80,42 @@ public sealed class ExamsController(
.Where(x => x.Id == id && (manager || x.Status == ExamPlanStatus.Published)) .Where(x => x.Id == id && (manager || x.Status == ExamPlanStatus.Published))
.Select(x => new .Select(x => new
{ {
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, x.Id,
x.Status, x.Notes, x.PublishedAt, x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
x.Status,
x.Notes,
x.PublishedAt,
Sessions = x.Sessions.OrderBy(item => item.ExamDate) Sessions = x.Sessions.OrderBy(item => item.ExamDate)
.ThenBy(item => item.StartPeriod).Select(item => new .ThenBy(item => item.StartPeriod).Select(item => new
{ {
item.Id, item.Id,
item.TeachingTaskId, item.TeachingTaskId,
item.TeachingTask!.TaskNumber, item.TeachingTask!.TaskNumber,
TaskName = item.TeachingTask.Name, TaskName = item.TeachingTask.Name,
CourseCode = item.TeachingTask.Course!.Code, CourseCode = item.TeachingTask.Course!.Code,
CourseName = item.TeachingTask.Course.Name, CourseName = item.TeachingTask.Course.Name,
item.ClassroomId, item.ClassroomId,
ClassroomName = item.Classroom != null ? item.Classroom.Name : null, ClassroomName = item.Classroom != null ? item.Classroom.Name : null,
BuildingName = item.Classroom != null ? item.Classroom.Building!.Name : null, BuildingName = item.Classroom != null ? item.Classroom.Building!.Name : null,
ClassroomCapacity = item.Classroom != null ? (int?)item.Classroom.Capacity : null, ClassroomCapacity = item.Classroom != null ? (int?)item.Classroom.Capacity : null,
item.ExamDate, item.ExamDate,
item.StartPeriod, item.StartPeriod,
item.PeriodCount, item.PeriodCount,
item.StartsAt, item.StartsAt,
item.EndsAt, item.EndsAt,
item.RequiredBuildingId, item.RequiredBuildingId,
RequiredBuildingName = item.RequiredBuilding != null RequiredBuildingName = item.RequiredBuilding != null
? item.RequiredBuilding.Name : null, ? item.RequiredBuilding.Name : null,
item.RequiredInvigilatorCount, item.RequiredInvigilatorCount,
item.Notes, item.Notes,
InvigilatorIds = item.Invigilators.Select(i => i.TeacherId), InvigilatorIds = item.Invigilators.Select(i => i.TeacherId),
InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name), InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name),
StudentCount = db.CourseEnrollments.Count(e => StudentCount = db.CourseEnrollments.Count(e =>
e.Status == CourseEnrollmentStatus.Enrolled && e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId == item.TeachingTaskId) e.CourseSelectionOffering!.TeachingTaskId == item.TeachingTaskId)
}) })
}).FirstOrDefaultAsync(cancellationToken); }).FirstOrDefaultAsync(cancellationToken);
return plan is null ? NotFound() : Ok(plan); return plan is null ? NotFound() : Ok(plan);
} }
@@ -270,7 +283,7 @@ public sealed class ExamsController(
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (occupiedIds.Count > 0) if (occupiedIds.Count > 0)
query = query.Where(x => !occupiedIds.Contains(x.Id)); query = query.WhereNotIn(occupiedIds, x => x.Id);
return Ok(await query.OrderBy(x => x.Building!.Name) return Ok(await query.OrderBy(x => x.Building!.Name)
.ThenBy(x => x.Capacity) .ThenBy(x => x.Capacity)
@@ -313,8 +326,8 @@ public sealed class ExamsController(
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return Ok(await db.Teachers.AsNoTracking() return Ok(await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active && .Where(x => x.Status == TeacherStatus.Active)
!busyIds.Contains(x.Id)) .WhereNotIn(busyIds, x => x.Id)
.OrderBy(x => x.Name) .OrderBy(x => x.Name)
.Select(x => new .Select(x => new
{ {
@@ -547,8 +560,10 @@ public sealed class ExamsController(
var teacherIds = (invigilatorIds ?? []).Distinct().ToArray(); var teacherIds = (invigilatorIds ?? []).Distinct().ToArray();
if (teacherIds.Length > 0) if (teacherIds.Length > 0)
{ {
if (await db.Teachers.CountAsync(x => teacherIds.Contains(x.Id) && if (await db.Teachers
x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length) .Where(x => x.Status == TeacherStatus.Active)
.WhereIn(teacherIds, x => x.Id)
.CountAsync(cancellationToken) != teacherIds.Length)
return ValidationProblem("存在无效监考教师。"); return ValidationProblem("存在无效监考教师。");
} }
@@ -564,9 +579,10 @@ public sealed class ExamsController(
if (teacherIds.Length > 0) if (teacherIds.Length > 0)
{ {
if (await overlaps.AnyAsync(x => if (await db.ExamSessionInvigilators
x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)), .Where(i => overlaps.Any(x => x.Id == i.ExamSessionId))
cancellationToken)) .WhereIn(teacherIds, i => i.TeacherId)
.AnyAsync(cancellationToken))
return ConflictProblem("监考教师在该时段已有考试任务。"); return ConflictProblem("监考教师在该时段已有考试任务。");
} }
@@ -32,8 +32,15 @@ public sealed class GraduationAuditsController(
.ThenByDescending(x => x.CreatedAt) .ThenByDescending(x => x.CreatedAt)
.Select(x => new .Select(x => new
{ {
x.Id, x.Name, x.GraduationYear, x.EnrollmentYear, x.Status, x.Id,
x.Notes, x.CalculatedAt, x.PublishedAt, x.CreatedAt, x.Name,
x.GraduationYear,
x.EnrollmentYear,
x.Status,
x.Notes,
x.CalculatedAt,
x.PublishedAt,
x.CreatedAt,
ResultCount = x.Results.Count(result => ResultCount = x.Results.Count(result =>
!collegeId.HasValue || !collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId), result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId),
@@ -68,23 +75,38 @@ public sealed class GraduationAuditsController(
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId); x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
return Ok(new return Ok(new
{ {
batch.Id, batch.Name, batch.GraduationYear, batch.EnrollmentYear, batch.Id,
batch.Status, batch.Notes, batch.CalculatedAt, batch.PublishedAt, batch.Name,
batch.GraduationYear,
batch.EnrollmentYear,
batch.Status,
batch.Notes,
batch.CalculatedAt,
batch.PublishedAt,
Results = await results Results = await results
.OrderBy(x => x.Student!.StudentNumber) .OrderBy(x => x.Student!.StudentNumber)
.Select(x => new .Select(x => new
{ {
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name, x.Id,
x.StudentId,
x.Student!.StudentNumber,
x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name, ClassName = x.Student.AdministrativeClass!.Name,
MajorName = x.Student.AdministrativeClass.Major!.Name, MajorName = x.Student.AdministrativeClass.Major!.Name,
CollegeName = x.Student.AdministrativeClass.Major.College!.Name, CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
x.StudentStatusSnapshot, x.StudentStatusSnapshot,
PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null, PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null,
x.RequiredCredits, x.EarnedCredits, x.RequiredCredits,
x.RequiredCourseCount, x.PassedRequiredCourseCount, x.EarnedCredits,
x.FailedCourseCount, x.MissingCourseNames, x.RequiredCourseCount,
x.CalculatedConclusion, x.Conclusion, x.IsOverridden, x.PassedRequiredCourseCount,
x.ReviewComment, x.ReviewedAt x.FailedCourseCount,
x.MissingCourseNames,
x.CalculatedConclusion,
x.Conclusion,
x.IsOverridden,
x.ReviewComment,
x.ReviewedAt
}).ToListAsync(token) }).ToListAsync(token)
}); });
} }
@@ -136,8 +158,8 @@ public sealed class GraduationAuditsController(
.ToListAsync(token); .ToListAsync(token);
var studentIds = students.Select(x => x.Id).ToArray(); var studentIds = students.Select(x => x.Id).ToArray();
var grades = await db.GradeRecords.AsNoTracking() var grades = await db.GradeRecords.AsNoTracking()
.Where(x => studentIds.Contains(x.StudentId) && .Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published)
x.GradeSheet!.Status == GradeSheetStatus.Published) .WhereIn(studentIds, x => x.StudentId)
.Select(x => new GradeSnapshot( .Select(x => new GradeSnapshot(
x.StudentId, x.StudentId,
x.GradeSheet!.TeachingTask!.CourseId, x.GradeSheet!.TeachingTask!.CourseId,
@@ -275,13 +297,19 @@ public sealed class GraduationAuditsController(
x.Id, x.Id,
BatchName = x.GraduationAuditBatch!.Name, BatchName = x.GraduationAuditBatch!.Name,
x.GraduationAuditBatch.GraduationYear, x.GraduationAuditBatch.GraduationYear,
x.Student!.StudentNumber, x.Student.Name, x.Student!.StudentNumber,
x.Student.Name,
MajorName = x.Student.AdministrativeClass!.Major!.Name, MajorName = x.Student.AdministrativeClass!.Major!.Name,
PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null, PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null,
x.RequiredCredits, x.EarnedCredits, x.RequiredCredits,
x.RequiredCourseCount, x.PassedRequiredCourseCount, x.EarnedCredits,
x.FailedCourseCount, x.MissingCourseNames, x.RequiredCourseCount,
x.Conclusion, x.IsOverridden, x.ReviewComment, x.PassedRequiredCourseCount,
x.FailedCourseCount,
x.MissingCourseNames,
x.Conclusion,
x.IsOverridden,
x.ReviewComment,
x.GraduationAuditBatch.PublishedAt x.GraduationAuditBatch.PublishedAt
}).FirstOrDefaultAsync(token); }).FirstOrDefaultAsync(token);
return Ok(result); return Ok(result);
@@ -44,8 +44,16 @@ public sealed class MakeupExamsController(
.ThenByDescending(x => x.CreatedAt) .ThenByDescending(x => x.CreatedAt)
.Select(x => new .Select(x => new
{ {
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, x.Id,
x.Status, SessionCount = x.Sessions.Count, x.Notes, x.PublishedAt x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
TermIsCurrent = x.AcademicTerm.IsCurrent,
TermIsArchived = x.AcademicTerm.IsArchived,
x.Status,
SessionCount = x.Sessions.Count,
x.Notes,
x.PublishedAt
}).ToListAsync(cancellationToken)); }).ToListAsync(cancellationToken));
} }
@@ -77,46 +85,51 @@ public sealed class MakeupExamsController(
.Where(x => x.Id == id && (manager || x.Status == MakeupExamPlanStatus.Published)) .Where(x => x.Id == id && (manager || x.Status == MakeupExamPlanStatus.Published))
.Select(x => new .Select(x => new
{ {
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, x.Id,
x.Status, x.Notes, x.PublishedAt, x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
x.Status,
x.Notes,
x.PublishedAt,
Sessions = x.Sessions.OrderBy(item => item.ExamDate) Sessions = x.Sessions.OrderBy(item => item.ExamDate)
.ThenBy(item => item.StartPeriod).Select(item => new .ThenBy(item => item.StartPeriod).Select(item => new
{
item.Id,
item.TeachingTaskId,
item.TeachingTask!.TaskNumber,
TaskName = item.TeachingTask.Name,
CourseCode = item.TeachingTask.Course!.Code,
CourseName = item.TeachingTask.Course.Name,
item.ClassroomId,
ClassroomName = item.Classroom != null ? item.Classroom.Name : null,
BuildingName = item.Classroom != null ? item.Classroom.Building!.Name : null,
ClassroomCapacity = item.Classroom != null ? (int?)item.Classroom.Capacity : null,
item.ExamDate,
item.StartPeriod,
item.PeriodCount,
item.StartsAt,
item.EndsAt,
item.RequiredBuildingId,
RequiredBuildingName = item.RequiredBuilding != null
? item.RequiredBuilding.Name : null,
item.RequiredInvigilatorCount,
item.Notes,
InvigilatorIds = item.Invigilators.Select(i => i.TeacherId),
InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name),
EnrolledCount = item.Enrollments.Count,
Enrollments = item.Enrollments.Select(e => new
{ {
e.StudentId, item.Id,
e.Student!.StudentNumber, item.TeachingTaskId,
e.Student.Name, item.TeachingTask!.TaskNumber,
ClassName = e.Student.AdministrativeClass!.Name, TaskName = item.TeachingTask.Name,
e.Reason, CourseCode = item.TeachingTask.Course!.Code,
e.SourceGradeRecordId, CourseName = item.TeachingTask.Course.Name,
e.SourceDeferredExamId, item.ClassroomId,
e.MakeupScore ClassroomName = item.Classroom != null ? item.Classroom.Name : null,
BuildingName = item.Classroom != null ? item.Classroom.Building!.Name : null,
ClassroomCapacity = item.Classroom != null ? (int?)item.Classroom.Capacity : null,
item.ExamDate,
item.StartPeriod,
item.PeriodCount,
item.StartsAt,
item.EndsAt,
item.RequiredBuildingId,
RequiredBuildingName = item.RequiredBuilding != null
? item.RequiredBuilding.Name : null,
item.RequiredInvigilatorCount,
item.Notes,
InvigilatorIds = item.Invigilators.Select(i => i.TeacherId),
InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name),
EnrolledCount = item.Enrollments.Count,
Enrollments = item.Enrollments.Select(e => new
{
e.StudentId,
e.Student!.StudentNumber,
e.Student.Name,
ClassName = e.Student.AdministrativeClass!.Name,
e.Reason,
e.SourceGradeRecordId,
e.SourceDeferredExamId,
e.MakeupScore
})
}) })
})
}).FirstOrDefaultAsync(cancellationToken); }).FirstOrDefaultAsync(cancellationToken);
return plan is null ? NotFound() : Ok(plan); return plan is null ? NotFound() : Ok(plan);
} }
@@ -311,8 +324,12 @@ public sealed class MakeupExamsController(
.OrderByDescending(x => x.CreatedAt) .OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
if (existing is not null) if (existing is not null)
return Ok(new { jobId = existing.Id, status = existing.Status.ToString(), return Ok(new
message = "该计划已有正在执行的任务。" }); {
jobId = existing.Id,
status = existing.Status.ToString(),
message = "该计划已有正在执行的任务。"
});
var job = new MakeupExamAutoJob var job = new MakeupExamAutoJob
{ {
@@ -404,9 +421,9 @@ public sealed class MakeupExamsController(
// Check for time conflicts with other makeup sessions // Check for time conflicts with other makeup sessions
var conflictIds = await db.MakeupExamEnrollments.AsNoTracking() var conflictIds = await db.MakeupExamEnrollments.AsNoTracking()
.Where(x => validIds.Contains(x.StudentId) && .Where(x => x.MakeupExamSession!.StartsAt < session.EndsAt &&
x.MakeupExamSession!.StartsAt < session.EndsAt &&
session.StartsAt < x.MakeupExamSession.EndsAt) session.StartsAt < x.MakeupExamSession.EndsAt)
.WhereIn(validIds, x => x.StudentId)
.Select(x => x.StudentId) .Select(x => x.StudentId)
.Distinct() .Distinct()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -414,7 +431,7 @@ public sealed class MakeupExamsController(
if (conflictIds.Count > 0) if (conflictIds.Count > 0)
{ {
var conflictNumbers = await db.Students var conflictNumbers = await db.Students
.Where(s => conflictIds.Contains(s.Id)) .WhereIn(conflictIds, s => s.Id)
.Select(s => s.StudentNumber) .Select(s => s.StudentNumber)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return ConflictProblem( return ConflictProblem(
@@ -590,7 +607,7 @@ public sealed class MakeupExamsController(
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (occupiedIds.Count > 0) if (occupiedIds.Count > 0)
query = query.Where(x => !occupiedIds.Contains(x.Id)); query = query.WhereNotIn(occupiedIds, x => x.Id);
return Ok(await query.OrderBy(x => x.Building!.Name) return Ok(await query.OrderBy(x => x.Building!.Name)
.ThenBy(x => x.Capacity) .ThenBy(x => x.Capacity)
@@ -634,8 +651,8 @@ public sealed class MakeupExamsController(
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return Ok(await db.Teachers.AsNoTracking() return Ok(await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active && .Where(x => x.Status == TeacherStatus.Active)
!busyIds.Contains(x.Id)) .WhereNotIn(busyIds, x => x.Id)
.OrderBy(x => x.Name) .OrderBy(x => x.Name)
.Select(x => new .Select(x => new
{ {
@@ -845,8 +862,10 @@ public sealed class MakeupExamsController(
var teacherIds = (invigilatorIds ?? []).Distinct().ToArray(); var teacherIds = (invigilatorIds ?? []).Distinct().ToArray();
if (teacherIds.Length > 0) if (teacherIds.Length > 0)
{ {
if (await db.Teachers.CountAsync(x => teacherIds.Contains(x.Id) && if (await db.Teachers
x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length) .Where(x => x.Status == TeacherStatus.Active)
.WhereIn(teacherIds, x => x.Id)
.CountAsync(cancellationToken) != teacherIds.Length)
return ValidationProblem("存在无效监考教师。"); return ValidationProblem("存在无效监考教师。");
} }
@@ -862,9 +881,10 @@ public sealed class MakeupExamsController(
if (teacherIds.Length > 0) if (teacherIds.Length > 0)
{ {
if (await overlaps.AnyAsync(x => if (await db.MakeupExamSessionInvigilators
x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)), .Where(i => overlaps.Any(x => x.Id == i.MakeupExamSessionId))
cancellationToken)) .WhereIn(teacherIds, i => i.TeacherId)
.AnyAsync(cancellationToken))
return ConflictProblem("监考教师在该时段已有补考任务。"); return ConflictProblem("监考教师在该时段已有补考任务。");
} }
@@ -158,52 +158,60 @@ public sealed class PersonnelController(
TeacherAccountActivationRequest request, TeacherAccountActivationRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var teacher = await db.Teachers.FindAsync([id], cancellationToken); return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
if (teacher is null) return NotFound(); async transaction =>
if (!CanAccessCollege(teacher.CollegeId)) return Forbid(); {
if (teacher.Status != TeacherStatus.Active) db.ChangeTracker.Clear();
{ var teacher = await db.Teachers.FindAsync([id], cancellationToken);
return ConflictProblem("仅在职教师可以激活登录账号。"); if (teacher is null) return NotFound();
} if (!CanAccessCollege(teacher.CollegeId)) return Forbid();
if (teacher.UserId.HasValue) if (teacher.Status != TeacherStatus.Active)
{ {
return ConflictProblem("该教师档案已经关联登录账号。"); return ConflictProblem("仅在职教师可以激活登录账号。");
} }
if (teacher.UserId.HasValue)
{
return ConflictProblem("该教师档案已经关联登录账号。");
}
var userName = teacher.TeacherNumber.Trim(); var userName = teacher.TeacherNumber.Trim();
if (await userManager.FindByNameAsync(userName) is not null) if (await userManager.FindByNameAsync(userName) is not null)
{ {
return ConflictProblem("该工号已有登录账号但未正确关联,请到账号管理中核对。"); return ConflictProblem(
} "该工号已有登录账号但未正确关联,请到账号管理中核对。");
}
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); var user = new ApplicationUser
var user = new ApplicationUser {
{ UserName = userName,
UserName = userName, DisplayName = teacher.Name,
DisplayName = teacher.Name, StaffNumber = userName,
StaffNumber = userName, CollegeId = teacher.CollegeId,
CollegeId = teacher.CollegeId, IsEnabled = true,
IsEnabled = true, LockoutEnabled = true
LockoutEnabled = true };
}; var result = await userManager.CreateAsync(user, request.Password);
var result = await userManager.CreateAsync(user, request.Password); if (!result.Succeeded)
if (!result.Succeeded) {
{ await transaction.RollbackAsync(cancellationToken);
await transaction.RollbackAsync(cancellationToken); return IdentityValidationProblem(result);
return IdentityValidationProblem(result); }
}
result = await userManager.AddToRoleAsync(user, SystemRoles.Teacher); result = await userManager.AddToRoleAsync(
if (!result.Succeeded) user,
{ SystemRoles.Teacher);
await transaction.RollbackAsync(cancellationToken); if (!result.Succeeded)
return IdentityValidationProblem(result); {
} await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(result);
}
teacher.UserId = user.Id; teacher.UserId = user.Id;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
return Ok(new { user.Id, UserName = userName }); return Ok(new { user.Id, UserName = userName });
},
cancellationToken);
} }
[HttpGet("students")] [HttpGet("students")]
@@ -123,32 +123,39 @@ public sealed class PersonnelExcelController(
} }
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。"); if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。");
var errors = new List<string>(); return await db.ExecuteInRetriableTransactionAsync<
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); ActionResult<ExcelImportResult>>(
try async transaction =>
{
var result = kind.Equals("teachers", StringComparison.OrdinalIgnoreCase)
? await ImportTeachersAsync(rows, errors, cancellationToken)
: await ImportStudentsAsync(rows, errors, cancellationToken);
if (errors.Count > 0)
{ {
await transaction.RollbackAsync(cancellationToken); var errors = new List<string>();
return ImportValidationProblem(errors); try
} {
await db.SaveChangesAsync(cancellationToken); var result = kind.Equals(
await transaction.CommitAsync(cancellationToken); "teachers",
return Ok(result); StringComparison.OrdinalIgnoreCase)
} ? await ImportTeachersAsync(rows, errors, cancellationToken)
catch (DbUpdateException) : await ImportStudentsAsync(rows, errors, cancellationToken);
{ if (errors.Count > 0)
await transaction.RollbackAsync(cancellationToken); {
return Conflict(new ProblemDetails await transaction.RollbackAsync(cancellationToken);
{ return ImportValidationProblem(errors);
Title = "导入失败", }
Detail = "存在重复编号或无效关联,未写入任何档案。", await db.SaveChangesAsync(cancellationToken);
Status = StatusCodes.Status409Conflict await transaction.CommitAsync(cancellationToken);
}); return Ok(result);
} }
catch (DbUpdateException)
{
await transaction.RollbackAsync(cancellationToken);
return Conflict(new ProblemDetails
{
Title = "导入失败",
Detail = "存在重复编号或无效关联,未写入任何档案。",
Status = StatusCodes.Status409Conflict
});
}
},
cancellationToken);
} }
private async Task<ExcelImportResult> ImportTeachersAsync( private async Task<ExcelImportResult> ImportTeachersAsync(
@@ -82,24 +82,24 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
{ {
x.Id, x.Id,
x.TaskNumber, x.TaskNumber,
x.Name, x.Name,
CourseCode = x.Course!.Code, CourseCode = x.Course!.Code,
CourseName = x.Course!.Name, CourseName = x.Course!.Name,
CollegeId = x.Course.CollegeId, CollegeId = x.Course.CollegeId,
CollegeName = x.Course.College!.Name, CollegeName = x.Course.College!.Name,
TeacherNames = x.Teachers TeacherNames = x.Teachers
.OrderByDescending(item => item.IsPrimary) .OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name), .Select(item => item.Teacher!.Name),
x.Capacity, x.Capacity,
x.StartWeek, x.StartWeek,
x.EndWeek, x.EndWeek,
x.WeeklyHours, x.WeeklyHours,
x.SchedulingMode x.SchedulingMode
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var taskIds = tasks.Select(x => x.Id).ToList(); var taskIds = tasks.Select(x => x.Id).ToList();
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking() var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Where(x => taskIds.Contains(x.TeachingTaskId)) .WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
return Ok(tasks.Select(task => return Ok(tasks.Select(task =>
@@ -109,19 +109,19 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
{ {
task.Id, task.Id,
task.TaskNumber, task.TaskNumber,
task.Name, task.Name,
task.CourseCode, task.CourseCode,
task.CourseName, task.CourseName,
task.CollegeId, task.CollegeId,
task.CollegeName, task.CollegeName,
task.TeacherNames, task.TeacherNames,
task.Capacity, task.Capacity,
task.StartWeek, task.StartWeek,
task.EndWeek, task.EndWeek,
task.WeeklyHours, task.WeeklyHours,
task.SchedulingMode, task.SchedulingMode,
HasCustomConstraint = constraint is not null, HasCustomConstraint = constraint is not null,
RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible
? false ? false
: constraint?.RequiresClassroom ?? true, : constraint?.RequiresClassroom ?? true,
constraint?.RequiredCampusId, constraint?.RequiredCampusId,
@@ -143,34 +143,34 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
{ {
if (request.AllowedDayOfWeeks.Any(day => day is < 1 or > 7)) if (request.AllowedDayOfWeeks.Any(day => day is < 1 or > 7))
return ValidationProblem("允许上课日必须位于星期一至星期日。"); return ValidationProblem("允许上课日必须位于星期一至星期日。");
if (request.EarliestPeriod.HasValue && if (request.EarliestPeriod.HasValue &&
request.LatestPeriod.HasValue && request.LatestPeriod.HasValue &&
request.EarliestPeriod > request.LatestPeriod) request.EarliestPeriod > request.LatestPeriod)
return ValidationProblem("最早节次不能晚于最晚节次。"); return ValidationProblem("最早节次不能晚于最晚节次。");
if (!Enum.IsDefined(request.SchedulingMode)) if (!Enum.IsDefined(request.SchedulingMode))
return ValidationProblem("授课方式无效。"); return ValidationProblem("授课方式无效。");
var task = await db.TeachingTasks var task = await db.TeachingTasks
.FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken); .FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
if (task is null) return NotFound(); if (task is null) return NotFound();
if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible && if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
await HasScheduleEntriesAsync([teachingTaskId], cancellationToken)) await HasScheduleEntriesAsync([teachingTaskId], cancellationToken))
return ConflictProblem("该教学任务已有正常排课记录,请先删除排课记录后再改为非排时课程。"); return ConflictProblem("该教学任务已有正常排课记录,请先删除排课记录后再改为非排时课程。");
task.SchedulingMode = request.SchedulingMode; task.SchedulingMode = request.SchedulingMode;
if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible) if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible)
{ {
var flexibleConstraint = await db.TeachingTaskScheduleConstraints var flexibleConstraint = await db.TeachingTaskScheduleConstraints
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
.FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken); .FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken);
if (flexibleConstraint is not null) if (flexibleConstraint is not null)
{ {
ClearConstraint(flexibleConstraint); ClearConstraint(flexibleConstraint);
} }
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
return NoContent(); return NoContent();
} }
Building? building = null; Building? building = null;
if (request.RequiredBuildingId.HasValue) if (request.RequiredBuildingId.HasValue)
{ {
building = await db.Buildings.AsNoTracking() building = await db.Buildings.AsNoTracking()
@@ -189,7 +189,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
return ValidationProblem("指定校区不存在或已停用。"); return ValidationProblem("指定校区不存在或已停用。");
var allowedRooms = await db.Classrooms.AsNoTracking() var allowedRooms = await db.Classrooms.AsNoTracking()
.Where(x => request.AllowedClassroomIds.Contains(x.Id) && x.IsEnabled) .Where(x => x.IsEnabled)
.WhereIn(request.AllowedClassroomIds, x => x.Id)
.Include(x => x.Building) .Include(x => x.Building)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (allowedRooms.Count != request.AllowedClassroomIds.Distinct().Count()) if (allowedRooms.Count != request.AllowedClassroomIds.Distinct().Count())
@@ -225,9 +226,9 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
? request.AllowedClassroomIds.Distinct().Select(classroomId => ? request.AllowedClassroomIds.Distinct().Select(classroomId =>
new TeachingTaskAllowedClassroom { ClassroomId = classroomId }).ToList() new TeachingTaskAllowedClassroom { ClassroomId = classroomId }).ToList()
: []; : [];
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
return NoContent(); return NoContent();
} }
[HttpPut("constraints/batch")] [HttpPut("constraints/batch")]
public async Task<ActionResult> SaveConstraintsBatch( public async Task<ActionResult> SaveConstraintsBatch(
@@ -246,22 +247,22 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
if (request.SchedulingMode.HasValue && if (request.SchedulingMode.HasValue &&
!Enum.IsDefined(request.SchedulingMode.Value)) !Enum.IsDefined(request.SchedulingMode.Value))
return ValidationProblem("授课方式无效。"); return ValidationProblem("授课方式无效。");
if (!request.SchedulingMode.HasValue && if (!request.SchedulingMode.HasValue &&
!request.RequiresClassroom.HasValue && !request.RequiresClassroom.HasValue &&
request.AllowedDayOfWeeks is null && request.AllowedDayOfWeeks is null &&
!request.UpdateClassroomScope && !request.UpdateClassroomScope &&
!request.UpdatePeriodRange && !request.UpdatePeriodRange &&
!request.EarliestPeriod.HasValue && !request.EarliestPeriod.HasValue &&
!request.LatestPeriod.HasValue) !request.LatestPeriod.HasValue)
return ValidationProblem("请至少选择一项需要批量修改的设置。"); return ValidationProblem("请至少选择一项需要批量修改的设置。");
if (request.UpdateClassroomScope && request.RequiresClassroom == false) if (request.UpdateClassroomScope && request.RequiresClassroom == false)
return ValidationProblem("批量指定教室范围时,场地要求不能设置为不占用教室。"); return ValidationProblem("批量指定教室范围时,场地要求不能设置为不占用教室。");
var tasks = await db.TeachingTasks var tasks = await db.TeachingTasks
.Where(x => .Where(x =>
taskIds.Contains(x.Id) &&
x.AcademicTermId == request.AcademicTermId && x.AcademicTermId == request.AcademicTermId &&
x.Status == TeachingTaskStatus.Published) x.Status == TeachingTaskStatus.Published)
.WhereIn(taskIds, x => x.Id)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (tasks.Count != taskIds.Length) if (tasks.Count != taskIds.Length)
return ValidationProblem("部分教学任务不存在、未发布或不属于当前学期。"); return ValidationProblem("部分教学任务不存在、未发布或不属于当前学期。");
@@ -297,7 +298,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
var roomIds = request.AllowedClassroomIds?.Distinct().ToArray() ?? []; var roomIds = request.AllowedClassroomIds?.Distinct().ToArray() ?? [];
allowedRooms = await db.Classrooms.AsNoTracking() allowedRooms = await db.Classrooms.AsNoTracking()
.Where(x => roomIds.Contains(x.Id) && x.IsEnabled) .Where(x => x.IsEnabled)
.WhereIn(roomIds, x => x.Id)
.Include(x => x.Building) .Include(x => x.Building)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (allowedRooms.Count != roomIds.Length) if (allowedRooms.Count != roomIds.Length)
@@ -311,12 +313,12 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
return ValidationProblem("指定教室必须位于所选校区。"); return ValidationProblem("指定教室必须位于所选校区。");
} }
var constraints = await db.TeachingTaskScheduleConstraints var constraints = await db.TeachingTaskScheduleConstraints
.Where(x => taskIds.Contains(x.TeachingTaskId)) .WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
foreach (var task in tasks) foreach (var task in tasks)
{ {
if (request.SchedulingMode.HasValue) if (request.SchedulingMode.HasValue)
task.SchedulingMode = request.SchedulingMode.Value; task.SchedulingMode = request.SchedulingMode.Value;
if (task.SchedulingMode == TeachingTaskSchedulingMode.Flexible) if (task.SchedulingMode == TeachingTaskSchedulingMode.Flexible)
@@ -384,7 +386,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
IReadOnlyCollection<Guid> taskIds, IReadOnlyCollection<Guid> taskIds,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
db.ScheduleEntries.AsNoTracking() db.ScheduleEntries.AsNoTracking()
.AnyAsync(x => taskIds.Contains(x.TeachingTaskId), cancellationToken); .WhereIn(taskIds, x => x.TeachingTaskId)
.AnyAsync(cancellationToken);
private void ClearConstraint(TeachingTaskScheduleConstraint constraint) private void ClearConstraint(TeachingTaskScheduleConstraint constraint)
{ {
@@ -95,7 +95,12 @@ public sealed class StatisticsController(
return new return new
{ {
byCollege, byMajor, byClass, byGrade, byStatus, byGender, byCollege,
byMajor,
byClass,
byGrade,
byStatus,
byGender,
enrollmentTrend, enrollmentTrend,
totals = new { totalStudents } totals = new { totalStudents }
}; };
@@ -255,7 +260,8 @@ public sealed class StatisticsController(
var passRateByCollegeResult = passRateByCollege.Select(x => new var passRateByCollegeResult = passRateByCollege.Select(x => new
{ {
x.collegeName, x.totalRecords, x.collegeName,
x.totalRecords,
passRate = x.totalRecords > 0 ? Math.Round((double)x.passedRecords / x.totalRecords, 4) : 0, passRate = x.totalRecords > 0 ? Math.Round((double)x.passedRecords / x.totalRecords, 4) : 0,
averageScore = Math.Round(x.averageScore, 1) averageScore = Math.Round(x.averageScore, 1)
}).ToList(); }).ToList();
@@ -336,7 +342,8 @@ public sealed class StatisticsController(
var byCollegeResult = byCollege.Select(x => new var byCollegeResult = byCollege.Select(x => new
{ {
x.collegeName, x.total, x.collegeName,
x.total,
passRate = x.total > 0 ? Math.Round((double)x.passed / x.total, 4) : 0 passRate = x.total > 0 ? Math.Round((double)x.passed / x.total, 4) : 0
}).OrderByDescending(x => x.passRate).ToList(); }).OrderByDescending(x => x.passRate).ToList();
@@ -353,7 +360,9 @@ public sealed class StatisticsController(
var byCourseResult = byCourse.Select(x => new var byCourseResult = byCourse.Select(x => new
{ {
x.courseCode, x.courseName, x.total, x.courseCode,
x.courseName,
x.total,
passRate = x.total > 0 ? Math.Round((double)x.passed / x.total, 4) : 0 passRate = x.total > 0 ? Math.Round((double)x.passed / x.total, 4) : 0
}).OrderByDescending(x => x.passRate).ToList(); }).OrderByDescending(x => x.passRate).ToList();
@@ -567,7 +576,8 @@ public sealed class StatisticsController(
// get entries // get entries
var entries = await db.ScheduleEntries.AsNoTracking() var entries = await db.ScheduleEntries.AsNoTracking()
.Where(e => e.SchedulePlanId == plan.Id) .Where(e => e.SchedulePlanId == plan.Id)
.Where(e => e.ClassroomId != null && classroomIds.Contains(e.ClassroomId.Value)) .Where(e => e.ClassroomId != null)
.WhereIn(classroomIds, e => e.ClassroomId!.Value)
.Select(e => new .Select(e => new
{ {
e.ClassroomId, e.ClassroomId,
@@ -633,7 +643,8 @@ public sealed class StatisticsController(
var used = entries.Where(e => e.DayOfWeek == day).Sum(e => e.PeriodCount); var used = entries.Where(e => e.DayOfWeek == day).Sum(e => e.PeriodCount);
return new return new
{ {
day, dayLabel = day < dayLabels.Length ? dayLabels[day] : $"周{day}", day,
dayLabel = day < dayLabels.Length ? dayLabels[day] : $"周{day}",
utilizationRate = dailyAvailable > 0 ? Math.Round((double)used / dailyAvailable, 4) : 0 utilizationRate = dailyAvailable > 0 ? Math.Round((double)used / dailyAvailable, 4) : 0
}; };
}).ToList(); }).ToList();
@@ -74,8 +74,8 @@ public sealed class StudentCurriculumController(
var gradeAttempts = await db.GradeRecords.AsNoTracking() var gradeAttempts = await db.GradeRecords.AsNoTracking()
.Where(x => .Where(x =>
x.StudentId == student.Id && x.StudentId == student.Id &&
planCourseIds.Contains(x.GradeSheet!.TeachingTask!.CourseId) && x.GradeSheet!.Status == GradeSheetStatus.Published)
x.GradeSheet.Status == GradeSheetStatus.Published) .WhereIn(planCourseIds, x => x.GradeSheet!.TeachingTask!.CourseId)
.Select(x => new StudentGradeAttempt( .Select(x => new StudentGradeAttempt(
x.GradeSheet!.TeachingTask!.CourseId, x.GradeSheet!.TeachingTask!.CourseId,
x.TotalScore, x.TotalScore,
@@ -88,15 +88,15 @@ public sealed class StudentCurriculumController(
var currentCourseIds = await db.TeachingTasks.AsNoTracking() var currentCourseIds = await db.TeachingTasks.AsNoTracking()
.Where(task => .Where(task =>
planCourseIds.Contains(task.CourseId) &&
task.Status == TeachingTaskStatus.Published && task.Status == TeachingTaskStatus.Published &&
task.AcademicTerm!.IsCurrent && task.AcademicTerm!.IsCurrent &&
(task.Classes.Any(item => (task.Classes.Any(item =>
item.AdministrativeClassId == student.AdministrativeClassId) || item.AdministrativeClassId == student.AdministrativeClassId) ||
db.CourseEnrollments.Any(enrollment => db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == student.Id && enrollment.StudentId == student.Id &&
enrollment.Status == CourseEnrollmentStatus.Enrolled && enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId == task.Id))) enrollment.CourseSelectionOffering!.TeachingTaskId == task.Id)))
.WhereIn(planCourseIds, task => task.CourseId)
.Select(x => x.CourseId) .Select(x => x.CourseId)
.Distinct() .Distinct()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -76,11 +76,11 @@ public sealed class TeachingTasksController(
CollegeName = x.Course.College!.Name, CollegeName = x.Course.College!.Name,
x.Capacity, x.Capacity,
x.StartWeek, x.StartWeek,
x.EndWeek, x.EndWeek,
x.WeeklyHours, x.WeeklyHours,
x.SchedulingMode, x.SchedulingMode,
CourseTotalHours = x.Course.TotalHours, CourseTotalHours = x.Course.TotalHours,
x.GenerationBatchCode, x.GenerationBatchCode,
x.Status, x.Status,
TeacherNames = x.Teachers TeacherNames = x.Teachers
.OrderByDescending(item => item.IsPrimary) .OrderByDescending(item => item.IsPrimary)
@@ -157,11 +157,11 @@ public sealed class TeachingTasksController(
CollegeName = x.Course.College!.Name, CollegeName = x.Course.College!.Name,
x.Capacity, x.Capacity,
x.StartWeek, x.StartWeek,
x.EndWeek, x.EndWeek,
x.WeeklyHours, x.WeeklyHours,
x.SchedulingMode, x.SchedulingMode,
CourseTotalHours = x.Course.TotalHours, CourseTotalHours = x.Course.TotalHours,
x.GenerationBatchCode, x.GenerationBatchCode,
x.Status, x.Status,
x.Notes, x.Notes,
x.PublishedAt, x.PublishedAt,
@@ -215,10 +215,10 @@ public sealed class TeachingTasksController(
CourseId = request.CourseId, CourseId = request.CourseId,
Capacity = request.Capacity, Capacity = request.Capacity,
StartWeek = request.StartWeek, StartWeek = request.StartWeek,
EndWeek = request.EndWeek, EndWeek = request.EndWeek,
WeeklyHours = request.WeeklyHours, WeeklyHours = request.WeeklyHours,
SchedulingMode = request.SchedulingMode, SchedulingMode = request.SchedulingMode,
Notes = Normalize(request.Notes) Notes = Normalize(request.Notes)
}; };
SetAssignments(task, request); SetAssignments(task, request);
db.TeachingTasks.Add(task); db.TeachingTasks.Add(task);
@@ -249,10 +249,10 @@ public sealed class TeachingTasksController(
task.CourseId = request.CourseId; task.CourseId = request.CourseId;
task.Capacity = request.Capacity; task.Capacity = request.Capacity;
task.StartWeek = request.StartWeek; task.StartWeek = request.StartWeek;
task.EndWeek = request.EndWeek; task.EndWeek = request.EndWeek;
task.WeeklyHours = request.WeeklyHours; task.WeeklyHours = request.WeeklyHours;
task.SchedulingMode = request.SchedulingMode; task.SchedulingMode = request.SchedulingMode;
task.Notes = Normalize(request.Notes); task.Notes = Normalize(request.Notes);
db.TeachingTaskTeachers.RemoveRange(task.Teachers); db.TeachingTaskTeachers.RemoveRange(task.Teachers);
db.TeachingTaskClasses.RemoveRange(task.Classes); db.TeachingTaskClasses.RemoveRange(task.Classes);
task.Teachers = []; task.Teachers = [];
@@ -342,7 +342,7 @@ public sealed class TeachingTasksController(
.Include(x => x.Classes) .Include(x => x.Classes)
.ThenInclude(x => x.AdministrativeClass) .ThenInclude(x => x.AdministrativeClass)
.ThenInclude(x => x!.Students) .ThenInclude(x => x!.Students)
.Where(x => ids.Contains(x.Id)) .WhereIn(ids, x => x.Id)
.OrderBy(x => x.TaskNumber) .OrderBy(x => x.TaskNumber)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (tasks.Count != ids.Length) if (tasks.Count != ids.Length)
@@ -352,17 +352,17 @@ public sealed class TeachingTasksController(
switch (request.Operation) switch (request.Operation)
{ {
case TeachingTaskBatchOperation.Publish: case TeachingTaskBatchOperation.Publish:
{
var validation = await ValidatePublishingTasksAsync(tasks, cancellationToken);
if (validation is not null) return ConflictProblem(validation);
var publishedAt = DateTime.UtcNow;
foreach (var task in tasks)
{ {
task.Status = TeachingTaskStatus.Published; var validation = await ValidatePublishingTasksAsync(tasks, cancellationToken);
task.PublishedAt = publishedAt; if (validation is not null) return ConflictProblem(validation);
var publishedAt = DateTime.UtcNow;
foreach (var task in tasks)
{
task.Status = TeachingTaskStatus.Published;
task.PublishedAt = publishedAt;
}
break;
} }
break;
}
case TeachingTaskBatchOperation.Delete: case TeachingTaskBatchOperation.Delete:
if (tasks.Any(x => if (tasks.Any(x =>
x.Status is not (TeachingTaskStatus.Draft or TeachingTaskStatus.Closed))) x.Status is not (TeachingTaskStatus.Draft or TeachingTaskStatus.Closed)))
@@ -414,14 +414,14 @@ public sealed class TeachingTasksController(
.FirstOrDefaultAsync( .FirstOrDefaultAsync(
x => x.Id == request.CourseId && x.IsEnabled, x => x.Id == request.CourseId && x.IsEnabled,
cancellationToken); cancellationToken);
if (course is null) return ValidationProblem("所选课程不存在或已停用。"); if (course is null) return ValidationProblem("所选课程不存在或已停用。");
if (!CanManage(course)) return Forbid(); if (!CanManage(course)) return Forbid();
var hoursProblem = TeachingTaskHours.Validate( var hoursProblem = TeachingTaskHours.Validate(
course, course,
request.StartWeek, request.StartWeek,
request.EndWeek, request.EndWeek,
request.WeeklyHours); request.WeeklyHours);
if (hoursProblem is not null) return ValidationProblem(hoursProblem); if (hoursProblem is not null) return ValidationProblem(hoursProblem);
if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective)) if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective))
return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。"); return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。");
var scopedCollegeId = ScopedCollegeId(); var scopedCollegeId = ScopedCollegeId();
@@ -431,7 +431,8 @@ public sealed class TeachingTasksController(
var classIds = request.ClassIds.Distinct().ToArray(); var classIds = request.ClassIds.Distinct().ToArray();
if (classIds.Length == 0) return ValidationProblem("请至少选择一个行政班。"); if (classIds.Length == 0) return ValidationProblem("请至少选择一个行政班。");
var classesQuery = db.AdministrativeClasses var classesQuery = db.AdministrativeClasses
.Where(x => classIds.Contains(x.Id) && x.IsEnabled) .Where(x => x.IsEnabled)
.WhereIn(classIds, x => x.Id)
.Include(x => x.Major) .Include(x => x.Major)
.Include(x => x.Students) .Include(x => x.Students)
.AsQueryable(); .AsQueryable();
@@ -443,123 +444,127 @@ public sealed class TeachingTasksController(
if (classes.Count != classIds.Length) if (classes.Count != classIds.Length)
return ValidationProblem("存在无效或不在当前数据范围内的行政班。"); return ValidationProblem("存在无效或不在当前数据范围内的行政班。");
await using var transaction = await db.Database.BeginTransactionAsync( return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
IsolationLevel.Serializable, async transaction =>
cancellationToken);
var assignedClassIds = await db.TeachingTaskClasses.AsNoTracking()
.Where(x =>
classIds.Contains(x.AdministrativeClassId) &&
x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
x.TeachingTask.CourseId == request.CourseId)
.Select(x => x.AdministrativeClassId)
.Distinct()
.ToListAsync(cancellationToken);
if (assignedClassIds.Count > 0)
{
var names = classes
.Where(x => assignedClassIds.Contains(x.Id))
.Select(x => x.Name);
return ConflictProblem(
$"以下行政班已生成该课程教学任务:{string.Join('、', names)}。");
}
var eligibleTeachers = await db.TeacherCourseApplications.AsNoTracking()
.Where(x =>
x.AcademicTermId == request.AcademicTermId &&
x.CourseId == request.CourseId &&
x.Status == TeacherCourseApplicationStatus.Approved &&
x.Teacher!.Status == TeacherStatus.Active)
.Where(x => !scopedCollegeId.HasValue ||
x.Teacher!.CollegeId == scopedCollegeId.Value)
.Select(x => x.Teacher!)
.ToListAsync(cancellationToken);
if (eligibleTeachers.Count == 0)
return ConflictProblem("该课程没有学院审核通过的可授课教师,无法生成教学任务。");
var eligibleTeacherIds = eligibleTeachers.Select(x => x.Id).ToArray();
var existingLoads = await db.TeachingTaskTeachers.AsNoTracking()
.Where(x =>
eligibleTeacherIds.Contains(x.TeacherId) &&
x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
x.TeachingTask.Status != TeachingTaskStatus.Closed)
.GroupBy(x => x.TeacherId)
.Select(group => new { TeacherId = group.Key, Count = group.Count() })
.ToDictionaryAsync(x => x.TeacherId, x => x.Count, cancellationToken);
var existingNumbers = await db.TeachingTasks.AsNoTracking()
.Where(x => x.AcademicTermId == request.AcademicTermId)
.Select(x => x.TaskNumber)
.ToHashSetAsync(cancellationToken);
var batchCode =
$"AUTO-{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}"[..31];
var groups = classes.Chunk(request.ClassesPerTask).ToList();
var teacherAssignments = PublicCourseTaskAssignmentPlanner.AssignTeachers(
eligibleTeacherIds,
existingLoads,
groups.Count);
var created = new List<TeachingTask>();
for (var index = 0; index < groups.Count; index++)
{
var teacher = eligibleTeachers.First(x => x.Id == teacherAssignments[index]);
var group = groups[index];
var taskNumber = NextTaskNumber(
term.Code,
course.Code,
index + 1,
existingNumbers);
existingNumbers.Add(taskNumber);
var studentCount = group.Sum(administrativeClass =>
administrativeClass.Students.Count(student =>
student.Status == StudentStatus.Active));
var task = new TeachingTask
{ {
TaskNumber = taskNumber, db.ChangeTracker.Clear();
Name = $"{course.Name}教学班 {index + 1:D2}", var assignedClassIds = await db.TeachingTaskClasses.AsNoTracking()
AcademicTermId = term.Id, .Where(x =>
CourseId = course.Id, x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
Capacity = Math.Max(1, studentCount), x.TeachingTask.CourseId == request.CourseId)
StartWeek = request.StartWeek, .WhereIn(classIds, x => x.AdministrativeClassId)
EndWeek = request.EndWeek, .Select(x => x.AdministrativeClassId)
WeeklyHours = request.WeeklyHours, .Distinct()
GenerationBatchCode = batchCode, .ToListAsync(cancellationToken);
Notes = "公共课合班自动生成,发布前可继续调整。", if (assignedClassIds.Count > 0)
Teachers = {
[ var names = classes
new TeachingTaskTeacher .Where(x => assignedClassIds.Contains(x.Id))
.Select(x => x.Name);
return ConflictProblem(
$"以下行政班已生成该课程教学任务:{string.Join('、', names)}。");
}
var eligibleTeachers = await db.TeacherCourseApplications.AsNoTracking()
.Where(x =>
x.AcademicTermId == request.AcademicTermId &&
x.CourseId == request.CourseId &&
x.Status == TeacherCourseApplicationStatus.Approved &&
x.Teacher!.Status == TeacherStatus.Active)
.Where(x => !scopedCollegeId.HasValue ||
x.Teacher!.CollegeId == scopedCollegeId.Value)
.Select(x => x.Teacher!)
.ToListAsync(cancellationToken);
if (eligibleTeachers.Count == 0)
return ConflictProblem("该课程没有学院审核通过的可授课教师,无法生成教学任务。");
var eligibleTeacherIds = eligibleTeachers.Select(x => x.Id).ToArray();
var existingLoads = await db.TeachingTaskTeachers.AsNoTracking()
.Where(x =>
x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
x.TeachingTask.Status != TeachingTaskStatus.Closed)
.WhereIn(eligibleTeacherIds, x => x.TeacherId)
.GroupBy(x => x.TeacherId)
.Select(group => new { TeacherId = group.Key, Count = group.Count() })
.ToDictionaryAsync(x => x.TeacherId, x => x.Count, cancellationToken);
var existingNumbers = await db.TeachingTasks.AsNoTracking()
.Where(x => x.AcademicTermId == request.AcademicTermId)
.Select(x => x.TaskNumber)
.ToHashSetAsync(cancellationToken);
var batchCode =
$"AUTO-{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}"[..31];
var groups = classes.Chunk(request.ClassesPerTask).ToList();
var teacherAssignments = PublicCourseTaskAssignmentPlanner.AssignTeachers(
eligibleTeacherIds,
existingLoads,
groups.Count);
var created = new List<TeachingTask>();
for (var index = 0; index < groups.Count; index++)
{
var teacher = eligibleTeachers.First(x => x.Id == teacherAssignments[index]);
var group = groups[index];
var taskNumber = NextTaskNumber(
term.Code,
course.Code,
index + 1,
existingNumbers);
existingNumbers.Add(taskNumber);
var studentCount = group.Sum(administrativeClass =>
administrativeClass.Students.Count(student =>
student.Status == StudentStatus.Active));
var task = new TeachingTask
{
TaskNumber = taskNumber,
Name = $"{course.Name}教学班 {index + 1:D2}",
AcademicTermId = term.Id,
CourseId = course.Id,
Capacity = Math.Max(1, studentCount),
StartWeek = request.StartWeek,
EndWeek = request.EndWeek,
WeeklyHours = request.WeeklyHours,
GenerationBatchCode = batchCode,
Notes = "公共课合班自动生成,发布前可继续调整。",
Teachers =
[
new TeachingTaskTeacher
{ {
TeacherId = teacher.Id, TeacherId = teacher.Id,
IsPrimary = true IsPrimary = true
} }
], ],
Classes = group.Select(administrativeClass => Classes = group.Select(administrativeClass =>
new TeachingTaskClass new TeachingTaskClass
{ {
AdministrativeClassId = administrativeClass.Id AdministrativeClassId = administrativeClass.Id
}).ToList() }).ToList()
}; };
created.Add(task); created.Add(task);
} }
db.TeachingTasks.AddRange(created); db.TeachingTasks.AddRange(created);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
return Ok(new return Ok(new
{ {
BatchCode = batchCode, BatchCode = batchCode,
CreatedCount = created.Count, CreatedCount = created.Count,
Tasks = created.Select(task => new Tasks = created.Select(task => new
{ {
task.Id, task.Id,
task.TaskNumber, task.TaskNumber,
task.Name, task.Name,
TeacherName = eligibleTeachers TeacherName = eligibleTeachers
.First(x => x.Id == task.Teachers.Single().TeacherId).Name, .First(x => x.Id == task.Teachers.Single().TeacherId).Name,
ClassNames = classes ClassNames = classes
.Where(x => task.Classes.Any(item => .Where(x => task.Classes.Any(item =>
item.AdministrativeClassId == x.Id)) item.AdministrativeClassId == x.Id))
.Select(x => x.Name), .Select(x => x.Name),
task.Capacity task.Capacity
}) })
}); });
},
cancellationToken,
IsolationLevel.Serializable);
} }
private IQueryable<TeachingTask> ScopedTasks() private IQueryable<TeachingTask> ScopedTasks()
@@ -579,8 +584,8 @@ public sealed class TeachingTasksController(
return ValidationProblem("开始周不能晚于结束周。"); return ValidationProblem("开始周不能晚于结束周。");
var course = await db.Courses.AsNoTracking() var course = await db.Courses.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == request.CourseId && x.IsEnabled, cancellationToken); .FirstOrDefaultAsync(x => x.Id == request.CourseId && x.IsEnabled, cancellationToken);
if (course is null) return ValidationProblem("所选课程不存在或已停用。"); if (course is null) return ValidationProblem("所选课程不存在或已停用。");
if (!CanManage(course)) return Forbid(); if (!CanManage(course)) return Forbid();
if (!Enum.IsDefined(request.SchedulingMode)) if (!Enum.IsDefined(request.SchedulingMode))
return ValidationProblem("授课方式无效。"); return ValidationProblem("授课方式无效。");
var hoursProblem = TeachingTaskHours.Validate( var hoursProblem = TeachingTaskHours.Validate(
@@ -589,7 +594,7 @@ public sealed class TeachingTasksController(
request.EndWeek, request.EndWeek,
request.WeeklyHours); request.WeeklyHours);
if (hoursProblem is not null) return ValidationProblem(hoursProblem); if (hoursProblem is not null) return ValidationProblem(hoursProblem);
var collegeId = ScopedCollegeId(); var collegeId = ScopedCollegeId();
if (!await db.AcademicTerms.AnyAsync( if (!await db.AcademicTerms.AnyAsync(
x => x.Id == request.AcademicTermId && x.IsEnabled, x => x.Id == request.AcademicTermId && x.IsEnabled,
cancellationToken)) cancellationToken))
@@ -599,25 +604,26 @@ public sealed class TeachingTasksController(
if (request.PrimaryTeacherId.HasValue && if (request.PrimaryTeacherId.HasValue &&
!teacherIds.Contains(request.PrimaryTeacherId.Value)) !teacherIds.Contains(request.PrimaryTeacherId.Value))
return ValidationProblem("主讲教师必须包含在授课教师中。"); return ValidationProblem("主讲教师必须包含在授课教师中。");
if (await db.Teachers.CountAsync( if (await db.Teachers
x => teacherIds.Contains(x.Id) && x.Status == TeacherStatus.Active, .Where(x => x.Status == TeacherStatus.Active)
cancellationToken) != teacherIds.Length) .WhereIn(teacherIds, x => x.Id)
.CountAsync(cancellationToken) != teacherIds.Length)
return ValidationProblem("存在无效或非在职授课教师。"); return ValidationProblem("存在无效或非在职授课教师。");
if (teacherIds.Length > 0 && if (teacherIds.Length > 0 &&
await db.TeacherCourseApplications.CountAsync( await db.TeacherCourseApplications
x => .Where(x =>
x.AcademicTermId == request.AcademicTermId && x.AcademicTermId == request.AcademicTermId &&
x.CourseId == request.CourseId && x.CourseId == request.CourseId &&
teacherIds.Contains(x.TeacherId) && x.Status == TeacherCourseApplicationStatus.Approved)
x.Status == TeacherCourseApplicationStatus.Approved, .WhereIn(teacherIds, x => x.TeacherId)
cancellationToken) != teacherIds.Length) .CountAsync(cancellationToken) != teacherIds.Length)
return ValidationProblem("授课教师必须已完成该课程申报并经学院审核通过。"); return ValidationProblem("授课教师必须已完成该课程申报并经学院审核通过。");
var classIds = request.ClassIds.Distinct().ToArray(); var classIds = request.ClassIds.Distinct().ToArray();
if (classIds.Length > 0) if (classIds.Length > 0)
{ {
var classes = db.AdministrativeClasses.AsNoTracking() var classes = db.AdministrativeClasses.AsNoTracking()
.Where(x => classIds.Contains(x.Id)); .WhereIn(classIds, x => x.Id);
if (collegeId.HasValue) if (collegeId.HasValue)
classes = classes.Where(x => x.Major!.CollegeId == collegeId.Value); classes = classes.Where(x => x.Major!.CollegeId == collegeId.Value);
if (await classes.CountAsync(cancellationToken) != classIds.Length) if (await classes.CountAsync(cancellationToken) != classIds.Length)
@@ -673,11 +679,10 @@ public sealed class TeachingTasksController(
.ToArray(); .ToArray();
var approvedApplications = await db.TeacherCourseApplications var approvedApplications = await db.TeacherCourseApplications
.AsNoTracking() .AsNoTracking()
.Where(x => .Where(x => x.Status == TeacherCourseApplicationStatus.Approved)
termIds.Contains(x.AcademicTermId) && .WhereIn(termIds, x => x.AcademicTermId)
courseIds.Contains(x.CourseId) && .WhereIn(courseIds, x => x.CourseId)
teacherIds.Contains(x.TeacherId) && .WhereIn(teacherIds, x => x.TeacherId)
x.Status == TeacherCourseApplicationStatus.Approved)
.Select(x => new { x.AcademicTermId, x.CourseId, x.TeacherId }) .Select(x => new { x.AcademicTermId, x.CourseId, x.TeacherId })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var approvedAssignments = approvedApplications var approvedAssignments = approvedApplications
@@ -220,6 +220,7 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
var terms = await db.AcademicTerms.AsNoTracking() var terms = await db.AcademicTerms.AsNoTracking()
.Where(x => x.IsEnabled) .Where(x => x.IsEnabled)
.OrderByDescending(x => x.IsCurrent) .OrderByDescending(x => x.IsCurrent)
.ThenBy(x => x.IsArchived)
.ThenByDescending(x => x.StartDate) .ThenByDescending(x => x.StartDate)
.Select(x => new .Select(x => new
{ {
@@ -228,6 +229,7 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
x.StartDate, x.StartDate,
x.EndDate, x.EndDate,
x.IsCurrent, x.IsCurrent,
x.IsArchived,
HasPublishedTimetable = db.SchedulePlans.Any(plan => HasPublishedTimetable = db.SchedulePlans.Any(plan =>
plan.AcademicTermId == x.Id && plan.AcademicTermId == x.Id &&
plan.Status == SchedulePlanStatus.Published) plan.Status == SchedulePlanStatus.Published)
@@ -235,6 +237,7 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var selectedTermId = academicTermId var selectedTermId = academicTermId
?? terms.FirstOrDefault(x => x.IsCurrent && x.HasPublishedTimetable)?.Id ?? terms.FirstOrDefault(x => x.IsCurrent && x.HasPublishedTimetable)?.Id
?? terms.FirstOrDefault(x => !x.IsArchived && x.HasPublishedTimetable)?.Id
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id; ?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
var campuses = await db.Campuses.AsNoTracking() var campuses = await db.Campuses.AsNoTracking()
.Where(x => x.IsEnabled) .Where(x => x.IsEnabled)
@@ -317,11 +320,12 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
.CountAsync(x => x.AcademicTermId == academicTermId, cancellationToken); .CountAsync(x => x.AcademicTermId == academicTermId, cancellationToken);
var activePeriodCount = configuredPeriodCount == 0 var activePeriodCount = configuredPeriodCount == 0
? requestedPeriods.Length ? requestedPeriods.Length
: await db.ScheduleTimeSlots.AsNoTracking().CountAsync(x => : await db.ScheduleTimeSlots.AsNoTracking()
x.AcademicTermId == academicTermId && .Where(x =>
x.IsEnabled && x.AcademicTermId == academicTermId &&
requestedPeriods.Contains(x.PeriodNumber), x.IsEnabled)
cancellationToken); .WhereIn(requestedPeriods, x => x.PeriodNumber)
.CountAsync(cancellationToken);
if (activePeriodCount != requestedPeriods.Length) if (activePeriodCount != requestedPeriods.Length)
return ValidationProblem("查询范围包含不存在或未启用的节次。"); return ValidationProblem("查询范围包含不存在或未启用的节次。");
@@ -357,8 +361,8 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
.Where(x => .Where(x =>
x.IsEnabled && x.IsEnabled &&
x.Building!.IsEnabled && x.Building!.IsEnabled &&
x.Building.Campus!.IsEnabled && x.Building.Campus!.IsEnabled)
!occupiedIds.Contains(x.Id)); .WhereNotIn(occupiedIds, x => x.Id);
if (campusId.HasValue) if (campusId.HasValue)
rooms = rooms.Where(x => x.Building!.CampusId == campusId.Value); rooms = rooms.Where(x => x.Building!.CampusId == campusId.Value);
if (buildingId.HasValue) if (buildingId.HasValue)
@@ -32,6 +32,7 @@ public sealed class TimetablesController(
x.StartDate, x.StartDate,
x.EndDate, x.EndDate,
x.IsCurrent, x.IsCurrent,
x.IsArchived,
HasPublishedTimetable = db.SchedulePlans.Any(plan => HasPublishedTimetable = db.SchedulePlans.Any(plan =>
plan.AcademicTermId == x.Id && plan.AcademicTermId == x.Id &&
plan.Status == SchedulePlanStatus.Published) || plan.Status == SchedulePlanStatus.Published) ||
+117 -65
View File
@@ -25,8 +25,14 @@ public sealed class UsersController(
.OrderBy(x => x.UserName) .OrderBy(x => x.UserName)
.Select(x => new .Select(x => new
{ {
x.Id, x.UserName, x.DisplayName, x.StaffNumber, x.Id,
x.CollegeId, x.IsEnabled, x.LastLoginAt, x.CreatedAt x.UserName,
x.DisplayName,
x.StaffNumber,
x.CollegeId,
x.IsEnabled,
x.LastLoginAt,
x.CreatedAt
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -36,8 +42,14 @@ public sealed class UsersController(
var identityUser = await userManager.FindByIdAsync(user.Id.ToString()); var identityUser = await userManager.FindByIdAsync(user.Id.ToString());
result.Add(new result.Add(new
{ {
user.Id, user.UserName, user.DisplayName, user.StaffNumber, user.Id,
user.CollegeId, user.IsEnabled, user.LastLoginAt, user.CreatedAt, user.UserName,
user.DisplayName,
user.StaffNumber,
user.CollegeId,
user.IsEnabled,
user.LastLoginAt,
user.CreatedAt,
Roles = identityUser is null Roles = identityUser is null
? [] ? []
: await userManager.GetRolesAsync(identityUser) : await userManager.GetRolesAsync(identityUser)
@@ -54,38 +66,50 @@ public sealed class UsersController(
.ToListAsync(cancellationToken)); .ToListAsync(cancellationToken));
[HttpPost] [HttpPost]
public async Task<ActionResult> Create(CreateUserRequest request) public async Task<ActionResult> Create(
CreateUserRequest request,
CancellationToken cancellationToken)
{ {
var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
var invalidRoles = ValidateRoles(roles); var invalidRoles = ValidateRoles(roles);
if (invalidRoles is not null) return invalidRoles; if (invalidRoles is not null) return invalidRoles;
var staffNumber = Normalize(request.StaffNumber); var staffNumber = Normalize(request.StaffNumber);
var profiles = await ResolveProfilesAsync(staffNumber, roles); return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
if (profiles.Error is not null) return profiles.Error; async transaction =>
{
db.ChangeTracker.Clear();
var profiles = await ResolveProfilesAsync(staffNumber, roles);
if (profiles.Error is not null) return profiles.Error;
var user = new ApplicationUser var user = new ApplicationUser
{ {
UserName = request.UserName.Trim(), UserName = request.UserName.Trim(),
DisplayName = request.DisplayName.Trim(), DisplayName = request.DisplayName.Trim(),
StaffNumber = staffNumber, StaffNumber = staffNumber,
CollegeId = request.CollegeId, CollegeId = request.CollegeId,
LockoutEnabled = true, LockoutEnabled = true,
IsEnabled = true IsEnabled = true
}; };
await using var transaction = await db.Database.BeginTransactionAsync(); var result = await userManager.CreateAsync(user, request.Password);
var result = await userManager.CreateAsync(user, request.Password); if (!result.Succeeded)
if (!result.Succeeded) return IdentityValidationProblem(result);
return IdentityValidationProblem(result);
result = await userManager.AddToRolesAsync(user, roles); result = await userManager.AddToRolesAsync(user, roles);
if (!result.Succeeded) if (!result.Succeeded)
return IdentityValidationProblem(result); return IdentityValidationProblem(result);
if (profiles.Teacher is not null) profiles.Teacher.UserId = user.Id; if (profiles.Teacher is not null)
if (profiles.Student is not null) profiles.Student.UserId = user.Id; profiles.Teacher.UserId = user.Id;
await db.SaveChangesAsync(); if (profiles.Student is not null)
await transaction.CommitAsync(); profiles.Student.UserId = user.Id;
return CreatedAtAction(nameof(GetUsers), new { id = user.Id }, new { user.Id }); await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return CreatedAtAction(
nameof(GetUsers),
new { id = user.Id },
new { user.Id });
},
cancellationToken);
} }
[HttpPut("{id:guid}/status")] [HttpPut("{id:guid}/status")]
@@ -103,51 +127,79 @@ public sealed class UsersController(
} }
[HttpPut("{id:guid}/roles")] [HttpPut("{id:guid}/roles")]
public async Task<IActionResult> SetRoles(Guid id, SetRolesRequest request) public async Task<IActionResult> SetRoles(
Guid id,
SetRolesRequest request,
CancellationToken cancellationToken)
{ {
var user = await userManager.FindByIdAsync(id.ToString());
if (user is null) return NotFound();
var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
var invalidRoles = ValidateRoles(roles); var invalidRoles = ValidateRoles(roles);
if (invalidRoles is not null) return invalidRoles; if (invalidRoles is not null) return invalidRoles;
var staffNumber = Normalize(request.StaffNumber); var staffNumber = Normalize(request.StaffNumber);
var profiles = await ResolveProfilesAsync(staffNumber, roles, user.Id); return await db.ExecuteInRetriableTransactionAsync<IActionResult>(
if (profiles.Error is not null) return profiles.Error; async transaction =>
{
db.ChangeTracker.Clear();
var user = await userManager.FindByIdAsync(id.ToString());
if (user is null) return NotFound();
var profiles = await ResolveProfilesAsync(
staffNumber,
roles,
user.Id);
if (profiles.Error is not null) return profiles.Error;
var existing = await userManager.GetRolesAsync(user); var existing = await userManager.GetRolesAsync(user);
if (User.FindFirstValue(ClaimTypes.NameIdentifier) == id.ToString() && if (User.FindFirstValue(ClaimTypes.NameIdentifier) == id.ToString() &&
existing.Contains(SystemRoles.SuperAdmin) && existing.Contains(SystemRoles.SuperAdmin) &&
!roles.Contains(SystemRoles.SuperAdmin, StringComparer.OrdinalIgnoreCase)) !roles.Contains(
{ SystemRoles.SuperAdmin,
return ValidationProblem("不能移除当前账号的超级管理员角色。"); StringComparer.OrdinalIgnoreCase))
} {
return ValidationProblem(
"不能移除当前账号的超级管理员角色。");
}
await using var transaction = await db.Database.BeginTransactionAsync(); user.StaffNumber = staffNumber;
user.StaffNumber = staffNumber; user.CollegeId = request.CollegeId;
user.CollegeId = request.CollegeId; var updateResult = await userManager.UpdateAsync(user);
var updateResult = await userManager.UpdateAsync(user); if (!updateResult.Succeeded)
if (!updateResult.Succeeded) return IdentityValidationProblem(updateResult); return IdentityValidationProblem(updateResult);
var removeResult = await userManager.RemoveFromRolesAsync( var removeResult = await userManager.RemoveFromRolesAsync(
user, user,
existing.Except(roles, StringComparer.OrdinalIgnoreCase)); existing.Except(roles, StringComparer.OrdinalIgnoreCase));
if (!removeResult.Succeeded) return IdentityValidationProblem(removeResult); if (!removeResult.Succeeded)
var addResult = await userManager.AddToRolesAsync( return IdentityValidationProblem(removeResult);
user, var addResult = await userManager.AddToRolesAsync(
roles.Except(existing, StringComparer.OrdinalIgnoreCase)); user,
if (!addResult.Succeeded) return IdentityValidationProblem(addResult); roles.Except(existing, StringComparer.OrdinalIgnoreCase));
if (!addResult.Succeeded)
return IdentityValidationProblem(addResult);
var linkedTeachers = await db.Teachers.Where(x => x.UserId == id).ToListAsync(); var linkedTeachers = await db.Teachers
var linkedStudents = await db.Students.Where(x => x.UserId == id).ToListAsync(); .Where(x => x.UserId == id)
if (!roles.Contains(SystemRoles.Teacher, StringComparer.OrdinalIgnoreCase)) .ToListAsync();
foreach (var teacher in linkedTeachers) teacher.UserId = null; var linkedStudents = await db.Students
if (!roles.Contains(SystemRoles.Student, StringComparer.OrdinalIgnoreCase)) .Where(x => x.UserId == id)
foreach (var student in linkedStudents) student.UserId = null; .ToListAsync();
if (profiles.Teacher is not null) profiles.Teacher.UserId = id; if (!roles.Contains(
if (profiles.Student is not null) profiles.Student.UserId = id; SystemRoles.Teacher,
await db.SaveChangesAsync(); StringComparer.OrdinalIgnoreCase))
await transaction.CommitAsync(); foreach (var teacher in linkedTeachers)
return NoContent(); teacher.UserId = null;
if (!roles.Contains(
SystemRoles.Student,
StringComparer.OrdinalIgnoreCase))
foreach (var student in linkedStudents)
student.UserId = null;
if (profiles.Teacher is not null)
profiles.Teacher.UserId = id;
if (profiles.Student is not null)
profiles.Student.UserId = id;
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return NoContent();
},
cancellationToken);
} }
private ActionResult? ValidateRoles(IReadOnlyCollection<string> roles) private ActionResult? ValidateRoles(IReadOnlyCollection<string> roles)
+196 -153
View File
@@ -83,169 +83,212 @@ public sealed class UsersExcelController(
} }
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的账号。"); if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的账号。");
var colleges = await db.Colleges.AsNoTracking().ToDictionaryAsync( return await db.ExecuteInRetriableTransactionAsync<
x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken); ActionResult<ExcelImportResult>>(
var users = await userManager.Users.ToDictionaryAsync( async transaction =>
x => x.UserName!, StringComparer.OrdinalIgnoreCase, cancellationToken);
var teachers = await db.Teachers.ToDictionaryAsync(
x => x.TeacherNumber, StringComparer.OrdinalIgnoreCase, cancellationToken);
var students = await db.Students.ToDictionaryAsync(
x => x.StudentNumber, StringComparer.OrdinalIgnoreCase, cancellationToken);
var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var errors = new List<string>();
var plans = new List<UserImportPlan>();
var seenNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var plannedTeachers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var plannedStudents = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var row in rows)
{
var userName = Required(row, "登录账号", errors);
var displayName = Required(row, "姓名", errors);
if (userName is null || displayName is null) continue;
if (!seenNames.Add(userName))
{ {
errors.Add($"第 {row.RowNumber} 行:登录账号“{userName}”在文件中重复。"); db.ChangeTracker.Clear();
continue; var colleges = await db.Colleges.AsNoTracking().ToDictionaryAsync(
} x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken);
var users = await userManager.Users.ToDictionaryAsync(
x => x.UserName!, StringComparer.OrdinalIgnoreCase, cancellationToken);
var teachers = await db.Teachers.ToDictionaryAsync(
x => x.TeacherNumber,
StringComparer.OrdinalIgnoreCase,
cancellationToken);
var students = await db.Students.ToDictionaryAsync(
x => x.StudentNumber,
StringComparer.OrdinalIgnoreCase,
cancellationToken);
var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var errors = new List<string>();
var plans = new List<UserImportPlan>();
var seenNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var plannedTeachers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var plannedStudents = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var existing = users.GetValueOrDefault(userName); foreach (var row in rows)
var password = Optional(row, "初始密码");
if (existing is null && (password?.Length ?? 0) < 8)
errors.Add($"第 {row.RowNumber} 行:新账号的初始密码至少需要 8 位。");
if (password is { Length: > 0 and < 8 })
errors.Add($"第 {row.RowNumber} 行:密码至少需要 8 位。");
var roleNames = ParseRoles(row, errors);
var enabled = ParseEnabled(row, errors);
var staffNumber = Optional(row, "工号/学号");
Guid? collegeId = null;
var collegeCode = Optional(row, "学院编码");
if (collegeCode is not null)
{
if (!colleges.TryGetValue(collegeCode, out var college))
errors.Add($"第 {row.RowNumber} 行:学院编码“{collegeCode}”不存在。");
else collegeId = college.Id;
}
Teacher? teacher = null;
Student? student = null;
if (roleNames.Contains(SystemRoles.Teacher, StringComparer.OrdinalIgnoreCase))
{
if (staffNumber is null || !teachers.TryGetValue(staffNumber, out teacher))
errors.Add($"第 {row.RowNumber} 行:教师角色必须填写有效工号。");
else if (teacher.UserId.HasValue && teacher.UserId != existing?.Id)
errors.Add($"第 {row.RowNumber} 行:工号“{staffNumber}”已关联其他账号。");
else if (!plannedTeachers.Add(staffNumber))
errors.Add($"第 {row.RowNumber} 行:工号“{staffNumber}”在文件中关联了多个账号。");
}
if (roleNames.Contains(SystemRoles.Student, StringComparer.OrdinalIgnoreCase))
{
if (staffNumber is null || !students.TryGetValue(staffNumber, out student))
errors.Add($"第 {row.RowNumber} 行:学生角色必须填写有效学号。");
else if (student.UserId.HasValue && student.UserId != existing?.Id)
errors.Add($"第 {row.RowNumber} 行:学号“{staffNumber}”已关联其他账号。");
else if (!plannedStudents.Add(staffNumber))
errors.Add($"第 {row.RowNumber} 行:学号“{staffNumber}”在文件中关联了多个账号。");
}
if (existing?.Id.ToString() == currentUserId)
{
if (!enabled)
errors.Add($"第 {row.RowNumber} 行:不能停用当前登录账号。");
if (!roleNames.Contains(SystemRoles.SuperAdmin, StringComparer.OrdinalIgnoreCase))
errors.Add($"第 {row.RowNumber} 行:不能移除当前账号的超级管理员角色。");
}
plans.Add(new UserImportPlan(
row.RowNumber, existing, userName, displayName, password,
staffNumber, collegeId, roleNames, enabled, teacher, student));
}
if (errors.Count > 0) return ImportValidationProblem(errors);
var created = 0;
var updated = 0;
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
foreach (var plan in plans)
{
var user = plan.Existing;
if (user is null)
{
user = new ApplicationUser
{ {
UserName = plan.UserName, var userName = Required(row, "登录账号", errors);
DisplayName = plan.DisplayName, var displayName = Required(row, "姓名", errors);
StaffNumber = plan.StaffNumber, if (userName is null || displayName is null) continue;
CollegeId = plan.CollegeId, if (!seenNames.Add(userName))
IsEnabled = plan.IsEnabled, {
LockoutEnabled = true errors.Add($"第 {row.RowNumber} 行:登录账号“{userName}”在文件中重复。");
}; continue;
var createResult = await userManager.CreateAsync(user, plan.Password!); }
if (!createResult.Succeeded)
{ var existing = users.GetValueOrDefault(userName);
await transaction.RollbackAsync(cancellationToken); var password = Optional(row, "初始密码");
return IdentityValidationProblem(createResult, plan.RowNumber); if (existing is null && (password?.Length ?? 0) < 8)
errors.Add($"第 {row.RowNumber} 行:新账号的初始密码至少需要 8 位。");
if (password is { Length: > 0 and < 8 })
errors.Add($"第 {row.RowNumber} 行:密码至少需要 8 位。");
var roleNames = ParseRoles(row, errors);
var enabled = ParseEnabled(row, errors);
var staffNumber = Optional(row, "工号/学号");
Guid? collegeId = null;
var collegeCode = Optional(row, "学院编码");
if (collegeCode is not null)
{
if (!colleges.TryGetValue(collegeCode, out var college))
errors.Add(
$"第 {row.RowNumber} 行:学院编码“{collegeCode}”不存在。");
else collegeId = college.Id;
}
Teacher? teacher = null;
Student? student = null;
if (roleNames.Contains(
SystemRoles.Teacher,
StringComparer.OrdinalIgnoreCase))
{
if (staffNumber is null ||
!teachers.TryGetValue(staffNumber, out teacher))
errors.Add($"第 {row.RowNumber} 行:教师角色必须填写有效工号。");
else if (teacher.UserId.HasValue &&
teacher.UserId != existing?.Id)
errors.Add(
$"第 {row.RowNumber} 行:工号“{staffNumber}”已关联其他账号。");
else if (!plannedTeachers.Add(staffNumber))
errors.Add(
$"第 {row.RowNumber} 行:工号“{staffNumber}”在文件中关联了多个账号。");
}
if (roleNames.Contains(
SystemRoles.Student,
StringComparer.OrdinalIgnoreCase))
{
if (staffNumber is null ||
!students.TryGetValue(staffNumber, out student))
errors.Add($"第 {row.RowNumber} 行:学生角色必须填写有效学号。");
else if (student.UserId.HasValue &&
student.UserId != existing?.Id)
errors.Add(
$"第 {row.RowNumber} 行:学号“{staffNumber}”已关联其他账号。");
else if (!plannedStudents.Add(staffNumber))
errors.Add(
$"第 {row.RowNumber} 行:学号“{staffNumber}”在文件中关联了多个账号。");
}
if (existing?.Id.ToString() == currentUserId)
{
if (!enabled)
errors.Add($"第 {row.RowNumber} 行:不能停用当前登录账号。");
if (!roleNames.Contains(
SystemRoles.SuperAdmin,
StringComparer.OrdinalIgnoreCase))
errors.Add(
$"第 {row.RowNumber} 行:不能移除当前账号的超级管理员角色。");
}
plans.Add(new UserImportPlan(
row.RowNumber, existing, userName, displayName, password,
staffNumber, collegeId, roleNames, enabled, teacher, student));
} }
created++;
} if (errors.Count > 0) return ImportValidationProblem(errors);
else
{ var created = 0;
user.DisplayName = plan.DisplayName; var updated = 0;
user.StaffNumber = plan.StaffNumber; foreach (var plan in plans)
user.CollegeId = plan.CollegeId;
user.IsEnabled = plan.IsEnabled;
var updateResult = await userManager.UpdateAsync(user);
if (!updateResult.Succeeded)
{ {
await transaction.RollbackAsync(cancellationToken); var user = plan.Existing;
return IdentityValidationProblem(updateResult, plan.RowNumber); if (user is null)
} {
if (plan.Password is not null) user = new ApplicationUser
{ {
var token = await userManager.GeneratePasswordResetTokenAsync(user); UserName = plan.UserName,
var passwordResult = await userManager.ResetPasswordAsync( DisplayName = plan.DisplayName,
user, token, plan.Password); StaffNumber = plan.StaffNumber,
if (!passwordResult.Succeeded) CollegeId = plan.CollegeId,
IsEnabled = plan.IsEnabled,
LockoutEnabled = true
};
var createResult = await userManager.CreateAsync(
user,
plan.Password!);
if (!createResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(
createResult,
plan.RowNumber);
}
created++;
}
else
{
user.DisplayName = plan.DisplayName;
user.StaffNumber = plan.StaffNumber;
user.CollegeId = plan.CollegeId;
user.IsEnabled = plan.IsEnabled;
var updateResult = await userManager.UpdateAsync(user);
if (!updateResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(
updateResult,
plan.RowNumber);
}
if (plan.Password is not null)
{
var token = await userManager
.GeneratePasswordResetTokenAsync(user);
var passwordResult = await userManager.ResetPasswordAsync(
user,
token,
plan.Password);
if (!passwordResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(
passwordResult,
plan.RowNumber);
}
}
updated++;
}
var existingRoles = await userManager.GetRolesAsync(user);
var removeResult = await userManager.RemoveFromRolesAsync(
user,
existingRoles.Except(
plan.Roles,
StringComparer.OrdinalIgnoreCase));
if (!removeResult.Succeeded)
{ {
await transaction.RollbackAsync(cancellationToken); await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(passwordResult, plan.RowNumber); return IdentityValidationProblem(removeResult, plan.RowNumber);
} }
var addResult = await userManager.AddToRolesAsync(
user,
plan.Roles.Except(
existingRoles,
StringComparer.OrdinalIgnoreCase));
if (!addResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(addResult, plan.RowNumber);
}
foreach (var linked in await db.Teachers
.Where(x => x.UserId == user.Id)
.ToListAsync(cancellationToken))
if (linked != plan.Teacher) linked.UserId = null;
foreach (var linked in await db.Students
.Where(x => x.UserId == user.Id)
.ToListAsync(cancellationToken))
if (linked != plan.Student) linked.UserId = null;
if (plan.Teacher is not null) plan.Teacher.UserId = user.Id;
if (plan.Student is not null) plan.Student.UserId = user.Id;
await db.SaveChangesAsync(cancellationToken);
} }
updated++;
}
var existingRoles = await userManager.GetRolesAsync(user); await transaction.CommitAsync(cancellationToken);
var removeResult = await userManager.RemoveFromRolesAsync( return Ok(new ExcelImportResult(created, updated, plans.Count));
user, },
existingRoles.Except(plan.Roles, StringComparer.OrdinalIgnoreCase)); cancellationToken);
if (!removeResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(removeResult, plan.RowNumber);
}
var addResult = await userManager.AddToRolesAsync(
user,
plan.Roles.Except(existingRoles, StringComparer.OrdinalIgnoreCase));
if (!addResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(addResult, plan.RowNumber);
}
foreach (var linked in await db.Teachers.Where(x => x.UserId == user.Id)
.ToListAsync(cancellationToken))
if (linked != plan.Teacher) linked.UserId = null;
foreach (var linked in await db.Students.Where(x => x.UserId == user.Id)
.ToListAsync(cancellationToken))
if (linked != plan.Student) linked.UserId = null;
if (plan.Teacher is not null) plan.Teacher.UserId = user.Id;
if (plan.Student is not null) plan.Student.UserId = user.Id;
await db.SaveChangesAsync(cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
return Ok(new ExcelImportResult(created, updated, plans.Count));
} }
[HttpPut("{id:guid}/password")] [HttpPut("{id:guid}/password")]
@@ -34,9 +34,13 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
{ {
db.WarningRules.Add(new WarningRule db.WarningRules.Add(new WarningRule
{ {
AcademicTermId = academicTermId, Type = r.Type, Name = r.Name.Trim(), AcademicTermId = academicTermId,
Threshold = r.Threshold, IsEnabled = r.IsEnabled, Type = r.Type,
NotifyStudent = r.NotifyStudent, NotifyCounselor = r.NotifyCounselor, Name = r.Name.Trim(),
Threshold = r.Threshold,
IsEnabled = r.IsEnabled,
NotifyStudent = r.NotifyStudent,
NotifyCounselor = r.NotifyCounselor,
Description = r.Description?.Trim(), Description = r.Description?.Trim(),
AutoCheckEnabled = r.AutoCheckEnabled, AutoCheckEnabled = r.AutoCheckEnabled,
CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek, CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek,
@@ -110,7 +114,7 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
{ {
var classIds = await db.AdministrativeClasses.Where(c => c.CounselorUserId == scope.Current.UserId).Select(c => c.Id).ToListAsync(ct); var classIds = await db.AdministrativeClasses.Where(c => c.CounselorUserId == scope.Current.UserId).Select(c => c.Id).ToListAsync(ct);
if (classIds.Count > 0) if (classIds.Count > 0)
q = q.Where(x => classIds.Contains(x.Student!.AdministrativeClassId)); q = q.WhereIn(classIds, x => x.Student!.AdministrativeClassId);
else if (scope.Current.RestrictedCollegeId.HasValue) else if (scope.Current.RestrictedCollegeId.HasValue)
q = q.Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == scope.Current.RestrictedCollegeId); q = q.Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == scope.Current.RestrictedCollegeId);
} }
@@ -9,6 +9,14 @@ public sealed class AttendanceSheet : EntityBase
public required string Name { get; set; } public required string Name { get; set; }
public DateTime AttendanceDate { get; set; } public DateTime AttendanceDate { get; set; }
public AttendanceSheetStatus Status { get; set; } = AttendanceSheetStatus.Draft; public AttendanceSheetStatus Status { get; set; } = AttendanceSheetStatus.Draft;
public AttendanceCheckInMethod CheckInMethod { get; set; } =
AttendanceCheckInMethod.Manual;
public string? CheckInToken { get; set; }
public DateTime? CheckInStartsAt { get; set; }
public DateTime? CheckInEndsAt { get; set; }
public decimal? TargetLatitude { get; set; }
public decimal? TargetLongitude { get; set; }
public int? LocationRadiusMeters { get; set; }
public string? Notes { get; set; } public 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; } = [];
@@ -22,6 +30,12 @@ public sealed class AttendanceRecord
public Student? Student { get; set; } public Student? Student { get; set; }
public AttendanceStatus Status { get; set; } = AttendanceStatus.Present; public AttendanceStatus Status { get; set; } = AttendanceStatus.Present;
public string? Notes { get; set; } public string? Notes { get; set; }
public DateTime? CheckInAt { get; set; }
public AttendanceCheckInMethod? CheckedInMethod { get; set; }
public decimal? CheckInLatitude { get; set; }
public decimal? CheckInLongitude { get; set; }
public double? CheckInAccuracyMeters { get; set; }
public double? CheckInDistanceMeters { get; set; }
public AttendanceAppealStatus AppealStatus { get; set; } = AttendanceAppealStatus.None; public AttendanceAppealStatus AppealStatus { get; set; } = AttendanceAppealStatus.None;
public string? AppealReason { get; set; } public string? AppealReason { get; set; }
public DateTime? AppealSubmittedAt { get; set; } public DateTime? AppealSubmittedAt { get; set; }
@@ -44,6 +58,13 @@ public enum AttendanceStatus
Excused = 5 Excused = 5
} }
public enum AttendanceCheckInMethod
{
Manual = 1,
QrCode = 2,
Location = 3
}
public enum AttendanceAppealStatus public enum AttendanceAppealStatus
{ {
None = 0, None = 0,
@@ -56,6 +56,8 @@ public sealed class AcademicTerm : CatalogEntity
public DateOnly StartDate { get; set; } public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; } public DateOnly EndDate { get; set; }
public bool IsCurrent { get; set; } public bool IsCurrent { get; set; }
public bool IsArchived { get; set; }
public DateTime? ArchivedAt { get; set; }
} }
public enum TermSeason public enum TermSeason
@@ -168,7 +168,7 @@ public sealed class ExamArrangementService(AppDbContext db)
.ToHashSet(); .ToHashSet();
if (occupiedRoomIds.Count > 0) if (occupiedRoomIds.Count > 0)
query = query.Where(x => !occupiedRoomIds.Contains(x.Id)); query = query.WhereNotIn(occupiedRoomIds, x => x.Id);
// Exclude classrooms occupied by DB sessions not yet tracked in memory // Exclude classrooms occupied by DB sessions not yet tracked in memory
var dbOccupiedRooms = await db.ExamSessions.AsNoTracking() var dbOccupiedRooms = await db.ExamSessions.AsNoTracking()
@@ -181,7 +181,7 @@ public sealed class ExamArrangementService(AppDbContext db)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (dbOccupiedRooms.Count > 0) if (dbOccupiedRooms.Count > 0)
query = query.Where(x => !dbOccupiedRooms.Contains(x.Id)); query = query.WhereNotIn(dbOccupiedRooms, x => x.Id);
return await query return await query
.OrderBy(x => x.Capacity) .OrderBy(x => x.Capacity)
@@ -213,8 +213,8 @@ public sealed class ExamArrangementService(AppDbContext db)
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id); foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
return await db.Teachers.AsNoTracking() return await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active && .Where(x => x.Status == TeacherStatus.Active)
!busyTeacherIds.Contains(x.Id)) .WhereNotIn(busyTeacherIds, x => x.Id)
.OrderBy(x => Guid.NewGuid()) .OrderBy(x => Guid.NewGuid())
.Take(needed) .Take(needed)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -164,7 +164,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
.ToHashSet(); .ToHashSet();
if (occupiedRoomIds.Count > 0) if (occupiedRoomIds.Count > 0)
query = query.Where(x => !occupiedRoomIds.Contains(x.Id)); query = query.WhereNotIn(occupiedRoomIds, x => x.Id);
var dbOccupiedRooms = await db.MakeupExamSessions.AsNoTracking() var dbOccupiedRooms = await db.MakeupExamSessions.AsNoTracking()
.Where(x => x.MakeupExamPlanId == session.MakeupExamPlanId && .Where(x => x.MakeupExamPlanId == session.MakeupExamPlanId &&
@@ -176,7 +176,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (dbOccupiedRooms.Count > 0) if (dbOccupiedRooms.Count > 0)
query = query.Where(x => !dbOccupiedRooms.Contains(x.Id)); query = query.WhereNotIn(dbOccupiedRooms, x => x.Id);
return await query return await query
.OrderBy(x => x.Capacity) .OrderBy(x => x.Capacity)
@@ -208,8 +208,8 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id); foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
return await db.Teachers.AsNoTracking() return await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active && .Where(x => x.Status == TeacherStatus.Active)
!busyTeacherIds.Contains(x.Id)) .WhereNotIn(busyTeacherIds, x => x.Id)
.OrderBy(x => Guid.NewGuid()) .OrderBy(x => Guid.NewGuid())
.Take(needed) .Take(needed)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -32,8 +32,8 @@ public sealed class MakeupExamEligibilityService(AppDbContext db)
// 1. Approved deferred exams (highest priority) // 1. Approved deferred exams (highest priority)
var deferredStudents = await db.DeferredExams.AsNoTracking() var deferredStudents = await db.DeferredExams.AsNoTracking()
.Where(x => x.TeachingTaskId == teachingTaskId && .Where(x => x.TeachingTaskId == teachingTaskId &&
x.Status == ApprovalStatus.Approved && x.Status == ApprovalStatus.Approved)
!enrolledSet.Contains(x.StudentId)) .WhereNotIn(enrolledSet, x => x.StudentId)
.Select(x => new .Select(x => new
{ {
x.StudentId, x.StudentId,
@@ -58,8 +58,8 @@ public sealed class MakeupExamEligibilityService(AppDbContext db)
// 2. Absent students // 2. Absent students
var absentStudents = await db.GradeRecords.AsNoTracking() var absentStudents = await db.GradeRecords.AsNoTracking()
.Where(x => x.GradeSheetId == gradeSheet.Id && .Where(x => x.GradeSheetId == gradeSheet.Id &&
x.ExamStatus == GradeExamStatus.Absent && x.ExamStatus == GradeExamStatus.Absent)
!enrolledSet.Contains(x.StudentId)) .WhereNotIn(enrolledSet, x => x.StudentId)
.Select(x => new .Select(x => new
{ {
x.StudentId, x.StudentId,
@@ -85,8 +85,8 @@ public sealed class MakeupExamEligibilityService(AppDbContext db)
var failedStudents = await db.GradeRecords.AsNoTracking() var failedStudents = await db.GradeRecords.AsNoTracking()
.Where(x => x.GradeSheetId == gradeSheet.Id && .Where(x => x.GradeSheetId == gradeSheet.Id &&
x.ExamStatus == GradeExamStatus.Normal && x.ExamStatus == GradeExamStatus.Normal &&
x.TotalScore < 60 && x.TotalScore < 60)
!enrolledSet.Contains(x.StudentId)) .WhereNotIn(enrolledSet, x => x.StudentId)
.Select(x => new .Select(x => new
{ {
x.StudentId, x.StudentId,
@@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System; using Jiaowu.Api.Domain.System;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Jiaowu.Api.Infrastructure.Persistence; namespace Jiaowu.Api.Infrastructure.Persistence;
@@ -90,6 +91,18 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<GraduationClearanceRecord>(); Set<GraduationClearanceRecord>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>(); public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void ConfigureConventions(
ModelConfigurationBuilder configurationBuilder)
{
base.ConfigureConventions(configurationBuilder);
// Connector/NET returns MySQL DATE values as DateTime. An explicit
// provider conversion prevents EF from asking the reader for DateOnly.
configurationBuilder.Properties<DateOnly>()
.HaveConversion<DateOnlyDateTimeConverter>()
.HaveColumnType("date");
}
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {
base.OnModelCreating(builder); base.OnModelCreating(builder);
@@ -154,8 +167,11 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.HasForeignKey(x => x.BuildingId) .HasForeignKey(x => x.BuildingId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
builder.Entity<AcademicTerm>() builder.Entity<AcademicTerm>(entity =>
.HasIndex(x => x.IsCurrent); {
entity.HasIndex(x => x.IsCurrent);
entity.HasIndex(x => x.IsArchived);
});
builder.Entity<Teacher>(entity => builder.Entity<Teacher>(entity =>
{ {
@@ -548,8 +564,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
builder.Entity<AttendanceSheet>(entity => builder.Entity<AttendanceSheet>(entity =>
{ {
entity.Property(x => x.Name).HasMaxLength(120); entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.CheckInToken).HasMaxLength(64);
entity.Property(x => x.TargetLatitude).HasPrecision(10, 7);
entity.Property(x => x.TargetLongitude).HasPrecision(10, 7);
entity.Property(x => x.Notes).HasMaxLength(500); entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.TeachingTaskId, x.AttendanceDate }); entity.HasIndex(x => new { x.TeachingTaskId, x.AttendanceDate });
entity.HasIndex(x => x.CheckInToken).IsUnique();
entity.HasOne(x => x.TeachingTask) entity.HasOne(x => x.TeachingTask)
.WithMany() .WithMany()
.HasForeignKey(x => x.TeachingTaskId) .HasForeignKey(x => x.TeachingTaskId)
@@ -562,6 +582,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.Property(x => x.Notes).HasMaxLength(300); entity.Property(x => x.Notes).HasMaxLength(300);
entity.Property(x => x.AppealReason).HasMaxLength(500); entity.Property(x => x.AppealReason).HasMaxLength(500);
entity.Property(x => x.AppealReviewComment).HasMaxLength(300); entity.Property(x => x.AppealReviewComment).HasMaxLength(300);
entity.Property(x => x.CheckInLatitude).HasPrecision(10, 7);
entity.Property(x => x.CheckInLongitude).HasPrecision(10, 7);
entity.HasIndex(x => x.AppealStatus); entity.HasIndex(x => x.AppealStatus);
entity.HasOne(x => x.AttendanceSheet) entity.HasOne(x => x.AttendanceSheet)
.WithMany(x => x.Records) .WithMany(x => x.Records)
@@ -904,3 +926,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
}); });
} }
} }
public sealed class DateOnlyDateTimeConverter()
: ValueConverter<DateOnly, DateTime>(
date => date.ToDateTime(TimeOnly.MinValue),
value => DateOnly.FromDateTime(value));
@@ -8,6 +8,8 @@ public sealed class DemoDataSeeder(
AppDbContext db, AppDbContext db,
ILogger<DemoDataSeeder> logger) ILogger<DemoDataSeeder> logger)
{ {
// MySql.EntityFrameworkCore 10 cannot type-map parameterized primitive
// collections, so membership in definition/ID sets is checked after loading.
private const int Grade = 2026; private const int Grade = 2026;
private const int ClassesPerMajor = 2; private const int ClassesPerMajor = 2;
private const int StudentsPerClass = 35; private const int StudentsPerClass = 35;
@@ -176,14 +178,18 @@ public sealed class DemoDataSeeder(
.SelectMany(x => x.Majors) .SelectMany(x => x.Majors)
.Select(x => x.Code) .Select(x => x.Code)
.ToHashSet(StringComparer.OrdinalIgnoreCase); .ToHashSet(StringComparer.OrdinalIgnoreCase);
var majors = await db.Majors var majors = (await db.Majors
.OrderBy(x => x.Code)
.ToListAsync(cancellationToken))
.Where(x => targetMajorCodes.Contains(x.Code)) .Where(x => targetMajorCodes.Contains(x.Code))
.ToList();
var majorIds = majors.Select(x => x.Id).ToHashSet();
var existingClasses = (await db.AdministrativeClasses
.Where(x => x.Grade == Grade)
.OrderBy(x => x.Code) .OrderBy(x => x.Code)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken))
var existingClasses = await db.AdministrativeClasses .Where(x => majorIds.Contains(x.MajorId))
.Where(x => x.Grade == Grade && targetMajorCodes.Contains(x.Major!.Code)) .ToList();
.OrderBy(x => x.Code)
.ToListAsync(cancellationToken);
var existingCodes = (await db.AdministrativeClasses var existingCodes = (await db.AdministrativeClasses
.Select(x => x.Code) .Select(x => x.Code)
.ToListAsync(cancellationToken)) .ToListAsync(cancellationToken))
@@ -218,13 +224,16 @@ public sealed class DemoDataSeeder(
var collegeCodes = CollegeDefinitions var collegeCodes = CollegeDefinitions
.Select(x => x.Code) .Select(x => x.Code)
.ToHashSet(StringComparer.OrdinalIgnoreCase); .ToHashSet(StringComparer.OrdinalIgnoreCase);
var colleges = await db.Colleges var colleges = (await db.Colleges
.Where(x => collegeCodes.Contains(x.Code))
.OrderBy(x => x.Code) .OrderBy(x => x.Code)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken))
var existingTeachers = await db.Teachers .Where(x => collegeCodes.Contains(x.Code))
.Where(x => colleges.Select(c => c.Id).Contains(x.CollegeId)) .ToList();
.ToListAsync(cancellationToken); var collegeIds = colleges.Select(x => x.Id).ToHashSet();
var existingTeachers = (await db.Teachers
.ToListAsync(cancellationToken))
.Where(x => collegeIds.Contains(x.CollegeId))
.ToList();
var existingNumbers = (await db.Teachers var existingNumbers = (await db.Teachers
.Select(x => x.TeacherNumber) .Select(x => x.TeacherNumber)
.ToListAsync(cancellationToken)) .ToListAsync(cancellationToken))
@@ -264,14 +273,23 @@ public sealed class DemoDataSeeder(
.SelectMany(x => x.Majors) .SelectMany(x => x.Majors)
.Select(x => x.Code) .Select(x => x.Code)
.ToHashSet(StringComparer.OrdinalIgnoreCase); .ToHashSet(StringComparer.OrdinalIgnoreCase);
var classes = await db.AdministrativeClasses var targetMajorIds = (await db.Majors
.Where(x => x.Grade == Grade && targetMajorCodes.Contains(x.Major!.Code)) .Select(x => new { x.Id, x.Code })
.ToListAsync(cancellationToken))
.Where(x => targetMajorCodes.Contains(x.Code))
.Select(x => x.Id)
.ToHashSet();
var classes = (await db.AdministrativeClasses
.Where(x => x.Grade == Grade)
.OrderBy(x => x.Code) .OrderBy(x => x.Code)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken))
.Where(x => targetMajorIds.Contains(x.MajorId))
.ToList();
var classIds = classes.Select(x => x.Id).ToHashSet(); var classIds = classes.Select(x => x.Id).ToHashSet();
var existingStudents = await db.Students var existingStudents = (await db.Students
.ToListAsync(cancellationToken))
.Where(x => classIds.Contains(x.AdministrativeClassId)) .Where(x => classIds.Contains(x.AdministrativeClassId))
.ToListAsync(cancellationToken); .ToList();
var existingNumbers = (await db.Students var existingNumbers = (await db.Students
.Select(x => x.StudentNumber) .Select(x => x.StudentNumber)
.ToListAsync(cancellationToken)) .ToListAsync(cancellationToken))
@@ -404,19 +422,23 @@ public sealed class DemoDataSeeder(
var collegeCodes = CollegeDefinitions var collegeCodes = CollegeDefinitions
.Select(x => x.Code) .Select(x => x.Code)
.ToHashSet(StringComparer.OrdinalIgnoreCase); .ToHashSet(StringComparer.OrdinalIgnoreCase);
var colleges = await db.Colleges var colleges = (await db.Colleges
.OrderBy(x => x.Code)
.ToListAsync(cancellationToken))
.Where(x => collegeCodes.Contains(x.Code)) .Where(x => collegeCodes.Contains(x.Code))
.OrderBy(x => x.Code) .ToList();
.ToListAsync(cancellationToken);
var collegeIds = colleges.Select(x => x.Id).ToHashSet(); var collegeIds = colleges.Select(x => x.Id).ToHashSet();
var teachers = await db.Teachers var teachers = (await db.Teachers
.Where(x => collegeIds.Contains(x.CollegeId) && x.Status == TeacherStatus.Active) .Where(x => x.Status == TeacherStatus.Active)
.OrderBy(x => x.TeacherNumber) .OrderBy(x => x.TeacherNumber)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken))
var courses = await db.Courses
.Where(x => collegeIds.Contains(x.CollegeId)) .Where(x => collegeIds.Contains(x.CollegeId))
.ToList();
var courses = (await db.Courses
.OrderBy(x => x.Code) .OrderBy(x => x.Code)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken))
.Where(x => collegeIds.Contains(x.CollegeId))
.ToList();
var publicCourses = courses var publicCourses = courses
.Where(x => x.Nature is CourseNature.GeneralRequired or CourseNature.GeneralElective) .Where(x => x.Nature is CourseNature.GeneralRequired or CourseNature.GeneralElective)
.ToList(); .ToList();
@@ -40,10 +40,14 @@ public sealed class DevelopmentSqliteMigrator(
"20260725_22_course_adjustments"; "20260725_22_course_adjustments";
private const string AttendanceAppealMigration = private const string AttendanceAppealMigration =
"20260725_23_attendance_appeal"; "20260725_23_attendance_appeal";
private const string AttendanceCheckInMigration =
"20260726_26_attendance_check_in";
private const string ApprovalTablesMigration = private const string ApprovalTablesMigration =
"20260725_24_approval_tables"; "20260725_24_approval_tables";
private const string AcademicWarningsMigration = private const string AcademicWarningsMigration =
"20260725_25_academic_warnings"; "20260725_25_academic_warnings";
private const string AcademicTermArchivingMigration =
"20260726_27_academic_term_archiving";
public async Task MigrateAsync(CancellationToken cancellationToken = default) public async Task MigrateAsync(CancellationToken cancellationToken = default)
{ {
@@ -275,6 +279,19 @@ public sealed class DevelopmentSqliteMigrator(
attendanceAppealExists ? [] : AttendanceAppealStatements, attendanceAppealExists ? [] : AttendanceAppealStatements,
cancellationToken); cancellationToken);
var attendanceCheckInExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('AttendanceSheets')
WHERE name = 'CheckInMethod'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
AttendanceCheckInMigration,
attendanceCheckInExists ? [] : AttendanceCheckInStatements,
cancellationToken);
var approvalTablesExist = await db.Database 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'")
.AnyAsync(value => value > 0, cancellationToken); .AnyAsync(value => value > 0, cancellationToken);
@@ -314,6 +331,19 @@ public sealed class DevelopmentSqliteMigrator(
MakeupExamAutoJobsMigration, MakeupExamAutoJobsMigration,
makeupAutoJobsExist ? [] : MakeupExamAutoJobStatements, makeupAutoJobsExist ? [] : MakeupExamAutoJobStatements,
cancellationToken); cancellationToken);
var academicTermArchivingExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('AcademicTerms')
WHERE name = 'IsArchived'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
AcademicTermArchivingMigration,
academicTermArchivingExists ? [] : AcademicTermArchivingStatements,
cancellationToken);
} }
private async Task ApplyMigrationAsync( private async Task ApplyMigrationAsync(
@@ -1623,6 +1653,24 @@ public sealed class DevelopmentSqliteMigrator(
"""CREATE INDEX IF NOT EXISTS "IX_AttendanceRecords_AppealStatus" ON "AttendanceRecords" ("AppealStatus");""" """CREATE INDEX IF NOT EXISTS "IX_AttendanceRecords_AppealStatus" ON "AttendanceRecords" ("AppealStatus");"""
]; ];
private static readonly string[] AttendanceCheckInStatements =
[
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "CheckInMethod" INTEGER NOT NULL DEFAULT 1;""",
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "CheckInToken" TEXT NULL;""",
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "CheckInStartsAt" TEXT NULL;""",
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "CheckInEndsAt" TEXT NULL;""",
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "TargetLatitude" TEXT NULL;""",
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "TargetLongitude" TEXT NULL;""",
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "LocationRadiusMeters" INTEGER NULL;""",
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_AttendanceSheets_CheckInToken" ON "AttendanceSheets" ("CheckInToken");""",
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInAt" TEXT NULL;""",
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckedInMethod" INTEGER NULL;""",
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInLatitude" TEXT NULL;""",
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInLongitude" TEXT NULL;""",
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInAccuracyMeters" REAL NULL;""",
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInDistanceMeters" REAL NULL;"""
];
private static readonly string[] ApprovalTableStatements = 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);""",
@@ -1655,6 +1703,13 @@ public sealed class DevelopmentSqliteMigrator(
"""ALTER TABLE "WarningRules" ADD COLUMN "LastCheckAt" TEXT NULL;""" """ALTER TABLE "WarningRules" ADD COLUMN "LastCheckAt" TEXT NULL;"""
]; ];
private static readonly string[] AcademicTermArchivingStatements =
[
"""ALTER TABLE "AcademicTerms" ADD COLUMN "IsArchived" INTEGER NOT NULL DEFAULT 0;""",
"""ALTER TABLE "AcademicTerms" ADD COLUMN "ArchivedAt" TEXT NULL;""",
"""CREATE INDEX IF NOT EXISTS "IX_AcademicTerms_IsArchived" ON "AcademicTerms" ("IsArchived");"""
];
private const string TeachingEvaluationMigration = "TeachingEvaluation"; private const string TeachingEvaluationMigration = "TeachingEvaluation";
private static readonly string[] TeachingEvaluationStatements = private static readonly string[] TeachingEvaluationStatements =
@@ -0,0 +1,131 @@
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
[DbContext(typeof(AppDbContext))]
[Migration("20260726022634_AttendanceCheckIn")]
public partial class AttendanceCheckIn : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "CheckInMethod",
table: "AttendanceSheets",
type: "int",
nullable: false,
defaultValue: 1);
migrationBuilder.AddColumn<string>(
name: "CheckInToken",
table: "AttendanceSheets",
type: "varchar(64)",
maxLength: 64,
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<DateTime>(
name: "CheckInStartsAt",
table: "AttendanceSheets",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CheckInEndsAt",
table: "AttendanceSheets",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "TargetLatitude",
table: "AttendanceSheets",
type: "decimal(10,7)",
precision: 10,
scale: 7,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "TargetLongitude",
table: "AttendanceSheets",
type: "decimal(10,7)",
precision: 10,
scale: 7,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "LocationRadiusMeters",
table: "AttendanceSheets",
type: "int",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CheckInAt",
table: "AttendanceRecords",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CheckedInMethod",
table: "AttendanceRecords",
type: "int",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "CheckInLatitude",
table: "AttendanceRecords",
type: "decimal(10,7)",
precision: 10,
scale: 7,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "CheckInLongitude",
table: "AttendanceRecords",
type: "decimal(10,7)",
precision: 10,
scale: 7,
nullable: true);
migrationBuilder.AddColumn<double>(
name: "CheckInAccuracyMeters",
table: "AttendanceRecords",
type: "double",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "CheckInDistanceMeters",
table: "AttendanceRecords",
type: "double",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_AttendanceSheets_CheckInToken",
table: "AttendanceSheets",
column: "CheckInToken",
unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_AttendanceSheets_CheckInToken",
table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "CheckInMethod", table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "CheckInToken", table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "CheckInStartsAt", table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "CheckInEndsAt", table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "TargetLatitude", table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "TargetLongitude", table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "LocationRadiusMeters", table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "CheckInAt", table: "AttendanceRecords");
migrationBuilder.DropColumn(name: "CheckedInMethod", table: "AttendanceRecords");
migrationBuilder.DropColumn(name: "CheckInLatitude", table: "AttendanceRecords");
migrationBuilder.DropColumn(name: "CheckInLongitude", table: "AttendanceRecords");
migrationBuilder.DropColumn(name: "CheckInAccuracyMeters", table: "AttendanceRecords");
migrationBuilder.DropColumn(name: "CheckInDistanceMeters", table: "AttendanceRecords");
}
}
@@ -0,0 +1,48 @@
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
[DbContext(typeof(AppDbContext))]
[Migration("20260726143000_AcademicTermArchiving")]
public partial class AcademicTermArchiving : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "ArchivedAt",
table: "AcademicTerms",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsArchived",
table: "AcademicTerms",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.CreateIndex(
name: "IX_AcademicTerms_IsArchived",
table: "AcademicTerms",
column: "IsArchived");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_AcademicTerms_IsArchived",
table: "AcademicTerms");
migrationBuilder.DropColumn(
name: "ArchivedAt",
table: "AcademicTerms");
migrationBuilder.DropColumn(
name: "IsArchived",
table: "AcademicTerms");
}
}
@@ -29,6 +29,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
.IsRequired() .IsRequired()
.HasColumnType("longtext"); .HasColumnType("longtext");
b.Property<DateTime?>("ArchivedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Code") b.Property<string>("Code")
.IsRequired() .IsRequired()
.HasMaxLength(40) .HasMaxLength(40)
@@ -43,6 +46,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<bool>("IsCurrent") b.Property<bool>("IsCurrent")
.HasColumnType("tinyint(1)"); .HasColumnType("tinyint(1)");
b.Property<bool>("IsArchived")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsEnabled") b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)"); .HasColumnType("tinyint(1)");
@@ -70,6 +76,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("IsCurrent"); b.HasIndex("IsCurrent");
b.HasIndex("IsArchived");
b.HasIndex("IsEnabled", "SortOrder"); b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("AcademicTerms"); b.ToTable("AcademicTerms");
@@ -154,6 +162,26 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<DateTime?>("AppealSubmittedAt") b.Property<DateTime?>("AppealSubmittedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
b.Property<double?>("CheckInAccuracyMeters")
.HasColumnType("double");
b.Property<DateTime?>("CheckInAt")
.HasColumnType("datetime(6)");
b.Property<double?>("CheckInDistanceMeters")
.HasColumnType("double");
b.Property<decimal?>("CheckInLatitude")
.HasPrecision(10, 7)
.HasColumnType("decimal(10,7)");
b.Property<decimal?>("CheckInLongitude")
.HasPrecision(10, 7)
.HasColumnType("decimal(10,7)");
b.Property<int?>("CheckedInMethod")
.HasColumnType("int");
b.Property<string>("Notes") b.Property<string>("Notes")
.HasMaxLength(300) .HasMaxLength(300)
.HasColumnType("varchar(300)"); .HasColumnType("varchar(300)");
@@ -179,9 +207,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<DateTime>("AttendanceDate") b.Property<DateTime>("AttendanceDate")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
b.Property<DateTime?>("CheckInEndsAt")
.HasColumnType("datetime(6)");
b.Property<int>("CheckInMethod")
.HasColumnType("int");
b.Property<DateTime?>("CheckInStartsAt")
.HasColumnType("datetime(6)");
b.Property<string>("CheckInToken")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
b.Property<int?>("LocationRadiusMeters")
.HasColumnType("int");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(120) .HasMaxLength(120)
@@ -197,6 +241,14 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<DateTime?>("SubmittedAt") b.Property<DateTime?>("SubmittedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
b.Property<decimal?>("TargetLatitude")
.HasPrecision(10, 7)
.HasColumnType("decimal(10,7)");
b.Property<decimal?>("TargetLongitude")
.HasPrecision(10, 7)
.HasColumnType("decimal(10,7)");
b.Property<Guid>("TeachingTaskId") b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)"); .HasColumnType("char(36)");
@@ -205,6 +257,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("CheckInToken")
.IsUnique();
b.HasIndex("TeachingTaskId", "AttendanceDate"); b.HasIndex("TeachingTaskId", "AttendanceDate");
b.ToTable("AttendanceSheets"); b.ToTable("AttendanceSheets");
@@ -0,0 +1,47 @@
using System.Linq.Expressions;
namespace Jiaowu.Api.Infrastructure.Persistence;
public static class QueryableCollectionExtensions
{
public static IQueryable<TEntity> WhereIn<TEntity, TValue>(
this IQueryable<TEntity> source,
IEnumerable<TValue> values,
Expression<Func<TEntity, TValue>> valueSelector)
{
var predicate = BuildPredicate(values, valueSelector, Expression.OrElse, false);
return source.Where(predicate);
}
public static IQueryable<TEntity> WhereNotIn<TEntity, TValue>(
this IQueryable<TEntity> source,
IEnumerable<TValue> values,
Expression<Func<TEntity, TValue>> valueSelector)
{
var predicate = BuildPredicate(values, valueSelector, Expression.AndAlso, true);
return source.Where(predicate);
}
private static Expression<Func<TEntity, bool>> BuildPredicate<TEntity, TValue>(
IEnumerable<TValue> values,
Expression<Func<TEntity, TValue>> valueSelector,
Func<Expression, Expression, BinaryExpression> combine,
bool negate)
{
Expression? body = null;
foreach (var value in values.Distinct())
{
Expression comparison = Expression.Equal(
valueSelector.Body,
Expression.Constant(value, typeof(TValue)));
if (negate)
comparison = Expression.Not(comparison);
body = body is null ? comparison : combine(body, comparison);
}
body ??= Expression.Constant(negate);
return Expression.Lambda<Func<TEntity, bool>>(
body,
valueSelector.Parameters);
}
}
@@ -0,0 +1,44 @@
using System.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace Jiaowu.Api.Infrastructure.Persistence;
public static class RetriableTransactionExtensions
{
public static Task<TResult> ExecuteInRetriableTransactionAsync<TResult>(
this DbContext db,
Func<IDbContextTransaction, Task<TResult>> operation,
CancellationToken cancellationToken,
IsolationLevel? isolationLevel = null)
{
var strategy = db.Database.CreateExecutionStrategy();
return strategy.ExecuteAsync(async () =>
{
await using var transaction = isolationLevel.HasValue
? await db.Database.BeginTransactionAsync(
isolationLevel.Value,
cancellationToken)
: await db.Database.BeginTransactionAsync(cancellationToken);
return await operation(transaction);
});
}
public static Task ExecuteInRetriableTransactionAsync(
this DbContext db,
Func<IDbContextTransaction, Task> operation,
CancellationToken cancellationToken,
IsolationLevel? isolationLevel = null)
{
var strategy = db.Database.CreateExecutionStrategy();
return strategy.ExecuteAsync(async () =>
{
await using var transaction = isolationLevel.HasValue
? await db.Database.BeginTransactionAsync(
isolationLevel.Value,
cancellationToken)
: await db.Database.BeginTransactionAsync(cancellationToken);
await operation(transaction);
});
}
}
@@ -38,8 +38,9 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
.ThenByDescending(x => x.Capacity) .ThenByDescending(x => x.Capacity)
.ThenBy(x => x.TaskNumber) .ThenBy(x => x.TaskNumber)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var taskIds = tasks.Select(task => task.Id).ToArray();
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking() var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Where(x => tasks.Select(task => task.Id).Contains(x.TeachingTaskId)) .WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
var classrooms = await db.Classrooms.AsNoTracking() var classrooms = await db.Classrooms.AsNoTracking()
@@ -169,10 +169,7 @@ public sealed class AutomaticScheduleJobProcessor(
job.CompletedTasks = result.CompletedTasks; job.CompletedTasks = result.CompletedTasks;
job.MessagesJson = JsonSerializer.Serialize(result.Messages); job.MessagesJson = JsonSerializer.Serialize(result.Messages);
job.CompletedAt = DateTime.UtcNow; job.CompletedAt = DateTime.UtcNow;
await using var transaction =
await db.Database.BeginTransactionAsync(stoppingToken);
await db.SaveChangesAsync(stoppingToken); await db.SaveChangesAsync(stoppingToken);
await transaction.CommitAsync(stoppingToken);
logger.LogInformation( logger.LogInformation(
"Automatic schedule job {JobId} completed with {CreatedEntries} entries.", "Automatic schedule job {JobId} completed with {CreatedEntries} entries.",
@@ -141,35 +141,46 @@ public sealed class SchedulePublishJobProcessor(
ReportProgress, ReportProgress,
stoppingToken); stoppingToken);
await using var transaction = var publishedPlanId = plan.Id;
await db.Database.BeginTransactionAsync(stoppingToken); await db.ExecuteInRetriableTransactionAsync(
if (plan.Status != SchedulePlanStatus.Draft) async transaction =>
throw new SchedulePublishValidationException( {
"排课草稿状态已发生变化,请刷新后重试。"); db.ChangeTracker.Clear();
var publishJob = await db.SchedulePublishJobs
.FirstAsync(x => x.Id == jobId, stoppingToken);
var publishPlan = await db.SchedulePlans
.FirstAsync(x => x.Id == publishedPlanId, stoppingToken);
if (publishPlan.Status != SchedulePlanStatus.Draft)
{
throw new SchedulePublishValidationException(
"排课草稿状态已发生变化,请刷新后重试。");
}
var previous = await db.SchedulePlans var previous = await db.SchedulePlans
.Where(x => .Where(x =>
x.Id != plan.Id && x.Id != publishPlan.Id &&
x.AcademicTermId == plan.AcademicTermId && x.AcademicTermId == publishPlan.AcademicTermId &&
x.Status == SchedulePlanStatus.Published) x.Status == SchedulePlanStatus.Published)
.ToListAsync(stoppingToken); .ToListAsync(stoppingToken);
foreach (var oldPlan in previous) foreach (var oldPlan in previous)
oldPlan.Status = SchedulePlanStatus.Archived; oldPlan.Status = SchedulePlanStatus.Archived;
plan.Status = SchedulePlanStatus.Published; publishPlan.Status = SchedulePlanStatus.Published;
plan.PublishedAt = DateTime.UtcNow; publishPlan.PublishedAt = DateTime.UtcNow;
job.Status = SchedulePublishJobStatus.Succeeded; publishJob.Status = SchedulePublishJobStatus.Succeeded;
job.ActiveAcademicTermId = null; publishJob.ActiveAcademicTermId = null;
job.CompletedSteps = job.TotalSteps; publishJob.CompletedSteps = publishJob.TotalSteps;
job.CurrentStep = "课表已发布"; publishJob.CurrentStep = "课表已发布";
job.CompletedAt = DateTime.UtcNow; publishJob.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(stoppingToken); await db.SaveChangesAsync(stoppingToken);
await transaction.CommitAsync(stoppingToken); await transaction.CommitAsync(stoppingToken);
},
stoppingToken);
logger.LogInformation( logger.LogInformation(
"Schedule publish job {JobId} published plan {SchedulePlanId}.", "Schedule publish job {JobId} published plan {SchedulePlanId}.",
job.Id, jobId,
plan.Id); publishedPlanId);
} }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{ {
@@ -245,7 +256,7 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
var taskIds = plan.Entries.Select(x => x.TeachingTaskId).Distinct().ToList(); var taskIds = plan.Entries.Select(x => x.TeachingTaskId).Distinct().ToList();
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking() var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Where(x => taskIds.Contains(x.TeachingTaskId)) .WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
@@ -194,6 +194,7 @@ public sealed class TimetableDataService(AppDbContext db)
query = query.Where(x => x.Id == academicTermId); query = query.Where(x => x.Id == academicTermId);
else else
query = query.OrderByDescending(x => x.IsCurrent) query = query.OrderByDescending(x => x.IsCurrent)
.ThenBy(x => x.IsArchived)
.ThenByDescending(x => x.StartDate); .ThenByDescending(x => x.StartDate);
return await query.Select(x => new TimetableTermDto( return await query.Select(x => new TimetableTermDto(
x.Id, x.Id,
@@ -38,10 +38,19 @@ public sealed class AttendanceControllerTests
MajorId = major.Id, MajorId = major.Id,
Grade = 2026 Grade = 2026
}; };
var studentUserId = Guid.NewGuid();
var studentUser = new ApplicationUser
{
Id = studentUserId,
UserName = "202601001",
NormalizedUserName = "202601001",
DisplayName = "周同学"
};
var firstStudent = new Student var firstStudent = new Student
{ {
StudentNumber = "202601001", StudentNumber = "202601001",
Name = "周同学", Name = "周同学",
UserId = studentUserId,
AdministrativeClassId = administrativeClass.Id, AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2026, EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1) EnrollmentDate = new DateOnly(2026, 9, 1)
@@ -85,6 +94,7 @@ public sealed class AttendanceControllerTests
Status = TeachingTaskStatus.Published Status = TeachingTaskStatus.Published
}; };
db.AddRange( db.AddRange(
studentUser,
college, college,
major, major,
administrativeClass, administrativeClass,
@@ -181,6 +191,111 @@ public sealed class AttendanceControllerTests
Assert.Equal( Assert.Equal(
3, 3,
workbook.Worksheet("历次点名趋势").LastRowUsed()!.RowNumber()); workbook.Worksheet("历次点名趋势").LastRowUsed()!.RowNumber());
var now = DateTime.UtcNow;
var qrRecord = new AttendanceRecord
{
StudentId = firstStudent.Id,
Status = AttendanceStatus.Absent
};
var qrSheet = new AttendanceSheet
{
TeachingTaskId = task.Id,
Name = "课堂扫码签到",
AttendanceDate = now,
Status = AttendanceSheetStatus.Draft,
CheckInMethod = AttendanceCheckInMethod.QrCode,
CheckInToken = "TEST-QR-TOKEN",
CheckInStartsAt = now.AddMinutes(-1),
CheckInEndsAt = now.AddMinutes(10),
Records = [qrRecord]
};
db.AttendanceSheets.Add(qrSheet);
await db.SaveChangesAsync();
var studentController = new AttendanceController(
db,
new StudentDataScope(studentUserId));
var myRecordsResult = await studentController.GetMyRecords(
null,
CancellationToken.None);
var myRecordsOk = Assert.IsType<OkObjectResult>(myRecordsResult);
var myRecords = Assert
.IsAssignableFrom<System.Collections.IEnumerable>(myRecordsOk.Value)
.Cast<object>()
.ToList();
Assert.Equal(2, myRecords.Count);
Assert.All(myRecords, item =>
Assert.Equal(
task.Id,
item.GetType().GetProperty("TeachingTaskId")!.GetValue(item)));
var infoResult = await studentController.GetCheckInInfo(
qrSheet.CheckInToken,
CancellationToken.None);
Assert.IsType<OkObjectResult>(infoResult);
var qrCheckInResult = await studentController.CheckIn(
new AttendanceCheckInRequest(
null,
qrSheet.CheckInToken,
null,
null,
null),
CancellationToken.None);
Assert.IsType<OkObjectResult>(qrCheckInResult);
Assert.Equal(AttendanceStatus.Present, qrRecord.Status);
Assert.Equal(AttendanceCheckInMethod.QrCode, qrRecord.CheckedInMethod);
Assert.NotNull(qrRecord.CheckInAt);
Assert.Null(qrRecord.CheckInLatitude);
var locationRecord = new AttendanceRecord
{
StudentId = firstStudent.Id,
Status = AttendanceStatus.Absent
};
var locationSheet = new AttendanceSheet
{
TeachingTaskId = task.Id,
Name = "课堂定位签到",
AttendanceDate = now,
Status = AttendanceSheetStatus.Draft,
CheckInMethod = AttendanceCheckInMethod.Location,
CheckInStartsAt = now.AddMinutes(-1),
CheckInEndsAt = now.AddMinutes(10),
TargetLatitude = 39.9m,
TargetLongitude = 116.4m,
LocationRadiusMeters = 100,
Records = [locationRecord]
};
db.AttendanceSheets.Add(locationSheet);
await db.SaveChangesAsync();
var outsideResult = await studentController.CheckIn(
new AttendanceCheckInRequest(
locationSheet.Id,
null,
39.91m,
116.4m,
8),
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(outsideResult);
Assert.Null(locationRecord.CheckInAt);
var nearbyResult = await studentController.CheckIn(
new AttendanceCheckInRequest(
locationSheet.Id,
null,
39.9001m,
116.4m,
8),
CancellationToken.None);
Assert.IsType<OkObjectResult>(nearbyResult);
Assert.Equal(AttendanceStatus.Present, locationRecord.Status);
Assert.Equal(
AttendanceCheckInMethod.Location,
locationRecord.CheckedInMethod);
Assert.InRange(locationRecord.CheckInDistanceMeters!.Value, 1, 100);
} }
private sealed class AllDataScope : ICurrentUserDataScope private sealed class AllDataScope : ICurrentUserDataScope
@@ -192,4 +307,14 @@ public sealed class AttendanceControllerTests
DataScope.All, DataScope.All,
new HashSet<string>([SystemRoles.SuperAdmin])); new HashSet<string>([SystemRoles.SuperAdmin]));
} }
private sealed class StudentDataScope(Guid userId) : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
userId,
"测试学生",
null,
DataScope.Self,
new HashSet<string>([SystemRoles.Student]));
}
} }
@@ -0,0 +1,145 @@
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.DependencyInjection;
namespace Jiaowu.Api.Tests;
public sealed class AuthControllerTests
{
[Fact]
public async Task RetriableTransaction_WorksWithRetryingExecutionStrategy()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.ReplaceService<IExecutionStrategyFactory, RetryingExecutionStrategyFactory>()
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
Assert.True(db.Database.CreateExecutionStrategy().RetriesOnFailure);
await db.ExecuteInRetriableTransactionAsync(
async transaction =>
{
db.Colleges.Add(new College { Code = "TX", Name = "事务测试学院" });
await db.SaveChangesAsync();
await transaction.CommitAsync();
},
CancellationToken.None);
Assert.Equal(1, await db.Colleges.CountAsync());
}
[Fact]
public async Task ActivateStudent_WorksWithRetryingExecutionStrategy()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var services = new ServiceCollection();
services.AddLogging();
services.AddDbContext<AppDbContext>(options => options
.UseSqlite(connection)
.ReplaceService<IExecutionStrategyFactory, RetryingExecutionStrategyFactory>());
services
.AddIdentityCore<ApplicationUser>(options =>
{
options.Password.RequiredLength = 8;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = true;
})
.AddRoles<ApplicationRole>()
.AddEntityFrameworkStores<AppDbContext>();
await using var provider = services.BuildServiceProvider();
await using var scope = provider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync();
var roleManager = scope.ServiceProvider
.GetRequiredService<RoleManager<ApplicationRole>>();
Assert.True((await roleManager.CreateAsync(new ApplicationRole
{
Name = SystemRoles.Student,
Description = "学生"
})).Succeeded);
var college = new College { Code = "CS", Name = "计算机学院" };
var major = new Major
{
Code = "SE",
Name = "软件工程",
CollegeId = college.Id,
DegreeType = "工学"
};
var administrativeClass = new AdministrativeClass
{
Code = "SE202601",
Name = "软件工程 2026 级 1 班",
MajorId = major.Id,
Grade = 2026
};
var student = new Student
{
StudentNumber = "202601999",
Name = "测试学生",
AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1),
Status = StudentStatus.Active
};
db.AddRange(college, major, administrativeClass, student);
await db.SaveChangesAsync();
var userManager = scope.ServiceProvider
.GetRequiredService<UserManager<ApplicationUser>>();
Assert.True(db.Database.CreateExecutionStrategy().RetriesOnFailure);
var controller = new AuthController(db, userManager, new StubTokenService());
var request = new StudentActivationRequest(
student.Name,
student.StudentNumber,
college.Id,
major.Id,
administrativeClass.Grade,
administrativeClass.Id,
"Student@123");
var result = await controller.ActivateStudent(request, CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
var user = await userManager.FindByNameAsync(student.StudentNumber);
Assert.NotNull(user);
Assert.Equal(student.Name, user.DisplayName);
Assert.Equal(college.Id, user.CollegeId);
Assert.True(await userManager.IsInRoleAsync(user, SystemRoles.Student));
await db.Entry(student).ReloadAsync();
Assert.Equal(user.Id, student.UserId);
}
private sealed class RetryingExecutionStrategyFactory(
ExecutionStrategyDependencies dependencies) : IExecutionStrategyFactory
{
public IExecutionStrategy Create() => new TestRetryingExecutionStrategy(dependencies);
}
private sealed class TestRetryingExecutionStrategy(
ExecutionStrategyDependencies dependencies)
: ExecutionStrategy(dependencies, 1, TimeSpan.Zero)
{
protected override bool ShouldRetryOn(Exception exception) => false;
}
private sealed class StubTokenService : ITokenService
{
public string Create(ApplicationUser user, IEnumerable<string> roles) => string.Empty;
}
}
@@ -0,0 +1,129 @@
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class BaseDataControllerTests : IAsyncDisposable
{
private readonly SqliteConnection connection = new("Data Source=:memory:");
private readonly AppDbContext db;
private readonly BaseDataController controller;
public BaseDataControllerTests()
{
connection.Open();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
db = new AppDbContext(options);
db.Database.EnsureCreated();
controller = new BaseDataController(db);
}
[Fact]
public async Task SetCurrentTerm_clears_previous_current_in_one_save()
{
var previous = CreateTerm("2025-2026-2", true);
var next = CreateTerm("2026-2027-1", false);
db.AcademicTerms.AddRange(previous, next);
await db.SaveChangesAsync();
var result = await controller.SetCurrentTerm(next.Id, CancellationToken.None);
Assert.Null(result.Result);
db.ChangeTracker.Clear();
Assert.False((await db.AcademicTerms.FindAsync(previous.Id))!.IsCurrent);
Assert.True((await db.AcademicTerms.FindAsync(next.Id))!.IsCurrent);
Assert.Equal(1, await db.AcademicTerms.CountAsync(x => x.IsCurrent));
}
[Fact]
public async Task Archive_requires_switching_current_and_is_reversible()
{
var current = CreateTerm("2026-2027-1", true);
var history = CreateTerm("2025-2026-2", false);
db.AcademicTerms.AddRange(current, history);
await db.SaveChangesAsync();
var currentResult = await controller.ArchiveTerm(
current.Id, CancellationToken.None);
Assert.IsType<ConflictObjectResult>(currentResult.Result);
var archiveResult = await controller.ArchiveTerm(
history.Id, CancellationToken.None);
Assert.Null(archiveResult.Result);
Assert.True(history.IsArchived);
Assert.NotNull(history.ArchivedAt);
Assert.True(history.IsEnabled);
var unarchiveResult = await controller.UnarchiveTerm(
history.Id, CancellationToken.None);
Assert.Null(unarchiveResult.Result);
Assert.False(history.IsArchived);
Assert.Null(history.ArchivedAt);
}
[Fact]
public async Task Archived_term_must_be_unarchived_before_becoming_current()
{
var current = CreateTerm("2026-2027-1", true);
var archived = CreateTerm("2025-2026-2", false);
archived.IsArchived = true;
archived.ArchivedAt = DateTime.UtcNow;
db.AcademicTerms.AddRange(current, archived);
await db.SaveChangesAsync();
var result = await controller.SetCurrentTerm(
archived.Id, CancellationToken.None);
Assert.IsType<ConflictObjectResult>(result.Result);
Assert.True(current.IsCurrent);
Assert.False(archived.IsCurrent);
}
[Fact]
public async Task Current_term_cannot_be_cleared_without_a_replacement()
{
var current = CreateTerm("2026-2027-1", true);
db.AcademicTerms.Add(current);
await db.SaveChangesAsync();
var request = new TermRequest(
current.Code,
current.Name,
true,
current.AcademicYear,
current.Season,
current.StartDate,
current.EndDate,
false);
var result = await controller.UpdateTerm(
current.Id, request, CancellationToken.None);
Assert.IsType<ConflictObjectResult>(result.Result);
Assert.True(current.IsCurrent);
}
private static AcademicTerm CreateTerm(string code, bool isCurrent) =>
new()
{
Code = code,
Name = code,
AcademicYear = code[..9],
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 1),
EndDate = new DateOnly(2027, 1, 20),
IsCurrent = isCurrent,
IsEnabled = true
};
public async ValueTask DisposeAsync()
{
await db.DisposeAsync();
await connection.DisposeAsync();
}
}
+50 -5
View File
@@ -8,15 +8,12 @@ namespace Jiaowu.Api.Tests;
public sealed class MySqlMigrationTests public sealed class MySqlMigrationTests
{ {
private const string LatestMigration = private const string LatestMigration =
"20260725120917_ProductionSchemaCompletion"; "20260726022634_AttendanceCheckIn";
[Fact] [Fact]
public void Production_migration_is_discoverable_and_generates_mysql_sql() public void Production_migration_is_discoverable_and_generates_mysql_sql()
{ {
var options = new DbContextOptionsBuilder<AppDbContext>() var options = CreateMySqlOptions();
.UseMySQL(
"Server=localhost;Database=jiaowu;User=__test__;Password=__not_used__;")
.Options;
using var db = new AppDbContext(options); using var db = new AppDbContext(options);
Assert.Contains(LatestMigration, db.Database.GetMigrations()); Assert.Contains(LatestMigration, db.Database.GetMigrations());
@@ -28,9 +25,57 @@ public sealed class MySqlMigrationTests
Assert.Contains("CREATE TABLE `GradeItems`", script); Assert.Contains("CREATE TABLE `GradeItems`", script);
Assert.Contains("CREATE TABLE `EvaluationSetups`", script); Assert.Contains("CREATE TABLE `EvaluationSetups`", script);
Assert.Contains("CREATE TABLE `WarningRules`", script); Assert.Contains("CREATE TABLE `WarningRules`", script);
Assert.Contains("ADD `CheckInMethod` int NOT NULL DEFAULT 1", script);
Assert.Contains("CREATE UNIQUE INDEX `IX_AttendanceSheets_CheckInToken`", script);
Assert.Contains("SET `ExamDate` = DATE(`StartsAt`)", script); Assert.Contains("SET `ExamDate` = DATE(`StartsAt`)", script);
Assert.Contains("DEFAULT 1", script); Assert.Contains("DEFAULT 1", script);
Assert.DoesNotContain("0001-01-01", script); Assert.DoesNotContain("0001-01-01", script);
Assert.DoesNotContain("0000-00-00", script); Assert.DoesNotContain("0000-00-00", script);
} }
[Fact]
public void MySql_date_properties_use_datetime_provider_conversion()
{
using var db = new AppDbContext(CreateMySqlOptions());
var dateProperties = db.Model.GetEntityTypes()
.SelectMany(x => x.GetProperties())
.Where(x => x.ClrType == typeof(DateOnly) ||
x.ClrType == typeof(DateOnly?))
.ToList();
Assert.NotEmpty(dateProperties);
Assert.All(dateProperties, property =>
{
Assert.Equal("date", property.GetColumnType());
Assert.Equal(
typeof(DateTime),
property.GetTypeMapping().Converter?.ProviderClrType);
});
}
[Fact]
public void MySql_guid_collections_use_provider_safe_predicates()
{
using var db = new AppDbContext(CreateMySqlOptions());
var ids = new[]
{
Guid.Parse("11111111-1111-1111-1111-111111111111"),
Guid.Parse("22222222-2222-2222-2222-222222222222")
};
var sql = db.Colleges
.WhereIn(ids, x => x.Id)
.ToQueryString();
Assert.Contains(" IN (", sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains(ids[0].ToString(), sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains(ids[1].ToString(), sql, StringComparison.OrdinalIgnoreCase);
}
private static DbContextOptions<AppDbContext> CreateMySqlOptions() =>
new DbContextOptionsBuilder<AppDbContext>()
.UseMySQL(
"Server=localhost;Database=jiaowu;User=__test__;Password=__not_used__;")
.Options;
} }
@@ -74,8 +74,9 @@ public sealed class PersonnelControllerTests
Assert.Equal(teacher.Name, user.DisplayName); Assert.Equal(teacher.Name, user.DisplayName);
Assert.Equal(teacher.CollegeId, user.CollegeId); Assert.Equal(teacher.CollegeId, user.CollegeId);
Assert.True(await userManager.IsInRoleAsync(user, SystemRoles.Teacher)); Assert.True(await userManager.IsInRoleAsync(user, SystemRoles.Teacher));
await db.Entry(teacher).ReloadAsync(); db.ChangeTracker.Clear();
Assert.Equal(user.Id, teacher.UserId); var linkedTeacher = await db.Teachers.SingleAsync(x => x.Id == teacher.Id);
Assert.Equal(user.Id, linkedTeacher.UserId);
} }
private sealed class TestDataScope(Guid collegeId) : ICurrentUserDataScope private sealed class TestDataScope(Guid collegeId) : ICurrentUserDataScope
+317
View File
@@ -15,11 +15,13 @@
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"pinia": "^4.0.2", "pinia": "^4.0.2",
"qrcode": "1.5.4",
"vue": "^3.5.39", "vue": "^3.5.39",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.13.2", "@types/node": "^24.13.2",
"@types/qrcode": "1.5.6",
"@vitejs/plugin-vue": "^6.0.7", "@vitejs/plugin-vue": "^6.0.7",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2", "typescript": "~6.0.2",
@@ -581,6 +583,16 @@
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/raf": { "node_modules/@types/raf": {
"version": "3.4.3", "version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
@@ -880,6 +892,30 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/async-validator": { "node_modules/async-validator": {
"version": "4.2.5", "version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
@@ -936,6 +972,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/canvg": { "node_modules/canvg": {
"version": "3.0.11", "version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
@@ -972,6 +1017,35 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -1041,6 +1115,15 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/delayed-stream": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1060,6 +1143,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.4.12", "version": "3.4.12",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
@@ -1126,6 +1215,12 @@
"vue": "^3.3.7" "vue": "^3.3.7"
} }
}, },
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/entities": { "node_modules/entities": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@@ -1244,6 +1339,19 @@
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.16.0", "version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
@@ -1304,6 +1412,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -1431,6 +1548,15 @@
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "9.0.1", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
@@ -1746,6 +1872,18 @@
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.18.1", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -1903,6 +2041,42 @@
"node": ">=12.20.0" "node": ">=12.20.0"
} }
}, },
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pako": { "node_modules/pako": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
@@ -1926,6 +2100,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pathe": { "node_modules/pathe": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -2003,6 +2186,15 @@
"pathe": "^2.0.3" "pathe": "^2.0.3"
} }
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.22", "version": "8.5.22",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
@@ -2040,6 +2232,23 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/quansync": { "node_modules/quansync": {
"version": "0.2.11", "version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@@ -2088,6 +2297,21 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/rgbcolor": { "node_modules/rgbcolor": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
@@ -2139,6 +2363,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2158,6 +2388,32 @@
"node": ">=0.1.14" "node": ">=0.1.14"
} }
}, },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-literal": { "node_modules/strip-literal": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
@@ -2599,6 +2855,67 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/zrender": { "node_modules/zrender": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
+2
View File
@@ -16,11 +16,13 @@
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"pinia": "^4.0.2", "pinia": "^4.0.2",
"qrcode": "1.5.4",
"vue": "^3.5.39", "vue": "^3.5.39",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.13.2", "@types/node": "^24.13.2",
"@types/qrcode": "1.5.6",
"@vitejs/plugin-vue": "^6.0.7", "@vitejs/plugin-vue": "^6.0.7",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2", "typescript": "~6.0.2",
+154
View File
@@ -182,6 +182,160 @@ button { cursor: pointer; }
.table-toolbar .el-input { width: min(340px, 60vw); } .table-toolbar .el-input { width: min(340px, 60vw); }
.table-toolbar > span { margin-left: auto; color: var(--muted); font-size: 11px; } .table-toolbar > span { margin-left: auto; color: var(--muted); font-size: 11px; }
.data-table { min-height: 360px; } .data-table { min-height: 360px; }
.el-select-dropdown__item.academic-term-option {
transition: color 160ms ease, opacity 160ms ease, background-color 160ms ease;
}
.el-select-dropdown__item.academic-term-option--current {
color: #17694b;
font-weight: 700;
}
.el-select-dropdown__item.academic-term-option--historical {
color: #6f7d8c;
opacity: .76;
}
.el-select-dropdown__item.academic-term-option--archived {
color: #98a2ad;
opacity: .62;
}
.el-select-dropdown__item.academic-term-option--historical.is-hovering,
.el-select-dropdown__item.academic-term-option--archived.is-hovering {
opacity: 1;
}
.academic-term-row--historical > .el-table__cell {
color: #73808e;
opacity: .78;
}
.academic-term-row--archived > .el-table__cell {
color: #8e98a3;
background: #f8fafc;
opacity: .66;
}
.academic-term-row--historical:hover > .el-table__cell,
.academic-term-row--archived:hover > .el-table__cell {
opacity: .94;
}
.historical-record {
opacity: .68;
filter: saturate(.72);
}
.historical-record:hover,
.historical-record.active {
opacity: 1;
filter: none;
}
.archived-record {
opacity: .54;
filter: grayscale(.18) saturate(.58);
}
.archived-record:hover,
.archived-record.active {
opacity: .92;
}
.term-policy-note {
margin: 0 0 14px;
}
.field-help {
margin-left: 10px;
color: #8994a1;
font-size: 12px;
}
.term-mobile-list {
display: none;
}
@media (max-width: 720px) {
.term-desktop-table {
display: none;
}
.term-mobile-list {
display: grid;
gap: 10px;
}
.term-mobile-list article {
border: 1px solid #e5e9ef;
border-radius: 12px;
background: #fff;
padding: 14px;
}
.term-mobile-list article.academic-term-row--historical {
color: #73808e;
opacity: .78;
}
.term-mobile-list article.academic-term-row--archived {
color: #8e98a3;
background: #f8fafc;
opacity: .66;
}
.term-mobile-list article:focus-within,
.term-mobile-list article:hover {
opacity: 1;
}
.term-mobile-list header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.term-mobile-list header span:first-child {
color: #258779;
font-size: 12px;
font-weight: 700;
letter-spacing: .06em;
}
.term-mobile-list h3 {
margin: 5px 0 3px;
color: inherit;
font-size: 15px;
line-height: 1.45;
}
.term-mobile-list p {
margin: 0;
font-size: 12px;
}
.term-history-label {
white-space: nowrap;
color: #7c8895;
font-size: 12px;
}
.term-mobile-list footer {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #edf0f4;
}
.term-mobile-list footer .el-button {
margin-left: 0;
}
}
.el-table th.el-table__cell { color: #596274; background: #fafbfc; font-size: 12px; font-weight: 650; } .el-table th.el-table__cell { color: #596274; background: #fafbfc; font-size: 12px; font-weight: 650; }
.el-table .cell { font-size: 12px; } .el-table .cell { font-size: 12px; }
.table-status { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--teal); } .table-status { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--teal); }
+41
View File
@@ -0,0 +1,41 @@
export interface AcademicTermLike {
id: string
name: string
isCurrent?: boolean
isArchived?: boolean
isEnabled?: boolean
hasPublishedTimetable?: boolean
}
export function academicTermLabel(term: AcademicTermLike): string {
if (term.isCurrent) return `${term.name} · 当前`
if (term.isArchived) return `${term.name} · 已归档`
return `${term.name} · 历史`
}
export function academicTermOptionClass(term: AcademicTermLike): string {
if (term.isCurrent) return 'academic-term-option academic-term-option--current'
if (term.isArchived) return 'academic-term-option academic-term-option--archived'
return 'academic-term-option academic-term-option--historical'
}
export function defaultAcademicTermId(
terms: AcademicTermLike[],
requirePublishedTimetable = false,
): string | undefined {
const usable = requirePublishedTimetable
? terms.filter((term) => term.hasPublishedTimetable)
: terms
return usable.find((term) => term.isCurrent)?.id
?? usable.find((term) => !term.isArchived)?.id
?? usable[0]?.id
}
export function academicTermRowClass(term: {
isCurrent?: boolean
isArchived?: boolean
}): string {
if (term.isCurrent) return 'academic-term-row academic-term-row--current'
if (term.isArchived) return 'academic-term-row academic-term-row--archived'
return 'academic-term-row academic-term-row--historical'
}
+149 -7
View File
@@ -5,9 +5,19 @@ import { useRoute } from 'vue-router'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile, importExcel } from '../api/excel' import { downloadApiFile, importExcel } from '../api/excel'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermRowClass } from '../utils/academicTerms'
type Kind = 'campuses' | 'colleges' | 'majors' | 'classes' | 'terms' | 'buildings' | 'classrooms' | 'course-categories' type Kind = 'campuses' | 'colleges' | 'majors' | 'classes' | 'terms' | 'buildings' | 'classrooms' | 'course-categories'
interface Row { id: string; code: string; name: string; isEnabled: boolean; [key: string]: unknown } interface Row {
id: string
code: string
name: string
isEnabled: boolean
academicYear?: string
isCurrent?: boolean
isArchived?: boolean
[key: string]: unknown
}
const allTabs: { key: Kind; label: string; hint: string }[] = [ const allTabs: { key: Kind; label: string; hint: string }[] = [
{ key: 'campuses', label: '校区', hint: '学校的物理校区' }, { key: 'campuses', label: '校区', hint: '学校的物理校区' },
@@ -210,6 +220,47 @@ async function remove(row: any) {
} }
} }
function termTableRowClass({ row }: { row: any }) {
return active.value === 'terms' ? academicTermRowClass(row) : ''
}
async function setCurrentTerm(row: any) {
try {
await ElMessageBox.confirm(
`将“${row.name}”切换为当前学期?其他学期会自动转为历史显示,但不会自动归档,也不会冻结成绩和补考。`,
'切换当前学期',
{ type: 'warning', confirmButtonText: '切换为当前', cancelButtonText: '取消' },
)
await http.post(`/base-data/terms/${row.id}/set-current`)
ElMessage.success(`当前学期已切换为“${row.name}`)
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function toggleTermArchive(row: any) {
const restoring = row.isArchived
try {
await ElMessageBox.confirm(
restoring
? `撤销“${row.name}”的归档状态?撤销后仍作为历史学期显示,可再次设为当前。`
: `归档“${row.name}”?归档只降低历史数据的显示优先级,不会冻结成绩更正或补考成绩录入。`,
restoring ? '撤销学期归档' : '归档学期',
{
type: restoring ? 'info' : 'warning',
confirmButtonText: restoring ? '撤销归档' : '确认归档',
cancelButtonText: '取消',
},
)
await http.post(`/base-data/terms/${row.id}/${restoring ? 'unarchive' : 'archive'}`)
ElMessage.success(restoring ? '已撤销归档' : '学期已归档')
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
onMounted(async () => { onMounted(async () => {
await Promise.all([load(), loadReferences()]) await Promise.all([load(), loadReferences()])
}) })
@@ -276,7 +327,65 @@ watch(
<span> {{ filteredRows.length }} </span> <span> {{ filteredRows.length }} </span>
</div> </div>
<el-table v-loading="loading" :data="filteredRows" class="data-table"> <el-alert
v-if="active === 'terms'"
class="term-policy-note"
type="info"
:closable="false"
show-icon
title="切换当前学期只改变默认工作学期;归档需手动执行,归档后仍可处理补考与成绩更正。"
/>
<div v-if="active === 'terms'" class="term-mobile-list">
<article
v-for="row in filteredRows"
:key="row.id"
:class="academicTermRowClass(row)"
>
<header>
<div>
<span>{{ row.code }}</span>
<h3>{{ row.name }}</h3>
<p>{{ row.academicYear }}</p>
</div>
<el-tag v-if="row.isCurrent" type="success">当前</el-tag>
<el-tag v-else-if="row.isArchived" type="info">已归档</el-tag>
<span v-else class="term-history-label">历史学期</span>
</header>
<footer v-if="canManage">
<el-button
v-if="!row.isCurrent && !row.isArchived"
size="small"
type="success"
plain
@click="setCurrentTerm(row)"
>设为当前</el-button>
<el-button
v-if="!row.isCurrent"
size="small"
:type="row.isArchived ? 'primary' : 'warning'"
plain
@click="toggleTermArchive(row)"
>{{ row.isArchived ? '撤销归档' : '归档' }}</el-button>
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button
v-if="!row.isCurrent"
size="small"
type="danger"
plain
@click="remove(row)"
>删除</el-button>
</footer>
</article>
</div>
<el-table
v-loading="loading"
:data="filteredRows"
:row-class-name="termTableRowClass"
class="data-table"
:class="{ 'term-desktop-table': active === 'terms' }"
>
<el-table-column prop="code" label="编码" min-width="130" /> <el-table-column prop="code" label="编码" min-width="130" />
<el-table-column prop="name" label="名称" min-width="180" /> <el-table-column prop="name" label="名称" min-width="180" />
<el-table-column v-if="active === 'colleges'" prop="campusName" label="所属校区" min-width="140" /> <el-table-column v-if="active === 'colleges'" prop="campusName" label="所属校区" min-width="140" />
@@ -288,8 +397,12 @@ watch(
<template #default="{ row }">{{ row.counselorName || '未分配' }}</template> <template #default="{ row }">{{ row.counselorName || '未分配' }}</template>
</el-table-column> </el-table-column>
<el-table-column v-if="active === 'terms'" prop="academicYear" label="学年" width="120" /> <el-table-column v-if="active === 'terms'" prop="academicYear" label="学年" width="120" />
<el-table-column v-if="active === 'terms'" label="当前学期" width="100"> <el-table-column v-if="active === 'terms'" label="运行状态" width="120">
<template #default="{ row }"><el-tag v-if="row.isCurrent" type="success">当前</el-tag><span v-else></span></template> <template #default="{ row }">
<el-tag v-if="row.isCurrent" type="success">当前</el-tag>
<el-tag v-else-if="row.isArchived" type="info">已归档</el-tag>
<span v-else>历史学期</span>
</template>
</el-table-column> </el-table-column>
<el-table-column v-if="active === 'buildings'" prop="campusName" label="所属校区" min-width="140" /> <el-table-column v-if="active === 'buildings'" prop="campusName" label="所属校区" min-width="140" />
<el-table-column v-if="active === 'classrooms'" prop="buildingName" label="教学楼" min-width="130" /> <el-table-column v-if="active === 'classrooms'" prop="buildingName" label="教学楼" min-width="130" />
@@ -299,10 +412,34 @@ watch(
<span class="table-status" :class="{ off: !row.isEnabled }">{{ row.isEnabled ? '启用' : '停用' }}</span> <span class="table-status" :class="{ off: !row.isEnabled }">{{ row.isEnabled ? '启用' : '停用' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column v-if="canManage" label="操作" width="150" fixed="right"> <el-table-column
v-if="canManage"
label="操作"
:width="active === 'terms' ? 280 : 150"
fixed="right"
>
<template #default="{ row }"> <template #default="{ row }">
<template v-if="active === 'terms'">
<el-button
v-if="!row.isCurrent && !row.isArchived"
link
type="success"
@click="setCurrentTerm(row)"
>设为当前</el-button>
<el-button
v-if="!row.isCurrent"
link
:type="row.isArchived ? 'primary' : 'warning'"
@click="toggleTermArchive(row)"
>{{ row.isArchived ? '撤销归档' : '归档' }}</el-button>
</template>
<el-button link type="primary" @click="openEdit(row)">编辑</el-button> <el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button> <el-button
v-if="active !== 'terms' || !row.isCurrent"
link
type="danger"
@click="remove(row)"
>删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
<template #empty><el-empty description="暂无数据,点击右上角开始新增" /></template> <template #empty><el-empty description="暂无数据,点击右上角开始新增" /></template>
@@ -366,7 +503,12 @@ watch(
<el-form-item label="开始日期"><el-date-picker v-model="form.startDate" value-format="YYYY-MM-DD" /></el-form-item> <el-form-item label="开始日期"><el-date-picker v-model="form.startDate" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="结束日期"><el-date-picker v-model="form.endDate" value-format="YYYY-MM-DD" /></el-form-item> <el-form-item label="结束日期"><el-date-picker v-model="form.endDate" value-format="YYYY-MM-DD" /></el-form-item>
</div> </div>
<el-form-item><el-checkbox v-model="form.isCurrent">设为当前学期</el-checkbox></el-form-item> <el-form-item>
<el-checkbox v-model="form.isCurrent" :disabled="form.isArchived">
设为当前学期
</el-checkbox>
<span v-if="form.isArchived" class="field-help">已归档学期需先撤销归档</span>
</el-form-item>
</template> </template>
<el-form-item v-if="active === 'classrooms'" label="所属教学楼" required> <el-form-item v-if="active === 'classrooms'" label="所属教学楼" required>
<el-select v-model="form.buildingId"> <el-select v-model="form.buildingId">
+3 -2
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { Bell, Check, Plus, Refresh } from '@element-plus/icons-vue' import { Bell, Check, Plus, Refresh } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore() const auth = useAuthStore()
const isManager = computed(() => const isManager = computed(() =>
@@ -172,7 +173,7 @@ onMounted(async () => {
]) ])
terms.value = termRes.data terms.value = termRes.data
tasks.value = taskRes.data.items tasks.value = taskRes.data.items
termId.value = terms.value.find(t => t.isCurrent)?.id termId.value = defaultAcademicTermId(terms.value)
} catch (_) {} } catch (_) {}
await load() await load()
}) })
@@ -204,7 +205,7 @@ function showCancel(type: string) { return type === 'Cancel' }
<section class="adj-toolbar"> <section class="adj-toolbar">
<el-select v-model="termId" clearable placeholder="全部学期" @change="load"> <el-select v-model="termId" clearable placeholder="全部学期" @change="load">
<el-option v-for="t in terms" :key="t.id" :label="t.name" :value="t.id" /> <el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" />
</el-select> </el-select>
<el-segmented v-model="tab" :options="[ <el-segmented v-model="tab" :options="[
...(isTeacher ? [{ label: '我的申请', value: 'mine' }] : []), ...(isTeacher ? [{ label: '我的申请', value: 'mine' }] : []),
+10 -4
View File
@@ -12,6 +12,7 @@ import {
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore() const auth = useAuthStore()
const managerRoles = ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'] const managerRoles = ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin']
@@ -309,6 +310,8 @@ async function loadRounds(keepSelection = true) {
rounds.value = (await http.get('/course-selections/rounds')).data rounds.value = (await http.get('/course-selections/rounds')).data
const previousId = keepSelection ? selectedRound.value?.id : undefined const previousId = keepSelection ? selectedRound.value?.id : undefined
const preferred = rounds.value.find((item) => item.id === previousId) const preferred = rounds.value.find((item) => item.id === previousId)
?? rounds.value.find((item) => item.isAvailableNow && item.termIsCurrent)
?? rounds.value.find((item) => item.termIsCurrent)
?? rounds.value.find((item) => item.isAvailableNow) ?? rounds.value.find((item) => item.isAvailableNow)
?? rounds.value[0] ?? rounds.value[0]
if (preferred) await selectRound(preferred) if (preferred) await selectRound(preferred)
@@ -362,12 +365,11 @@ async function loadTasks(academicTermId: string) {
function openRound(round?: any) { function openRound(round?: any) {
editingRoundId.value = round?.id ?? '' editingRoundId.value = round?.id ?? ''
const currentTerm = terms.value.find((item) => item.isCurrent)
const now = new Date() const now = new Date()
const ends = new Date(now.getTime() + 7 * 86400000) const ends = new Date(now.getTime() + 7 * 86400000)
const withdrawal = new Date(now.getTime() + 14 * 86400000) const withdrawal = new Date(now.getTime() + 14 * 86400000)
Object.assign(roundForm, { Object.assign(roundForm, {
academicTermId: round?.academicTermId ?? currentTerm?.id, academicTermId: round?.academicTermId ?? defaultAcademicTermId(terms.value),
name: round?.name ?? '', name: round?.name ?? '',
startsAt: round ? toPickerValue(round.startsAt) : toPickerValue(now.toISOString()), startsAt: round ? toPickerValue(round.startsAt) : toPickerValue(now.toISOString()),
endsAt: round ? toPickerValue(round.endsAt) : toPickerValue(ends.toISOString()), endsAt: round ? toPickerValue(round.endsAt) : toPickerValue(ends.toISOString()),
@@ -719,7 +721,11 @@ onMounted(async () => {
v-for="round in rounds" v-for="round in rounds"
:key="round.id" :key="round.id"
type="button" type="button"
:class="{ active: selectedRound?.id === round.id }" :class="{
active: selectedRound?.id === round.id,
'historical-record': !round.termIsCurrent && !round.termIsArchived,
'archived-record': round.termIsArchived,
}"
@click="selectRound(round)" @click="selectRound(round)"
> >
<span>{{ round.termName }}</span> <span>{{ round.termName }}</span>
@@ -1056,7 +1062,7 @@ onMounted(async () => {
<div class="form-grid"> <div class="form-grid">
<el-form-item label="开课学期" required> <el-form-item label="开课学期" required>
<el-select v-model="roundForm.academicTermId"> <el-select v-model="roundForm.academicTermId">
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" /> <el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="批次名称" required> <el-form-item label="批次名称" required>
+11 -3
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { Delete, Edit, Lock, Plus, Unlock } from '@element-plus/icons-vue' import { Delete, Edit, Lock, Plus, Unlock } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, academicTermRowClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore() const auth = useAuthStore()
const roles = computed(() => auth.user?.roles ?? []) const roles = computed(() => auth.user?.roles ?? [])
@@ -31,7 +32,7 @@ function addDim() { setupForm.dimensions.push({ name: '', maxScore: 10 }) }
function removeDim(i: number) { setupForm.dimensions.splice(i, 1) } function removeDim(i: number) { setupForm.dimensions.splice(i, 1) }
function resetSetupForm() { function resetSetupForm() {
setupForm.academicTermId = undefined setupForm.academicTermId = defaultAcademicTermId(terms.value)
setupForm.name = '' setupForm.name = ''
setupForm.startsAt = '' setupForm.startsAt = ''
setupForm.endsAt = '' setupForm.endsAt = ''
@@ -43,6 +44,13 @@ function resetSetupForm() {
] ]
} }
function setupRowClass({ row }: { row: any }) {
return academicTermRowClass({
isCurrent: row.termIsCurrent,
isArchived: row.termIsArchived,
})
}
async function loadSetups() { async function loadSetups() {
setupLoading.value = true setupLoading.value = true
try { setups.value = (await http.get('/evaluations/setups')).data } try { setups.value = (await http.get('/evaluations/setups')).data }
@@ -207,7 +215,7 @@ onMounted(async () => {
<div style="display:flex;justify-content:flex-end;margin-bottom:12px"> <div style="display:flex;justify-content:flex-end;margin-bottom:12px">
<el-button v-if="isAdmin" type="primary" :icon="Plus" @click="openCreate">新建方案</el-button> <el-button v-if="isAdmin" type="primary" :icon="Plus" @click="openCreate">新建方案</el-button>
</div> </div>
<el-table :data="setups" size="small" empty-text="暂无评教方案"> <el-table :data="setups" :row-class-name="setupRowClass" size="small" empty-text="暂无评教方案">
<el-table-column prop="name" label="方案名称" min-width="200" /> <el-table-column prop="name" label="方案名称" min-width="200" />
<el-table-column prop="termName" label="学期" width="140" /> <el-table-column prop="termName" label="学期" width="140" />
<el-table-column label="状态" width="90"> <el-table-column label="状态" width="90">
@@ -273,7 +281,7 @@ onMounted(async () => {
<div class="form-grid"> <div class="form-grid">
<el-form-item label="学期"> <el-form-item label="学期">
<el-select v-model="setupForm.academicTermId" placeholder="选择学期" :disabled="!!editingSetup"> <el-select v-model="setupForm.academicTermId" placeholder="选择学期" :disabled="!!editingSetup">
<el-option v-for="t in terms" :key="t.id" :label="t.name" :value="t.id" /> <el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="方案名称"> <el-form-item label="方案名称">
+7 -4
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { Plus, Promotion, Refresh, UserFilled, Setting } from '@element-plus/icons-vue' import { Plus, Promotion, Refresh, UserFilled, Setting } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore() const auth = useAuthStore()
const isManager = computed(() => const isManager = computed(() =>
@@ -53,7 +54,9 @@ async function load() {
return return
} }
plans.value = (await http.get('/exams/plans')).data plans.value = (await http.get('/exams/plans')).data
const plan = plans.value.find((x) => x.id === selected.value?.id) ?? plans.value[0] const plan = plans.value.find((x) => x.id === selected.value?.id)
?? plans.value.find((x) => x.termIsCurrent)
?? plans.value[0]
if (plan) await selectPlan(plan.id) if (plan) await selectPlan(plan.id)
} catch (error) { ElMessage.error(apiErrorMessage(error)) } } catch (error) { ElMessage.error(apiErrorMessage(error)) }
finally { loading.value = false } finally { loading.value = false }
@@ -63,7 +66,7 @@ async function selectPlan(id: string) {
} }
function openPlan() { function openPlan() {
Object.assign(planForm, { Object.assign(planForm, {
academicTermId: terms.value.find((x) => x.isCurrent)?.id, academicTermId: defaultAcademicTermId(terms.value),
name: '', notes: '', name: '', notes: '',
}) })
planDialog.value = true planDialog.value = true
@@ -196,7 +199,7 @@ onMounted(async () => {
<template v-if="isManager"> <template v-if="isManager">
<section class="exam-plan-strip"> <section class="exam-plan-strip">
<button v-for="plan in plans" :key="plan.id" :class="{ active: selected?.id === plan.id }" @click="selectPlan(plan.id)"> <button v-for="plan in plans" :key="plan.id" :class="{ active: selected?.id === plan.id, 'historical-record': !plan.termIsCurrent && !plan.termIsArchived, 'archived-record': plan.termIsArchived }" @click="selectPlan(plan.id)">
<span>{{ plan.termName }}</span><b>{{ plan.name }}</b> <span>{{ plan.termName }}</span><b>{{ plan.name }}</b>
<small>{{ plan.sessionCount }} 个场次</small><i>{{ statusLabels[plan.status] }}</i> <small>{{ plan.sessionCount }} 个场次</small><i>{{ statusLabels[plan.status] }}</i>
</button> </button>
@@ -271,7 +274,7 @@ onMounted(async () => {
<el-form label-position="top"> <el-form label-position="top">
<el-form-item label="学期"> <el-form-item label="学期">
<el-select v-model="planForm.academicTermId"> <el-select v-model="planForm.academicTermId">
<el-option v-for="x in terms" :key="x.id" :label="x.name" :value="x.id" /> <el-option v-for="x in terms" :key="x.id" :label="academicTermLabel(x)" :value="x.id" :class="academicTermOptionClass(x)" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="计划名称"><el-input v-model="planForm.name" maxlength="120" /></el-form-item> <el-form-item label="计划名称"><el-input v-model="planForm.name" maxlength="120" /></el-form-item>
+4 -1
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { Location, Refresh, Search } from '@element-plus/icons-vue' import { Location, Refresh, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { academicTermLabel, academicTermOptionClass } from '../utils/academicTerms'
interface TermOption { interface TermOption {
id: string id: string
@@ -9,6 +10,7 @@ interface TermOption {
startDate: string startDate: string
endDate: string endDate: string
isCurrent: boolean isCurrent: boolean
isArchived: boolean
hasPublishedTimetable: boolean hasPublishedTimetable: boolean
} }
@@ -205,8 +207,9 @@ onMounted(async () => {
<el-option <el-option
v-for="term in terms" v-for="term in terms"
:key="term.id" :key="term.id"
:label="term.name" :label="academicTermLabel(term)"
:value="term.id" :value="term.id"
:class="academicTermOptionClass(term)"
:disabled="!term.hasPublishedTimetable" :disabled="!term.hasPublishedTimetable"
> >
<span>{{ term.name }}</span> <span>{{ term.name }}</span>
+4 -3
View File
@@ -14,6 +14,7 @@ import {
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile, importExcel } from '../api/excel' import { downloadApiFile, importExcel } from '../api/excel'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore() const auth = useAuthStore()
const isStudent = computed(() => auth.user?.roles.includes('Student') && const isStudent = computed(() => auth.user?.roles.includes('Student') &&
@@ -351,7 +352,7 @@ function calcPreviewTotal(record: any): number | null {
onMounted(async () => { onMounted(async () => {
try { try {
terms.value = (await http.get('/base-data/terms')).data terms.value = (await http.get('/base-data/terms')).data
termId.value = terms.value.find((item) => item.isCurrent)?.id termId.value = defaultAcademicTermId(terms.value)
await load() await load()
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
@@ -381,7 +382,7 @@ onMounted(async () => {
<label> <label>
<span>查看学期</span> <span>查看学期</span>
<el-select v-model="termId" clearable placeholder="全部学期" @change="load"> <el-select v-model="termId" clearable placeholder="全部学期" @change="load">
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" /> <el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
</el-select> </el-select>
</label> </label>
</section> </section>
@@ -421,7 +422,7 @@ onMounted(async () => {
<template v-else> <template v-else>
<section class="grade-toolbar"> <section class="grade-toolbar">
<el-select v-model="termId" clearable placeholder="全部学期" @change="load"> <el-select v-model="termId" clearable placeholder="全部学期" @change="load">
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" /> <el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
</el-select> </el-select>
<el-select v-model="status" clearable placeholder="全部状态" @change="load"> <el-select v-model="status" clearable placeholder="全部状态" @change="load">
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" /> <el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
+7 -4
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { Plus, Promotion, Refresh, UserFilled, Setting, Search, MagicStick } from '@element-plus/icons-vue' import { Plus, Promotion, Refresh, UserFilled, Setting, Search, MagicStick } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore() const auth = useAuthStore()
const isManager = computed(() => const isManager = computed(() =>
@@ -66,7 +67,9 @@ async function load() {
return return
} }
plans.value = (await http.get('/makeup-exams/plans')).data plans.value = (await http.get('/makeup-exams/plans')).data
const plan = plans.value.find((x) => x.id === selected.value?.id) ?? plans.value[0] const plan = plans.value.find((x) => x.id === selected.value?.id)
?? plans.value.find((x) => x.termIsCurrent)
?? plans.value[0]
if (plan) await selectPlan(plan.id) if (plan) await selectPlan(plan.id)
} catch (error) { ElMessage.error(apiErrorMessage(error)) } } catch (error) { ElMessage.error(apiErrorMessage(error)) }
finally { loading.value = false } finally { loading.value = false }
@@ -76,7 +79,7 @@ async function selectPlan(id: string) {
} }
function openPlan() { function openPlan() {
Object.assign(planForm, { Object.assign(planForm, {
academicTermId: terms.value.find((x) => x.isCurrent)?.id, academicTermId: defaultAcademicTermId(terms.value),
name: '', notes: '', name: '', notes: '',
}) })
planDialog.value = true planDialog.value = true
@@ -339,7 +342,7 @@ onMounted(async () => {
<template v-if="isManager"> <template v-if="isManager">
<section class="exam-plan-strip"> <section class="exam-plan-strip">
<button v-for="plan in plans" :key="plan.id" :class="{ active: selected?.id === plan.id }" @click="selectPlan(plan.id)"> <button v-for="plan in plans" :key="plan.id" :class="{ active: selected?.id === plan.id, 'historical-record': !plan.termIsCurrent && !plan.termIsArchived, 'archived-record': plan.termIsArchived }" @click="selectPlan(plan.id)">
<span>{{ plan.termName }}</span><b>{{ plan.name }}</b> <span>{{ plan.termName }}</span><b>{{ plan.name }}</b>
<small>{{ plan.sessionCount }} 个场次</small><i>{{ statusLabels[plan.status] }}</i> <small>{{ plan.sessionCount }} 个场次</small><i>{{ statusLabels[plan.status] }}</i>
</button> </button>
@@ -478,7 +481,7 @@ onMounted(async () => {
<el-form label-position="top"> <el-form label-position="top">
<el-form-item label="学期"> <el-form-item label="学期">
<el-select v-model="planForm.academicTermId"> <el-select v-model="planForm.academicTermId">
<el-option v-for="x in terms" :key="x.id" :label="x.name" :value="x.id" /> <el-option v-for="x in terms" :key="x.id" :label="academicTermLabel(x)" :value="x.id" :class="academicTermOptionClass(x)" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="计划名称"><el-input v-model="planForm.name" maxlength="120" /></el-form-item> <el-form-item label="计划名称"><el-input v-model="planForm.name" maxlength="120" /></el-form-item>
+4 -3
View File
@@ -2,6 +2,7 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { CopyDocument, EditPen, Plus, Promotion, Refresh, Search, Setting } from '@element-plus/icons-vue' import { CopyDocument, EditPen, Plus, Promotion, Refresh, Search, Setting } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const plans = ref<any[]>([]) const plans = ref<any[]>([])
const selected = ref<any | null>(null) const selected = ref<any | null>(null)
@@ -749,7 +750,7 @@ onMounted(async () => {
classrooms.value = classroomRes.data.filter((item: any) => item.isEnabled) classrooms.value = classroomRes.data.filter((item: any) => item.isEnabled)
campuses.value = campusRes.data.filter((item: any) => item.isEnabled) campuses.value = campusRes.data.filter((item: any) => item.isEnabled)
buildings.value = buildingRes.data.filter((item: any) => item.isEnabled) buildings.value = buildingRes.data.filter((item: any) => item.isEnabled)
termId.value = terms.value.find((item) => item.isCurrent)?.id termId.value = defaultAcademicTermId(terms.value)
await Promise.all([loadPlans(false), loadSchedulingSettings()]) await Promise.all([loadPlans(false), loadSchedulingSettings()])
}) })
@@ -775,7 +776,7 @@ onBeforeUnmount(() => {
<section class="schedule-toolbar"> <section class="schedule-toolbar">
<el-select v-model="termId" placeholder="选择学期" @change="changeTerm"> <el-select v-model="termId" placeholder="选择学期" @change="changeTerm">
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /> <el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" />
</el-select> </el-select>
<div class="schedule-version-strip"> <div class="schedule-version-strip">
<button <button
@@ -946,7 +947,7 @@ onBeforeUnmount(() => {
<el-dialog v-model="planDialog" :title="editingPlanId ? '编辑排课版本' : '新建排课版本'" width="560px"> <el-dialog v-model="planDialog" :title="editingPlanId ? '编辑排课版本' : '新建排课版本'" width="560px">
<el-form label-position="top"> <el-form label-position="top">
<el-form-item label="学期" required><el-select v-model="planForm.academicTermId"><el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item> <el-form-item label="学期" required><el-select v-model="planForm.academicTermId"><el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" /></el-select></el-form-item>
<el-form-item label="版本名称" required><el-input v-model="planForm.name" placeholder="如:第一轮正式课表" /></el-form-item> <el-form-item label="版本名称" required><el-input v-model="planForm.name" placeholder="如:第一轮正式课表" /></el-form-item>
<el-form-item label="版本号" required><el-input v-model="planForm.version" placeholder="如 V1" /></el-form-item> <el-form-item label="版本号" required><el-input v-model="planForm.version" placeholder="如 V1" /></el-form-item>
<el-form-item label="备注"><el-input v-model="planForm.notes" type="textarea" :rows="2" /></el-form-item> <el-form-item label="备注"><el-input v-model="planForm.notes" type="textarea" :rows="2" /></el-form-item>
+3 -5
View File
@@ -7,6 +7,7 @@ import { GridComponent, LegendComponent, TitleComponent, TooltipComponent } from
import { CanvasRenderer } from 'echarts/renderers' import { CanvasRenderer } from 'echarts/renderers'
import { downloadApiFile } from '../api/excel' import { downloadApiFile } from '../api/excel'
import http from '../api/http' import http from '../api/http'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
echarts.use([BarChart, LineChart, PieChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent, CanvasRenderer]) echarts.use([BarChart, LineChart, PieChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent, CanvasRenderer])
@@ -367,10 +368,7 @@ onMounted(async () => {
courseCategories.value = catRes.data courseCategories.value = catRes.data
classroomBuildings.value = bRes.data classroomBuildings.value = bRes.data
classroomCampuses.value = campRes.data classroomCampuses.value = campRes.data
if (terms.value.length > 0) { globalTermId.value = defaultAcademicTermId(terms.value) ?? ''
const cur = terms.value.find((t: any) => t.isCurrent) ?? terms.value[terms.value.length - 1]
globalTermId.value = cur.id
}
loadStudentStats() loadStudentStats()
}) })
</script> </script>
@@ -385,7 +383,7 @@ onMounted(async () => {
</div> </div>
<div class="page-actions"> <div class="page-actions">
<el-select v-model="globalTermId" placeholder="选择学期" style="width:220px" clearable @change="() => { loadStudentStats(); loadGradeStats(); loadPassRateStats(); loadWorkloadStats(); loadClassroomStats() }"> <el-select v-model="globalTermId" placeholder="选择学期" style="width:220px" clearable @change="() => { loadStudentStats(); loadGradeStats(); loadPassRateStats(); loadWorkloadStats(); loadClassroomStats() }">
<el-option v-for="t in terms" :key="t.id" :label="t.name" :value="t.id" /> <el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" />
</el-select> </el-select>
</div> </div>
</section> </section>
+579 -36
View File
@@ -1,10 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { Refresh, Warning } from '@element-plus/icons-vue' import { useRoute, useRouter } from 'vue-router'
import { 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 router = useRouter()
const records = ref<any[]>([]) const records = ref<any[]>([])
const openActivities = ref<any[]>([])
const scannedActivity = ref<any>(null)
const loading = ref(false) const loading = ref(false)
const scanLoading = ref(false)
const checkInTargetId = ref('')
const now = ref(Date.now())
const appealDialog = ref(false) const appealDialog = ref(false)
const appealTarget = ref<any>(null) const appealTarget = ref<any>(null)
const appealReason = ref('') const appealReason = ref('')
@@ -18,14 +26,165 @@ 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: '已驳回',
} }
const courseGroups = computed(() => {
const groups = new Map<string, any>()
records.value.forEach((record: any) => {
const key = record.teachingTaskId ?? `${record.courseCode}-${record.taskNumber}`
let group = groups.get(key)
if (!group) {
group = {
key,
courseCode: record.courseCode,
courseName: record.courseName,
taskNumber: record.taskNumber,
teacherNames: [...record.teacherNames],
records: [],
presentCount: 0,
absentCount: 0,
lateCount: 0,
leaveCount: 0,
excusedCount: 0,
requiredCount: 0,
attendedCount: 0,
attendanceRate: null,
latestAt: 0,
}
groups.set(key, group)
}
group.records.push(record)
group.latestAt = Math.max(group.latestAt, new Date(record.attendanceDate).getTime())
const countKey = `${record.status.charAt(0).toLowerCase()}${record.status.slice(1)}Count`
if (countKey in group) group[countKey] += 1
if (record.status !== 'Excused') group.requiredCount += 1
if (record.status === 'Present' || record.status === 'Late') group.attendedCount += 1
})
return [...groups.values()]
.map(group => ({
...group,
attendanceRate: group.requiredCount > 0
? Math.round(group.attendedCount * 1000 / group.requiredCount) / 10
: null,
}))
.sort((a, b) => b.latestAt - a.latestAt)
})
let clockTimer: number | undefined
async function load() { async function load() {
loading.value = true loading.value = true
try { records.value = (await http.get('/attendance/my-records')).data } try {
const [recordResponse, activityResponse] = await Promise.all([
http.get('/attendance/my-records'),
http.get('/attendance/open-check-ins'),
])
records.value = recordResponse.data
openActivities.value = activityResponse.data
const token = typeof route.query.token === 'string' ? route.query.token : ''
if (token) await loadScannedActivity(token)
else scannedActivity.value = null
}
catch (e) { ElMessage.error(apiErrorMessage(e)) } catch (e) { ElMessage.error(apiErrorMessage(e)) }
finally { loading.value = false } finally { loading.value = false }
} }
async function loadScannedActivity(token: string) {
scanLoading.value = true
try {
scannedActivity.value = (
await http.get('/attendance/check-in-info', { params: { token } })
).data
} catch (error: any) {
scannedActivity.value = null
if (error?.response?.status === 404) {
ElMessage.error('签到码无效,或你不在本次课程名单中。')
} else {
ElMessage.error(apiErrorMessage(error))
}
} finally {
scanLoading.value = false
}
}
function serverUtcTime(value: string | null | undefined) {
if (!value) return Number.NaN
const normalized = /(?:Z|[+-]\d{2}:\d{2})$/i.test(value) ? value : `${value}Z`
return new Date(normalized).getTime()
}
function formatServerTime(value: string) {
return new Date(serverUtcTime(value)).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})
}
function remainingLabel(activity: any) {
if (!activity?.checkInEndsAt) return ''
const remaining = serverUtcTime(activity.checkInEndsAt) - now.value
if (remaining <= 0) return '签到已结束'
const minutes = Math.floor(remaining / 60000)
const seconds = Math.floor((remaining % 60000) / 1000)
return `${minutes}${String(seconds).padStart(2, '0')}秒后结束`
}
async function confirmQrCheckIn() {
const token = typeof route.query.token === 'string' ? route.query.token : ''
if (!token || !scannedActivity.value) return
checkInTargetId.value = scannedActivity.value.sheetId
try {
const { data } = await http.post('/attendance/check-in', { token })
ElMessage.success(data.alreadyCheckedIn ? '你已完成本次签到' : '签到成功')
await router.replace({ path: route.path, query: {} })
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
checkInTargetId.value = ''
}
}
async function captureLocation() {
if (!navigator.geolocation) throw new Error('unsupported')
return await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
timeout: 12000,
maximumAge: 0,
})
})
}
async function locationCheckIn(activity: any) {
checkInTargetId.value = activity.sheetId
try {
const position = await captureLocation()
const { data } = await http.post('/attendance/check-in', {
attendanceSheetId: activity.sheetId,
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracyMeters: position.coords.accuracy,
})
ElMessage.success(data.alreadyCheckedIn
? '你已完成本次签到'
: `签到成功${data.checkInDistanceMeters === null
? ''
: `,距签到点约 ${Math.round(data.checkInDistanceMeters)}`}`)
await load()
} catch (error: any) {
if (!error?.response) {
const message = error?.code === 1
? '定位权限被拒绝,请在浏览器中允许本网站获取位置。'
: error?.code === 3
? '获取位置超时,请移到信号较好的位置后重试。'
: '无法获取当前位置,请检查手机定位服务后重试。'
ElMessage.error(message)
} else {
ElMessage.error(apiErrorMessage(error))
}
} finally {
checkInTargetId.value = ''
}
}
function openAppeal(record: any) { function openAppeal(record: any) {
appealTarget.value = record appealTarget.value = record
appealReason.value = '' appealReason.value = ''
@@ -49,7 +208,32 @@ function canAppeal(record: any) {
return record.appealStatus === 'None' || record.appealStatus === 'Rejected' return record.appealStatus === 'None' || record.appealStatus === 'Rejected'
} }
onMounted(load) function formatRate(value: number | null) {
return value === null ? '暂无' : `${value.toFixed(1)}%`
}
function rateTone(value: number | null) {
if (value === null) return 'neutral'
if (value < 80) return 'danger'
if (value < 90) return 'warning'
return 'good'
}
function recordDate(value: string) {
const date = new Date(value)
return {
month: `${date.getMonth() + 1}`,
day: String(date.getDate()).padStart(2, '0'),
}
}
onMounted(() => {
clockTimer = window.setInterval(() => { now.value = Date.now() }, 1000)
load()
})
onUnmounted(() => {
if (clockTimer) window.clearInterval(clockTimer)
})
</script> </script>
<template> <template>
@@ -58,39 +242,149 @@ onMounted(load)
<div> <div>
<span class="section-kicker">ATTENDANCE RECORD</span> <span class="section-kicker">ATTENDANCE RECORD</span>
<h2>我的考勤</h2> <h2>我的考勤</h2>
<p>查看所有已提交的考勤记录对记录有异议可以提交申诉辅导员将进行审核</p> <p>完成课堂扫码或定位签到并查看已提交的考勤记录</p>
</div> </div>
<el-button :icon="Refresh" @click="load">刷新</el-button> <el-button :icon="Refresh" @click="load">刷新</el-button>
</section> </section>
<section v-loading="loading" class="att-list"> <section
<article v-for="r in records" :key="`${r.attendanceSheetId}`" class="att-card"> v-if="route.query.token || openActivities.length"
<div class="att-info"> v-loading="loading || scanLoading"
<span>{{ r.courseCode }} · {{ r.taskNumber }}</span> class="check-in-board"
<h3>{{ r.courseName }}</h3> >
<p>{{ r.sheetName }} · {{ new Date(r.attendanceDate).toLocaleDateString('zh-CN') }} · {{ r.teacherNames.join('、') }}</p> <header>
<div>
<span>LIVE CHECK-IN</span>
<strong>待签到</strong>
</div> </div>
<div class="att-status"> <small>签到由服务器校验课程名单有效时间和位置范围</small>
<el-tag :type="statusColors[r.status]" size="small">{{ statusLabels[r.status] }}</el-tag> </header>
<span v-if="r.notes" class="att-note">{{ r.notes }}</span>
<article v-if="scannedActivity" class="scan-ticket">
<div class="ticket-mark"><el-icon><Check /></el-icon></div>
<div class="ticket-course">
<span>{{ scannedActivity.courseCode }} · {{ scannedActivity.taskNumber }}</span>
<strong>{{ scannedActivity.courseName }}</strong>
<p>{{ scannedActivity.sheetName }} · 扫码签到</p>
</div> </div>
<div class="att-appeal"> <div class="ticket-time">
<template v-if="r.appealStatus !== 'None'"> <Clock />
<el-tag size="small" :type="r.appealStatus === 'Pending' ? 'warning' : r.appealStatus === 'Approved' ? 'success' : 'danger'"> <b>{{ remainingLabel(scannedActivity) }}</b>
{{ appealStatusLabels[r.appealStatus] }} <small v-if="scannedActivity.checkInAt">
</el-tag> 已于 {{ formatServerTime(scannedActivity.checkInAt) }} 签到
<span v-if="r.appealReviewComment" class="att-note">{{ r.appealReviewComment }}</span> </small>
</template> <small v-else>请确认课程信息后完成签到</small>
</div>
<el-button
type="primary"
size="large"
:disabled="!scannedActivity.isOpen || Boolean(scannedActivity.checkInAt)"
:loading="checkInTargetId === scannedActivity.sheetId"
@click="confirmQrCheckIn"
>{{ scannedActivity.checkInAt ? '已签到' : scannedActivity.isOpen ? '确认签到' : '签到已结束' }}</el-button>
</article>
<div v-if="openActivities.length" class="location-list">
<article v-for="activity in openActivities" :key="activity.sheetId">
<div class="location-pin"><Location /></div>
<div>
<span>{{ activity.courseCode }} · {{ activity.taskNumber }}</span>
<strong>{{ activity.courseName }}</strong>
<p>{{ activity.sheetName }} · {{ activity.locationRadiusMeters }} 米范围内</p>
</div>
<div class="location-clock">
<b>{{ remainingLabel(activity) }}</b>
<small v-if="activity.checkInAt">
已于 {{ formatServerTime(activity.checkInAt) }} 签到
</small>
<small v-else>将获取一次当前位置用于本次签到</small>
</div>
<el-button <el-button
v-if="canAppeal(r) && (r.status === 'Absent' || r.status === 'Late')" type="primary"
size="small" plain
type="warning" :disabled="Boolean(activity.checkInAt)"
:icon="Warning" :loading="checkInTargetId === activity.sheetId"
@click="openAppeal(r)" @click="locationCheckIn(activity)"
>申诉</el-button> >{{ activity.checkInAt ? '已签到' : '定位并签到' }}</el-button>
</article>
</div>
</section>
<section class="record-head">
<div>
<strong>课程考勤档案</strong>
<span>按课程汇总出勤率仅统计教师已经提交的考勤结果</span>
</div>
<span>{{ courseGroups.length }} 门课程 · {{ records.length }} 次点名</span>
</section>
<section v-loading="loading" class="course-archive">
<article v-for="course in courseGroups" :key="course.key" class="course-record">
<header>
<div class="course-identity">
<span>{{ course.courseCode }} · {{ course.taskNumber }}</span>
<h3>{{ course.courseName }}</h3>
<small>{{ course.teacherNames.join('、') || '任课教师未登记' }}</small>
</div>
<div class="course-rate" :class="rateTone(course.attendanceRate)">
<span>课程出勤率</span>
<strong>{{ formatRate(course.attendanceRate) }}</strong>
<small>{{ course.attendedCount }} / {{ course.requiredCount }} 次到课</small>
</div>
</header>
<div class="course-summary">
<div><b>{{ course.records.length }}</b><span>点名次数</span></div>
<div class="present"><b>{{ course.presentCount }}</b><span>出勤</span></div>
<div class="late"><b>{{ course.lateCount }}</b><span>迟到</span></div>
<div class="absent"><b>{{ course.absentCount }}</b><span>缺勤</span></div>
<div><b>{{ course.leaveCount }}</b><span>请假</span></div>
<div><b>{{ course.excusedCount }}</b><span>免修</span></div>
</div>
<div class="session-list">
<div
v-for="r in course.records"
:key="r.attendanceSheetId"
class="session-row"
>
<div class="session-date" aria-hidden="true">
<span>{{ recordDate(r.attendanceDate).month }}</span>
<strong>{{ recordDate(r.attendanceDate).day }}</strong>
</div>
<div class="session-info">
<strong>{{ r.sheetName }}</strong>
<span v-if="r.notes">{{ r.notes }}</span>
<span v-else>本次点名没有备注</span>
</div>
<div class="session-result">
<el-tag :type="statusColors[r.status]" size="small">
{{ statusLabels[r.status] }}
</el-tag>
<template v-if="r.appealStatus !== 'None'">
<el-tag
size="small"
:type="r.appealStatus === 'Pending' ? 'warning' : r.appealStatus === 'Approved' ? 'success' : 'danger'"
>
{{ appealStatusLabels[r.appealStatus] }}
</el-tag>
<span v-if="r.appealReviewComment" class="review-comment">
{{ r.appealReviewComment }}
</span>
</template>
</div>
<el-button
v-if="canAppeal(r) && (r.status === 'Absent' || r.status === 'Late')"
size="small"
type="warning"
plain
:icon="Warning"
@click="openAppeal(r)"
>申诉</el-button>
</div>
</div> </div>
</article> </article>
<el-empty v-if="!records.length" description="暂无考勤记录" /> <el-empty v-if="!courseGroups.length" description="还没有已提交的课程考勤记录" />
</section> </section>
<el-dialog v-model="appealDialog" title="考勤申诉" width="500px"> <el-dialog v-model="appealDialog" title="考勤申诉" width="500px">
@@ -108,12 +402,261 @@ onMounted(load)
</template> </template>
<style scoped> <style scoped>
.att-list { display: grid; gap: 10px; } .check-in-board {
.att-card { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 18px; background: #fff; border: 1px solid #e4e7ed; border-radius: 8px; flex-wrap: wrap; } border: 1px solid #cdd9e5;
.att-info span { font-size: 11px; color: var(--muted); } background: #f6f9fc;
.att-info h3 { font-size: 14px; margin: 2px 0; } }
.att-info p { font-size: 12px; color: var(--muted); margin: 0; } .check-in-board > header {
.att-status { display: flex; align-items: center; gap: 8px; } min-height: 58px;
.att-appeal { display: flex; align-items: center; gap: 8px; } padding: 10px 16px;
.att-note { font-size: 11px; color: var(--muted); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
color: white;
background: #17395e;
}
.check-in-board > header > div { display: grid; }
.check-in-board > header span {
color: #6de0c0;
font-size: 9px;
font-weight: 800;
letter-spacing: .14em;
}
.check-in-board > header strong { font-size: 17px; }
.check-in-board > header small { font-size: 10px; opacity: .72; }
.scan-ticket {
margin: 14px;
padding: 16px;
display: grid;
grid-template-columns: 44px minmax(180px, 1fr) minmax(150px, .7fr) auto;
align-items: center;
gap: 14px;
border-left: 4px solid #2d8975;
background: white;
box-shadow: 0 4px 14px rgba(23, 43, 77, .07);
}
.ticket-mark {
width: 42px;
height: 42px;
display: grid;
place-items: center;
color: #247261;
font-size: 23px;
background: #e8f5ef;
}
.ticket-course,
.ticket-time { display: grid; gap: 2px; }
.ticket-course span,
.location-list article > div:nth-child(2) > span {
color: #176b87;
font: 700 10px/1.2 Consolas, monospace;
}
.ticket-course strong,
.location-list article strong { color: #172b4d; font-size: 15px; }
.ticket-course p,
.location-list article p { margin: 0; color: var(--muted); font-size: 10px; }
.ticket-time { grid-template-columns: 16px minmax(0, 1fr); }
.ticket-time svg { width: 14px; color: #176b87; }
.ticket-time b { color: #344054; font-size: 12px; }
.ticket-time small { grid-column: 2; color: var(--muted); font-size: 9px; }
.location-list { border-top: 1px solid #dce4ed; }
.location-list article {
padding: 13px 16px;
display: grid;
grid-template-columns: 38px minmax(180px, 1fr) minmax(160px, .7fr) auto;
align-items: center;
gap: 12px;
border-bottom: 1px solid #e3e9ef;
background: white;
}
.location-list article:last-child { border-bottom: none; }
.location-pin {
width: 34px;
height: 34px;
display: grid;
place-items: center;
color: #176b87;
background: #e9f3f5;
}
.location-pin svg { width: 19px; }
.location-list article > div:nth-child(2) { display: grid; gap: 2px; }
.location-clock { display: grid; gap: 2px; }
.location-clock b { color: #247261; font-size: 11px; }
.location-clock small { color: var(--muted); font-size: 9px; }
.record-head {
padding: 11px 15px;
display: flex;
align-items: center;
justify-content: space-between;
border: 1px solid var(--line);
background: #fbfcfd;
}
.record-head > div { display: grid; gap: 1px; }
.record-head strong { color: #172b4d; font-size: 14px; }
.record-head span { color: var(--muted); font-size: 10px; }
.course-archive { display: grid; gap: 14px; }
.course-record {
overflow: hidden;
border: 1px solid #d8e0e9;
background: white;
}
.course-record > header {
min-height: 104px;
display: grid;
grid-template-columns: minmax(0, 1fr) 176px;
border-bottom: 1px solid #dce4ed;
}
.course-identity {
padding: 18px 20px;
display: grid;
align-content: center;
gap: 2px;
}
.course-identity > span {
color: #176b87;
font: 700 10px/1.2 Consolas, monospace;
letter-spacing: .03em;
}
.course-identity h3 {
margin: 2px 0;
color: #172b4d;
font-size: 20px;
line-height: 1.25;
}
.course-identity small { color: var(--muted); font-size: 10px; }
.course-rate {
padding: 14px 18px;
display: grid;
align-content: center;
color: white;
background: #17395e;
}
.course-rate > span {
font-size: 9px;
font-weight: 800;
letter-spacing: .08em;
opacity: .75;
}
.course-rate strong {
margin: 3px 0;
font: 700 30px/1 "Arial Narrow", "Microsoft YaHei", sans-serif;
}
.course-rate small { font-size: 9px; opacity: .72; }
.course-rate.good { background: #245f57; }
.course-rate.warning { background: #8b5a18; }
.course-rate.danger { background: #8d403d; }
.course-rate.neutral { background: #526276; }
.course-summary {
display: grid;
grid-template-columns: repeat(6, 1fr);
border-bottom: 1px solid #e3e9ef;
background: #f8fafc;
}
.course-summary > div {
min-width: 0;
padding: 10px 12px;
display: grid;
gap: 2px;
border-right: 1px solid #e3e9ef;
}
.course-summary > div:last-child { border-right: none; }
.course-summary b {
color: #344054;
font: 700 18px/1.1 "Arial Narrow", "Microsoft YaHei", sans-serif;
}
.course-summary span { color: var(--muted); font-size: 9px; }
.course-summary .present b { color: #247261; }
.course-summary .late b { color: #a66716; }
.course-summary .absent b { color: #a9433e; }
.session-list { display: grid; }
.session-row {
min-height: 68px;
padding: 9px 14px;
display: grid;
grid-template-columns: 46px minmax(160px, 1fr) minmax(120px, auto) auto;
align-items: center;
gap: 12px;
border-bottom: 1px solid #edf0f4;
}
.session-row:last-child { border-bottom: none; }
.session-date {
width: 42px;
height: 46px;
display: grid;
align-content: center;
justify-items: center;
color: #17395e;
border: 1px solid #cbd7e3;
background: #f5f8fb;
}
.session-date span { font-size: 8px; font-weight: 700; }
.session-date strong {
font: 700 19px/1 "Arial Narrow", sans-serif;
font-variant-numeric: tabular-nums;
}
.session-info { min-width: 0; display: grid; gap: 3px; }
.session-info strong {
overflow: hidden;
color: #344054;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-info span,
.review-comment {
overflow: hidden;
color: var(--muted);
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-result {
min-width: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
flex-wrap: wrap;
}
.review-comment { max-width: 150px; }
@media (max-width: 720px) {
.check-in-board > header { align-items: flex-start; flex-direction: column; }
.scan-ticket,
.location-list article {
grid-template-columns: 38px minmax(0, 1fr);
}
.ticket-time,
.location-clock,
.scan-ticket > .el-button,
.location-list article > .el-button {
grid-column: 1 / -1;
}
.scan-ticket > .el-button,
.location-list article > .el-button { width: 100%; }
.ticket-time { grid-template-columns: 16px minmax(0, 1fr); }
.record-head { align-items: flex-start; flex-direction: column; gap: 5px; }
.record-head > span { text-align: left; }
.course-record > header { grid-template-columns: minmax(0, 1fr) 118px; }
.course-identity { padding: 15px 13px; }
.course-identity h3 { font-size: 17px; }
.course-rate { padding: 12px; }
.course-rate strong { font-size: 22px; }
.course-summary { grid-template-columns: repeat(3, 1fr); }
.course-summary > div:nth-child(3) { border-right: none; }
.course-summary > div:nth-child(-n + 3) { border-bottom: 1px solid #e3e9ef; }
.session-row {
grid-template-columns: 42px minmax(0, 1fr) auto;
gap: 9px;
}
.session-result {
grid-column: 2 / -1;
justify-content: flex-start;
}
.session-row > .el-button {
grid-column: 2 / -1;
width: 100%;
}
}
</style> </style>
+535 -10
View File
@@ -1,12 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Check, Download, Plus, Refresh, Search, Upload } from '@element-plus/icons-vue' import {
Check,
Clock,
CopyDocument,
Download,
Grid,
Location,
Plus,
Refresh,
Search,
Upload,
} from '@element-plus/icons-vue'
import * as QRCode from 'qrcode'
import * as echarts from 'echarts/core' import * as echarts from 'echarts/core'
import { LineChart, PieChart } from 'echarts/charts' import { LineChart, PieChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components' import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers' import { CanvasRenderer } from 'echarts/renderers'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile, importExcel } from '../api/excel' import { downloadApiFile, importExcel } from '../api/excel'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
echarts.use([ echarts.use([
LineChart, LineChart,
@@ -28,6 +41,12 @@ const selectedSheet = ref<any>(null)
const sheetDetail = ref<any>(null) const sheetDetail = ref<any>(null)
const statistics = ref<any>(null) const statistics = ref<any>(null)
const createDialog = ref(false) const createDialog = ref(false)
const qrDialog = ref(false)
const createSubmitting = ref(false)
const teacherLocationLoading = ref(false)
const qrDataUrl = ref('')
const qrCheckInUrl = ref('')
const now = ref(Date.now())
const fileInput = ref<HTMLInputElement>() const fileInput = ref<HTMLInputElement>()
const termId = ref<string>() const termId = ref<string>()
const activeMode = ref<'rollcall' | 'statistics'>('rollcall') const activeMode = ref<'rollcall' | 'statistics'>('rollcall')
@@ -36,8 +55,18 @@ const classFilter = ref('')
const attentionFilter = ref('') const attentionFilter = ref('')
const statusChartEl = ref<HTMLElement>() const statusChartEl = ref<HTMLElement>()
const trendChartEl = ref<HTMLElement>() const trendChartEl = ref<HTMLElement>()
const createForm = ref({ name: '', attendanceDate: '' }) const createForm = ref({
name: '',
attendanceDate: '',
checkInMethod: 'Manual',
checkInDurationMinutes: 15,
targetLatitude: null as number | null,
targetLongitude: null as number | null,
locationRadiusMeters: 100,
locationAccuracyMeters: null as number | null,
})
const chartInstances: echarts.ECharts[] = [] const chartInstances: echarts.ECharts[] = []
let clockTimer: number | undefined
const statusOptions = [ const statusOptions = [
{ value: 'Present', label: '出勤' }, { value: 'Present', label: '出勤' },
@@ -148,26 +177,181 @@ function openCreate() {
createForm.value = { createForm.value = {
name: '', name: '',
attendanceDate: new Date().toISOString().slice(0, 10), attendanceDate: new Date().toISOString().slice(0, 10),
checkInMethod: 'Manual',
checkInDurationMinutes: 15,
targetLatitude: null,
targetLongitude: null,
locationRadiusMeters: 100,
locationAccuracyMeters: null,
} }
createDialog.value = true createDialog.value = true
} }
async function captureTeacherLocation() {
if (!navigator.geolocation) {
ElMessage.error('当前浏览器不支持定位,请更换浏览器或使用扫码签到。')
return false
}
teacherLocationLoading.value = true
try {
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
timeout: 12000,
maximumAge: 0,
})
})
createForm.value.targetLatitude = Number(position.coords.latitude.toFixed(7))
createForm.value.targetLongitude = Number(position.coords.longitude.toFixed(7))
createForm.value.locationAccuracyMeters = Math.round(position.coords.accuracy)
ElMessage.success('已获取当前签到点')
return true
} catch (error: any) {
const message = error?.code === 1
? '定位权限被拒绝,请在浏览器地址栏中允许本网站使用位置信息。'
: error?.code === 3
? '获取位置超时,请移到信号较好的位置后重试。'
: '暂时无法获取位置,请检查系统定位服务。'
ElMessage.error(message)
return false
} finally {
teacherLocationLoading.value = false
}
}
async function createSheet() { async function createSheet() {
if (!createForm.value.name.trim()) { if (!createForm.value.name.trim()) {
ElMessage.warning('请填写考勤表名称。') ElMessage.warning('请填写考勤表名称。')
return return
} }
if (createForm.value.checkInMethod === 'Location' &&
(createForm.value.targetLatitude === null ||
createForm.value.targetLongitude === null) &&
!await captureTeacherLocation()) return
createSubmitting.value = true
try { try {
await http.post('/attendance/sheets', { const { data } = await http.post('/attendance/sheets', {
teachingTaskId: selectedTask.value.id, teachingTaskId: selectedTask.value.id,
name: createForm.value.name, name: createForm.value.name,
attendanceDate: new Date(createForm.value.attendanceDate).toISOString(), attendanceDate: new Date(createForm.value.attendanceDate).toISOString(),
checkInMethod: createForm.value.checkInMethod,
checkInDurationMinutes: createForm.value.checkInMethod === 'Manual'
? null
: createForm.value.checkInDurationMinutes,
targetLatitude: createForm.value.checkInMethod === 'Location'
? createForm.value.targetLatitude
: null,
targetLongitude: createForm.value.checkInMethod === 'Location'
? createForm.value.targetLongitude
: null,
locationRadiusMeters: createForm.value.checkInMethod === 'Location'
? createForm.value.locationRadiusMeters
: null,
}) })
createDialog.value = false createDialog.value = false
ElMessage.success('考勤表已建立') ElMessage.success(createForm.value.checkInMethod === 'Manual'
? '考勤表已建立'
: '签到活动已发起')
await selectTask(selectedTask.value) await selectTask(selectedTask.value)
const createdSheet = sheets.value.find((sheet: any) => sheet.id === data.id)
if (createdSheet) {
await selectSheet(createdSheet)
if (data.checkInMethod === 'QrCode') await showQrCode()
}
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
} finally {
createSubmitting.value = false
}
}
function isOnlineMethod(method: string | number) {
return method === 'QrCode' || method === 'Location' || method === 2 || method === 3
}
function methodLabel(method: string | number) {
if (method === 'QrCode' || method === 2) return '扫码签到'
if (method === 'Location' || method === 3) return '定位签到'
return '教师点名'
}
function isQrCode(method: string | number) {
return method === 'QrCode' || method === 2
}
function serverUtcTime(value: string | null | undefined) {
if (!value) return Number.NaN
const normalized = /(?:Z|[+-]\d{2}:\d{2})$/i.test(value) ? value : `${value}Z`
return new Date(normalized).getTime()
}
function formatServerTime(value: string) {
return new Date(serverUtcTime(value)).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})
}
function isCheckInOpen(sheet: any) {
if (!sheet || !isDraft(sheet.status) || !isOnlineMethod(sheet.checkInMethod)) return false
const start = serverUtcTime(sheet.checkInStartsAt)
const end = serverUtcTime(sheet.checkInEndsAt)
return start <= now.value && now.value < end
}
function remainingLabel(sheet: any) {
if (!sheet?.checkInEndsAt) return ''
const remaining = serverUtcTime(sheet.checkInEndsAt) - now.value
if (remaining <= 0) return '签到已结束'
const minutes = Math.floor(remaining / 60000)
const seconds = Math.floor((remaining % 60000) / 1000)
return `${minutes}${String(seconds).padStart(2, '0')}秒后结束`
}
async function showQrCode() {
const sheet = sheetDetail.value?.sheet
if (!sheet?.checkInToken) {
ElMessage.warning('未取得签到码,请刷新考勤表后重试。')
return
}
qrCheckInUrl.value =
`${window.location.origin}/my-attendance?token=${encodeURIComponent(sheet.checkInToken)}`
try {
qrDataUrl.value = await QRCode.toDataURL(qrCheckInUrl.value, {
width: 360,
margin: 2,
errorCorrectionLevel: 'M',
color: { dark: '#172b4d', light: '#ffffff' },
})
qrDialog.value = true
} catch {
ElMessage.error('签到二维码生成失败,请刷新页面后重试。')
}
}
async function copyCheckInLink() {
try {
await navigator.clipboard.writeText(qrCheckInUrl.value)
ElMessage.success('签到链接已复制')
} catch {
ElMessage.error('无法自动复制,请手动选择签到链接。')
}
}
async function closeCheckIn() {
if (!sheetDetail.value?.sheet) return
try {
await ElMessageBox.confirm(
'提前结束后,学生将不能再扫码或定位签到,仍可由教师调整名单。',
'提前结束签到',
{ type: 'warning', confirmButtonText: '结束签到', cancelButtonText: '继续签到' },
)
await http.post(`/attendance/sheets/${sheetDetail.value.sheet.id}/close-check-in`)
ElMessage.success('签到已结束')
qrDialog.value = false
await selectTask(selectedTask.value)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
} }
} }
@@ -407,13 +591,15 @@ watch(activeMode, async mode => {
onMounted(async () => { onMounted(async () => {
window.addEventListener('resize', resizeCharts) window.addEventListener('resize', resizeCharts)
clockTimer = window.setInterval(() => { now.value = Date.now() }, 1000)
terms.value = (await http.get('/base-data/terms')).data terms.value = (await http.get('/base-data/terms')).data
termId.value = terms.value.find((item: any) => item.isCurrent)?.id termId.value = defaultAcademicTermId(terms.value)
await loadTasks() await loadTasks()
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', resizeCharts) window.removeEventListener('resize', resizeCharts)
if (clockTimer) window.clearInterval(clockTimer)
disposeCharts() disposeCharts()
}) })
</script> </script>
@@ -431,7 +617,7 @@ onUnmounted(() => {
<section class="attendance-toolbar"> <section class="attendance-toolbar">
<el-select v-model="termId" clearable placeholder="全部学期" @change="loadTasks"> <el-select v-model="termId" clearable placeholder="全部学期" @change="loadTasks">
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" /> <el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
</el-select> </el-select>
<span> {{ tasks.length }} 个教学班</span> <span> {{ tasks.length }} 个教学班</span>
<el-radio-group v-model="activeMode" class="mode-switch" size="small"> <el-radio-group v-model="activeMode" class="mode-switch" size="small">
@@ -482,10 +668,20 @@ onUnmounted(() => {
@click="selectSheet(sheet)" @click="selectSheet(sheet)"
> >
<div> <div>
<b>{{ sheet.name }}</b> <b>
{{ sheet.name }}
<span
v-if="isOnlineMethod(sheet.checkInMethod)"
class="method-mark"
>{{ methodLabel(sheet.checkInMethod) }}</span>
</b>
<span>{{ new Date(sheet.attendanceDate).toLocaleDateString('zh-CN') }}</span> <span>{{ new Date(sheet.attendanceDate).toLocaleDateString('zh-CN') }}</span>
</div> </div>
<div class="sheet-stats"> <div class="sheet-stats">
<span
v-if="isOnlineMethod(sheet.checkInMethod)"
class="checked-in"
>已签到 {{ sheet.checkedInCount }}</span>
<span class="present">出勤 {{ sheet.presentCount }}</span> <span class="present">出勤 {{ sheet.presentCount }}</span>
<span class="absent" v-if="sheet.absentCount">缺勤 {{ sheet.absentCount }}</span> <span class="absent" v-if="sheet.absentCount">缺勤 {{ sheet.absentCount }}</span>
<span class="late" v-if="sheet.lateCount">迟到 {{ sheet.lateCount }}</span> <span class="late" v-if="sheet.lateCount">迟到 {{ sheet.lateCount }}</span>
@@ -510,9 +706,26 @@ onUnmounted(() => {
<div> <div>
<strong>{{ sheetDetail.sheet.name }}</strong> <strong>{{ sheetDetail.sheet.name }}</strong>
<span>{{ sheetDetail.sheet.courseCode }} · {{ sheetDetail.sheet.taskNumber }}</span> <span>{{ sheetDetail.sheet.courseCode }} · {{ sheetDetail.sheet.taskNumber }}</span>
<small>{{ new Date(sheetDetail.sheet.attendanceDate).toLocaleDateString('zh-CN') }} · {{ isSubmitted(sheetDetail.sheet.status) ? '已提交' : '草稿' }}</small> <small>
{{ new Date(sheetDetail.sheet.attendanceDate).toLocaleDateString('zh-CN') }}
· {{ methodLabel(sheetDetail.sheet.checkInMethod) }}
· {{ isSubmitted(sheetDetail.sheet.status) ? '已提交' : '草稿' }}
</small>
</div> </div>
<div class="panel-actions"> <div class="panel-actions">
<el-button
v-if="sheetDetail.canEdit && isQrCode(sheetDetail.sheet.checkInMethod)"
size="small"
:icon="Grid"
@click="showQrCode"
>显示签到码</el-button>
<el-button
v-if="sheetDetail.canEdit && isCheckInOpen(sheetDetail.sheet)"
size="small"
type="warning"
plain
@click="closeCheckIn"
>提前结束</el-button>
<el-button <el-button
v-if="sheetDetail.canEdit" v-if="sheetDetail.canEdit"
size="small" size="small"
@@ -542,6 +755,27 @@ onUnmounted(() => {
>提交</el-button> >提交</el-button>
</div> </div>
</header> </header>
<div
v-if="isOnlineMethod(sheetDetail.sheet.checkInMethod)"
class="check-in-console"
:class="{ open: isCheckInOpen(sheetDetail.sheet) }"
>
<div class="check-in-signal">
<span />
{{ isCheckInOpen(sheetDetail.sheet) ? '签到进行中' : '签到已结束' }}
</div>
<strong>
{{ sheetDetail.sheet.checkedInCount }}
<small>/ {{ sheetDetail.sheet.records.length }} 人已自主签到</small>
</strong>
<p>
<Clock />
{{ remainingLabel(sheetDetail.sheet) }}
<template v-if="sheetDetail.sheet.locationRadiusMeters">
· 签到点 {{ sheetDetail.sheet.locationRadiusMeters }} 米内有效
</template>
</p>
</div>
<div v-if="sheetDetail.canEdit" class="batch-row"> <div v-if="sheetDetail.canEdit" class="batch-row">
<span>批量设置</span> <span>批量设置</span>
<el-button size="small" @click="batchStatus('Present')">全部出勤</el-button> <el-button size="small" @click="batchStatus('Present')">全部出勤</el-button>
@@ -573,6 +807,22 @@ onUnmounted(() => {
<span v-else>{{ statusLabel(row.status) }}</span> <span v-else>{{ statusLabel(row.status) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column
v-if="isOnlineMethod(sheetDetail.sheet.checkInMethod)"
label="自主签到"
min-width="138"
>
<template #default="{ row }">
<div v-if="row.checkInAt" class="record-check-in">
<b>{{ methodLabel(row.checkedInMethod) }}</b>
<span>{{ formatServerTime(row.checkInAt) }}</span>
<small v-if="row.checkInDistanceMeters !== null">
距签到点 {{ Math.round(row.checkInDistanceMeters) }}
</small>
</div>
<span v-else class="muted-cell">尚未签到</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="120"> <el-table-column label="备注" min-width="120">
<template #default="{ row }"> <template #default="{ row }">
<el-input <el-input
@@ -713,7 +963,7 @@ onUnmounted(() => {
</main> </main>
</section> </section>
<el-dialog v-model="createDialog" title="新建考勤" width="500px"> <el-dialog v-model="createDialog" title="发起课堂考勤" width="620px">
<el-form label-position="top"> <el-form label-position="top">
<el-form-item label="考勤名称" required> <el-form-item label="考勤名称" required>
<el-input v-model="createForm.name" placeholder="如:第3周课堂点名" maxlength="120" /> <el-input v-model="createForm.name" placeholder="如:第3周课堂点名" maxlength="120" />
@@ -725,10 +975,109 @@ onUnmounted(() => {
class="full-width" class="full-width"
/> />
</el-form-item> </el-form-item>
<el-form-item label="签到方式" required>
<div class="method-options">
<button
v-for="method in [
{ value: 'Manual', icon: Check, name: '教师点名', hint: '逐人确认,可随时修改' },
{ value: 'QrCode', icon: Grid, name: '扫码签到', hint: '投屏二维码,学生登录后签到' },
{ value: 'Location', icon: Location, name: '定位签到', hint: '在指定签到点范围内签到' },
]"
:key="method.value"
type="button"
:class="{ active: createForm.checkInMethod === method.value }"
@click="createForm.checkInMethod = method.value"
>
<el-icon><component :is="method.icon" /></el-icon>
<b>{{ method.name }}</b>
<span>{{ method.hint }}</span>
</button>
</div>
</el-form-item>
<div v-if="createForm.checkInMethod !== 'Manual'" class="online-settings">
<el-form-item label="签到时长" required>
<el-input-number
v-model="createForm.checkInDurationMinutes"
:min="1"
:max="180"
:step="5"
controls-position="right"
/>
<span class="field-unit">分钟</span>
</el-form-item>
<template v-if="createForm.checkInMethod === 'Location'">
<el-form-item label="有效范围" required>
<el-input-number
v-model="createForm.locationRadiusMeters"
:min="20"
:max="1000"
:step="10"
controls-position="right"
/>
<span class="field-unit"></span>
</el-form-item>
<div class="location-anchor">
<div>
<Location />
<p>
<b>教师当前位置作为签到点</b>
<span v-if="createForm.targetLatitude !== null">
已定位精度约 {{ createForm.locationAccuracyMeters }}
</span>
<span v-else>创建前需要允许浏览器获取位置</span>
</p>
</div>
<el-button
:loading="teacherLocationLoading"
@click="captureTeacherLocation"
>{{ createForm.targetLatitude === null ? '获取位置' : '重新定位' }}</el-button>
</div>
</template>
</div>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="createDialog = false">取消</el-button> <el-button @click="createDialog = false">取消</el-button>
<el-button type="primary" @click="createSheet">建立考勤表</el-button> <el-button
type="primary"
:loading="createSubmitting"
@click="createSheet"
>{{ createForm.checkInMethod === 'Manual' ? '建立考勤表' : '立即发起签到' }}</el-button>
</template>
</el-dialog>
<el-dialog
v-model="qrDialog"
class="qr-dialog"
title="课堂扫码签到"
width="520px"
align-center
>
<div v-if="sheetDetail" class="qr-stage">
<div class="qr-course">
<span>{{ sheetDetail.sheet.courseCode }} · {{ sheetDetail.sheet.taskNumber }}</span>
<strong>{{ sheetDetail.sheet.name }}</strong>
<small>{{ sheetDetail.sheet.courseName }}</small>
</div>
<img :src="qrDataUrl" alt="课堂签到二维码" />
<div class="qr-status" :class="{ ended: !isCheckInOpen(sheetDetail.sheet) }">
<span />
{{ isCheckInOpen(sheetDetail.sheet) ? remainingLabel(sheetDetail.sheet) : '本次签到已结束' }}
</div>
<p>学生使用手机扫码登录教务系统后完成签到</p>
<el-input v-model="qrCheckInUrl" readonly>
<template #append>
<el-button :icon="CopyDocument" @click="copyCheckInLink">复制链接</el-button>
</template>
</el-input>
</div>
<template #footer>
<el-button
v-if="sheetDetail && isCheckInOpen(sheetDetail.sheet)"
type="warning"
plain
@click="closeCheckIn"
>提前结束签到</el-button>
<el-button type="primary" @click="qrDialog = false">完成</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
@@ -833,6 +1182,16 @@ onUnmounted(() => {
} }
.attendance-sheet-list > button b { font-size: 14px; } .attendance-sheet-list > button b { font-size: 14px; }
.attendance-sheet-list > button span { color: var(--muted); font-size: 11px; } .attendance-sheet-list > button span { color: var(--muted); font-size: 11px; }
.method-mark {
margin-left: 5px;
padding: 2px 5px;
color: #176b87 !important;
font-size: 9px !important;
font-weight: 700;
vertical-align: middle;
border: 1px solid #b9d5dc;
background: #edf7f8;
}
.attendance-sheet-list > button i { .attendance-sheet-list > button i {
color: var(--indigo); color: var(--indigo);
font-size: 10px; font-size: 10px;
@@ -842,6 +1201,7 @@ onUnmounted(() => {
.sheet-stats { display: flex; gap: 8px; flex-wrap: wrap; } .sheet-stats { display: flex; gap: 8px; flex-wrap: wrap; }
.sheet-stats span { font-size: 10px !important; font-weight: 700; } .sheet-stats span { font-size: 10px !important; font-weight: 700; }
.sheet-stats .present { color: #2d8975; } .sheet-stats .present { color: #2d8975; }
.sheet-stats .checked-in { color: #176b87; }
.sheet-stats .absent { color: #b34e48; } .sheet-stats .absent { color: #b34e48; }
.sheet-stats .late { color: #c78724; } .sheet-stats .late { color: #c78724; }
.sheet-stats .leave { color: #79579a; } .sheet-stats .leave { color: #79579a; }
@@ -864,6 +1224,56 @@ onUnmounted(() => {
margin-top: 8px; margin-top: 8px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.check-in-console {
padding: 13px 15px;
display: grid;
gap: 4px;
color: #64748b;
border-bottom: 1px solid #dce4ed;
background: #f5f7fa;
}
.check-in-console.open {
color: #d8f4ec;
border-color: #21506d;
background: #17395e;
}
.check-in-signal {
display: flex;
align-items: center;
gap: 7px;
font-size: 10px;
font-weight: 800;
letter-spacing: .08em;
}
.check-in-signal > span,
.qr-status > span {
width: 7px;
height: 7px;
border-radius: 50%;
background: #94a3b8;
}
.check-in-console.open .check-in-signal > span,
.qr-status:not(.ended) > span {
background: #4fe0b1;
box-shadow: 0 0 0 4px rgba(79, 224, 177, .14);
}
.check-in-console > strong {
color: #334155;
font: 700 30px/1.1 "Arial Narrow", "Microsoft YaHei", sans-serif;
}
.check-in-console.open > strong { color: white; }
.check-in-console > strong small {
font-size: 11px;
font-weight: 500;
}
.check-in-console > p {
margin: 0;
display: flex;
align-items: center;
gap: 5px;
font-size: 10px;
}
.check-in-console > p svg { width: 12px; }
.batch-row { .batch-row {
padding: 8px 14px; padding: 8px 14px;
display: flex; display: flex;
@@ -875,8 +1285,119 @@ onUnmounted(() => {
.batch-row > span { font-size: 11px; color: var(--muted); } .batch-row > span { font-size: 11px; color: var(--muted); }
.batch-row > small { margin-left: auto; font-size: 10px; color: var(--muted); } .batch-row > small { margin-left: auto; font-size: 10px; color: var(--muted); }
.student-flag { margin-left: 4px; } .student-flag { margin-left: 4px; }
.record-check-in { display: grid; gap: 1px; line-height: 1.25; }
.record-check-in b { color: #176b87; font-size: 10px; }
.record-check-in span,
.record-check-in small,
.muted-cell { color: var(--muted); font-size: 10px; }
.full-width { width: 100%; } .full-width { width: 100%; }
.method-options {
width: 100%;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.method-options > button {
min-width: 0;
padding: 13px 10px;
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
gap: 2px 7px;
color: #344054;
text-align: left;
border: 1px solid #d8dee7;
background: white;
cursor: pointer;
}
.method-options > button:hover { border-color: #87aeb9; }
.method-options > button.active {
color: #17395e;
border-color: #176b87;
box-shadow: inset 0 -3px #176b87;
background: #f1f8f9;
}
.method-options .el-icon {
grid-row: 1 / 3;
width: 28px;
height: 28px;
color: #176b87;
font-size: 19px;
background: #e7f2f4;
}
.method-options b { font-size: 12px; }
.method-options span {
overflow: hidden;
color: #7b8794;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.online-settings {
padding: 13px 15px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0 16px;
border: 1px solid #dce4ed;
background: #f7f9fb;
}
.online-settings :deep(.el-form-item) { margin-bottom: 10px; }
.field-unit { margin-left: 8px; color: var(--muted); font-size: 11px; }
.location-anchor {
grid-column: 1 / -1;
padding-top: 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
border-top: 1px solid #dce4ed;
}
.location-anchor > div {
min-width: 0;
display: flex;
align-items: center;
gap: 9px;
}
.location-anchor svg {
width: 22px;
color: #176b87;
flex: 0 0 auto;
}
.location-anchor p { margin: 0; display: grid; }
.location-anchor b { color: #344054; font-size: 11px; }
.location-anchor span { color: var(--muted); font-size: 9px; }
.qr-stage {
display: grid;
justify-items: center;
gap: 11px;
text-align: center;
}
.qr-course { display: grid; gap: 2px; }
.qr-course span {
color: #176b87;
font: 700 10px/1.2 Consolas, monospace;
}
.qr-course strong { color: #172b4d; font-size: 20px; }
.qr-course small { color: var(--muted); }
.qr-stage > img {
width: min(340px, 78vw);
aspect-ratio: 1;
border: 10px solid white;
box-shadow: 0 0 0 1px #d8dee7, 0 12px 32px rgba(23, 43, 77, .12);
}
.qr-status {
display: flex;
align-items: center;
gap: 8px;
color: #247261;
font-size: 12px;
font-weight: 800;
}
.qr-status.ended { color: #64748b; }
.qr-stage > p { margin: 0; color: var(--muted); font-size: 11px; }
.qr-stage :deep(.el-input) { width: 100%; }
.attendance-statistics { .attendance-statistics {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@@ -1094,6 +1615,10 @@ onUnmounted(() => {
.student-statistics { margin: 0 12px 12px; } .student-statistics { margin: 0 12px 12px; }
.student-filters .el-input, .student-filters .el-input,
.student-filters .el-select { width: 100%; } .student-filters .el-select { width: 100%; }
.method-options { grid-template-columns: 1fr; }
.method-options > button { grid-template-columns: 32px minmax(0, 1fr); }
.online-settings { grid-template-columns: 1fr; }
.location-anchor { align-items: flex-start; }
.batch-row { align-items: flex-start; flex-wrap: wrap; } .batch-row { align-items: flex-start; flex-wrap: wrap; }
.batch-row > small { width: 100%; margin-left: 0; } .batch-row > small { width: 100%; margin-left: 0; }
} }
+3 -2
View File
@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue'
import { Download, Refresh } from '@element-plus/icons-vue' import { Download, Refresh } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile } from '../api/excel' import { downloadApiFile } from '../api/excel'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const loading = ref(false) const loading = ref(false)
const detailLoading = ref(false) const detailLoading = ref(false)
@@ -59,7 +60,7 @@ async function exportRoster() {
onMounted(async () => { onMounted(async () => {
terms.value = (await http.get('/base-data/terms')).data terms.value = (await http.get('/base-data/terms')).data
termId.value = terms.value.find((item: any) => item.isCurrent)?.id termId.value = defaultAcademicTermId(terms.value)
await loadOfferings() await loadOfferings()
}) })
</script> </script>
@@ -77,7 +78,7 @@ onMounted(async () => {
<section class="roster-toolbar"> <section class="roster-toolbar">
<el-select v-model="termId" clearable placeholder="全部学期" @change="loadOfferings"> <el-select v-model="termId" clearable placeholder="全部学期" @change="loadOfferings">
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" /> <el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
</el-select> </el-select>
<span> {{ offerings.length }} 个教学班</span> <span> {{ offerings.length }} 个教学班</span>
</section> </section>
+7 -6
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { Plus, Refresh } from '@element-plus/icons-vue' import { Plus, Refresh } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore() const auth = useAuthStore()
const isReviewer = computed(() => const isReviewer = computed(() =>
@@ -56,7 +57,7 @@ async function load() {
function openSubmit() { function openSubmit() {
Object.assign(form, { Object.assign(form, {
academicTermId: filters.academicTermId ?? academicTermId: filters.academicTermId ??
terms.value.find((item) => item.isCurrent)?.id, defaultAcademicTermId(terms.value),
courseId: undefined, courseId: undefined,
statement: '', statement: '',
}) })
@@ -118,7 +119,7 @@ async function review() {
function openAssignment() { function openAssignment() {
Object.assign(assignmentForm, { Object.assign(assignmentForm, {
academicTermId: filters.academicTermId ?? academicTermId: filters.academicTermId ??
terms.value.find((item) => item.isCurrent)?.id, defaultAcademicTermId(terms.value),
teacherId: undefined, teacherId: undefined,
courseId: undefined, courseId: undefined,
comment: '', comment: '',
@@ -158,7 +159,7 @@ onMounted(async () => {
courses.value = optionRes?.data?.courses ?? [] courses.value = optionRes?.data?.courses ?? []
teachers.value = optionRes?.data?.teachers ?? [] teachers.value = optionRes?.data?.teachers ?? []
} }
filters.academicTermId = terms.value.find((item) => item.isCurrent)?.id filters.academicTermId = defaultAcademicTermId(terms.value)
await load() await load()
}) })
</script> </script>
@@ -195,7 +196,7 @@ onMounted(async () => {
<section class="data-card"> <section class="data-card">
<div class="filter-bar"> <div class="filter-bar">
<el-select v-model="filters.academicTermId" clearable placeholder="全部学期" @change="load"> <el-select v-model="filters.academicTermId" clearable placeholder="全部学期" @change="load">
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /> <el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" />
</el-select> </el-select>
<el-select v-model="filters.status" clearable placeholder="全部状态" @change="load"> <el-select v-model="filters.status" clearable placeholder="全部状态" @change="load">
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" /> <el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
@@ -241,7 +242,7 @@ onMounted(async () => {
<div class="form-grid"> <div class="form-grid">
<el-form-item label="学期" required> <el-form-item label="学期" required>
<el-select v-model="form.academicTermId"> <el-select v-model="form.academicTermId">
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /> <el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="拟授课程" required> <el-form-item label="拟授课程" required>
@@ -286,7 +287,7 @@ onMounted(async () => {
<el-form label-position="top" style="margin-top: 18px"> <el-form label-position="top" style="margin-top: 18px">
<el-form-item label="学期" required> <el-form-item label="学期" required>
<el-select v-model="assignmentForm.academicTermId" style="width: 100%"> <el-select v-model="assignmentForm.academicTermId" style="width: 100%">
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /> <el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<div class="form-grid"> <div class="form-grid">
+7 -8
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { Plus, Refresh, Search } from '@element-plus/icons-vue' import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore() const auth = useAuthStore()
const isSuperAdmin = computed(() => auth.user?.roles.includes('SuperAdmin') ?? false) const isSuperAdmin = computed(() => auth.user?.roles.includes('SuperAdmin') ?? false)
@@ -283,11 +284,10 @@ function resetFilters() {
function resetForm(detail?: any) { function resetForm(detail?: any) {
Object.keys(form).forEach((key) => delete form[key]) Object.keys(form).forEach((key) => delete form[key])
const currentTerm = terms.value.find((item) => item.isCurrent)
Object.assign(form, { Object.assign(form, {
taskNumber: '', taskNumber: '',
name: '', name: '',
academicTermId: currentTerm?.id, academicTermId: defaultAcademicTermId(terms.value),
courseId: undefined, courseId: undefined,
capacity: 60, capacity: 60,
startWeek: 1, startWeek: 1,
@@ -499,9 +499,8 @@ async function batchAction(
} }
function openGeneration() { function openGeneration() {
const currentTerm = terms.value.find((item) => item.isCurrent)
Object.assign(generationForm, { Object.assign(generationForm, {
academicTermId: query.academicTermId ?? currentTerm?.id, academicTermId: query.academicTermId ?? defaultAcademicTermId(terms.value),
courseId: undefined, courseId: undefined,
classesPerTask: 3, classesPerTask: 3,
startWeek: 1, startWeek: 1,
@@ -576,7 +575,7 @@ onMounted(async () => {
courses.value = courseRes.data courses.value = courseRes.data
classes.value = classRes.data classes.value = classRes.data
majors.value = majorRes.data majors.value = majorRes.data
query.academicTermId = terms.value.find((item) => item.isCurrent)?.id query.academicTermId = defaultAcademicTermId(terms.value)
await load() await load()
}) })
</script> </script>
@@ -604,7 +603,7 @@ onMounted(async () => {
<div class="filter-bar"> <div class="filter-bar">
<el-input v-model="query.keyword" :prefix-icon="Search" clearable placeholder="搜索任务编号、教学班或课程" @keyup.enter="query.page = 1; load()" /> <el-input v-model="query.keyword" :prefix-icon="Search" clearable placeholder="搜索任务编号、教学班或课程" @keyup.enter="query.page = 1; load()" />
<el-select v-model="query.academicTermId" clearable placeholder="全部学期"> <el-select v-model="query.academicTermId" clearable placeholder="全部学期">
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /> <el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" />
</el-select> </el-select>
<el-select v-model="query.status" clearable placeholder="全部状态"> <el-select v-model="query.status" clearable placeholder="全部状态">
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" /> <el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
@@ -712,7 +711,7 @@ onMounted(async () => {
<el-form-item label="教学班名称" required><el-input v-model="form.name" /></el-form-item> <el-form-item label="教学班名称" required><el-input v-model="form.name" /></el-form-item>
</div> </div>
<div class="form-grid"> <div class="form-grid">
<el-form-item label="开课学期" required><el-select v-model="form.academicTermId" @change="loadManualEligibleTeachers"><el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item> <el-form-item label="开课学期" required><el-select v-model="form.academicTermId" @change="loadManualEligibleTeachers"><el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" /></el-select></el-form-item>
<el-form-item label="课程" required> <el-form-item label="课程" required>
<el-select v-model="form.courseId" filterable @change="form.teacherIds = []; form.primaryTeacherId = undefined; loadManualEligibleTeachers()"> <el-select v-model="form.courseId" filterable @change="form.teacherIds = []; form.primaryTeacherId = undefined; loadManualEligibleTeachers()">
<el-option v-for="item in filteredManageableCourses" :key="item.id" :label="`${item.code} · ${item.name}${item.totalHours} 学时)`" :value="item.id" /> <el-option v-for="item in filteredManageableCourses" :key="item.id" :label="`${item.code} · ${item.name}${item.totalHours} 学时)`" :value="item.id" />
@@ -807,7 +806,7 @@ onMounted(async () => {
<div class="form-grid"> <div class="form-grid">
<el-form-item label="开课学期" required> <el-form-item label="开课学期" required>
<el-select v-model="generationForm.academicTermId" @change="loadEligibleTeachers"> <el-select v-model="generationForm.academicTermId" @change="loadEligibleTeachers">
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /> <el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="公共课程" required> <el-form-item label="公共课程" required>
+5 -6
View File
@@ -5,6 +5,7 @@ import { useRoute } from 'vue-router'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile } from '../api/excel' import { downloadApiFile } from '../api/excel'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const route = useRoute() const route = useRoute()
const auth = useAuthStore() const auth = useAuthStore()
@@ -297,9 +298,8 @@ async function loadPublicOptions() {
colleges.value = data.colleges colleges.value = data.colleges
majors.value = data.majors majors.value = data.majors
classes.value = data.classes classes.value = data.classes
termId.value = data.terms.find((item: any) => item.isCurrent)?.id termId.value = defaultAcademicTermId(data.terms)
?? data.terms.find((item: any) => item.hasPublishedTimetable)?.id ?? data.terms.find((item: any) => item.hasPublishedTimetable)?.id
?? data.terms[0]?.id
?? '' ?? ''
const initialClass = data.classes.find((item: any) => item.hasPublishedTimetable) const initialClass = data.classes.find((item: any) => item.hasPublishedTimetable)
?? data.classes[0] ?? data.classes[0]
@@ -502,9 +502,7 @@ onMounted(async () => {
if (isTeacherView.value) { if (isTeacherView.value) {
const { data } = await http.get('/timetables/options') const { data } = await http.get('/timetables/options')
terms.value = data.terms terms.value = data.terms
termId.value = data.terms.find((item: any) => item.isCurrent)?.id termId.value = defaultAcademicTermId(data.terms) ?? ''
?? data.terms[0]?.id
?? ''
await loadTimetable() await loadTimetable()
return return
} }
@@ -539,8 +537,9 @@ onMounted(async () => {
<el-option <el-option
v-for="term in terms" v-for="term in terms"
:key="term.id" :key="term.id"
:label="`${term.name}${!isManager && !term.hasPublishedTimetable ? '(未发布)' : ''}`" :label="`${academicTermLabel(term)}${!isManager && !term.hasPublishedTimetable ? '(未发布)' : ''}`"
:value="term.id" :value="term.id"
:class="academicTermOptionClass(term)"
/> />
</el-select> </el-select>
<el-select v-if="isManager" v-model="planId" placeholder="选择课表版本"> <el-select v-if="isManager" v-model="planId" placeholder="选择课表版本">
+3 -2
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { Check, Refresh, Search } from '@element-plus/icons-vue' import { Check, Refresh, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore() const auth = useAuthStore()
const isSuperAdmin = computed(() => auth.user?.roles.includes('SuperAdmin')) const isSuperAdmin = computed(() => auth.user?.roles.includes('SuperAdmin'))
@@ -93,7 +94,7 @@ async function acknowledge(w: any) {
} }
onMounted(async () => { onMounted(async () => {
try { terms.value = (await http.get('/base-data/terms')).data; termId.value = terms.value.find(t => t.isCurrent)?.id; await load() } try { terms.value = (await http.get('/base-data/terms')).data; termId.value = defaultAcademicTermId(terms.value) ?? ''; await load() }
catch (e) { ElMessage.error(apiErrorMessage(e)) } catch (e) { ElMessage.error(apiErrorMessage(e)) }
}) })
</script> </script>
@@ -103,7 +104,7 @@ onMounted(async () => {
<section class="page-intro"> <section class="page-intro">
<div><span class="section-kicker">ACADEMIC WARNING</span><h2>{{ isStudent ? '我的预警' : '学业预警' }}</h2><p>{{ isSuperAdmin ? '配置预警规则,执行检测,查看预警记录。' : isCounselor ? '查看所管学生的学业预警情况。' : '查看并确认您的学业预警通知。' }}</p></div> <div><span class="section-kicker">ACADEMIC WARNING</span><h2>{{ isStudent ? '我的预警' : '学业预警' }}</h2><p>{{ isSuperAdmin ? '配置预警规则,执行检测,查看预警记录。' : isCounselor ? '查看所管学生的学业预警情况。' : '查看并确认您的学业预警通知。' }}</p></div>
<div style="display:flex;gap:8px;align-items:center"> <div style="display:flex;gap:8px;align-items:center">
<el-select v-model="termId" clearable @change="load" style="width:240px"><el-option v-for="t in terms" :key="t.id" :label="t.name" :value="t.id" /></el-select> <el-select v-model="termId" clearable @change="load" style="width:240px"><el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" /></el-select>
<el-button :icon="Refresh" @click="load">刷新</el-button> <el-button :icon="Refresh" @click="load">刷新</el-button>
</div> </div>
</section> </section>