已把选课的“年级划分”和限制规则补成完整闭环:
选课批次可指定多个适用年级;留空兼容原有“全部年级”。 新增可选的“最多课程门数”,与原有学分上限并行。 年级、门数限制统一作用于学生可见批次、自主选课、候补、管理员代选和自动递补。 普通候选名单遵守年级及行政班范围;强制选课可绕过业务限制,但仍严格遵守管理员学院权限。 管理页面会展示“2026 级 · 最多 6 门 · 最多 30 学分”,名单和候补列表也增加年级列。 新增 MySQL 迁移:[CourseSelectionGradeLimits.cs](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726180000_CourseSelectionGradeLimits.cs)。
This commit is contained in:
@@ -46,7 +46,24 @@ public sealed class CourseSelectionsController(
|
|||||||
if (academicTermId.HasValue)
|
if (academicTermId.HasValue)
|
||||||
source = source.Where(x => x.AcademicTermId == academicTermId);
|
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||||
if (currentUserDataScope.Current.IsInRole(SystemRoles.Student))
|
if (currentUserDataScope.Current.IsInRole(SystemRoles.Student))
|
||||||
|
{
|
||||||
|
var userId = currentUserDataScope.Current.UserId;
|
||||||
|
var studentGrade = await db.Students.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == userId)
|
||||||
|
.Select(x => (int?)x.AdministrativeClass!.Grade)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
source = source.Where(x => x.Status != CourseSelectionRoundStatus.Draft);
|
source = source.Where(x => x.Status != CourseSelectionRoundStatus.Draft);
|
||||||
|
if (studentGrade.HasValue)
|
||||||
|
{
|
||||||
|
source = source.Where(x =>
|
||||||
|
!x.EligibleGrades.Any() ||
|
||||||
|
x.EligibleGrades.Any(item => item.Grade == studentGrade.Value));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
source = source.Where(_ => false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
return Ok(await source
|
return Ok(await source
|
||||||
@@ -64,6 +81,10 @@ public sealed class CourseSelectionsController(
|
|||||||
x.EndsAt,
|
x.EndsAt,
|
||||||
x.WithdrawalEndsAt,
|
x.WithdrawalEndsAt,
|
||||||
x.MaxCredits,
|
x.MaxCredits,
|
||||||
|
x.MaxCourseCount,
|
||||||
|
EligibleGrades = x.EligibleGrades
|
||||||
|
.OrderBy(item => item.Grade)
|
||||||
|
.Select(item => item.Grade),
|
||||||
x.Status,
|
x.Status,
|
||||||
IsAvailableNow =
|
IsAvailableNow =
|
||||||
x.Status == CourseSelectionRoundStatus.Open &&
|
x.Status == CourseSelectionRoundStatus.Open &&
|
||||||
@@ -76,6 +97,20 @@ public sealed class CourseSelectionsController(
|
|||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("configuration-options")]
|
||||||
|
[Authorize(Roles = RoundManagers)]
|
||||||
|
public async Task<ActionResult> GetConfigurationOptions(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var grades = await db.AdministrativeClasses.AsNoTracking()
|
||||||
|
.Where(x => x.IsEnabled)
|
||||||
|
.Select(x => x.Grade)
|
||||||
|
.Distinct()
|
||||||
|
.OrderByDescending(x => x)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new { Grades = grades });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("rounds")]
|
[HttpPost("rounds")]
|
||||||
[Authorize(Roles = RoundManagers)]
|
[Authorize(Roles = RoundManagers)]
|
||||||
public async Task<ActionResult> CreateRound(
|
public async Task<ActionResult> CreateRound(
|
||||||
@@ -92,6 +127,10 @@ public sealed class CourseSelectionsController(
|
|||||||
EndsAt = request.EndsAt.ToUniversalTime(),
|
EndsAt = request.EndsAt.ToUniversalTime(),
|
||||||
WithdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime(),
|
WithdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime(),
|
||||||
MaxCredits = request.MaxCredits,
|
MaxCredits = request.MaxCredits,
|
||||||
|
MaxCourseCount = request.MaxCourseCount,
|
||||||
|
EligibleGrades = NormalizeGrades(request.EligibleGrades)
|
||||||
|
.Select(grade => new CourseSelectionRoundGrade { Grade = grade })
|
||||||
|
.ToList(),
|
||||||
Notes = Normalize(request.Notes)
|
Notes = Normalize(request.Notes)
|
||||||
};
|
};
|
||||||
db.CourseSelectionRounds.Add(round);
|
db.CourseSelectionRounds.Add(round);
|
||||||
@@ -105,7 +144,9 @@ public sealed class CourseSelectionsController(
|
|||||||
CourseSelectionRoundRequest request,
|
CourseSelectionRoundRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var round = await db.CourseSelectionRounds.FindAsync([id], cancellationToken);
|
var round = await db.CourseSelectionRounds
|
||||||
|
.Include(x => x.EligibleGrades)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
if (round is null) return NotFound();
|
if (round is null) return NotFound();
|
||||||
if (round.Status != CourseSelectionRoundStatus.Draft)
|
if (round.Status != CourseSelectionRoundStatus.Draft)
|
||||||
return ConflictProblem("只有草稿选课批次可以修改。");
|
return ConflictProblem("只有草稿选课批次可以修改。");
|
||||||
@@ -117,6 +158,20 @@ public sealed class CourseSelectionsController(
|
|||||||
round.EndsAt = request.EndsAt.ToUniversalTime();
|
round.EndsAt = request.EndsAt.ToUniversalTime();
|
||||||
round.WithdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime();
|
round.WithdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime();
|
||||||
round.MaxCredits = request.MaxCredits;
|
round.MaxCredits = request.MaxCredits;
|
||||||
|
round.MaxCourseCount = request.MaxCourseCount;
|
||||||
|
var requestedGrades = NormalizeGrades(request.EligibleGrades);
|
||||||
|
var requestedGradeSet = requestedGrades.ToHashSet();
|
||||||
|
db.CourseSelectionRoundGrades.RemoveRange(
|
||||||
|
round.EligibleGrades.Where(x => !requestedGradeSet.Contains(x.Grade)));
|
||||||
|
foreach (var grade in requestedGrades.Where(grade =>
|
||||||
|
round.EligibleGrades.All(x => x.Grade != grade)))
|
||||||
|
{
|
||||||
|
round.EligibleGrades.Add(new CourseSelectionRoundGrade
|
||||||
|
{
|
||||||
|
CourseSelectionRoundId = round.Id,
|
||||||
|
Grade = grade
|
||||||
|
});
|
||||||
|
}
|
||||||
round.Notes = Normalize(request.Notes);
|
round.Notes = Normalize(request.Notes);
|
||||||
return await SaveAsync(id, false, cancellationToken);
|
return await SaveAsync(id, false, cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -356,6 +411,7 @@ public sealed class CourseSelectionsController(
|
|||||||
x.Student.Name,
|
x.Student.Name,
|
||||||
ClassName = x.Student.AdministrativeClass!.Name,
|
ClassName = x.Student.AdministrativeClass!.Name,
|
||||||
MajorName = x.Student.AdministrativeClass.Major!.Name,
|
MajorName = x.Student.AdministrativeClass.Major!.Name,
|
||||||
|
Grade = x.Student.AdministrativeClass.Grade,
|
||||||
x.EnrolledAt
|
x.EnrolledAt
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
@@ -373,6 +429,7 @@ public sealed class CourseSelectionsController(
|
|||||||
x.Student.Name,
|
x.Student.Name,
|
||||||
ClassName = x.Student.AdministrativeClass!.Name,
|
ClassName = x.Student.AdministrativeClass!.Name,
|
||||||
MajorName = x.Student.AdministrativeClass.Major!.Name,
|
MajorName = x.Student.AdministrativeClass.Major!.Name,
|
||||||
|
Grade = x.Student.AdministrativeClass.Grade,
|
||||||
x.WaitlistedAt
|
x.WaitlistedAt
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
@@ -385,6 +442,7 @@ public sealed class CourseSelectionsController(
|
|||||||
item.Name,
|
item.Name,
|
||||||
item.ClassName,
|
item.ClassName,
|
||||||
item.MajorName,
|
item.MajorName,
|
||||||
|
item.Grade,
|
||||||
item.WaitlistedAt,
|
item.WaitlistedAt,
|
||||||
Position = index + 1
|
Position = index + 1
|
||||||
})
|
})
|
||||||
@@ -435,12 +493,17 @@ public sealed class CourseSelectionsController(
|
|||||||
x.Id,
|
x.Id,
|
||||||
x.IsOpenToAll,
|
x.IsOpenToAll,
|
||||||
CourseNature = x.TeachingTask!.Course!.Nature,
|
CourseNature = x.TeachingTask!.Course!.Nature,
|
||||||
|
CollegeId = x.TeachingTask.Course.CollegeId,
|
||||||
RoundStatus = x.CourseSelectionRound!.Status,
|
RoundStatus = x.CourseSelectionRound!.Status,
|
||||||
|
EligibleGrades = x.CourseSelectionRound.EligibleGrades
|
||||||
|
.Select(item => item.Grade),
|
||||||
ClassIds = x.TeachingTask.Classes
|
ClassIds = x.TeachingTask.Classes
|
||||||
.Select(item => item.AdministrativeClassId)
|
.Select(item => item.AdministrativeClassId)
|
||||||
})
|
})
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
if (offering is null) return NotFound();
|
if (offering is null) return NotFound();
|
||||||
|
if (!currentUserDataScope.Current.CanAccessCollege(offering.CollegeId))
|
||||||
|
return Forbid();
|
||||||
if (!forceMode && !CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature))
|
if (!forceMode && !CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature))
|
||||||
return ConflictProblem("管理员代选仅适用于公共必修课。");
|
return ConflictProblem("管理员代选仅适用于公共必修课。");
|
||||||
if (offering.RoundStatus == CourseSelectionRoundStatus.Draft)
|
if (offering.RoundStatus == CourseSelectionRoundStatus.Draft)
|
||||||
@@ -453,7 +516,13 @@ public sealed class CourseSelectionsController(
|
|||||||
enrollment.CourseSelectionOfferingId == id &&
|
enrollment.CourseSelectionOfferingId == id &&
|
||||||
enrollment.StudentId == x.Id &&
|
enrollment.StudentId == x.Id &&
|
||||||
enrollment.Status == CourseEnrollmentStatus.Enrolled));
|
enrollment.Status == CourseEnrollmentStatus.Enrolled));
|
||||||
if (!offering.IsOpenToAll)
|
if (!forceMode && offering.EligibleGrades.Any())
|
||||||
|
{
|
||||||
|
source = source.WhereIn(
|
||||||
|
offering.EligibleGrades,
|
||||||
|
x => x.AdministrativeClass!.Grade);
|
||||||
|
}
|
||||||
|
if (!forceMode && !offering.IsOpenToAll)
|
||||||
source = source.WhereIn(offering.ClassIds, x => x.AdministrativeClassId);
|
source = source.WhereIn(offering.ClassIds, x => x.AdministrativeClassId);
|
||||||
if (!string.IsNullOrWhiteSpace(keyword))
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
{
|
{
|
||||||
@@ -475,6 +544,7 @@ public sealed class CourseSelectionsController(
|
|||||||
x.StudentNumber,
|
x.StudentNumber,
|
||||||
x.Name,
|
x.Name,
|
||||||
ClassName = x.AdministrativeClass!.Name,
|
ClassName = x.AdministrativeClass!.Name,
|
||||||
|
Grade = x.AdministrativeClass.Grade,
|
||||||
MajorName = x.AdministrativeClass.Major!.Name,
|
MajorName = x.AdministrativeClass.Major!.Name,
|
||||||
CollegeName = x.AdministrativeClass.Major.College!.Name
|
CollegeName = x.AdministrativeClass.Major.College!.Name
|
||||||
})
|
})
|
||||||
@@ -501,6 +571,7 @@ public sealed class CourseSelectionsController(
|
|||||||
db.ChangeTracker.Clear();
|
db.ChangeTracker.Clear();
|
||||||
var offering = await db.CourseSelectionOfferings
|
var offering = await db.CourseSelectionOfferings
|
||||||
.Include(x => x.CourseSelectionRound)
|
.Include(x => x.CourseSelectionRound)
|
||||||
|
.ThenInclude(x => x!.EligibleGrades)
|
||||||
.Include(x => x.TeachingTask)
|
.Include(x => x.TeachingTask)
|
||||||
.ThenInclude(x => x!.Course)
|
.ThenInclude(x => x!.Course)
|
||||||
.Include(x => x.TeachingTask)
|
.Include(x => x.TeachingTask)
|
||||||
@@ -526,6 +597,18 @@ public sealed class CourseSelectionsController(
|
|||||||
var inactive = students.FirstOrDefault(x => x.Status != StudentStatus.Active);
|
var inactive = students.FirstOrDefault(x => x.Status != StudentStatus.Active);
|
||||||
if (inactive is not null)
|
if (inactive is not null)
|
||||||
return ConflictProblem($"学生 {inactive.StudentNumber} {inactive.Name} 当前不是在籍状态。");
|
return ConflictProblem($"学生 {inactive.StudentNumber} {inactive.Name} 当前不是在籍状态。");
|
||||||
|
var eligibleGrades = round.EligibleGrades
|
||||||
|
.Select(x => x.Grade)
|
||||||
|
.ToArray();
|
||||||
|
var wrongGrade = students.FirstOrDefault(student =>
|
||||||
|
!CourseSelectionRules.IsGradeEligible(
|
||||||
|
eligibleGrades,
|
||||||
|
student.AdministrativeClass!.Grade));
|
||||||
|
if (wrongGrade is not null)
|
||||||
|
{
|
||||||
|
return ConflictProblem(
|
||||||
|
$"学生 {wrongGrade.StudentNumber} {wrongGrade.Name} 所在年级不属于本轮选课对象。");
|
||||||
|
}
|
||||||
var outOfScope = students.FirstOrDefault(student =>
|
var outOfScope = students.FirstOrDefault(student =>
|
||||||
!offering.IsOpenToAll &&
|
!offering.IsOpenToAll &&
|
||||||
!task.Classes.Any(item =>
|
!task.Classes.Any(item =>
|
||||||
@@ -600,6 +683,20 @@ public sealed class CourseSelectionsController(
|
|||||||
return ConflictProblem(
|
return ConflictProblem(
|
||||||
$"学生 {student.StudentNumber} {student.Name} 代选后将超过本轮 {round.MaxCredits:0.#} 学分上限。");
|
$"学生 {student.StudentNumber} {student.Name} 代选后将超过本轮 {round.MaxCredits:0.#} 学分上限。");
|
||||||
}
|
}
|
||||||
|
var selectedCourseCount = await db.CourseEnrollments.CountAsync(
|
||||||
|
x =>
|
||||||
|
x.StudentId == student.Id &&
|
||||||
|
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id,
|
||||||
|
cancellationToken);
|
||||||
|
if (CourseSelectionRules.HasReachedCourseLimit(
|
||||||
|
round.MaxCourseCount,
|
||||||
|
selectedCourseCount))
|
||||||
|
{
|
||||||
|
return ConflictProblem(
|
||||||
|
$"学生 {student.StudentNumber} {student.Name} 已达到本轮最多 " +
|
||||||
|
$"{round.MaxCourseCount} 门课程限制。");
|
||||||
|
}
|
||||||
|
|
||||||
var selectedTaskIds = await db.CourseEnrollments.AsNoTracking()
|
var selectedTaskIds = await db.CourseEnrollments.AsNoTracking()
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
@@ -685,6 +782,8 @@ public sealed class CourseSelectionsController(
|
|||||||
if (offering is null) return NotFound();
|
if (offering is null) return NotFound();
|
||||||
var round = offering.CourseSelectionRound!;
|
var round = offering.CourseSelectionRound!;
|
||||||
var task = offering.TeachingTask!;
|
var task = offering.TeachingTask!;
|
||||||
|
if (!currentUserDataScope.Current.CanAccessCollege(task.Course!.CollegeId))
|
||||||
|
return Forbid();
|
||||||
|
|
||||||
if (round.Status == CourseSelectionRoundStatus.Draft)
|
if (round.Status == CourseSelectionRoundStatus.Draft)
|
||||||
return ConflictProblem("选课批次开放后才能办理强制选课。");
|
return ConflictProblem("选课批次开放后才能办理强制选课。");
|
||||||
@@ -769,6 +868,7 @@ public sealed class CourseSelectionsController(
|
|||||||
var enrollment = await db.CourseEnrollments
|
var enrollment = await db.CourseEnrollments
|
||||||
.Include(x => x.CourseSelectionOffering)
|
.Include(x => x.CourseSelectionOffering)
|
||||||
.ThenInclude(x => x!.CourseSelectionRound)
|
.ThenInclude(x => x!.CourseSelectionRound)
|
||||||
|
.ThenInclude(x => x!.EligibleGrades)
|
||||||
.Include(x => x.CourseSelectionOffering)
|
.Include(x => x.CourseSelectionOffering)
|
||||||
.ThenInclude(x => x!.TeachingTask)
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
.ThenInclude(x => x!.Course)
|
.ThenInclude(x => x!.Course)
|
||||||
@@ -860,9 +960,19 @@ public sealed class CourseSelectionsController(
|
|||||||
var student = await CurrentStudentAsync(cancellationToken);
|
var student = await CurrentStudentAsync(cancellationToken);
|
||||||
if (student is null) return ProfileNotFound();
|
if (student is null) return ProfileNotFound();
|
||||||
var round = await db.CourseSelectionRounds.AsNoTracking()
|
var round = await db.CourseSelectionRounds.AsNoTracking()
|
||||||
|
.Include(x => x.EligibleGrades)
|
||||||
.FirstOrDefaultAsync(x => x.Id == roundId, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == roundId, cancellationToken);
|
||||||
if (round is null || round.Status == CourseSelectionRoundStatus.Draft)
|
if (round is null || round.Status == CourseSelectionRoundStatus.Draft)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
var eligibleGrades = round.EligibleGrades
|
||||||
|
.Select(x => x.Grade)
|
||||||
|
.ToArray();
|
||||||
|
if (!CourseSelectionRules.IsGradeEligible(
|
||||||
|
eligibleGrades,
|
||||||
|
student.AdministrativeClass!.Grade))
|
||||||
|
{
|
||||||
|
return Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
var offerings = await db.CourseSelectionOfferings.AsNoTracking()
|
var offerings = await db.CourseSelectionOfferings.AsNoTracking()
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
@@ -968,6 +1078,8 @@ public sealed class CourseSelectionsController(
|
|||||||
round.EndsAt,
|
round.EndsAt,
|
||||||
round.WithdrawalEndsAt,
|
round.WithdrawalEndsAt,
|
||||||
round.MaxCredits,
|
round.MaxCredits,
|
||||||
|
round.MaxCourseCount,
|
||||||
|
EligibleGrades = eligibleGrades,
|
||||||
round.Status,
|
round.Status,
|
||||||
IsAvailableNow = CourseSelectionRules.IsSelectionOpen(
|
IsAvailableNow = CourseSelectionRules.IsSelectionOpen(
|
||||||
round,
|
round,
|
||||||
@@ -978,7 +1090,8 @@ public sealed class CourseSelectionsController(
|
|||||||
student.Id,
|
student.Id,
|
||||||
student.StudentNumber,
|
student.StudentNumber,
|
||||||
student.Name,
|
student.Name,
|
||||||
ClassName = student.AdministrativeClass!.Name
|
ClassName = student.AdministrativeClass!.Name,
|
||||||
|
Grade = student.AdministrativeClass.Grade
|
||||||
},
|
},
|
||||||
Offerings = offerings
|
Offerings = offerings
|
||||||
});
|
});
|
||||||
@@ -1050,6 +1163,7 @@ public sealed class CourseSelectionsController(
|
|||||||
|
|
||||||
var offering = await db.CourseSelectionOfferings
|
var offering = await db.CourseSelectionOfferings
|
||||||
.Include(x => x.CourseSelectionRound)
|
.Include(x => x.CourseSelectionRound)
|
||||||
|
.ThenInclude(x => x!.EligibleGrades)
|
||||||
.Include(x => x.TeachingTask)
|
.Include(x => x.TeachingTask)
|
||||||
.ThenInclude(x => x!.Course)
|
.ThenInclude(x => x!.Course)
|
||||||
.Include(x => x.TeachingTask)
|
.Include(x => x.TeachingTask)
|
||||||
@@ -1063,6 +1177,12 @@ public sealed class CourseSelectionsController(
|
|||||||
return ConflictProblem("当前不在该选课批次的开放时间内。");
|
return ConflictProblem("当前不在该选课批次的开放时间内。");
|
||||||
if (task.Status != TeachingTaskStatus.Published)
|
if (task.Status != TeachingTaskStatus.Published)
|
||||||
return ConflictProblem("该教学班当前不可选。");
|
return ConflictProblem("该教学班当前不可选。");
|
||||||
|
if (!CourseSelectionRules.IsGradeEligible(
|
||||||
|
round.EligibleGrades.Select(x => x.Grade),
|
||||||
|
student.AdministrativeClass!.Grade))
|
||||||
|
{
|
||||||
|
return ConflictProblem("你所在的年级不属于本轮选课对象。");
|
||||||
|
}
|
||||||
if (!offering.IsOpenToAll &&
|
if (!offering.IsOpenToAll &&
|
||||||
!task.Classes.Any(x =>
|
!task.Classes.Any(x =>
|
||||||
x.AdministrativeClassId == student.AdministrativeClassId))
|
x.AdministrativeClassId == student.AdministrativeClassId))
|
||||||
@@ -1128,6 +1248,19 @@ public sealed class CourseSelectionsController(
|
|||||||
$"选课后将达到 {selectedCredits + task.Course.Credits:0.#} 学分," +
|
$"选课后将达到 {selectedCredits + task.Course.Credits:0.#} 学分," +
|
||||||
$"超过本轮 {round.MaxCredits:0.#} 学分上限。");
|
$"超过本轮 {round.MaxCredits:0.#} 学分上限。");
|
||||||
}
|
}
|
||||||
|
var selectedCourseCount = await db.CourseEnrollments.CountAsync(
|
||||||
|
x =>
|
||||||
|
x.StudentId == student.Id &&
|
||||||
|
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id,
|
||||||
|
cancellationToken);
|
||||||
|
if (CourseSelectionRules.HasReachedCourseLimit(
|
||||||
|
round.MaxCourseCount,
|
||||||
|
selectedCourseCount))
|
||||||
|
{
|
||||||
|
return ConflictProblem(
|
||||||
|
$"你已达到本轮最多 {round.MaxCourseCount} 门课程限制。");
|
||||||
|
}
|
||||||
|
|
||||||
// Schedule conflict check
|
// Schedule conflict check
|
||||||
var candidateEntries = await PublishedScheduleEntries(
|
var candidateEntries = await PublishedScheduleEntries(
|
||||||
@@ -1209,6 +1342,7 @@ public sealed class CourseSelectionsController(
|
|||||||
|
|
||||||
var offering = await db.CourseSelectionOfferings
|
var offering = await db.CourseSelectionOfferings
|
||||||
.Include(x => x.CourseSelectionRound)
|
.Include(x => x.CourseSelectionRound)
|
||||||
|
.ThenInclude(x => x!.EligibleGrades)
|
||||||
.Include(x => x.TeachingTask)
|
.Include(x => x.TeachingTask)
|
||||||
.ThenInclude(x => x!.Course)
|
.ThenInclude(x => x!.Course)
|
||||||
.Include(x => x.TeachingTask)
|
.Include(x => x.TeachingTask)
|
||||||
@@ -1317,6 +1451,7 @@ public sealed class CourseSelectionsController(
|
|||||||
var enrollment = await db.CourseEnrollments
|
var enrollment = await db.CourseEnrollments
|
||||||
.Include(x => x.CourseSelectionOffering)
|
.Include(x => x.CourseSelectionOffering)
|
||||||
.ThenInclude(x => x!.CourseSelectionRound)
|
.ThenInclude(x => x!.CourseSelectionRound)
|
||||||
|
.ThenInclude(x => x!.EligibleGrades)
|
||||||
.Include(x => x.CourseSelectionOffering)
|
.Include(x => x.CourseSelectionOffering)
|
||||||
.ThenInclude(x => x!.TeachingTask)
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
.ThenInclude(x => x!.Course)
|
.ThenInclude(x => x!.Course)
|
||||||
@@ -1504,6 +1639,11 @@ public sealed class CourseSelectionsController(
|
|||||||
return ValidationProblem("选课开始时间必须早于结束时间。");
|
return ValidationProblem("选课开始时间必须早于结束时间。");
|
||||||
if (withdrawalEndsAt < endsAt)
|
if (withdrawalEndsAt < endsAt)
|
||||||
return ValidationProblem("退课截止时间不能早于选课结束时间。");
|
return ValidationProblem("退课截止时间不能早于选课结束时间。");
|
||||||
|
var eligibleGrades = NormalizeGrades(request.EligibleGrades);
|
||||||
|
if (eligibleGrades.Length > 20)
|
||||||
|
return ValidationProblem("单个选课批次最多配置 20 个适用年级。");
|
||||||
|
if (eligibleGrades.Any(grade => grade is < 2000 or > 2200))
|
||||||
|
return ValidationProblem("适用年级必须在 2000—2200 之间。");
|
||||||
if (!await db.AcademicTerms.AnyAsync(
|
if (!await db.AcademicTerms.AnyAsync(
|
||||||
x => x.Id == request.AcademicTermId && x.IsEnabled,
|
x => x.Id == request.AcademicTermId && x.IsEnabled,
|
||||||
cancellationToken))
|
cancellationToken))
|
||||||
@@ -1545,6 +1685,12 @@ public sealed class CourseSelectionsController(
|
|||||||
return new(false, "只有在籍学生可以选课或候补。");
|
return new(false, "只有在籍学生可以选课或候补。");
|
||||||
if (task.Status != TeachingTaskStatus.Published)
|
if (task.Status != TeachingTaskStatus.Published)
|
||||||
return new(false, "该教学班当前不可选。");
|
return new(false, "该教学班当前不可选。");
|
||||||
|
if (!CourseSelectionRules.IsGradeEligible(
|
||||||
|
round.EligibleGrades.Select(x => x.Grade),
|
||||||
|
student.AdministrativeClass!.Grade))
|
||||||
|
{
|
||||||
|
return new(false, "你所在的年级不属于本轮选课对象。");
|
||||||
|
}
|
||||||
if (!offering.IsOpenToAll &&
|
if (!offering.IsOpenToAll &&
|
||||||
!await db.TeachingTaskClasses.AnyAsync(
|
!await db.TeachingTaskClasses.AnyAsync(
|
||||||
x =>
|
x =>
|
||||||
@@ -1591,6 +1737,20 @@ public sealed class CourseSelectionsController(
|
|||||||
$"获得名额后将达到 {selectedCredits + task.Course.Credits:0.#} 学分," +
|
$"获得名额后将达到 {selectedCredits + task.Course.Credits:0.#} 学分," +
|
||||||
$"超过本轮 {round.MaxCredits:0.#} 学分上限。");
|
$"超过本轮 {round.MaxCredits:0.#} 学分上限。");
|
||||||
}
|
}
|
||||||
|
var selectedCourseCount = await db.CourseEnrollments.CountAsync(
|
||||||
|
x =>
|
||||||
|
x.StudentId == student.Id &&
|
||||||
|
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id,
|
||||||
|
cancellationToken);
|
||||||
|
if (CourseSelectionRules.HasReachedCourseLimit(
|
||||||
|
round.MaxCourseCount,
|
||||||
|
selectedCourseCount))
|
||||||
|
{
|
||||||
|
return new(
|
||||||
|
isRetake,
|
||||||
|
$"你已达到本轮最多 {round.MaxCourseCount} 门课程限制。");
|
||||||
|
}
|
||||||
|
|
||||||
var candidateEntries = await PublishedScheduleEntries(
|
var candidateEntries = await PublishedScheduleEntries(
|
||||||
round.AcademicTermId,
|
round.AcademicTermId,
|
||||||
@@ -1772,6 +1932,9 @@ public sealed class CourseSelectionsController(
|
|||||||
private static string? Normalize(string? value) =>
|
private static string? Normalize(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
private static int[] NormalizeGrades(IEnumerable<int>? values) =>
|
||||||
|
values?.Distinct().OrderBy(x => x).ToArray() ?? [];
|
||||||
|
|
||||||
private sealed record EnrollmentEligibility(bool IsRetake, string? Error);
|
private sealed record EnrollmentEligibility(bool IsRetake, string? Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1782,6 +1945,8 @@ public sealed record CourseSelectionRoundRequest(
|
|||||||
DateTime EndsAt,
|
DateTime EndsAt,
|
||||||
DateTime WithdrawalEndsAt,
|
DateTime WithdrawalEndsAt,
|
||||||
[Range(typeof(decimal), "0.5", "99")] decimal MaxCredits,
|
[Range(typeof(decimal), "0.5", "99")] decimal MaxCredits,
|
||||||
|
[Range(1, 100)] int? MaxCourseCount,
|
||||||
|
IReadOnlyCollection<int>? EligibleGrades,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes);
|
||||||
|
|
||||||
public sealed record CourseSelectionOfferingRequest(
|
public sealed record CourseSelectionOfferingRequest(
|
||||||
|
|||||||
@@ -11,12 +11,21 @@ public sealed class CourseSelectionRound : EntityBase
|
|||||||
public DateTime EndsAt { get; set; }
|
public DateTime EndsAt { get; set; }
|
||||||
public DateTime WithdrawalEndsAt { get; set; }
|
public DateTime WithdrawalEndsAt { get; set; }
|
||||||
public decimal MaxCredits { get; set; } = 30;
|
public decimal MaxCredits { get; set; } = 30;
|
||||||
|
public int? MaxCourseCount { get; set; }
|
||||||
public CourseSelectionRoundStatus Status { get; set; } =
|
public CourseSelectionRoundStatus Status { get; set; } =
|
||||||
CourseSelectionRoundStatus.Draft;
|
CourseSelectionRoundStatus.Draft;
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
public ICollection<CourseSelectionRoundGrade> EligibleGrades { get; set; } = [];
|
||||||
public ICollection<CourseSelectionOffering> Offerings { get; set; } = [];
|
public ICollection<CourseSelectionOffering> Offerings { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class CourseSelectionRoundGrade : EntityBase
|
||||||
|
{
|
||||||
|
public Guid CourseSelectionRoundId { get; set; }
|
||||||
|
public CourseSelectionRound? CourseSelectionRound { get; set; }
|
||||||
|
public int Grade { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class CourseSelectionOffering : EntityBase
|
public sealed class CourseSelectionOffering : EntityBase
|
||||||
{
|
{
|
||||||
public Guid CourseSelectionRoundId { get; set; }
|
public Guid CourseSelectionRoundId { get; set; }
|
||||||
|
|||||||
@@ -21,6 +21,16 @@ public static class CourseSelectionRules
|
|||||||
public static bool SupportsProxyEnrollment(CourseNature nature) =>
|
public static bool SupportsProxyEnrollment(CourseNature nature) =>
|
||||||
nature == CourseNature.GeneralRequired;
|
nature == CourseNature.GeneralRequired;
|
||||||
|
|
||||||
|
public static bool IsGradeEligible(
|
||||||
|
IEnumerable<int> eligibleGrades,
|
||||||
|
int studentGrade) =>
|
||||||
|
!eligibleGrades.Any() || eligibleGrades.Contains(studentGrade);
|
||||||
|
|
||||||
|
public static bool HasReachedCourseLimit(
|
||||||
|
int? maxCourseCount,
|
||||||
|
int selectedCourseCount) =>
|
||||||
|
maxCourseCount.HasValue && selectedCourseCount >= maxCourseCount.Value;
|
||||||
|
|
||||||
public static bool RequiresPublishedSchedule(TeachingTaskSchedulingMode schedulingMode) =>
|
public static bool RequiresPublishedSchedule(TeachingTaskSchedulingMode schedulingMode) =>
|
||||||
schedulingMode == TeachingTaskSchedulingMode.Standard;
|
schedulingMode == TeachingTaskSchedulingMode.Standard;
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
Set<SchedulePublishJob>();
|
Set<SchedulePublishJob>();
|
||||||
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
||||||
Set<CourseSelectionRound>();
|
Set<CourseSelectionRound>();
|
||||||
|
public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades =>
|
||||||
|
Set<CourseSelectionRoundGrade>();
|
||||||
public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
|
public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
|
||||||
Set<CourseSelectionOffering>();
|
Set<CourseSelectionOffering>();
|
||||||
public DbSet<CourseEnrollment> CourseEnrollments => Set<CourseEnrollment>();
|
public DbSet<CourseEnrollment> CourseEnrollments => Set<CourseEnrollment>();
|
||||||
@@ -477,6 +479,17 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<CourseSelectionRoundGrade>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasIndex(x => new { x.CourseSelectionRoundId, x.Grade })
|
||||||
|
.IsUnique();
|
||||||
|
entity.HasIndex(x => x.Grade);
|
||||||
|
entity.HasOne(x => x.CourseSelectionRound)
|
||||||
|
.WithMany(x => x.EligibleGrades)
|
||||||
|
.HasForeignKey(x => x.CourseSelectionRoundId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<CourseSelectionOffering>(entity =>
|
builder.Entity<CourseSelectionOffering>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||||
|
|||||||
+4390
File diff suppressed because it is too large
Load Diff
+65
@@ -0,0 +1,65 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class CourseSelectionGradeLimits : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "MaxCourseCount",
|
||||||
|
table: "CourseSelectionRounds",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CourseSelectionRoundGrades",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
CourseSelectionRoundId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
Grade = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CourseSelectionRoundGrades", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CourseSelectionRoundGrades_CourseSelectionRounds_CourseSelec~",
|
||||||
|
column: x => x.CourseSelectionRoundId,
|
||||||
|
principalTable: "CourseSelectionRounds",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CourseSelectionRoundGrades_CourseSelectionRoundId_Grade",
|
||||||
|
table: "CourseSelectionRoundGrades",
|
||||||
|
columns: new[] { "CourseSelectionRoundId", "Grade" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CourseSelectionRoundGrades_Grade",
|
||||||
|
table: "CourseSelectionRoundGrades",
|
||||||
|
column: "Grade");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CourseSelectionRoundGrades");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MaxCourseCount",
|
||||||
|
table: "CourseSelectionRounds");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
-17
@@ -40,13 +40,13 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
b.Property<DateOnly>("EndDate")
|
b.Property<DateTime>("EndDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
b.Property<bool>("IsCurrent")
|
b.Property<bool>("IsArchived")
|
||||||
.HasColumnType("tinyint(1)");
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
b.Property<bool>("IsArchived")
|
b.Property<bool>("IsCurrent")
|
||||||
.HasColumnType("tinyint(1)");
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
b.Property<bool>("IsEnabled")
|
b.Property<bool>("IsEnabled")
|
||||||
@@ -63,7 +63,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<int>("SortOrder")
|
b.Property<int>("SortOrder")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<DateOnly>("StartDate")
|
b.Property<DateTime>("StartDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
b.Property<DateTime>("UpdatedAt")
|
b.Property<DateTime>("UpdatedAt")
|
||||||
@@ -74,10 +74,10 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.HasIndex("Code")
|
b.HasIndex("Code")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.HasIndex("IsCurrent");
|
|
||||||
|
|
||||||
b.HasIndex("IsArchived");
|
b.HasIndex("IsArchived");
|
||||||
|
|
||||||
|
b.HasIndex("IsCurrent");
|
||||||
|
|
||||||
b.HasIndex("IsEnabled", "SortOrder");
|
b.HasIndex("IsEnabled", "SortOrder");
|
||||||
|
|
||||||
b.ToTable("AcademicTerms");
|
b.ToTable("AcademicTerms");
|
||||||
@@ -595,7 +595,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<Guid>("ApplicantUserId")
|
b.Property<Guid>("ApplicantUserId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<DateOnly?>("CancelDate")
|
b.Property<DateTime?>("CancelDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
b.Property<int?>("CancelWeek")
|
b.Property<int?>("CancelWeek")
|
||||||
@@ -640,7 +640,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<Guid?>("SubstituteTeacherId")
|
b.Property<Guid?>("SubstituteTeacherId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<DateOnly?>("TargetDate")
|
b.Property<DateTime?>("TargetDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
b.Property<Guid>("TeachingTaskId")
|
b.Property<Guid>("TeachingTaskId")
|
||||||
@@ -743,10 +743,10 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.HasIndex("CourseSelectionOfferingId", "StudentId")
|
b.HasIndex("CourseSelectionOfferingId", "StudentId")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.HasIndex("CourseSelectionOfferingId", "Status", "WaitlistedAt");
|
|
||||||
|
|
||||||
b.HasIndex("StudentId", "Status");
|
b.HasIndex("StudentId", "Status");
|
||||||
|
|
||||||
|
b.HasIndex("CourseSelectionOfferingId", "Status", "WaitlistedAt");
|
||||||
|
|
||||||
b.ToTable("CourseEnrollments");
|
b.ToTable("CourseEnrollments");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -854,6 +854,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<DateTime>("EndsAt")
|
b.Property<DateTime>("EndsAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int?>("MaxCourseCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<decimal>("MaxCredits")
|
b.Property<decimal>("MaxCredits")
|
||||||
.HasPrecision(6, 1)
|
.HasPrecision(6, 1)
|
||||||
.HasColumnType("decimal(6,1)");
|
.HasColumnType("decimal(6,1)");
|
||||||
@@ -886,6 +889,34 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("CourseSelectionRounds");
|
b.ToTable("CourseSelectionRounds");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRoundGrade", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("CourseSelectionRoundId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("Grade")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Grade");
|
||||||
|
|
||||||
|
b.HasIndex("CourseSelectionRoundId", "Grade")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("CourseSelectionRoundGrades");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSubstitution", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSubstitution", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1407,7 +1438,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<DateTime>("EndsAt")
|
b.Property<DateTime>("EndsAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
b.Property<DateOnly>("ExamDate")
|
b.Property<DateTime>("ExamDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
b.Property<Guid>("ExamPlanId")
|
b.Property<Guid>("ExamPlanId")
|
||||||
@@ -2117,7 +2148,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<DateTime>("EndsAt")
|
b.Property<DateTime>("EndsAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
b.Property<DateOnly>("ExamDate")
|
b.Property<DateTime>("ExamDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
b.Property<Guid>("MakeupExamPlanId")
|
b.Property<Guid>("MakeupExamPlanId")
|
||||||
@@ -2391,7 +2422,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
b.Property<TimeOnly>("EndsAt")
|
b.Property<TimeSpan>("EndsAt")
|
||||||
.HasColumnType("time");
|
.HasColumnType("time");
|
||||||
|
|
||||||
b.Property<bool>("IsEnabled")
|
b.Property<bool>("IsEnabled")
|
||||||
@@ -2405,7 +2436,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<int>("PeriodNumber")
|
b.Property<int>("PeriodNumber")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<TimeOnly>("StartsAt")
|
b.Property<TimeSpan>("StartsAt")
|
||||||
.HasColumnType("time");
|
.HasColumnType("time");
|
||||||
|
|
||||||
b.Property<DateTime>("UpdatedAt")
|
b.Property<DateTime>("UpdatedAt")
|
||||||
@@ -2431,14 +2462,14 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
b.Property<DateOnly?>("DateOfBirth")
|
b.Property<DateTime?>("DateOfBirth")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
b.Property<string>("Email")
|
b.Property<string>("Email")
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("varchar(100)");
|
.HasColumnType("varchar(100)");
|
||||||
|
|
||||||
b.Property<DateOnly>("EnrollmentDate")
|
b.Property<DateTime>("EnrollmentDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
b.Property<int>("EnrollmentYear")
|
b.Property<int>("EnrollmentYear")
|
||||||
@@ -2559,7 +2590,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<int>("Gender")
|
b.Property<int>("Gender")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<DateOnly?>("HireDate")
|
b.Property<DateTime?>("HireDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
b.Property<bool>("IsExternal")
|
b.Property<bool>("IsExternal")
|
||||||
@@ -3412,6 +3443,17 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("AcademicTerm");
|
b.Navigation("AcademicTerm");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRoundGrade", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionRound", "CourseSelectionRound")
|
||||||
|
.WithMany("EligibleGrades")
|
||||||
|
.HasForeignKey("CourseSelectionRoundId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("CourseSelectionRound");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSubstitution", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSubstitution", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "OriginalCourse")
|
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "OriginalCourse")
|
||||||
@@ -4232,6 +4274,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b =>
|
||||||
{
|
{
|
||||||
|
b.Navigation("EligibleGrades");
|
||||||
|
|
||||||
b.Navigation("Offerings");
|
b.Navigation("Offerings");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,31 @@ public sealed class CourseSelectionRulesTests
|
|||||||
Assert.Equal(expected, CourseSelectionRules.SupportsProxyEnrollment(nature));
|
Assert.Equal(expected, CourseSelectionRules.SupportsProxyEnrollment(nature));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Empty_grade_scope_allows_all_students_and_configured_scope_is_enforced()
|
||||||
|
{
|
||||||
|
Assert.True(CourseSelectionRules.IsGradeEligible([], 2026));
|
||||||
|
Assert.True(CourseSelectionRules.IsGradeEligible([2025, 2026], 2026));
|
||||||
|
Assert.False(CourseSelectionRules.IsGradeEligible([2024, 2025], 2026));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null, 99, false)]
|
||||||
|
[InlineData(6, 5, false)]
|
||||||
|
[InlineData(6, 6, true)]
|
||||||
|
[InlineData(6, 7, true)]
|
||||||
|
public void Course_count_limit_is_optional_and_blocks_at_the_boundary(
|
||||||
|
int? maxCourseCount,
|
||||||
|
int selectedCourseCount,
|
||||||
|
bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
expected,
|
||||||
|
CourseSelectionRules.HasReachedCourseLimit(
|
||||||
|
maxCourseCount,
|
||||||
|
selectedCourseCount));
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(TeachingTaskSchedulingMode.Standard, true)]
|
[InlineData(TeachingTaskSchedulingMode.Standard, true)]
|
||||||
[InlineData(TeachingTaskSchedulingMode.Flexible, false)]
|
[InlineData(TeachingTaskSchedulingMode.Flexible, false)]
|
||||||
|
|||||||
@@ -109,6 +109,89 @@ public sealed class CourseSelectionsControllerTests
|
|||||||
x.Title == "课程候补已结束"));
|
x.Title == "课程候补已结束"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Student_cannot_join_waitlist_outside_the_round_grade_scope()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
await using var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
var data = await SeedFullOfferingAsync(db);
|
||||||
|
|
||||||
|
db.CourseSelectionRoundGrades.Add(new CourseSelectionRoundGrade
|
||||||
|
{
|
||||||
|
CourseSelectionRoundId = data.RoundId,
|
||||||
|
Grade = 2025
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new CourseSelectionsController(
|
||||||
|
db,
|
||||||
|
new StudentDataScope(data.FirstWaiterUserId));
|
||||||
|
var result = await controller.JoinWaitlist(
|
||||||
|
new StudentEnrollmentRequest(data.OfferingId),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var conflict = Assert.IsType<ConflictObjectResult>(result);
|
||||||
|
var problem = Assert.IsType<ProblemDetails>(conflict.Value);
|
||||||
|
Assert.Equal("你所在的年级不属于本轮选课对象。", problem.Detail);
|
||||||
|
Assert.False(await db.CourseEnrollments.AnyAsync(x =>
|
||||||
|
x.StudentId == data.FirstWaiterStudentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Updating_round_preserves_an_unchanged_grade_without_duplicate_rows()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
await using var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
var data = await SeedFullOfferingAsync(db);
|
||||||
|
|
||||||
|
var round = await db.CourseSelectionRounds.FindAsync(data.RoundId);
|
||||||
|
round!.Status = CourseSelectionRoundStatus.Draft;
|
||||||
|
db.CourseSelectionRoundGrades.Add(new CourseSelectionRoundGrade
|
||||||
|
{
|
||||||
|
CourseSelectionRoundId = data.RoundId,
|
||||||
|
Grade = 2026
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var request = new CourseSelectionRoundRequest(
|
||||||
|
round.AcademicTermId,
|
||||||
|
"2026 级第一轮选课",
|
||||||
|
DateTime.UtcNow.AddHours(-1),
|
||||||
|
DateTime.UtcNow.AddDays(7),
|
||||||
|
DateTime.UtcNow.AddDays(14),
|
||||||
|
30,
|
||||||
|
6,
|
||||||
|
[2026],
|
||||||
|
null);
|
||||||
|
var controller = new CourseSelectionsController(db, new AdminDataScope());
|
||||||
|
|
||||||
|
Assert.IsType<NoContentResult>(await controller.UpdateRound(
|
||||||
|
data.RoundId,
|
||||||
|
request,
|
||||||
|
CancellationToken.None));
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
Assert.IsType<NoContentResult>(await controller.UpdateRound(
|
||||||
|
data.RoundId,
|
||||||
|
request,
|
||||||
|
CancellationToken.None));
|
||||||
|
Assert.Equal(
|
||||||
|
1,
|
||||||
|
await db.CourseSelectionRoundGrades.CountAsync(x =>
|
||||||
|
x.CourseSelectionRoundId == data.RoundId &&
|
||||||
|
x.Grade == 2026));
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<SeededSelection> SeedFullOfferingAsync(AppDbContext db)
|
private static async Task<SeededSelection> SeedFullOfferingAsync(AppDbContext db)
|
||||||
{
|
{
|
||||||
var college = new College { Code = "CS", Name = "计算机学院" };
|
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace Jiaowu.Api.Tests;
|
|||||||
public sealed class MySqlMigrationTests
|
public sealed class MySqlMigrationTests
|
||||||
{
|
{
|
||||||
private const string LatestMigration =
|
private const string LatestMigration =
|
||||||
"20260726170000_CourseSelectionWaitlist";
|
"20260726180000_CourseSelectionGradeLimits";
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Production_migration_is_discoverable_and_generates_mysql_sql()
|
public void Production_migration_is_discoverable_and_generates_mysql_sql()
|
||||||
@@ -31,6 +31,15 @@ public sealed class MySqlMigrationTests
|
|||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
"CREATE INDEX `IX_CourseEnrollments_CourseSelectionOfferingId_Status_WaitlistedAt`",
|
"CREATE INDEX `IX_CourseEnrollments_CourseSelectionOfferingId_Status_WaitlistedAt`",
|
||||||
script);
|
script);
|
||||||
|
Assert.Contains(
|
||||||
|
"ADD `MaxCourseCount` int NULL",
|
||||||
|
script);
|
||||||
|
Assert.Contains(
|
||||||
|
"CREATE TABLE `CourseSelectionRoundGrades`",
|
||||||
|
script);
|
||||||
|
Assert.Contains(
|
||||||
|
"CREATE UNIQUE INDEX `IX_CourseSelectionRoundGrades_CourseSelectionRoundId_Grade`",
|
||||||
|
script);
|
||||||
Assert.Contains("SET `ExamDate` = DATE(`StartsAt`)", script);
|
Assert.Contains("SET `ExamDate` = DATE(`StartsAt`)", script);
|
||||||
Assert.Contains("DEFAULT 1", script);
|
Assert.Contains("DEFAULT 1", script);
|
||||||
Assert.DoesNotContain("0001-01-01", script);
|
Assert.DoesNotContain("0001-01-01", script);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const canManageRounds = computed(() =>
|
|||||||
)
|
)
|
||||||
const isStudent = computed(() => auth.user?.roles.includes('Student') && !isManager.value)
|
const isStudent = computed(() => auth.user?.roles.includes('Student') && !isManager.value)
|
||||||
const terms = ref<any[]>([])
|
const terms = ref<any[]>([])
|
||||||
|
const gradeOptions = ref<number[]>([])
|
||||||
const rounds = ref<any[]>([])
|
const rounds = ref<any[]>([])
|
||||||
const selectedRound = ref<any | null>(null)
|
const selectedRound = ref<any | null>(null)
|
||||||
const offerings = ref<any[]>([])
|
const offerings = ref<any[]>([])
|
||||||
@@ -130,6 +131,10 @@ function offeringEligibilityReason(offering: any) {
|
|||||||
if (!offering.isRetake && selectedOfferings.value.some((item) =>
|
if (!offering.isRetake && selectedOfferings.value.some((item) =>
|
||||||
item.id !== offering.id && item.courseCode === offering.courseCode,
|
item.id !== offering.id && item.courseCode === offering.courseCode,
|
||||||
)) return '本学期已选择同一课程'
|
)) return '本学期已选择同一课程'
|
||||||
|
if (selectedRound.value.maxCourseCount
|
||||||
|
&& selectedCount.value >= Number(selectedRound.value.maxCourseCount)) {
|
||||||
|
return `已达到本轮最多 ${selectedRound.value.maxCourseCount} 门课程限制`
|
||||||
|
}
|
||||||
if (selectedCredits.value + Number(offering.credits) > Number(selectedRound.value.maxCredits)) {
|
if (selectedCredits.value + Number(offering.credits) > Number(selectedRound.value.maxCredits)) {
|
||||||
return `选择后将超过 ${selectedRound.value.maxCredits} 学分上限`
|
return `选择后将超过 ${selectedRound.value.maxCredits} 学分上限`
|
||||||
}
|
}
|
||||||
@@ -302,6 +307,18 @@ function formatDateTime(value: string) {
|
|||||||
}).format(new Date(value))
|
}).format(new Date(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function roundGradeLabel(round: any) {
|
||||||
|
const grades = (round?.eligibleGrades ?? []).map(Number)
|
||||||
|
return grades.length ? grades.map((grade: number) => `${grade} 级`).join('、') : '全部年级'
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundLimitLabel(round: any) {
|
||||||
|
const courseLimit = round?.maxCourseCount
|
||||||
|
? `最多 ${round.maxCourseCount} 门`
|
||||||
|
: '课程门数不限'
|
||||||
|
return `${roundGradeLabel(round)} · ${courseLimit} · 最多 ${round.maxCredits} 学分`
|
||||||
|
}
|
||||||
|
|
||||||
function toPickerValue(value: string) {
|
function toPickerValue(value: string) {
|
||||||
if (!value) return ''
|
if (!value) return ''
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
@@ -395,6 +412,8 @@ function openRound(round?: any) {
|
|||||||
? toPickerValue(round.withdrawalEndsAt)
|
? toPickerValue(round.withdrawalEndsAt)
|
||||||
: toPickerValue(withdrawal.toISOString()),
|
: toPickerValue(withdrawal.toISOString()),
|
||||||
maxCredits: round?.maxCredits ?? 30,
|
maxCredits: round?.maxCredits ?? 30,
|
||||||
|
maxCourseCount: round?.maxCourseCount ?? undefined,
|
||||||
|
eligibleGrades: [...(round?.eligibleGrades ?? [])],
|
||||||
notes: round?.notes ?? '',
|
notes: round?.notes ?? '',
|
||||||
})
|
})
|
||||||
roundDialog.value = true
|
roundDialog.value = true
|
||||||
@@ -408,6 +427,8 @@ async function saveRound() {
|
|||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
...roundForm,
|
...roundForm,
|
||||||
|
maxCourseCount: roundForm.maxCourseCount || null,
|
||||||
|
eligibleGrades: roundForm.eligibleGrades ?? [],
|
||||||
startsAt: toIso(roundForm.startsAt),
|
startsAt: toIso(roundForm.startsAt),
|
||||||
endsAt: toIso(roundForm.endsAt),
|
endsAt: toIso(roundForm.endsAt),
|
||||||
withdrawalEndsAt: toIso(roundForm.withdrawalEndsAt),
|
withdrawalEndsAt: toIso(roundForm.withdrawalEndsAt),
|
||||||
@@ -763,7 +784,16 @@ async function cancelWaitlist(offering: any) {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
if (isManager.value) terms.value = (await http.get('/base-data/terms')).data
|
if (isManager.value) {
|
||||||
|
const [termResponse, optionResponse] = await Promise.all([
|
||||||
|
http.get('/base-data/terms'),
|
||||||
|
canManageRounds.value
|
||||||
|
? http.get('/course-selections/configuration-options')
|
||||||
|
: Promise.resolve({ data: { grades: [] } }),
|
||||||
|
])
|
||||||
|
terms.value = termResponse.data
|
||||||
|
gradeOptions.value = optionResponse.data.grades
|
||||||
|
}
|
||||||
await loadRounds(false)
|
await loadRounds(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
@@ -801,6 +831,7 @@ onMounted(async () => {
|
|||||||
<span>{{ round.termName }}</span>
|
<span>{{ round.termName }}</span>
|
||||||
<b>{{ round.name }}</b>
|
<b>{{ round.name }}</b>
|
||||||
<small>{{ formatDateTime(round.startsAt) }} — {{ formatDateTime(round.endsAt) }}</small>
|
<small>{{ formatDateTime(round.startsAt) }} — {{ formatDateTime(round.endsAt) }}</small>
|
||||||
|
<small>{{ roundLimitLabel(round) }}</small>
|
||||||
<i :class="round.status.toLowerCase()">{{ statusLabels[round.status] }}</i>
|
<i :class="round.status.toLowerCase()">{{ statusLabels[round.status] }}</i>
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
@@ -817,6 +848,7 @@ onMounted(async () => {
|
|||||||
<p>
|
<p>
|
||||||
选课 {{ formatDateTime(selectedRound.startsAt) }}—{{ formatDateTime(selectedRound.endsAt) }}
|
选课 {{ formatDateTime(selectedRound.startsAt) }}—{{ formatDateTime(selectedRound.endsAt) }}
|
||||||
<em>退课截止 {{ formatDateTime(selectedRound.withdrawalEndsAt) }}</em>
|
<em>退课截止 {{ formatDateTime(selectedRound.withdrawalEndsAt) }}</em>
|
||||||
|
<em>{{ roundLimitLabel(selectedRound) }}</em>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isStudent" class="credit-meter">
|
<div v-if="isStudent" class="credit-meter">
|
||||||
@@ -826,7 +858,10 @@ onMounted(async () => {
|
|||||||
<small>/ {{ selectedRound.maxCredits }}</small>
|
<small>/ {{ selectedRound.maxCredits }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="credit-track"><i :style="{ width: `${creditPercent}%` }" /></div>
|
<div class="credit-track"><i :style="{ width: `${creditPercent}%` }" /></div>
|
||||||
<p>{{ selectedCount }} 门课程 · 剩余可选 {{ Math.max(0, selectedRound.maxCredits - selectedCredits) }} 学分</p>
|
<p>
|
||||||
|
{{ selectedCount }}{{ selectedRound.maxCourseCount ? ` / ${selectedRound.maxCourseCount}` : '' }} 门课程
|
||||||
|
· 剩余可选 {{ Math.max(0, selectedRound.maxCredits - selectedCredits) }} 学分
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="round-actions">
|
<div v-else class="round-actions">
|
||||||
<el-button
|
<el-button
|
||||||
@@ -879,7 +914,10 @@ onMounted(async () => {
|
|||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="course-name">
|
<div class="course-name">
|
||||||
<b>{{ row.teacherNames.join('、') || '未安排' }}</b>
|
<b>{{ row.teacherNames.join('、') || '未安排' }}</b>
|
||||||
<span>{{ row.isOpenToAll ? '全校开放' : row.classNames.join('、') }}</span>
|
<span>
|
||||||
|
{{ row.isOpenToAll ? '全校学生' : row.classNames.join('、') }}
|
||||||
|
· {{ roundGradeLabel(selectedRound) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -1181,6 +1219,38 @@ onMounted(async () => {
|
|||||||
<el-input-number v-model="roundForm.maxCredits" :min="0.5" :max="99" :step="0.5" />
|
<el-input-number v-model="roundForm.maxCredits" :min="0.5" :max="99" :step="0.5" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<el-form-item label="适用年级">
|
||||||
|
<el-select
|
||||||
|
v-model="roundForm.eligibleGrades"
|
||||||
|
multiple
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
clearable
|
||||||
|
placeholder="不选择表示全部年级"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="grade in gradeOptions"
|
||||||
|
:key="grade"
|
||||||
|
:label="`${grade} 级`"
|
||||||
|
:value="grade"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="最多课程门数">
|
||||||
|
<el-input-number
|
||||||
|
v-model="roundForm.maxCourseCount"
|
||||||
|
:min="1"
|
||||||
|
:max="100"
|
||||||
|
placeholder="不限制"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-alert
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
title="适用年级会同时限制学生可见批次、自主选课、候补、管理员代选和自动递补;留空表示全部年级。"
|
||||||
|
/>
|
||||||
<el-form-item label="说明">
|
<el-form-item label="说明">
|
||||||
<el-input v-model="roundForm.notes" type="textarea" :rows="3" />
|
<el-input v-model="roundForm.notes" type="textarea" :rows="3" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -1266,7 +1336,7 @@ onMounted(async () => {
|
|||||||
class="roster-notice"
|
class="roster-notice"
|
||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
title="公共必修课支持校级教务代选;系统仍会校验教学班容量、学分上限、重复课程和课表冲突。"
|
title="公共必修课支持校级教务代选;系统仍会校验年级范围、教学班容量、课程门数、学分上限、重复课程和课表冲突。"
|
||||||
/>
|
/>
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="isManager"
|
v-if="isManager"
|
||||||
@@ -1278,6 +1348,9 @@ onMounted(async () => {
|
|||||||
<el-table v-loading="rosterLoading" :data="roster.students">
|
<el-table v-loading="rosterLoading" :data="roster.students">
|
||||||
<el-table-column prop="studentNumber" label="学号" width="130" />
|
<el-table-column prop="studentNumber" label="学号" width="130" />
|
||||||
<el-table-column prop="name" label="姓名" width="90" />
|
<el-table-column prop="name" label="姓名" width="90" />
|
||||||
|
<el-table-column label="年级" width="80">
|
||||||
|
<template #default="{ row }">{{ row.grade }} 级</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="className" label="行政班" min-width="150" />
|
<el-table-column prop="className" label="行政班" min-width="150" />
|
||||||
<el-table-column label="选课时间" min-width="130">
|
<el-table-column label="选课时间" min-width="130">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.enrolledAt) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.enrolledAt) }}</template>
|
||||||
@@ -1301,6 +1374,9 @@ onMounted(async () => {
|
|||||||
<el-table-column prop="position" label="顺位" width="70" />
|
<el-table-column prop="position" label="顺位" width="70" />
|
||||||
<el-table-column prop="studentNumber" label="学号" width="130" />
|
<el-table-column prop="studentNumber" label="学号" width="130" />
|
||||||
<el-table-column prop="name" label="姓名" width="90" />
|
<el-table-column prop="name" label="姓名" width="90" />
|
||||||
|
<el-table-column label="年级" width="80">
|
||||||
|
<template #default="{ row }">{{ row.grade }} 级</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="className" label="行政班" min-width="150" />
|
<el-table-column prop="className" label="行政班" min-width="150" />
|
||||||
<el-table-column label="候补时间" min-width="130">
|
<el-table-column label="候补时间" min-width="130">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.waitlistedAt) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.waitlistedAt) }}</template>
|
||||||
@@ -1347,6 +1423,9 @@ onMounted(async () => {
|
|||||||
<el-table-column type="selection" width="48" />
|
<el-table-column type="selection" width="48" />
|
||||||
<el-table-column prop="studentNumber" label="学号" width="130" />
|
<el-table-column prop="studentNumber" label="学号" width="130" />
|
||||||
<el-table-column prop="name" label="姓名" width="90" />
|
<el-table-column prop="name" label="姓名" width="90" />
|
||||||
|
<el-table-column label="年级" width="80">
|
||||||
|
<template #default="{ row }">{{ row.grade }} 级</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="className" label="行政班" min-width="150" />
|
<el-table-column prop="className" label="行政班" min-width="150" />
|
||||||
<el-table-column prop="majorName" label="专业" min-width="150" />
|
<el-table-column prop="majorName" label="专业" min-width="150" />
|
||||||
<template #empty><el-empty description="没有可代选的在籍学生" /></template>
|
<template #empty><el-empty description="没有可代选的在籍学生" /></template>
|
||||||
@@ -1408,6 +1487,9 @@ onMounted(async () => {
|
|||||||
<el-table-column type="selection" width="48" />
|
<el-table-column type="selection" width="48" />
|
||||||
<el-table-column prop="studentNumber" label="学号" width="130" />
|
<el-table-column prop="studentNumber" label="学号" width="130" />
|
||||||
<el-table-column prop="name" label="姓名" width="90" />
|
<el-table-column prop="name" label="姓名" width="90" />
|
||||||
|
<el-table-column label="年级" width="80">
|
||||||
|
<template #default="{ row }">{{ row.grade }} 级</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="className" label="行政班" min-width="150" />
|
<el-table-column prop="className" label="行政班" min-width="150" />
|
||||||
<el-table-column prop="collegeName" label="学院" min-width="120" />
|
<el-table-column prop="collegeName" label="学院" min-width="120" />
|
||||||
<template #empty><el-empty description="没有可强制选课的在籍学生" /></template>
|
<template #empty><el-empty description="没有可强制选课的在籍学生" /></template>
|
||||||
|
|||||||
Reference in New Issue
Block a user