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